@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.
- package/README.md +2 -1
- package/dist/categories.d.ts +9 -0
- package/dist/categories.js +79 -0
- package/dist/checks/config.js +8 -8
- package/dist/checks/debt.d.ts +2 -0
- package/dist/checks/debt.js +373 -0
- package/dist/checks/deps.js +5 -5
- package/dist/checks/diff.js +4 -4
- package/dist/checks/history.js +4 -4
- package/dist/checks/integrity.d.ts +2 -0
- package/dist/checks/integrity.js +317 -0
- package/dist/checks/map.d.ts +25 -0
- package/dist/checks/map.js +256 -0
- package/dist/checks/models.js +6 -6
- package/dist/checks/owasp.d.ts +2 -0
- package/dist/checks/owasp.js +794 -0
- package/dist/checks/ready.js +7 -7
- package/dist/checks/receipt.js +5 -5
- package/dist/checks/scan.js +3 -3
- package/dist/checks/secrets.js +4 -4
- package/dist/cli.js +77 -44
- package/dist/reporter.d.ts +1 -0
- package/dist/reporter.js +56 -25
- package/dist/scorer.d.ts +7 -1
- package/dist/scorer.js +4 -14
- package/dist/types.d.ts +11 -1
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/dist/checks/models.js
CHANGED
|
@@ -22,11 +22,11 @@ async function tryModelGraveyard(cwd) {
|
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
-
const score = Math.max(0,
|
|
25
|
+
const score = Math.max(0, 100 - issues.length * 20);
|
|
26
26
|
return {
|
|
27
27
|
name: 'models',
|
|
28
|
-
score: Math.min(
|
|
29
|
-
maxScore:
|
|
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,
|
|
114
|
+
const score = Math.max(0, 100 - issues.length * 20);
|
|
115
115
|
return {
|
|
116
116
|
name: 'models',
|
|
117
|
-
score: Math.min(
|
|
118
|
-
maxScore:
|
|
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
|
};
|