@safetnsr/vet 1.7.0 → 1.8.2

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.
Files changed (42) hide show
  1. package/dist/checks/debt.js +17 -4
  2. package/dist/checks/deps.js +146 -135
  3. package/dist/checks/diff.js +23 -4
  4. package/dist/checks/integrity.js +94 -17
  5. package/dist/checks/memory.js +6 -10
  6. package/dist/checks/models.js +60 -13
  7. package/dist/checks/owasp/asi01-goal-hijack.d.ts +5 -0
  8. package/dist/checks/owasp/asi01-goal-hijack.js +49 -0
  9. package/dist/checks/owasp/asi02-tool-misuse.d.ts +5 -0
  10. package/dist/checks/owasp/asi02-tool-misuse.js +98 -0
  11. package/dist/checks/owasp/asi03-identity-abuse.d.ts +5 -0
  12. package/dist/checks/owasp/asi03-identity-abuse.js +80 -0
  13. package/dist/checks/owasp/asi04-supply-chain.d.ts +5 -0
  14. package/dist/checks/owasp/asi04-supply-chain.js +79 -0
  15. package/dist/checks/owasp/asi05-code-execution.d.ts +5 -0
  16. package/dist/checks/owasp/asi05-code-execution.js +67 -0
  17. package/dist/checks/owasp/asi06-memory-poisoning.d.ts +5 -0
  18. package/dist/checks/owasp/asi06-memory-poisoning.js +61 -0
  19. package/dist/checks/owasp/asi07-inter-agent.d.ts +5 -0
  20. package/dist/checks/owasp/asi07-inter-agent.js +62 -0
  21. package/dist/checks/owasp/asi08-cascading.d.ts +5 -0
  22. package/dist/checks/owasp/asi08-cascading.js +36 -0
  23. package/dist/checks/owasp/asi09-trust-exploitation.d.ts +5 -0
  24. package/dist/checks/owasp/asi09-trust-exploitation.js +65 -0
  25. package/dist/checks/owasp/asi10-rogue-agents.d.ts +5 -0
  26. package/dist/checks/owasp/asi10-rogue-agents.js +31 -0
  27. package/dist/checks/owasp/index.d.ts +11 -0
  28. package/dist/checks/owasp/index.js +11 -0
  29. package/dist/checks/owasp/shared.d.ts +11 -0
  30. package/dist/checks/owasp/shared.js +61 -0
  31. package/dist/checks/owasp.js +1 -1
  32. package/dist/checks/ready.js +42 -7
  33. package/dist/checks/receipt.js +2 -16
  34. package/dist/checks/scan.js +54 -6
  35. package/dist/checks/secrets.js +2 -20
  36. package/dist/checks/tests.d.ts +3 -0
  37. package/dist/checks/tests.js +10 -0
  38. package/dist/checks/verify.js +35 -2
  39. package/dist/cli.js +111 -69
  40. package/dist/util.d.ts +0 -1
  41. package/dist/util.js +1 -1
  42. package/package.json +1 -1
@@ -189,6 +189,18 @@ function findDuplicates(allFuncs) {
189
189
  return issues;
190
190
  }
191
191
  // ── B) Orphaned exports ──────────────────────────────────────────────────────
192
+ function isLibrary(cwd) {
193
+ try {
194
+ const raw = readFile(join(cwd, 'package.json'));
195
+ if (!raw)
196
+ return false;
197
+ const pkg = JSON.parse(raw);
198
+ return !!(pkg.main || pkg.exports || pkg.module || pkg.types || pkg.bin);
199
+ }
200
+ catch {
201
+ return false;
202
+ }
203
+ }
192
204
  function findOrphanedExports(cwd, files) {
193
205
  const issues = [];
194
206
  const sourceFiles = files.filter(f => isSourceFile(f) && !isTestFile(f));
@@ -237,18 +249,19 @@ function findOrphanedExports(cwd, files) {
237
249
  allContent.push(content);
238
250
  }
239
251
  const allText = allContent.join('\n');
252
+ const lib = isLibrary(cwd);
240
253
  for (const exp of exports) {
241
254
  // Check if name appears in import statements across all files
242
255
  // import { name } from or import { x, name } from or import { name as y }
243
256
  const importPattern = new RegExp(`import\\s+[^;]*\\b${exp.name}\\b[^;]*from\\s+`, 'm');
244
257
  if (!importPattern.test(allText)) {
245
258
  issues.push({
246
- severity: 'warning',
247
- message: `orphaned export: "${exp.name}" is exported but never imported`,
259
+ severity: lib ? 'info' : 'warning',
260
+ message: `orphaned export: "${exp.name}" is exported but never imported${lib ? ' (library detected — exports may be consumed externally)' : ''}`,
248
261
  file: exp.file,
249
262
  line: exp.line,
250
263
  fixable: true,
251
- fixHint: 'remove the export keyword or delete the function',
264
+ fixHint: lib ? 'may be public API — verify if still needed' : 'remove the export keyword or delete the function',
252
265
  });
253
266
  }
254
267
  }
@@ -344,7 +357,7 @@ export async function checkDebt(cwd, ignore) {
344
357
  const driftIssues = findNamingDrift(allFuncs);
345
358
  issues.push(...driftIssues);
346
359
  // ── Scoring ──────────────────────────────────────────────────────────────
347
- const dupPenalty = Math.min(60, dupIssues.length * 15);
360
+ const dupPenalty = Math.min(50, dupIssues.length * 8);
348
361
  const orphanPenalty = Math.min(30, orphanIssues.length * 5);
349
362
  const wrapperPenalty = Math.min(15, wrapperIssues.length * 3);
350
363
  const driftPenalty = Math.min(10, driftIssues.length * 2);
@@ -73,7 +73,8 @@ export function extractImports(source) {
73
73
  while ((match = dynamicImport.exec(source)) !== null) {
74
74
  imports.add(match[1]);
75
75
  }
76
- return [...imports];
76
+ // Filter out template literal fragments (e.g. "${top}" from fixHint strings)
77
+ return [...imports].filter(s => !s.includes('$'));
77
78
  }
78
79
  // ── Package name extraction ──────────────────────────────────────────────────
79
80
  export function extractPackageName(specifier) {
@@ -130,10 +131,15 @@ async function checkRegistry(packages) {
130
131
  }
131
132
  }
132
133
  // Process in batches of 5
133
- const concurrency = 5;
134
- for (let i = 0; i < queue.length; i += concurrency) {
135
- const batch = queue.slice(i, i + concurrency);
136
- await Promise.all(batch.map(checkOne));
134
+ try {
135
+ const concurrency = 5;
136
+ for (let i = 0; i < queue.length; i += concurrency) {
137
+ const batch = queue.slice(i, i + concurrency);
138
+ await Promise.all(batch.map(checkOne));
139
+ }
140
+ }
141
+ catch {
142
+ networkError = true;
137
143
  }
138
144
  if (networkError) {
139
145
  results.set('__network_error__', true);
@@ -142,150 +148,155 @@ async function checkRegistry(packages) {
142
148
  }
143
149
  // ── Main check ───────────────────────────────────────────────────────────────
144
150
  export async function checkDeps(cwd) {
145
- const issues = [];
146
- // Read package.json
147
- let declaredDeps = {};
148
- let hasPkgJson = false;
149
151
  try {
150
- const pkgRaw = readFile(join(cwd, 'package.json'));
151
- if (pkgRaw) {
152
- const pkg = JSON.parse(pkgRaw);
153
- hasPkgJson = true;
154
- declaredDeps = { ...pkg.dependencies, ...pkg.devDependencies };
152
+ const issues = [];
153
+ // Read package.json
154
+ let declaredDeps = {};
155
+ let hasPkgJson = false;
156
+ try {
157
+ const pkgRaw = readFile(join(cwd, 'package.json'));
158
+ if (pkgRaw) {
159
+ const pkg = JSON.parse(pkgRaw);
160
+ hasPkgJson = true;
161
+ declaredDeps = { ...pkg.dependencies, ...pkg.devDependencies };
162
+ }
155
163
  }
156
- }
157
- catch { /* skip */ }
158
- if (!hasPkgJson) {
159
- return {
160
- name: 'deps',
161
- score: 100,
162
- maxScore: 100,
163
- issues: [],
164
- summary: 'no package.json found',
165
- };
166
- }
167
- const declaredNames = Object.keys(declaredDeps);
168
- // ── 1. Registry check (nonexistent packages) ──────────────────────────────
169
- const registryResults = await checkRegistry(declaredNames);
170
- if (registryResults.get('__network_error__')) {
171
- issues.push({
172
- severity: 'info',
173
- message: 'could not reach npm registry — skipping existence checks',
174
- fixable: false,
175
- });
176
- }
177
- for (const pkg of declaredNames) {
178
- if (registryResults.get(pkg) === false) {
164
+ catch { /* skip */ }
165
+ if (!hasPkgJson) {
166
+ return {
167
+ name: 'deps',
168
+ score: 100,
169
+ maxScore: 100,
170
+ issues: [],
171
+ summary: 'no package.json found',
172
+ };
173
+ }
174
+ const declaredNames = Object.keys(declaredDeps);
175
+ // ── 1. Registry check (nonexistent packages) ──────────────────────────────
176
+ const registryResults = await checkRegistry(declaredNames);
177
+ if (registryResults.get('__network_error__')) {
179
178
  issues.push({
180
- severity: 'error',
181
- message: `phantom dependency: "${pkg}" does not exist on npm`,
182
- file: 'package.json',
183
- fixable: true,
184
- fixHint: 'remove from package.json',
179
+ severity: 'info',
180
+ message: 'could not reach npm registry skipping existence checks',
181
+ fixable: false,
185
182
  });
186
183
  }
187
- }
188
- // ── 2. Typosquat detection ─────────────────────────────────────────────────
189
- const topSet = new Set(TOP_PACKAGES);
190
- // Known-legitimate short packages that happen to be close to popular ones
191
- const TYPOSQUAT_WHITELIST = new Set([
192
- 'ai', 'clsx', 'ws', 'os', 'ms', 'pg', 'ip', 'bn', 'qs', 'co', 'is',
193
- ]);
194
- for (const pkg of declaredNames) {
195
- if (topSet.has(pkg))
196
- continue; // it IS the popular package
197
- if (pkg.length <= 3)
198
- continue; // too short, too many false matches
199
- if (TYPOSQUAT_WHITELIST.has(pkg))
200
- continue;
201
- for (const top of TOP_PACKAGES) {
202
- const dist = levenshtein(pkg, top);
203
- if (dist >= 1 && dist <= 2) {
204
- // If the package exists on the registry, it's likely legitimate — downgrade to info
205
- const existsOnRegistry = registryResults.get(pkg) === true;
184
+ for (const pkg of declaredNames) {
185
+ if (registryResults.get(pkg) === false) {
206
186
  issues.push({
207
- severity: existsOnRegistry ? 'info' : 'error',
208
- message: `possible typosquat: "${pkg}" is ${dist} edit${dist > 1 ? 's' : ''} from "${top}"${existsOnRegistry ? ' (exists on npm)' : ''}`,
187
+ severity: 'error',
188
+ message: `phantom dependency: "${pkg}" does not exist on npm`,
209
189
  file: 'package.json',
210
190
  fixable: true,
211
- fixHint: `did you mean "${top}"?`,
191
+ fixHint: 'remove from package.json',
212
192
  });
213
- break; // one match is enough
214
193
  }
215
194
  }
216
- }
217
- // ── 3 & 4. Dead deps + phantom imports ─────────────────────────────────────
218
- const sourceExts = new Set(['.ts', '.js', '.tsx', '.jsx', '.mts', '.mjs', '.cts', '.cjs']);
219
- const allFiles = walkFiles(cwd);
220
- const isTestFile = (f) => /\.(test|spec)\.[jt]sx?$/.test(f) || f.includes('__tests__') || /^test[/\\]/.test(f);
221
- const sourceFiles = allFiles.filter(f => {
222
- const ext = f.substring(f.lastIndexOf('.'));
223
- // Skip test files — they contain import strings as test fixtures, not real imports
224
- return sourceExts.has(ext) && !isTestFile(f);
225
- });
226
- const importedPackages = new Set();
227
- for (const file of sourceFiles) {
228
- try {
229
- const content = readFileSync(join(cwd, file), 'utf-8');
230
- const rawImports = extractImports(content);
231
- for (const imp of rawImports) {
232
- if (isBuiltin(imp))
233
- continue;
234
- const pkg = extractPackageName(imp);
235
- if (pkg)
236
- importedPackages.add(pkg);
195
+ // ── 2. Typosquat detection ─────────────────────────────────────────────────
196
+ const topSet = new Set(TOP_PACKAGES);
197
+ // Known-legitimate short packages that happen to be close to popular ones
198
+ const TYPOSQUAT_WHITELIST = new Set([
199
+ 'ai', 'clsx', 'ws', 'os', 'ms', 'pg', 'ip', 'bn', 'qs', 'co', 'is',
200
+ ]);
201
+ for (const pkg of declaredNames) {
202
+ if (topSet.has(pkg))
203
+ continue; // it IS the popular package
204
+ if (pkg.length <= 3)
205
+ continue; // too short, too many false matches
206
+ if (TYPOSQUAT_WHITELIST.has(pkg))
207
+ continue;
208
+ for (const top of TOP_PACKAGES) {
209
+ const dist = levenshtein(pkg, top);
210
+ if (dist >= 1 && dist <= 2) {
211
+ // If the package exists on the registry, it's likely legitimate — downgrade to info
212
+ const existsOnRegistry = registryResults.get(pkg) === true;
213
+ issues.push({
214
+ severity: existsOnRegistry ? 'info' : 'error',
215
+ message: `possible typosquat: "${pkg}" is ${dist} edit${dist > 1 ? 's' : ''} from "${top}"${existsOnRegistry ? ' (exists on npm)' : ''}`,
216
+ file: 'package.json',
217
+ fixable: true,
218
+ fixHint: `did you mean "${top}"?`,
219
+ });
220
+ break; // one match is enough
221
+ }
237
222
  }
238
223
  }
239
- catch { /* skip unreadable files */ }
240
- }
241
- // Dead deps: declared but never imported
242
- const declaredSet = new Set(declaredNames);
243
- for (const pkg of declaredNames) {
244
- if (!importedPackages.has(pkg)) {
245
- // Check if it's a CLI tool / plugin / type package (common false positives)
246
- // Still flag it, but as info
247
- issues.push({
248
- severity: 'info',
249
- message: `unused dependency: "${pkg}" is declared but never imported`,
250
- file: 'package.json',
251
- fixable: true,
252
- fixHint: 'remove from package.json',
253
- });
224
+ // ── 3 & 4. Dead deps + phantom imports ─────────────────────────────────────
225
+ const sourceExts = new Set(['.ts', '.js', '.tsx', '.jsx', '.mts', '.mjs', '.cts', '.cjs']);
226
+ const allFiles = walkFiles(cwd);
227
+ const isTestFile = (f) => /\.(test|spec)\.[jt]sx?$/.test(f) || f.includes('__tests__') || /^test[/\\]/.test(f);
228
+ const sourceFiles = allFiles.filter(f => {
229
+ const ext = f.substring(f.lastIndexOf('.'));
230
+ // Skip test files they contain import strings as test fixtures, not real imports
231
+ return sourceExts.has(ext) && !isTestFile(f);
232
+ });
233
+ const importedPackages = new Set();
234
+ for (const file of sourceFiles) {
235
+ try {
236
+ const content = readFileSync(join(cwd, file), 'utf-8');
237
+ const rawImports = extractImports(content);
238
+ for (const imp of rawImports) {
239
+ if (isBuiltin(imp))
240
+ continue;
241
+ const pkg = extractPackageName(imp);
242
+ if (pkg)
243
+ importedPackages.add(pkg);
244
+ }
245
+ }
246
+ catch { /* skip unreadable files */ }
254
247
  }
255
- }
256
- // Phantom imports: imported but not declared
257
- for (const pkg of importedPackages) {
258
- if (!declaredSet.has(pkg)) {
259
- issues.push({
260
- severity: 'warning',
261
- message: `phantom import: "${pkg}" is imported but not in package.json`,
262
- fixable: true,
263
- fixHint: `run: npm install ${pkg}`,
264
- });
248
+ // Dead deps: declared but never imported
249
+ const declaredSet = new Set(declaredNames);
250
+ for (const pkg of declaredNames) {
251
+ if (!importedPackages.has(pkg)) {
252
+ // Check if it's a CLI tool / plugin / type package (common false positives)
253
+ // Still flag it, but as info
254
+ issues.push({
255
+ severity: 'info',
256
+ message: `unused dependency: "${pkg}" is declared but never imported`,
257
+ file: 'package.json',
258
+ fixable: true,
259
+ fixHint: 'remove from package.json',
260
+ });
261
+ }
265
262
  }
263
+ // Phantom imports: imported but not declared
264
+ for (const pkg of importedPackages) {
265
+ if (!declaredSet.has(pkg)) {
266
+ issues.push({
267
+ severity: 'warning',
268
+ message: `phantom import: "${pkg}" is imported but not in package.json`,
269
+ fixable: true,
270
+ fixHint: `run: npm install ${pkg}`,
271
+ });
272
+ }
273
+ }
274
+ // ── Scoring ────────────────────────────────────────────────────────────────
275
+ const errors = issues.filter(i => i.severity === 'error').length;
276
+ const warnings = issues.filter(i => i.severity === 'warning').length;
277
+ const rawScore = 100 - (errors * 30) - (warnings * 10);
278
+ const finalScore = Math.max(0, Math.min(100, rawScore));
279
+ // ── Summary ────────────────────────────────────────────────────────────────
280
+ const parts = [];
281
+ if (errors > 0)
282
+ parts.push(`${errors} error${errors !== 1 ? 's' : ''}`);
283
+ if (warnings > 0)
284
+ parts.push(`${warnings} warning${warnings !== 1 ? 's' : ''}`);
285
+ const infos = issues.filter(i => i.severity === 'info').length;
286
+ if (infos > 0)
287
+ parts.push(`${infos} info`);
288
+ const summary = parts.length === 0
289
+ ? `${declaredNames.length} dependencies checked, all clean`
290
+ : `${declaredNames.length} dependencies: ${parts.join(', ')}`;
291
+ return {
292
+ name: 'deps',
293
+ score: finalScore,
294
+ maxScore: 100,
295
+ issues,
296
+ summary,
297
+ };
298
+ }
299
+ catch {
300
+ return { name: 'deps', score: 100, maxScore: 100, issues: [], summary: 'deps check failed' };
266
301
  }
267
- // ── Scoring ────────────────────────────────────────────────────────────────
268
- const errors = issues.filter(i => i.severity === 'error').length;
269
- const warnings = issues.filter(i => i.severity === 'warning').length;
270
- const rawScore = 100 - (errors * 30) - (warnings * 10);
271
- const finalScore = Math.max(0, Math.min(100, rawScore));
272
- // ── Summary ────────────────────────────────────────────────────────────────
273
- const parts = [];
274
- if (errors > 0)
275
- parts.push(`${errors} error${errors !== 1 ? 's' : ''}`);
276
- if (warnings > 0)
277
- parts.push(`${warnings} warning${warnings !== 1 ? 's' : ''}`);
278
- const infos = issues.filter(i => i.severity === 'info').length;
279
- if (infos > 0)
280
- parts.push(`${infos} info`);
281
- const summary = parts.length === 0
282
- ? `${declaredNames.length} dependencies checked, all clean`
283
- : `${declaredNames.length} dependencies: ${parts.join(', ')}`;
284
- return {
285
- name: 'deps',
286
- score: finalScore,
287
- maxScore: 100,
288
- issues,
289
- summary,
290
- };
291
302
  }
@@ -1,4 +1,13 @@
1
- import { git } from '../util.js';
1
+ import { git, readFile } from '../util.js';
2
+ import { join } from 'node:path';
3
+ function fileHasVetIgnore(cwd, filePath, checkName) {
4
+ const content = readFile(join(cwd, filePath));
5
+ if (!content)
6
+ return false;
7
+ const lines = content.split('\n').slice(0, 5);
8
+ const re = new RegExp(`(?://|/\\*|#)\\s*vet-ignore:\\s*${checkName}\\b`);
9
+ return lines.some(l => re.test(l));
10
+ }
2
11
  // Generic patterns (still useful but not the star)
3
12
  const GENERIC_PATTERNS = [
4
13
  // Secrets
@@ -83,6 +92,8 @@ export function checkDiff(cwd, opts = {}) {
83
92
  const allPatterns = [...GENERIC_PATTERNS, ...AI_PATTERNS];
84
93
  // Pattern matching on added lines
85
94
  for (const file of files) {
95
+ if (fileHasVetIgnore(cwd, file.path, 'diff'))
96
+ continue;
86
97
  for (const { num, text } of file.addedLines) {
87
98
  for (const pattern of allPatterns) {
88
99
  if (pattern.regex.test(text)) {
@@ -117,9 +128,17 @@ export function checkDiff(cwd, opts = {}) {
117
128
  for (const name of names) {
118
129
  if (!name || name.length < 2)
119
130
  continue;
120
- // Check if name is used in any other added line
121
- const usedElsewhere = file.addedLines.some(l => l !== imp && l.text.includes(name));
122
- if (!usedElsewhere && file.addedLines.length > 3) {
131
+ // Check if name is used in any other added line OR in unchanged file content
132
+ const usedInAdded = file.addedLines.some(l => l !== imp && l.text.includes(name));
133
+ // Also read the full file to check if name is used in existing (unchanged) code
134
+ const fullContent = readFile(join(cwd, file.path));
135
+ const usedInFile = fullContent ? fullContent.split('\n').some((l, idx) => {
136
+ // Skip the import line itself
137
+ if (l.trim() === imp.text.trim())
138
+ return false;
139
+ return l.includes(name);
140
+ }) : false;
141
+ if (!usedInAdded && !usedInFile && file.addedLines.length > 3) {
123
142
  issues.push({
124
143
  severity: 'warning',
125
144
  message: `[ai] imported "${name}" but never used in new code`,
@@ -1,4 +1,4 @@
1
- import { join, resolve, dirname, extname } from 'node:path';
1
+ import { join, resolve, dirname, extname, basename } from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { walkFiles, readFile } from '../util.js';
4
4
  // ── Hallucinated imports ─────────────────────────────────────────────────────
@@ -277,6 +277,51 @@ function checkStubbedTests(cwd, files) {
277
277
  return issues;
278
278
  }
279
279
  // ── Unhandled async (removed error handling) ─────────────────────────────────
280
+ /** Files that ARE error boundaries — they handle errors by design */
281
+ function isErrorBoundaryFile(file) {
282
+ const normalized = file.replace(/\\/g, '/');
283
+ const base = basename(normalized);
284
+ // Next.js error boundaries
285
+ if (/^error\.[jt]sx?$/.test(base))
286
+ return true;
287
+ if (/^global-error\.[jt]sx?$/.test(base))
288
+ return true;
289
+ // Middleware files
290
+ if (/^middleware\.[jt]sx?$/.test(base))
291
+ return true;
292
+ // Error handler files
293
+ if (/error[-_]?handler/i.test(base))
294
+ return true;
295
+ if (/error[-_]?boundary/i.test(base))
296
+ return true;
297
+ return false;
298
+ }
299
+ /** Next.js server component files where framework handles errors */
300
+ const NEXTJS_SERVER_FILES = /^(page|layout|loading|not-found|template)\.[jt]sx?$/;
301
+ function isNextjsServerComponent(file) {
302
+ const normalized = file.replace(/\\/g, '/');
303
+ const base = basename(normalized);
304
+ // Next.js app directory server components
305
+ if (NEXTJS_SERVER_FILES.test(base))
306
+ return true;
307
+ // Next.js API route handlers (app/api/)
308
+ if (normalized.includes('app/api/') && /^route\.[jt]sx?$/.test(base))
309
+ return true;
310
+ return false;
311
+ }
312
+ /** Check if a file has a top-level error handler (global catch-all) */
313
+ function hasGlobalErrorHandling(content) {
314
+ // process.on('unhandledRejection'/'uncaughtException')
315
+ if (/process\.on\s*\(\s*['"](?:unhandledRejection|uncaughtException)['"]/i.test(content))
316
+ return true;
317
+ // Express/Koa-style error middleware: (err, req, res, next) or app.use with 4 params
318
+ if (/(?:app|router)\.use\s*\(\s*(?:async\s*)?\(\s*\w+\s*,\s*\w+\s*,\s*\w+\s*,\s*\w+\s*\)/.test(content))
319
+ return true;
320
+ // window.addEventListener('error'/'unhandledrejection')
321
+ if (/addEventListener\s*\(\s*['"](?:error|unhandledrejection)['"]/i.test(content))
322
+ return true;
323
+ return false;
324
+ }
280
325
  function checkUnhandledAsync(cwd, files) {
281
326
  const issues = [];
282
327
  const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
@@ -286,35 +331,67 @@ function checkUnhandledAsync(cwd, files) {
286
331
  // Skip test files — test runners handle errors at the framework level
287
332
  if (isTestFile(file))
288
333
  continue;
334
+ // Skip error boundary files — they ARE the error handlers
335
+ if (isErrorBoundaryFile(file))
336
+ continue;
289
337
  const content = readFile(join(cwd, file));
290
338
  if (!content)
291
339
  continue;
340
+ // Skip files with global error handling
341
+ if (hasGlobalErrorHandling(content))
342
+ continue;
292
343
  const lines = content.split('\n');
293
344
  let unhandledCount = 0;
345
+ // Build a map of which lines are inside try blocks using brace tracking
346
+ const insideTry = new Set();
347
+ const tryStack = [];
348
+ let braceDepth = 0;
349
+ for (let i = 0; i < lines.length; i++) {
350
+ const line = lines[i];
351
+ // Detect try {
352
+ if (/\btry\s*\{/.test(line)) {
353
+ tryStack.push({ braceDepth });
354
+ }
355
+ // Count braces
356
+ for (const ch of line) {
357
+ if (ch === '{')
358
+ braceDepth++;
359
+ if (ch === '}') {
360
+ braceDepth--;
361
+ // Check if we're closing a try block
362
+ if (tryStack.length > 0 && braceDepth <= tryStack[tryStack.length - 1].braceDepth) {
363
+ tryStack.pop();
364
+ }
365
+ }
366
+ }
367
+ if (tryStack.length > 0) {
368
+ insideTry.add(i);
369
+ }
370
+ }
294
371
  for (let i = 0; i < lines.length; i++) {
295
372
  const line = lines[i];
296
373
  // await without try/catch context — detect standalone awaits
297
- // We look for: const/let/var x = await or just await on its own, not inside try
298
374
  const hasAwait = /^\s*(?:const|let|var)\s+\w.*=\s*await\s+/.test(line) || /^\s*await\s+/.test(line);
299
375
  if (!hasAwait)
300
376
  continue;
301
- // Check context window look for try { in surrounding lines
302
- const contextStart = Math.max(0, i - 15);
303
- const contextEnd = Math.min(lines.length - 1, i + 5);
304
- const contextLines = lines.slice(contextStart, contextEnd + 1);
305
- const contextText = contextLines.join('\n');
306
- // Count try/catch blocks in context
307
- const tryCount = (contextText.match(/\btry\s*\{/g) || []).length;
308
- const catchCount = (contextText.match(/\bcatch\s*(?:\([^)]*\))?\s*\{/g) || []).length;
309
- if (tryCount === 0 || catchCount === 0) {
310
- // Also check for .catch() chained
377
+ // Skip if inside a try block (proper scope tracking)
378
+ if (insideTry.has(i))
379
+ continue;
380
+ {
381
+ // Check for .catch() chained on this or next line
311
382
  const hasCatch = /\.catch\s*\(/.test(line) || (i + 1 < lines.length && /\.catch\s*\(/.test(lines[i + 1]));
312
- if (!hasCatch) {
383
+ // Check for .then(..., errorHandler) pattern
384
+ const hasThenError = /\.then\s*\([^,]+,\s*\w+/.test(line) || (i + 1 < lines.length && /\.then\s*\([^,]+,\s*\w+/.test(lines[i + 1]));
385
+ if (!hasCatch && !hasThenError) {
313
386
  unhandledCount++;
387
+ // Downgrade Next.js server components to info (framework handles errors)
388
+ const isServerComp = isNextjsServerComponent(file);
314
389
  if (unhandledCount <= 10) {
315
390
  issues.push({
316
- severity: 'warning',
317
- message: 'unhandled async: await without try/catch',
391
+ severity: isServerComp ? 'info' : 'warning',
392
+ message: isServerComp
393
+ ? 'unhandled async: await without try/catch (Next.js server component — framework-managed)'
394
+ : 'unhandled async: await without try/catch',
318
395
  file,
319
396
  line: i + 1,
320
397
  fixable: false,
@@ -345,8 +422,8 @@ export async function checkIntegrity(cwd, ignore) {
345
422
  score -= hallucinatedIssues.length * 10;
346
423
  score -= emptyCatchIssues.filter(i => i.severity === 'error').length * 8;
347
424
  score -= stubbedTestIssues.filter(i => i.severity === 'error').length * 5;
348
- // Unhandled async capped at -30
349
- const unhandledErrors = unhandledAsyncIssues.length;
425
+ // Unhandled async capped at -30 (only count warnings, not info-downgraded ones)
426
+ const unhandledErrors = unhandledAsyncIssues.filter(i => i.severity === 'warning').length;
350
427
  score -= Math.min(30, unhandledErrors * 3);
351
428
  score = Math.max(0, Math.round(score));
352
429
  // Summary parts
@@ -1,5 +1,6 @@
1
1
  import { join, resolve } from 'node:path';
2
- import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
3
+ import { readFile } from '../util.js';
3
4
  // ── Memory file targets ──────────────────────────────────────────────────────
4
5
  const ROOT_FILES = ['CLAUDE.md', 'AGENTS.md', 'SOUL.md', '.cursorrules', 'codex.md'];
5
6
  const MEMORY_DIR = 'memory';
@@ -11,14 +12,6 @@ const TOOL_CATEGORIES = {
11
12
  'package manager': [/\bnpm\b/, /\byarn\b/, /\bpnpm\b/, /\bbun\b/],
12
13
  };
13
14
  // ── Helpers ──────────────────────────────────────────────────────────────────
14
- function safeRead(path) {
15
- try {
16
- return readFileSync(path, 'utf-8');
17
- }
18
- catch {
19
- return null;
20
- }
21
- }
22
15
  function collectMemoryFiles(cwd) {
23
16
  const files = [];
24
17
  // Root-level memory files
@@ -160,6 +153,9 @@ export function checkMemory(cwd) {
160
153
  if (existsSync(pkgPath)) {
161
154
  try {
162
155
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
156
+ // Include the package's own name
157
+ if (pkg.name)
158
+ allDeps.add(pkg.name);
163
159
  for (const key of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
164
160
  if (pkg[key]) {
165
161
  for (const name of Object.keys(pkg[key])) {
@@ -173,7 +169,7 @@ export function checkMemory(cwd) {
173
169
  // Collect all tool mentions across files for contradiction detection
174
170
  const globalToolMentions = new Map();
175
171
  for (const filePath of memoryFiles) {
176
- const content = safeRead(filePath);
172
+ const content = readFile(filePath);
177
173
  if (!content)
178
174
  continue;
179
175
  const relPath = filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath;