@safetnsr/vet 0.4.0 → 0.5.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.
@@ -0,0 +1,6 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function levenshtein(a: string, b: string): number;
3
+ export declare function extractImports(source: string): string[];
4
+ export declare function extractPackageName(specifier: string): string | null;
5
+ export declare function isBuiltin(specifier: string): boolean;
6
+ export declare function checkDeps(cwd: string): Promise<CheckResult>;
@@ -0,0 +1,276 @@
1
+ import { join } from 'node:path';
2
+ import { readFileSync } from 'node:fs';
3
+ import { walkFiles, readFile } from '../util.js';
4
+ // ── Top packages list (~150 popular npm packages) ────────────────────────────
5
+ const TOP_PACKAGES = [
6
+ 'react', 'react-dom', 'next', 'vue', 'angular', 'express', 'koa', 'fastify', 'hono',
7
+ 'axios', 'node-fetch', 'chalk', 'commander', 'yargs', 'inquirer', 'lodash', 'underscore',
8
+ 'ramda', 'moment', 'dayjs', 'date-fns', 'uuid', 'nanoid', 'dotenv', 'cors', 'helmet',
9
+ 'morgan', 'winston', 'pino', 'debug', 'zod', 'joi', 'yup', 'ajv', 'prettier', 'eslint',
10
+ 'typescript', 'webpack', 'vite', 'rollup', 'esbuild', 'swc', 'babel', 'jest', 'vitest',
11
+ 'mocha', 'chai', 'sinon', 'supertest', 'playwright', 'puppeteer', 'cypress', 'mongoose',
12
+ 'prisma', 'drizzle-orm', 'knex', 'sequelize', 'pg', 'mysql2', 'better-sqlite3', 'redis',
13
+ 'ioredis', 'bullmq', 'sharp', 'jimp', 'multer', 'formidable', 'nodemailer', 'socket.io',
14
+ 'ws', 'mqtt', 'graphql', 'apollo-server', 'trpc', 'stripe', 'aws-sdk', 'firebase',
15
+ 'supabase', 'openai', 'langchain', 'oclif', 'glob', 'minimatch', 'micromatch', 'semver',
16
+ 'minimist', 'cross-env', 'concurrently', 'tsx', 'ts-node', 'rimraf', 'mkdirp', 'fs-extra',
17
+ 'chokidar', 'ora', 'listr2', 'boxen', 'figlet', 'gradient-string', 'conf', 'cosmiconfig',
18
+ 'execa', 'got', 'ky', 'undici', 'cheerio', 'jsdom', 'marked', 'gray-matter', 'unified',
19
+ 'rehype', 'remark', 'mdast', 'hast', 'three', 'd3', 'chart.js', 'tailwindcss', 'postcss',
20
+ 'sass', 'less', 'styled-components', 'emotion',
21
+ ];
22
+ // ── Node.js builtins ─────────────────────────────────────────────────────────
23
+ const NODE_BUILTINS = new Set([
24
+ 'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console', 'constants',
25
+ 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain', 'events', 'fs', 'http',
26
+ 'http2', 'https', 'inspector', 'module', 'net', 'os', 'path', 'perf_hooks',
27
+ 'process', 'punycode', 'querystring', 'readline', 'repl', 'stream', 'string_decoder',
28
+ 'sys', 'timers', 'tls', 'trace_events', 'tty', 'url', 'util', 'v8', 'vm', 'wasi',
29
+ 'worker_threads', 'zlib', 'test',
30
+ // also with node: prefix variants handled separately
31
+ 'fs/promises', 'stream/promises', 'timers/promises', 'dns/promises',
32
+ 'stream/web', 'stream/consumers', 'readline/promises', 'util/types',
33
+ ]);
34
+ // ── Levenshtein distance ─────────────────────────────────────────────────────
35
+ export function levenshtein(a, b) {
36
+ const m = a.length;
37
+ const n = b.length;
38
+ if (m === 0)
39
+ return n;
40
+ if (n === 0)
41
+ return m;
42
+ const dp = [];
43
+ for (let i = 0; i <= m; i++) {
44
+ dp[i] = [i];
45
+ }
46
+ for (let j = 1; j <= n; j++) {
47
+ dp[0][j] = j;
48
+ }
49
+ for (let i = 1; i <= m; i++) {
50
+ for (let j = 1; j <= n; j++) {
51
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
52
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
53
+ }
54
+ }
55
+ return dp[m][n];
56
+ }
57
+ // ── Import extraction ────────────────────────────────────────────────────────
58
+ export function extractImports(source) {
59
+ const imports = new Set();
60
+ // import ... from 'pkg'
61
+ const importFrom = /import\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
62
+ let match;
63
+ while ((match = importFrom.exec(source)) !== null) {
64
+ imports.add(match[1]);
65
+ }
66
+ // require('pkg')
67
+ const requirePat = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
68
+ while ((match = requirePat.exec(source)) !== null) {
69
+ imports.add(match[1]);
70
+ }
71
+ // import('pkg')
72
+ const dynamicImport = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
73
+ while ((match = dynamicImport.exec(source)) !== null) {
74
+ imports.add(match[1]);
75
+ }
76
+ return [...imports];
77
+ }
78
+ // ── Package name extraction ──────────────────────────────────────────────────
79
+ export function extractPackageName(specifier) {
80
+ // Skip relative imports
81
+ if (specifier.startsWith('.') || specifier.startsWith('/'))
82
+ return null;
83
+ // Skip node: builtins
84
+ if (specifier.startsWith('node:'))
85
+ return null;
86
+ // Scoped packages: @scope/name or @scope/name/sub
87
+ if (specifier.startsWith('@')) {
88
+ const parts = specifier.split('/');
89
+ if (parts.length < 2)
90
+ return null;
91
+ return `${parts[0]}/${parts[1]}`;
92
+ }
93
+ // Regular package: name or name/sub
94
+ return specifier.split('/')[0];
95
+ }
96
+ // ── Builtin check ────────────────────────────────────────────────────────────
97
+ export function isBuiltin(specifier) {
98
+ if (specifier.startsWith('node:'))
99
+ return true;
100
+ const name = specifier.split('/')[0];
101
+ if (NODE_BUILTINS.has(name))
102
+ return true;
103
+ // Also check full specifier for subpath builtins
104
+ if (NODE_BUILTINS.has(specifier))
105
+ return true;
106
+ return false;
107
+ }
108
+ // ── Registry check with concurrency limit ────────────────────────────────────
109
+ async function checkRegistry(packages) {
110
+ const results = new Map();
111
+ const queue = [...packages];
112
+ let networkError = false;
113
+ async function checkOne(pkg) {
114
+ try {
115
+ const controller = new AbortController();
116
+ const timeout = setTimeout(() => controller.abort(), 5000);
117
+ const res = await fetch(`https://registry.npmjs.org/${pkg}`, {
118
+ method: 'HEAD',
119
+ signal: controller.signal,
120
+ });
121
+ clearTimeout(timeout);
122
+ results.set(pkg, res.status !== 404);
123
+ }
124
+ catch {
125
+ networkError = true;
126
+ results.set(pkg, true); // assume exists on error
127
+ }
128
+ }
129
+ // Process in batches of 5
130
+ const concurrency = 5;
131
+ for (let i = 0; i < queue.length; i += concurrency) {
132
+ const batch = queue.slice(i, i + concurrency);
133
+ await Promise.all(batch.map(checkOne));
134
+ }
135
+ if (networkError) {
136
+ results.set('__network_error__', true);
137
+ }
138
+ return results;
139
+ }
140
+ // ── Main check ───────────────────────────────────────────────────────────────
141
+ export async function checkDeps(cwd) {
142
+ const issues = [];
143
+ // Read package.json
144
+ let declaredDeps = {};
145
+ let hasPkgJson = false;
146
+ try {
147
+ const pkgRaw = readFile(join(cwd, 'package.json'));
148
+ if (pkgRaw) {
149
+ const pkg = JSON.parse(pkgRaw);
150
+ hasPkgJson = true;
151
+ declaredDeps = { ...pkg.dependencies, ...pkg.devDependencies };
152
+ }
153
+ }
154
+ catch { /* skip */ }
155
+ if (!hasPkgJson) {
156
+ return {
157
+ name: 'deps',
158
+ score: 10,
159
+ maxScore: 10,
160
+ issues: [],
161
+ summary: 'no package.json found',
162
+ };
163
+ }
164
+ const declaredNames = Object.keys(declaredDeps);
165
+ // ── 1. Registry check (nonexistent packages) ──────────────────────────────
166
+ const registryResults = await checkRegistry(declaredNames);
167
+ if (registryResults.get('__network_error__')) {
168
+ issues.push({
169
+ severity: 'info',
170
+ message: 'could not reach npm registry — skipping existence checks',
171
+ fixable: false,
172
+ });
173
+ }
174
+ for (const pkg of declaredNames) {
175
+ if (registryResults.get(pkg) === false) {
176
+ issues.push({
177
+ severity: 'error',
178
+ message: `phantom dependency: "${pkg}" does not exist on npm`,
179
+ file: 'package.json',
180
+ fixable: true,
181
+ fixHint: 'remove from package.json',
182
+ });
183
+ }
184
+ }
185
+ // ── 2. Typosquat detection ─────────────────────────────────────────────────
186
+ const topSet = new Set(TOP_PACKAGES);
187
+ for (const pkg of declaredNames) {
188
+ if (topSet.has(pkg))
189
+ continue; // it IS the popular package
190
+ for (const top of TOP_PACKAGES) {
191
+ const dist = levenshtein(pkg, top);
192
+ if (dist >= 1 && dist <= 2) {
193
+ issues.push({
194
+ severity: 'error',
195
+ message: `possible typosquat: "${pkg}" is ${dist} edit${dist > 1 ? 's' : ''} from "${top}"`,
196
+ file: 'package.json',
197
+ fixable: true,
198
+ fixHint: `did you mean "${top}"?`,
199
+ });
200
+ break; // one match is enough
201
+ }
202
+ }
203
+ }
204
+ // ── 3 & 4. Dead deps + phantom imports ─────────────────────────────────────
205
+ const sourceExts = new Set(['.ts', '.js', '.tsx', '.jsx', '.mts', '.mjs', '.cts', '.cjs']);
206
+ const allFiles = walkFiles(cwd);
207
+ const sourceFiles = allFiles.filter(f => {
208
+ const ext = f.substring(f.lastIndexOf('.'));
209
+ return sourceExts.has(ext);
210
+ });
211
+ const importedPackages = new Set();
212
+ for (const file of sourceFiles) {
213
+ try {
214
+ const content = readFileSync(join(cwd, file), 'utf-8');
215
+ const rawImports = extractImports(content);
216
+ for (const imp of rawImports) {
217
+ if (isBuiltin(imp))
218
+ continue;
219
+ const pkg = extractPackageName(imp);
220
+ if (pkg)
221
+ importedPackages.add(pkg);
222
+ }
223
+ }
224
+ catch { /* skip unreadable files */ }
225
+ }
226
+ // Dead deps: declared but never imported
227
+ const declaredSet = new Set(declaredNames);
228
+ for (const pkg of declaredNames) {
229
+ if (!importedPackages.has(pkg)) {
230
+ // Check if it's a CLI tool / plugin / type package (common false positives)
231
+ // Still flag it, but as info
232
+ issues.push({
233
+ severity: 'info',
234
+ message: `unused dependency: "${pkg}" is declared but never imported`,
235
+ file: 'package.json',
236
+ fixable: true,
237
+ fixHint: 'remove from package.json',
238
+ });
239
+ }
240
+ }
241
+ // Phantom imports: imported but not declared
242
+ for (const pkg of importedPackages) {
243
+ if (!declaredSet.has(pkg)) {
244
+ issues.push({
245
+ severity: 'warning',
246
+ message: `phantom import: "${pkg}" is imported but not in package.json`,
247
+ fixable: true,
248
+ fixHint: `run: npm install ${pkg}`,
249
+ });
250
+ }
251
+ }
252
+ // ── Scoring ────────────────────────────────────────────────────────────────
253
+ const errors = issues.filter(i => i.severity === 'error').length;
254
+ const warnings = issues.filter(i => i.severity === 'warning').length;
255
+ const rawScore = 10 - (errors * 3) - (warnings * 1);
256
+ const finalScore = Math.max(0, Math.min(10, rawScore));
257
+ // ── Summary ────────────────────────────────────────────────────────────────
258
+ const parts = [];
259
+ if (errors > 0)
260
+ parts.push(`${errors} error${errors !== 1 ? 's' : ''}`);
261
+ if (warnings > 0)
262
+ parts.push(`${warnings} warning${warnings !== 1 ? 's' : ''}`);
263
+ const infos = issues.filter(i => i.severity === 'info').length;
264
+ if (infos > 0)
265
+ parts.push(`${infos} info`);
266
+ const summary = parts.length === 0
267
+ ? `${declaredNames.length} dependencies checked, all clean`
268
+ : `${declaredNames.length} dependencies: ${parts.join(', ')}`;
269
+ return {
270
+ name: 'deps',
271
+ score: finalScore,
272
+ maxScore: 10,
273
+ issues,
274
+ summary,
275
+ };
276
+ }
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { checkConfig } from './checks/config.js';
9
9
  import { checkHistory } from './checks/history.js';
10
10
  import { checkScan } from './checks/scan.js';
11
11
  import { checkSecrets } from './checks/secrets.js';
12
+ import { checkDeps } from './checks/deps.js';
12
13
  import { checkReceipt, runReceiptCommand } from './checks/receipt.js';
13
14
  import { score } from './scorer.js';
14
15
  import { reportPretty, reportJSON } from './reporter.js';
@@ -48,6 +49,7 @@ if (flags.has('--help') || flags.has('-h')) {
48
49
  scan malicious patterns in agent config files
49
50
  secrets leaked secrets in build output and .env files
50
51
  receipt last agent session audit (informational)
52
+ deps phantom/hallucinated dependency detection
51
53
 
52
54
  ${c.dim}options:${c.reset}
53
55
  --ci CI mode (exit 1 if score < threshold)
@@ -120,7 +122,7 @@ if (isFix) {
120
122
  process.exit(0);
121
123
  }
122
124
  async function runChecks() {
123
- const allChecks = ['ready', 'diff', 'models', 'config', 'history', 'scan', 'secrets', 'receipt'];
125
+ const allChecks = ['ready', 'diff', 'models', 'config', 'history', 'scan', 'secrets', 'receipt', 'deps'];
124
126
  const enabledChecks = config.checks || allChecks;
125
127
  const results = [];
126
128
  // ready and models are async (try rich subpackages first, fallback to built-in)
@@ -140,6 +142,8 @@ async function runChecks() {
140
142
  results.push(await checkSecrets(cwd));
141
143
  if (enabledChecks.includes('receipt'))
142
144
  results.push(await checkReceipt(cwd));
145
+ if (enabledChecks.includes('deps'))
146
+ results.push(await checkDeps(cwd));
143
147
  return score(cwd, results);
144
148
  }
145
149
  // --watch mode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "vet your AI-generated code — one command, six checks, zero config",
5
5
  "type": "module",
6
6
  "bin": {