@safetnsr/vet 0.6.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,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;