@safetnsr/vet 0.5.0 → 1.0.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,317 @@
1
+ import { join, resolve, dirname, extname } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { walkFiles, readFile } from '../util.js';
4
+ // ── Hallucinated imports ─────────────────────────────────────────────────────
5
+ const RESOLVE_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.cts', '.cjs', '.json'];
6
+ function resolveRelativeImport(importPath, fromFile, cwd) {
7
+ // fromFile is relative to cwd
8
+ const fromDir = dirname(join(cwd, fromFile));
9
+ const base = resolve(fromDir, importPath);
10
+ // Try as-is
11
+ if (existsSync(base))
12
+ return true;
13
+ // Try with extensions appended
14
+ for (const ext of RESOLVE_EXTS) {
15
+ if (existsSync(base + ext))
16
+ return true;
17
+ }
18
+ // Try as directory with index
19
+ for (const ext of RESOLVE_EXTS) {
20
+ if (existsSync(join(base, 'index' + ext)))
21
+ return true;
22
+ }
23
+ // Handle ESM TypeScript pattern: ./foo.js → ./foo.ts (strip .js, try .ts/.tsx etc)
24
+ const baseExt = extname(base);
25
+ if (baseExt) {
26
+ const withoutExt = base.slice(0, -baseExt.length);
27
+ for (const ext of RESOLVE_EXTS) {
28
+ if (existsSync(withoutExt + ext))
29
+ return true;
30
+ }
31
+ // Also try as directory index
32
+ for (const ext of RESOLVE_EXTS) {
33
+ if (existsSync(join(withoutExt, 'index' + ext)))
34
+ return true;
35
+ }
36
+ }
37
+ return false;
38
+ }
39
+ function extractRelativeImports(source) {
40
+ const imports = [];
41
+ const lines = source.split('\n');
42
+ for (let i = 0; i < lines.length; i++) {
43
+ const line = lines[i];
44
+ // import ... from './foo' or '../bar'
45
+ const fromMatch = line.match(/from\s+['"](\.[^'"]+)['"]/);
46
+ if (fromMatch) {
47
+ imports.push({ path: fromMatch[1], line: i + 1 });
48
+ }
49
+ // require('./foo')
50
+ const reqMatch = line.match(/require\s*\(\s*['"](\.[^'"]+)['"]\s*\)/);
51
+ if (reqMatch) {
52
+ imports.push({ path: reqMatch[1], line: i + 1 });
53
+ }
54
+ // import('./foo')
55
+ const dynMatch = line.match(/import\s*\(\s*['"](\.[^'"]+)['"]\s*\)/);
56
+ if (dynMatch) {
57
+ imports.push({ path: dynMatch[1], line: i + 1 });
58
+ }
59
+ }
60
+ return imports;
61
+ }
62
+ function checkHallucinatedImports(cwd, files) {
63
+ const issues = [];
64
+ const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.cts', '.cjs']);
65
+ for (const file of files) {
66
+ const ext = extname(file);
67
+ if (!sourceExts.has(ext))
68
+ continue;
69
+ if (file.includes('node_modules'))
70
+ continue;
71
+ const content = readFile(join(cwd, file));
72
+ if (!content)
73
+ continue;
74
+ const relImports = extractRelativeImports(content);
75
+ for (const imp of relImports) {
76
+ // Skip .js extensions pointing to .ts files (common in ESM TypeScript)
77
+ // The resolver already handles this
78
+ if (!resolveRelativeImport(imp.path, file, cwd)) {
79
+ issues.push({
80
+ severity: 'error',
81
+ message: `hallucinated import: "${imp.path}" does not resolve to any file`,
82
+ file,
83
+ line: imp.line,
84
+ fixable: false,
85
+ });
86
+ }
87
+ }
88
+ }
89
+ return issues;
90
+ }
91
+ // ── Empty catch blocks ───────────────────────────────────────────────────────
92
+ function checkEmptyCatch(cwd, files) {
93
+ const issues = [];
94
+ const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
95
+ for (const file of files) {
96
+ if (!sourceExts.has(extname(file)))
97
+ continue;
98
+ const content = readFile(join(cwd, file));
99
+ if (!content)
100
+ continue;
101
+ const lines = content.split('\n');
102
+ for (let i = 0; i < lines.length; i++) {
103
+ const line = lines[i];
104
+ // catch(e) {} or catch(err) {} — empty catch
105
+ if (/catch\s*\([^)]*\)\s*\{\s*\}/.test(line)) {
106
+ issues.push({
107
+ severity: 'error',
108
+ message: 'empty catch block — error silently swallowed',
109
+ file,
110
+ line: i + 1,
111
+ fixable: false,
112
+ fixHint: 'log or handle the error, or add a comment explaining why it is intentional',
113
+ });
114
+ continue;
115
+ }
116
+ // catch {} (no param)
117
+ if (/catch\s*\{\s*\}/.test(line)) {
118
+ issues.push({
119
+ severity: 'error',
120
+ message: 'empty catch block — error silently swallowed',
121
+ file,
122
+ line: i + 1,
123
+ fixable: false,
124
+ fixHint: 'log or handle the error, or add a comment explaining why it is intentional',
125
+ });
126
+ continue;
127
+ }
128
+ // Multi-line: catch block that starts on this line — check if it's comment-only
129
+ const catchStart = line.match(/catch\s*(?:\([^)]*\))?\s*\{/);
130
+ if (catchStart) {
131
+ // Collect lines until matching }
132
+ let depth = 0;
133
+ let blockStart = -1;
134
+ for (let ci = line.indexOf('{'); ci < line.length; ci++) {
135
+ if (line[ci] === '{') {
136
+ depth++;
137
+ blockStart = ci;
138
+ break;
139
+ }
140
+ }
141
+ if (depth > 0) {
142
+ const blockLines = [line.slice(blockStart + 1)];
143
+ let j = i + 1;
144
+ while (j < lines.length && depth > 0) {
145
+ const l = lines[j];
146
+ for (const ch of l) {
147
+ if (ch === '{')
148
+ depth++;
149
+ else if (ch === '}')
150
+ depth--;
151
+ }
152
+ blockLines.push(l);
153
+ j++;
154
+ }
155
+ // Check if block body is only comments
156
+ const bodyText = blockLines.join('\n').replace(/\}$/, '').trim();
157
+ if (bodyText.length > 0 && /^(\s*(\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*)*$/.test(bodyText)) {
158
+ issues.push({
159
+ severity: 'warning',
160
+ message: 'catch block contains only comments — consider proper error handling',
161
+ file,
162
+ line: i + 1,
163
+ fixable: false,
164
+ });
165
+ }
166
+ }
167
+ }
168
+ }
169
+ }
170
+ return issues;
171
+ }
172
+ // ── Stubbed tests ────────────────────────────────────────────────────────────
173
+ function checkStubbedTests(cwd, files) {
174
+ const issues = [];
175
+ const testExts = /\.(test|spec)\.[jt]sx?$/;
176
+ for (const file of files) {
177
+ if (!testExts.test(file))
178
+ continue;
179
+ const content = readFile(join(cwd, file));
180
+ if (!content)
181
+ continue;
182
+ const lines = content.split('\n');
183
+ for (let i = 0; i < lines.length; i++) {
184
+ const line = lines[i];
185
+ // Trivial assertions
186
+ if (/expect\s*\(\s*true\s*\)\s*\.toBe\s*\(\s*true\s*\)/.test(line)) {
187
+ issues.push({
188
+ severity: 'error',
189
+ message: 'stubbed test: trivial assertion expect(true).toBe(true)',
190
+ file,
191
+ line: i + 1,
192
+ fixable: false,
193
+ });
194
+ }
195
+ if (/expect\s*\(\s*1\s*\)\s*\.toBe\s*\(\s*1\s*\)/.test(line)) {
196
+ issues.push({
197
+ severity: 'error',
198
+ message: 'stubbed test: trivial assertion expect(1).toBe(1)',
199
+ file,
200
+ line: i + 1,
201
+ fixable: false,
202
+ });
203
+ }
204
+ // Empty test body: test('...', () => {}) or it('...', () => {})
205
+ if (/(?:test|it)\s*\(\s*['"`][^'"]+['"`]\s*,\s*(?:async\s*)?\(\s*\)\s*=>\s*\{\s*\}\s*\)/.test(line)) {
206
+ issues.push({
207
+ severity: 'error',
208
+ message: 'stubbed test: empty test body',
209
+ file,
210
+ line: i + 1,
211
+ fixable: false,
212
+ fixHint: 'add assertions or mark as test.todo()',
213
+ });
214
+ }
215
+ // it.skip without .todo — skipped test (always check regardless of other matches on this line)
216
+ if (/(?:it|test)\.skip\s*\(/.test(line) && !/\.todo\s*\(/.test(line)) {
217
+ issues.push({
218
+ severity: 'warning',
219
+ message: 'skipped test: use test.todo() instead of .skip for unimplemented tests',
220
+ file,
221
+ line: i + 1,
222
+ fixable: true,
223
+ fixHint: 'change .skip to .todo if not yet implemented',
224
+ });
225
+ }
226
+ }
227
+ }
228
+ return issues;
229
+ }
230
+ // ── Unhandled async (removed error handling) ─────────────────────────────────
231
+ function checkUnhandledAsync(cwd, files) {
232
+ const issues = [];
233
+ const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
234
+ for (const file of files) {
235
+ if (!sourceExts.has(extname(file)))
236
+ continue;
237
+ const content = readFile(join(cwd, file));
238
+ if (!content)
239
+ continue;
240
+ const lines = content.split('\n');
241
+ let unhandledCount = 0;
242
+ for (let i = 0; i < lines.length; i++) {
243
+ const line = lines[i];
244
+ // await without try/catch context — detect standalone awaits
245
+ // We look for: const/let/var x = await or just await on its own, not inside try
246
+ const hasAwait = /^\s*(?:const|let|var)\s+\w.*=\s*await\s+/.test(line) || /^\s*await\s+/.test(line);
247
+ if (!hasAwait)
248
+ continue;
249
+ // Check context window — look for try { in surrounding lines
250
+ const contextStart = Math.max(0, i - 15);
251
+ const contextEnd = Math.min(lines.length - 1, i + 5);
252
+ const contextLines = lines.slice(contextStart, contextEnd + 1);
253
+ const contextText = contextLines.join('\n');
254
+ // Count try/catch blocks in context
255
+ const tryCount = (contextText.match(/\btry\s*\{/g) || []).length;
256
+ const catchCount = (contextText.match(/\bcatch\s*(?:\([^)]*\))?\s*\{/g) || []).length;
257
+ if (tryCount === 0 || catchCount === 0) {
258
+ // Also check for .catch() chained
259
+ const hasCatch = /\.catch\s*\(/.test(line) || (i + 1 < lines.length && /\.catch\s*\(/.test(lines[i + 1]));
260
+ if (!hasCatch) {
261
+ unhandledCount++;
262
+ if (unhandledCount <= 10) {
263
+ issues.push({
264
+ severity: 'warning',
265
+ message: 'unhandled async: await without try/catch',
266
+ file,
267
+ line: i + 1,
268
+ fixable: false,
269
+ fixHint: 'wrap in try/catch or chain .catch()',
270
+ });
271
+ }
272
+ }
273
+ }
274
+ }
275
+ }
276
+ return issues;
277
+ }
278
+ // ── Main check ───────────────────────────────────────────────────────────────
279
+ export async function checkIntegrity(cwd, ignore) {
280
+ const files = walkFiles(cwd, ignore);
281
+ const hallucinatedIssues = checkHallucinatedImports(cwd, files);
282
+ const emptyCatchIssues = checkEmptyCatch(cwd, files);
283
+ const stubbedTestIssues = checkStubbedTests(cwd, files);
284
+ const unhandledAsyncIssues = checkUnhandledAsync(cwd, files);
285
+ const allIssues = [
286
+ ...hallucinatedIssues,
287
+ ...emptyCatchIssues,
288
+ ...stubbedTestIssues,
289
+ ...unhandledAsyncIssues,
290
+ ];
291
+ // Scoring: start at 100, penalize per issue type
292
+ let score = 100;
293
+ score -= hallucinatedIssues.length * 10;
294
+ score -= emptyCatchIssues.filter(i => i.severity === 'error').length * 8;
295
+ score -= stubbedTestIssues.filter(i => i.severity === 'error').length * 5;
296
+ // Unhandled async capped at -30
297
+ const unhandledErrors = unhandledAsyncIssues.length;
298
+ score -= Math.min(30, unhandledErrors * 3);
299
+ score = Math.max(0, Math.round(score));
300
+ // Summary parts
301
+ const parts = [];
302
+ if (hallucinatedIssues.length > 0)
303
+ parts.push(`${hallucinatedIssues.length} hallucinated import${hallucinatedIssues.length !== 1 ? 's' : ''}`);
304
+ if (emptyCatchIssues.length > 0)
305
+ parts.push(`${emptyCatchIssues.length} empty catch${emptyCatchIssues.length !== 1 ? 'es' : ''}`);
306
+ if (stubbedTestIssues.length > 0)
307
+ parts.push(`${stubbedTestIssues.length} stubbed test${stubbedTestIssues.length !== 1 ? 's' : ''}`);
308
+ if (unhandledAsyncIssues.length > 0)
309
+ parts.push(`${unhandledAsyncIssues.length} unhandled async`);
310
+ return {
311
+ name: 'integrity',
312
+ score,
313
+ maxScore: 100,
314
+ issues: allIssues,
315
+ summary: parts.length === 0 ? 'no integrity issues found' : parts.join(', '),
316
+ };
317
+ }
@@ -0,0 +1,25 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare const AGENT_CONFIG_FILES: string[];
3
+ export declare function parseAgentConfigs(cwd: string): string[];
4
+ export declare function extractRefs(content: string, cwd: string): string[];
5
+ export type VisibilityTier = 'config' | 'visible' | 'invisible';
6
+ export interface ClassifiedFile {
7
+ path: string;
8
+ tier: VisibilityTier;
9
+ }
10
+ export declare function classifyFiles(cwd: string, configPaths: string[], refs: string[]): ClassifiedFile[];
11
+ export interface MapResult {
12
+ config: string[];
13
+ visible: string[];
14
+ invisible: string[];
15
+ stats: {
16
+ total: number;
17
+ visible_pct: number;
18
+ };
19
+ }
20
+ export declare function checkMap(cwd: string): Promise<CheckResult & {
21
+ mapData: MapResult;
22
+ }>;
23
+ export declare function renderMapReport(result: CheckResult & {
24
+ mapData: MapResult;
25
+ }, asJson: boolean): string;
@@ -0,0 +1,256 @@
1
+ import { join } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { walkFiles, readFile, c } from '../util.js';
4
+ // ── Agent config filenames to discover ───────────────────────────────────────
5
+ export const AGENT_CONFIG_FILES = [
6
+ 'CLAUDE.md',
7
+ 'AGENTS.md',
8
+ '.cursorrules',
9
+ 'codex.md',
10
+ '.github/copilot-instructions.md',
11
+ 'cursor.json',
12
+ '.cursor/rules',
13
+ 'copilot-instructions.md',
14
+ ];
15
+ // ── Parse all agent config files present in cwd ──────────────────────────────
16
+ export function parseAgentConfigs(cwd) {
17
+ const found = [];
18
+ for (const name of AGENT_CONFIG_FILES) {
19
+ const full = join(cwd, name);
20
+ if (existsSync(full)) {
21
+ found.push(name);
22
+ }
23
+ }
24
+ return found;
25
+ }
26
+ // ── Extract file/dir references from config file content ─────────────────────
27
+ export function extractRefs(content, cwd) {
28
+ const refs = new Set();
29
+ // Patterns to extract:
30
+ // 1. Backtick paths: `path/to/file.ts` or `./path`
31
+ const backtickPat = /`([^`\s]+)`/g;
32
+ // 2. Inline code in markdown: single-line code with path-like content
33
+ const codePat = /`([./][^`\s]+)`/g;
34
+ // 3. Explicit path patterns in text: word/word or ./word or ~/word
35
+ const pathPat = /(?:^|\s)((?:\.\/|\.\.\/|~\/)?(?:[a-zA-Z0-9_-]+\/)+[a-zA-Z0-9_.-]*[a-zA-Z0-9_-])/gm;
36
+ // 4. Absolute paths starting with /
37
+ const absPat = /(?:^|\s)(\/(?:[a-zA-Z0-9_.-]+\/)*[a-zA-Z0-9_.-]+)/gm;
38
+ const extractFromPattern = (pat) => {
39
+ let match;
40
+ pat.lastIndex = 0;
41
+ while ((match = pat.exec(content)) !== null) {
42
+ const raw = match[1].trim();
43
+ // Skip URLs
44
+ if (raw.startsWith('http://') || raw.startsWith('https://') || raw.includes('://'))
45
+ continue;
46
+ // Skip if looks like a domain
47
+ if (/^[a-z]+\.[a-z]{2,}/.test(raw) && !raw.includes('/'))
48
+ continue;
49
+ refs.add(raw);
50
+ }
51
+ };
52
+ extractFromPattern(backtickPat);
53
+ extractFromPattern(codePat);
54
+ extractFromPattern(pathPat);
55
+ extractFromPattern(absPat);
56
+ // Filter to only refs that actually exist on disk (relative to cwd)
57
+ const resolved = [];
58
+ for (const ref of refs) {
59
+ let resolvedPath;
60
+ if (ref.startsWith('/')) {
61
+ resolvedPath = ref;
62
+ }
63
+ else if (ref.startsWith('~/')) {
64
+ resolvedPath = join(process.env.HOME || '/root', ref.slice(2));
65
+ }
66
+ else {
67
+ resolvedPath = join(cwd, ref);
68
+ }
69
+ if (existsSync(resolvedPath)) {
70
+ // Store as relative to cwd
71
+ const rel = ref.startsWith('/') ? ref : ref;
72
+ resolved.push(rel.replace(/^\.\//, ''));
73
+ }
74
+ }
75
+ return [...new Set(resolved)];
76
+ }
77
+ // ── Classify all codebase files ───────────────────────────────────────────────
78
+ export function classifyFiles(cwd, configPaths, refs) {
79
+ const allFiles = walkFiles(cwd);
80
+ const configSet = new Set(configPaths);
81
+ // Build a set of ref prefixes for directory matching
82
+ const refSet = new Set(refs);
83
+ // Also include files whose parent directory is referenced
84
+ function isReferencedByRef(file) {
85
+ if (refSet.has(file))
86
+ return true;
87
+ // Check if any ref is a directory prefix of this file
88
+ for (const ref of refSet) {
89
+ if (file.startsWith(ref + '/') || file.startsWith(ref.replace(/\/$/, '') + '/')) {
90
+ return true;
91
+ }
92
+ }
93
+ return false;
94
+ }
95
+ const classified = [];
96
+ for (const file of allFiles) {
97
+ let tier;
98
+ if (configSet.has(file)) {
99
+ tier = 'config';
100
+ }
101
+ else if (isReferencedByRef(file)) {
102
+ tier = 'visible';
103
+ }
104
+ else {
105
+ tier = 'invisible';
106
+ }
107
+ classified.push({ path: file, tier });
108
+ }
109
+ // Also add config files that walkFiles might have missed (e.g. .github/copilot-instructions.md)
110
+ for (const cp of configPaths) {
111
+ if (!classified.find(f => f.path === cp)) {
112
+ classified.push({ path: cp, tier: 'config' });
113
+ }
114
+ }
115
+ return classified;
116
+ }
117
+ // ── Main check ───────────────────────────────────────────────────────────────
118
+ export async function checkMap(cwd) {
119
+ const issues = [];
120
+ // Discover agent configs
121
+ const configPaths = parseAgentConfigs(cwd);
122
+ // Extract all refs from all config files
123
+ const allRefs = [];
124
+ for (const cp of configPaths) {
125
+ const content = readFile(join(cwd, cp));
126
+ if (content) {
127
+ const refs = extractRefs(content, cwd);
128
+ allRefs.push(...refs);
129
+ }
130
+ }
131
+ const uniqueRefs = [...new Set(allRefs)];
132
+ // Classify files
133
+ const classified = classifyFiles(cwd, configPaths, uniqueRefs);
134
+ const configFiles = classified.filter(f => f.tier === 'config').map(f => f.path);
135
+ const visibleFiles = classified.filter(f => f.tier === 'visible').map(f => f.path);
136
+ const invisibleFiles = classified.filter(f => f.tier === 'invisible').map(f => f.path);
137
+ const total = classified.length;
138
+ const visible = visibleFiles.length + configFiles.length;
139
+ const visible_pct = total > 0 ? Math.round((visible / total) * 100) : 0;
140
+ // Issues
141
+ if (configPaths.length === 0) {
142
+ issues.push({
143
+ severity: 'warning',
144
+ message: 'no agent config files found (CLAUDE.md, .cursorrules, etc.) — agent has no guided context',
145
+ fixable: true,
146
+ fixHint: 'run: npx @safetnsr/vet init',
147
+ });
148
+ }
149
+ if (visible_pct < 20 && total > 0) {
150
+ issues.push({
151
+ severity: 'warning',
152
+ message: `agent is mostly blind: only ${visible_pct}% of codebase is referenced in agent configs`,
153
+ fixable: false,
154
+ });
155
+ }
156
+ // Surface top invisible dirs as info
157
+ const invisibleDirs = new Set();
158
+ for (const f of invisibleFiles) {
159
+ const parts = f.split('/');
160
+ if (parts.length > 1)
161
+ invisibleDirs.add(parts[0]);
162
+ }
163
+ const topInvisibleDirs = [...invisibleDirs].slice(0, 5);
164
+ if (topInvisibleDirs.length > 0 && invisibleFiles.length > 0) {
165
+ issues.push({
166
+ severity: 'info',
167
+ message: `top invisible directories: ${topInvisibleDirs.join(', ')}`,
168
+ fixable: false,
169
+ });
170
+ }
171
+ const mapData = {
172
+ config: configFiles,
173
+ visible: visibleFiles,
174
+ invisible: invisibleFiles,
175
+ stats: { total, visible_pct },
176
+ };
177
+ const summary = configPaths.length === 0
178
+ ? `no agent config files — all ${total} files invisible`
179
+ : `${visible_pct}% visible to agent (${visible}/${total} files)`;
180
+ return {
181
+ name: 'map',
182
+ score: visible_pct,
183
+ maxScore: 100,
184
+ issues,
185
+ summary,
186
+ mapData,
187
+ };
188
+ }
189
+ // ── Terminal renderer ─────────────────────────────────────────────────────────
190
+ export function renderMapReport(result, asJson) {
191
+ const { mapData } = result;
192
+ if (asJson) {
193
+ return JSON.stringify({
194
+ config: mapData.config,
195
+ visible: mapData.visible,
196
+ invisible: mapData.invisible,
197
+ stats: mapData.stats,
198
+ }, null, 2);
199
+ }
200
+ const lines = [];
201
+ lines.push('');
202
+ lines.push(` ${c.bold}vet map${c.reset} — agent visibility\n`);
203
+ lines.push(` ${c.dim}score:${c.reset} ${c.bold}${mapData.stats.visible_pct}%${c.reset} visible to agent`);
204
+ lines.push(` ${c.dim}total:${c.reset} ${mapData.stats.total} files`);
205
+ lines.push('');
206
+ // Config files tier
207
+ if (mapData.config.length > 0) {
208
+ lines.push(` ${c.yellow}${c.bold}config files${c.reset} ${c.dim}(${mapData.config.length})${c.reset}`);
209
+ for (const f of mapData.config.slice(0, 10)) {
210
+ lines.push(` ${c.yellow}●${c.reset} ${c.dim}${f}${c.reset}`);
211
+ }
212
+ if (mapData.config.length > 10) {
213
+ lines.push(` ${c.dim} ... and ${mapData.config.length - 10} more${c.reset}`);
214
+ }
215
+ lines.push('');
216
+ }
217
+ // Visible files tier
218
+ if (mapData.visible.length > 0) {
219
+ lines.push(` ${c.green}${c.bold}visible to agent${c.reset} ${c.dim}(${mapData.visible.length})${c.reset}`);
220
+ for (const f of mapData.visible.slice(0, 15)) {
221
+ lines.push(` ${c.green}●${c.reset} ${f}`);
222
+ }
223
+ if (mapData.visible.length > 15) {
224
+ lines.push(` ${c.dim} ... and ${mapData.visible.length - 15} more${c.reset}`);
225
+ }
226
+ lines.push('');
227
+ }
228
+ // Invisible dirs summary
229
+ if (mapData.invisible.length > 0) {
230
+ const invisibleDirs = new Map();
231
+ for (const f of mapData.invisible) {
232
+ const parts = f.split('/');
233
+ const dir = parts.length > 1 ? parts[0] : '(root)';
234
+ invisibleDirs.set(dir, (invisibleDirs.get(dir) || 0) + 1);
235
+ }
236
+ const sortedDirs = [...invisibleDirs.entries()].sort((a, b) => b[1] - a[1]);
237
+ lines.push(` ${c.dim}${c.bold}invisible to agent${c.reset} ${c.dim}(${mapData.invisible.length} files)${c.reset}`);
238
+ for (const [dir, count] of sortedDirs.slice(0, 8)) {
239
+ lines.push(` ${c.dim}○ ${dir}/ (${count} files)${c.reset}`);
240
+ }
241
+ if (sortedDirs.length > 8) {
242
+ lines.push(` ${c.dim} ... and ${sortedDirs.length - 8} more directories${c.reset}`);
243
+ }
244
+ lines.push('');
245
+ }
246
+ // Issues
247
+ for (const issue of result.issues) {
248
+ const icon = issue.severity === 'warning' ? c.yellow + '⚠' : c.dim + 'ℹ';
249
+ lines.push(` ${icon}${c.reset} ${issue.message}`);
250
+ if (issue.fixHint)
251
+ lines.push(` ${c.dim}→ ${issue.fixHint}${c.reset}`);
252
+ }
253
+ if (result.issues.length > 0)
254
+ lines.push('');
255
+ return lines.join('\n');
256
+ }
@@ -22,11 +22,11 @@ async function tryModelGraveyard(cwd) {
22
22
  });
23
23
  }
24
24
  }
25
- const score = Math.max(0, 10 - issues.length * 2);
25
+ const score = Math.max(0, 100 - issues.length * 20);
26
26
  return {
27
27
  name: 'models',
28
- score: Math.min(10, score),
29
- maxScore: 10,
28
+ score: Math.min(100, score),
29
+ maxScore: 100,
30
30
  issues,
31
31
  summary: issues.length === 0
32
32
  ? `${report.filesScanned} files scanned (via model-graveyard) — all current`
@@ -111,11 +111,11 @@ function builtinModels(cwd, ignore) {
111
111
  fixHint: `replace "${model}" with "${info.replacement}"`,
112
112
  });
113
113
  }
114
- const score = Math.max(0, 10 - issues.length * 2);
114
+ const score = Math.max(0, 100 - issues.length * 20);
115
115
  return {
116
116
  name: 'models',
117
- score: Math.min(10, score),
118
- maxScore: 10,
117
+ score: Math.min(100, score),
118
+ maxScore: 100,
119
119
  issues,
120
120
  summary: issues.length === 0 ? 'all model references current' : `${issues.length} deprecated model${issues.length > 1 ? 's' : ''} found`,
121
121
  };
@@ -0,0 +1,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkOwasp(cwd: string): CheckResult;