@safetnsr/vet 0.1.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 +101 -0
- package/dist/checks/config.d.ts +2 -0
- package/dist/checks/config.js +102 -0
- package/dist/checks/diff.d.ts +2 -0
- package/dist/checks/diff.js +98 -0
- package/dist/checks/history.d.ts +2 -0
- package/dist/checks/history.js +77 -0
- package/dist/checks/links.d.ts +2 -0
- package/dist/checks/links.js +76 -0
- package/dist/checks/models.d.ts +2 -0
- package/dist/checks/models.js +95 -0
- package/dist/checks/ready.d.ts +2 -0
- package/dist/checks/ready.js +68 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +99 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +127 -0
- package/dist/reporter.d.ts +3 -0
- package/dist/reporter.js +51 -0
- package/dist/scorer.d.ts +2 -0
- package/dist/scorer.js +15 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.js +1 -0
- package/dist/util.d.ts +18 -0
- package/dist/util.js +83 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# vet
|
|
2
|
+
|
|
3
|
+
vet your AI-generated code. one command, six checks, zero config.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @safetnsr/vet
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
works with Claude Code, Cursor, Copilot, Codex, Aider, Windsurf, Cline — anything that writes code in a git repo.
|
|
10
|
+
|
|
11
|
+
## what it checks
|
|
12
|
+
|
|
13
|
+
| check | what | how |
|
|
14
|
+
|-------|------|-----|
|
|
15
|
+
| **ready** | is your codebase AI-friendly? | scans structure, docs, types, tests |
|
|
16
|
+
| **diff** | did the AI leave anti-patterns? | checks staged/unstaged changes for secrets, stubs, empty catches |
|
|
17
|
+
| **models** | using deprecated AI models? | scans code for sunset model strings |
|
|
18
|
+
| **links** | broken markdown links? | validates relative links and wikilinks |
|
|
19
|
+
| **config** | agent configs in place? | checks for CLAUDE.md, .cursorrules, copilot-instructions, etc. |
|
|
20
|
+
| **history** | git patterns healthy? | analyzes commit churn, AI attribution, large changes |
|
|
21
|
+
|
|
22
|
+
## usage
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# run all checks (default)
|
|
26
|
+
npx @safetnsr/vet
|
|
27
|
+
|
|
28
|
+
# check a specific directory
|
|
29
|
+
npx @safetnsr/vet ./my-project
|
|
30
|
+
|
|
31
|
+
# CI mode — exit code 1 if score below threshold
|
|
32
|
+
npx @safetnsr/vet --ci
|
|
33
|
+
|
|
34
|
+
# auto-fix what we can
|
|
35
|
+
npx @safetnsr/vet --fix
|
|
36
|
+
|
|
37
|
+
# JSON output
|
|
38
|
+
npx @safetnsr/vet --json
|
|
39
|
+
|
|
40
|
+
# set up config + agent files + pre-commit hook
|
|
41
|
+
npx @safetnsr/vet init
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## output
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
my-project 8.2/10
|
|
48
|
+
|
|
49
|
+
ready ████████░░ 8 structure + docs look good
|
|
50
|
+
diff ██████████ 10 clean diff, no issues
|
|
51
|
+
models ██████████ 10 all models current
|
|
52
|
+
links ██████░░░░ 6 3 broken links in docs/
|
|
53
|
+
config ████████░░ 8 CLAUDE.md missing react patterns
|
|
54
|
+
history ████████░░ 8 2 high-churn files
|
|
55
|
+
|
|
56
|
+
run --fix to auto-repair 4 issues
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## config
|
|
60
|
+
|
|
61
|
+
create `.vetrc` in your project root (optional):
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"checks": ["ready", "diff", "models", "links", "config", "history"],
|
|
66
|
+
"ignore": ["vendor/", "generated/"],
|
|
67
|
+
"thresholds": { "min": 6 }
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## init
|
|
72
|
+
|
|
73
|
+
`npx @safetnsr/vet init` creates:
|
|
74
|
+
- `.vetrc` with sensible defaults
|
|
75
|
+
- `CLAUDE.md` generated from your codebase
|
|
76
|
+
- `.cursorrules` matching your project
|
|
77
|
+
- `.git/hooks/pre-commit` that runs vet before every commit
|
|
78
|
+
|
|
79
|
+
## ci
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
# .github/workflows/vet.yml
|
|
83
|
+
name: vet
|
|
84
|
+
on: [pull_request]
|
|
85
|
+
jobs:
|
|
86
|
+
vet:
|
|
87
|
+
runs-on: ubuntu-latest
|
|
88
|
+
steps:
|
|
89
|
+
- uses: actions/checkout@v4
|
|
90
|
+
with:
|
|
91
|
+
fetch-depth: 50
|
|
92
|
+
- run: npx @safetnsr/vet --ci
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## zero dependencies
|
|
96
|
+
|
|
97
|
+
vet uses only Node.js built-ins. no runtime dependencies. works with Node 18+.
|
|
98
|
+
|
|
99
|
+
## license
|
|
100
|
+
|
|
101
|
+
MIT
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readFile, fileExists, walkFiles } from '../util.js';
|
|
3
|
+
// Known agent config files per platform
|
|
4
|
+
const AGENT_CONFIGS = {
|
|
5
|
+
claude: { files: ['CLAUDE.md', '.claude/settings.json', 'AGENTS.md'], name: 'Claude Code' },
|
|
6
|
+
cursor: { files: ['.cursorrules', '.cursor/rules'], name: 'Cursor' },
|
|
7
|
+
copilot: { files: ['.github/copilot-instructions.md'], name: 'GitHub Copilot' },
|
|
8
|
+
aider: { files: ['.aider.conf.yml', '.aiderignore'], name: 'Aider' },
|
|
9
|
+
continue: { files: ['.continue/config.json', '.continuerules'], name: 'Continue' },
|
|
10
|
+
codex: { files: ['AGENTS.md', 'codex.md'], name: 'OpenAI Codex' },
|
|
11
|
+
windsurf: { files: ['.windsurfrules'], name: 'Windsurf' },
|
|
12
|
+
cline: { files: ['.clinerules', '.cline/settings.json'], name: 'Cline' },
|
|
13
|
+
};
|
|
14
|
+
export function checkConfig(cwd, ignore) {
|
|
15
|
+
const issues = [];
|
|
16
|
+
const files = walkFiles(cwd, ignore);
|
|
17
|
+
// Detect which agents have config
|
|
18
|
+
const detected = [];
|
|
19
|
+
const missing = [];
|
|
20
|
+
for (const [agent, info] of Object.entries(AGENT_CONFIGS)) {
|
|
21
|
+
const found = info.files.some(f => fileExists(join(cwd, f)));
|
|
22
|
+
if (found)
|
|
23
|
+
detected.push(info.name);
|
|
24
|
+
}
|
|
25
|
+
if (detected.length === 0) {
|
|
26
|
+
issues.push({
|
|
27
|
+
severity: 'warning',
|
|
28
|
+
message: 'no AI agent config found — add CLAUDE.md, .cursorrules, or similar to guide AI behavior',
|
|
29
|
+
fixable: true,
|
|
30
|
+
fixHint: 'run vet init to generate agent config',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
// Check quality of found configs
|
|
34
|
+
for (const [agent, info] of Object.entries(AGENT_CONFIGS)) {
|
|
35
|
+
for (const configFile of info.files) {
|
|
36
|
+
const content = readFile(join(cwd, configFile));
|
|
37
|
+
if (!content)
|
|
38
|
+
continue;
|
|
39
|
+
// Too short to be useful
|
|
40
|
+
if (content.length < 50) {
|
|
41
|
+
issues.push({
|
|
42
|
+
severity: 'warning',
|
|
43
|
+
message: `${configFile} is only ${content.length} chars — probably too sparse to guide AI effectively`,
|
|
44
|
+
file: configFile,
|
|
45
|
+
fixable: false,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// Check if config mentions key project patterns
|
|
49
|
+
const pkgJson = readFile(join(cwd, 'package.json'));
|
|
50
|
+
if (pkgJson) {
|
|
51
|
+
try {
|
|
52
|
+
const pkg = JSON.parse(pkgJson);
|
|
53
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
54
|
+
// Major frameworks that should be mentioned
|
|
55
|
+
const frameworks = {
|
|
56
|
+
react: ['react', 'jsx', 'tsx', 'component'],
|
|
57
|
+
next: ['next', 'nextjs', 'app router', 'pages router'],
|
|
58
|
+
vue: ['vue', 'composition api', 'options api'],
|
|
59
|
+
svelte: ['svelte', 'sveltekit'],
|
|
60
|
+
express: ['express', 'middleware', 'router'],
|
|
61
|
+
hono: ['hono'],
|
|
62
|
+
fastify: ['fastify'],
|
|
63
|
+
django: ['django'],
|
|
64
|
+
flask: ['flask'],
|
|
65
|
+
};
|
|
66
|
+
const contentLower = content.toLowerCase();
|
|
67
|
+
for (const [framework, keywords] of Object.entries(frameworks)) {
|
|
68
|
+
if (deps[framework] && !keywords.some(k => contentLower.includes(k))) {
|
|
69
|
+
issues.push({
|
|
70
|
+
severity: 'info',
|
|
71
|
+
message: `${configFile} doesn't mention ${framework} — but it's in your dependencies`,
|
|
72
|
+
file: configFile,
|
|
73
|
+
fixable: false,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch { /* invalid package.json, skip */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Check for .gitignore (agents need to know what to ignore)
|
|
83
|
+
if (!fileExists(join(cwd, '.gitignore'))) {
|
|
84
|
+
issues.push({
|
|
85
|
+
severity: 'info',
|
|
86
|
+
message: 'no .gitignore — agents may create files in wrong directories',
|
|
87
|
+
fixable: false,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const errors = issues.filter(i => i.severity === 'error').length;
|
|
91
|
+
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
92
|
+
const infos = issues.filter(i => i.severity === 'info').length;
|
|
93
|
+
const score = Math.max(0, Math.min(10, 10 - errors * 2 - warnings * 1.5 - infos * 0.3));
|
|
94
|
+
const configSummary = detected.length > 0 ? `configs: ${detected.join(', ')}` : 'no agent configs';
|
|
95
|
+
return {
|
|
96
|
+
name: 'config',
|
|
97
|
+
score: Math.round(score * 10) / 10,
|
|
98
|
+
maxScore: 10,
|
|
99
|
+
issues,
|
|
100
|
+
summary: issues.length === 0 ? `${configSummary} — well configured` : `${configSummary} — ${issues.length} suggestions`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { git } from '../util.js';
|
|
2
|
+
const PATTERNS = [
|
|
3
|
+
// Secrets
|
|
4
|
+
{ regex: /(?:api[_-]?key|secret|token|password|credential)\s*[:=]\s*['"][^'"]{8,}['"]/i, message: 'possible hardcoded secret', severity: 'error' },
|
|
5
|
+
{ regex: /sk-[a-zA-Z0-9]{20,}/, message: 'possible OpenAI API key', severity: 'error' },
|
|
6
|
+
{ regex: /AKIA[0-9A-Z]{16}/, message: 'possible AWS access key', severity: 'error' },
|
|
7
|
+
{ regex: /AIza[0-9A-Za-z_-]{35}/, message: 'possible Google API key', severity: 'error' },
|
|
8
|
+
// AI anti-patterns
|
|
9
|
+
{ regex: /\/\/\s*TODO[:\s]/i, message: 'TODO comment left in code', severity: 'warning' },
|
|
10
|
+
{ regex: /\/\/\s*FIXME[:\s]/i, message: 'FIXME comment left in code', severity: 'warning' },
|
|
11
|
+
{ regex: /\/\/\s*HACK[:\s]/i, message: 'HACK comment left in code', severity: 'warning' },
|
|
12
|
+
{ regex: /console\.log\(/, message: 'console.log left in code', severity: 'warning' },
|
|
13
|
+
{ regex: /catch\s*\([^)]*\)\s*\{\s*\}/, message: 'empty catch block — error silently swallowed', severity: 'error' },
|
|
14
|
+
{ regex: /catch\s*\([^)]*\)\s*\{\s*\/\//, message: 'catch block with only a comment — errors need handling', severity: 'warning' },
|
|
15
|
+
{ regex: /it\(\s*['"].*['"]\s*,\s*(?:async\s*)?\(\)\s*=>\s*\{\s*\}\s*\)/, message: 'empty test body — stubbed test', severity: 'error' },
|
|
16
|
+
{ regex: /test\(\s*['"].*['"]\s*,\s*(?:async\s*)?\(\)\s*=>\s*\{\s*\}\s*\)/, message: 'empty test body — stubbed test', severity: 'error' },
|
|
17
|
+
{ regex: /expect\(true\)\.toBe\(true\)/, message: 'trivial assertion — test proves nothing', severity: 'error' },
|
|
18
|
+
{ regex: /assert\s+True\s*$/, message: 'trivial assertion — test proves nothing', severity: 'error' },
|
|
19
|
+
{ regex: /\.only\(/, message: '.only() left in test — other tests will be skipped', severity: 'error' },
|
|
20
|
+
{ regex: /debugger;/, message: 'debugger statement left in code', severity: 'error' },
|
|
21
|
+
];
|
|
22
|
+
export function checkDiff(cwd) {
|
|
23
|
+
const issues = [];
|
|
24
|
+
// Get staged + unstaged diff
|
|
25
|
+
let diff = git('diff HEAD', cwd);
|
|
26
|
+
if (!diff)
|
|
27
|
+
diff = git('diff --cached', cwd);
|
|
28
|
+
if (!diff)
|
|
29
|
+
diff = git('diff', cwd);
|
|
30
|
+
if (!diff) {
|
|
31
|
+
return {
|
|
32
|
+
name: 'diff',
|
|
33
|
+
score: 10,
|
|
34
|
+
maxScore: 10,
|
|
35
|
+
issues: [],
|
|
36
|
+
summary: 'no uncommitted changes to check',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// Parse diff: only check added lines
|
|
40
|
+
let currentFile = '';
|
|
41
|
+
let lineNum = 0;
|
|
42
|
+
for (const line of diff.split('\n')) {
|
|
43
|
+
if (line.startsWith('diff --git')) {
|
|
44
|
+
const match = line.match(/b\/(.+)$/);
|
|
45
|
+
if (match)
|
|
46
|
+
currentFile = match[1];
|
|
47
|
+
lineNum = 0;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (line.startsWith('@@')) {
|
|
51
|
+
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)/);
|
|
52
|
+
if (match)
|
|
53
|
+
lineNum = parseInt(match[1]) - 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
57
|
+
lineNum++;
|
|
58
|
+
const added = line.slice(1);
|
|
59
|
+
for (const pattern of PATTERNS) {
|
|
60
|
+
if (pattern.regex.test(added)) {
|
|
61
|
+
issues.push({
|
|
62
|
+
severity: pattern.severity,
|
|
63
|
+
message: pattern.message,
|
|
64
|
+
file: currentFile,
|
|
65
|
+
line: lineNum,
|
|
66
|
+
fixable: false,
|
|
67
|
+
});
|
|
68
|
+
break; // one issue per line
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else if (!line.startsWith('-')) {
|
|
73
|
+
lineNum++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Check for deleted error handling
|
|
77
|
+
let deletedErrorHandling = 0;
|
|
78
|
+
for (const line of diff.split('\n')) {
|
|
79
|
+
if (line.startsWith('-') && !line.startsWith('---')) {
|
|
80
|
+
if (/catch|throw|Error|reject|finally/.test(line)) {
|
|
81
|
+
deletedErrorHandling++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (deletedErrorHandling > 3) {
|
|
86
|
+
issues.push({ severity: 'warning', message: `${deletedErrorHandling} lines of error handling removed — verify this was intentional`, fixable: false });
|
|
87
|
+
}
|
|
88
|
+
const errors = issues.filter(i => i.severity === 'error').length;
|
|
89
|
+
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
90
|
+
const score = Math.max(0, Math.min(10, 10 - errors * 1.5 - warnings * 0.5));
|
|
91
|
+
return {
|
|
92
|
+
name: 'diff',
|
|
93
|
+
score: Math.round(score * 10) / 10,
|
|
94
|
+
maxScore: 10,
|
|
95
|
+
issues,
|
|
96
|
+
summary: issues.length === 0 ? 'clean diff, no issues' : `${issues.length} issues in uncommitted changes`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { gitExec } from '../util.js';
|
|
2
|
+
// Git history analysis: AI commit patterns, churn hotspots
|
|
3
|
+
export function checkHistory(cwd) {
|
|
4
|
+
const issues = [];
|
|
5
|
+
// Get recent commits (last 50) — use execFileSync to avoid shell pipe interpretation
|
|
6
|
+
const log = gitExec(['log', '--oneline', '-50', '--format=%H|%an|%s'], cwd);
|
|
7
|
+
if (!log) {
|
|
8
|
+
return { name: 'history', score: 10, maxScore: 10, issues: [], summary: 'no git history to analyze' };
|
|
9
|
+
}
|
|
10
|
+
const commits = log.split('\n').filter(Boolean).map(line => {
|
|
11
|
+
const parts = line.split('|');
|
|
12
|
+
return { hash: parts[0] || '', author: parts[1] || '', message: parts.slice(2).join('|') };
|
|
13
|
+
});
|
|
14
|
+
// Detect AI-attributed commits
|
|
15
|
+
const aiIndicators = [
|
|
16
|
+
/co-authored-by:\s*(claude|cursor|copilot|codex|aider|cline|windsurf)/i,
|
|
17
|
+
/generated by/i,
|
|
18
|
+
];
|
|
19
|
+
const aiAuthors = /claude|cursor|copilot|codex|aider|bot/i;
|
|
20
|
+
let aiCommits = 0;
|
|
21
|
+
for (const c of commits) {
|
|
22
|
+
if (aiIndicators.some(p => p.test(c.message)))
|
|
23
|
+
aiCommits++;
|
|
24
|
+
else if (aiAuthors.test(c.author))
|
|
25
|
+
aiCommits++;
|
|
26
|
+
}
|
|
27
|
+
// File churn: which files change most in recent history
|
|
28
|
+
const churnOutput = gitExec(['log', '-50', '--name-only', '--format='], cwd);
|
|
29
|
+
if (churnOutput) {
|
|
30
|
+
const fileChanges = new Map();
|
|
31
|
+
for (const line of churnOutput.split('\n')) {
|
|
32
|
+
if (!line.trim())
|
|
33
|
+
continue;
|
|
34
|
+
fileChanges.set(line, (fileChanges.get(line) || 0) + 1);
|
|
35
|
+
}
|
|
36
|
+
// High churn files (changed >5 times in last 50 commits)
|
|
37
|
+
const hotFiles = [...fileChanges.entries()]
|
|
38
|
+
.filter(([, count]) => count >= 5)
|
|
39
|
+
.sort((a, b) => b[1] - a[1]);
|
|
40
|
+
if (hotFiles.length > 0) {
|
|
41
|
+
const top = hotFiles.slice(0, 3);
|
|
42
|
+
for (const [file, count] of top) {
|
|
43
|
+
issues.push({
|
|
44
|
+
severity: 'info',
|
|
45
|
+
message: `${file} changed ${count} times in last 50 commits — high churn, consider stabilizing`,
|
|
46
|
+
file,
|
|
47
|
+
fixable: false,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Large commits (many files changed at once — common AI pattern)
|
|
53
|
+
const diffStatLog = gitExec(['log', '-20', '--format=%H', '--shortstat'], cwd);
|
|
54
|
+
if (diffStatLog) {
|
|
55
|
+
const bigCommits = (diffStatLog.match(/(\d+) files? changed/g) || [])
|
|
56
|
+
.map(m => parseInt(m))
|
|
57
|
+
.filter(n => n > 20);
|
|
58
|
+
if (bigCommits.length > 2) {
|
|
59
|
+
issues.push({
|
|
60
|
+
severity: 'warning',
|
|
61
|
+
message: `${bigCommits.length} commits touched 20+ files — large AI-generated changes are harder to review`,
|
|
62
|
+
fixable: false,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const aiPct = commits.length > 0 ? Math.round((aiCommits / commits.length) * 100) : 0;
|
|
67
|
+
const infos = issues.filter(i => i.severity === 'info').length;
|
|
68
|
+
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
69
|
+
const score = Math.max(0, Math.min(10, 10 - warnings * 1 - infos * 0.2));
|
|
70
|
+
return {
|
|
71
|
+
name: 'history',
|
|
72
|
+
score: Math.round(score * 10) / 10,
|
|
73
|
+
maxScore: 10,
|
|
74
|
+
issues,
|
|
75
|
+
summary: `${commits.length} recent commits${aiPct > 0 ? ` (~${aiPct}% AI-attributed)` : ''}, ${issues.length} observation${issues.length !== 1 ? 's' : ''}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { join, dirname } from 'node:path';
|
|
2
|
+
import { readFile, fileExists, walkFiles } from '../util.js';
|
|
3
|
+
// Markdown link checker — broken relative links and wikilinks
|
|
4
|
+
export function checkLinks(cwd, ignore) {
|
|
5
|
+
const issues = [];
|
|
6
|
+
const files = walkFiles(cwd, ignore);
|
|
7
|
+
const mdFiles = files.filter(f => f.endsWith('.md'));
|
|
8
|
+
if (mdFiles.length === 0) {
|
|
9
|
+
return { name: 'links', score: 10, maxScore: 10, issues: [], summary: 'no markdown files to check' };
|
|
10
|
+
}
|
|
11
|
+
const allFilesSet = new Set(files);
|
|
12
|
+
// Also index without extension for wikilinks
|
|
13
|
+
const filesByName = new Map();
|
|
14
|
+
for (const f of files) {
|
|
15
|
+
const base = f.replace(/\.[^.]+$/, '');
|
|
16
|
+
filesByName.set(base.toLowerCase(), f);
|
|
17
|
+
// Also just the filename
|
|
18
|
+
const name = f.split('/').pop()?.replace(/\.[^.]+$/, '') || '';
|
|
19
|
+
if (name)
|
|
20
|
+
filesByName.set(name.toLowerCase(), f);
|
|
21
|
+
}
|
|
22
|
+
for (const mdFile of mdFiles) {
|
|
23
|
+
const content = readFile(join(cwd, mdFile));
|
|
24
|
+
if (!content)
|
|
25
|
+
continue;
|
|
26
|
+
const dir = dirname(mdFile);
|
|
27
|
+
const lines = content.split('\n');
|
|
28
|
+
for (let i = 0; i < lines.length; i++) {
|
|
29
|
+
const line = lines[i];
|
|
30
|
+
// Standard markdown links: [text](path)
|
|
31
|
+
const mdLinkRegex = /\[([^\]]*)\]\(([^)]+)\)/g;
|
|
32
|
+
let match;
|
|
33
|
+
while ((match = mdLinkRegex.exec(line)) !== null) {
|
|
34
|
+
const target = match[2].split('#')[0].split('?')[0]; // strip anchors/queries
|
|
35
|
+
if (!target)
|
|
36
|
+
continue; // anchor-only link
|
|
37
|
+
if (target.startsWith('http://') || target.startsWith('https://') || target.startsWith('mailto:'))
|
|
38
|
+
continue;
|
|
39
|
+
if (target.startsWith('/images/') || target.startsWith('/assets/'))
|
|
40
|
+
continue; // common static paths
|
|
41
|
+
const resolved = join(dir, target);
|
|
42
|
+
if (!allFilesSet.has(resolved) && !fileExists(join(cwd, resolved))) {
|
|
43
|
+
issues.push({
|
|
44
|
+
severity: 'warning',
|
|
45
|
+
message: `broken link to "${target}"`,
|
|
46
|
+
file: mdFile,
|
|
47
|
+
line: i + 1,
|
|
48
|
+
fixable: false,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Wikilinks: [[target]]
|
|
53
|
+
const wikiRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
|
|
54
|
+
while ((match = wikiRegex.exec(line)) !== null) {
|
|
55
|
+
const target = match[1].trim().toLowerCase();
|
|
56
|
+
if (!filesByName.has(target) && !allFilesSet.has(target + '.md')) {
|
|
57
|
+
issues.push({
|
|
58
|
+
severity: 'warning',
|
|
59
|
+
message: `broken wikilink [[${match[1].trim()}]]`,
|
|
60
|
+
file: mdFile,
|
|
61
|
+
line: i + 1,
|
|
62
|
+
fixable: false,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const score = Math.max(0, 10 - issues.length * 0.5);
|
|
69
|
+
return {
|
|
70
|
+
name: 'links',
|
|
71
|
+
score: Math.round(Math.min(10, score) * 10) / 10,
|
|
72
|
+
maxScore: 10,
|
|
73
|
+
issues,
|
|
74
|
+
summary: issues.length === 0 ? `${mdFiles.length} markdown files, all links valid` : `${issues.length} broken link${issues.length > 1 ? 's' : ''} across ${mdFiles.length} files`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readFile, walkFiles } from '../util.js';
|
|
3
|
+
// Model sunset/deprecation registry — kept inline for zero deps
|
|
4
|
+
const SUNSET_MODELS = {
|
|
5
|
+
// OpenAI
|
|
6
|
+
'gpt-3.5-turbo': { replacement: 'gpt-4o-mini', sunset: '2025-06' },
|
|
7
|
+
'gpt-4-turbo': { replacement: 'gpt-4o', sunset: '2025-04' },
|
|
8
|
+
'gpt-4-turbo-preview': { replacement: 'gpt-4o', sunset: '2025-04' },
|
|
9
|
+
'gpt-4-0314': { replacement: 'gpt-4o', sunset: '2024-06' },
|
|
10
|
+
'gpt-4-0613': { replacement: 'gpt-4o', sunset: '2025-06' },
|
|
11
|
+
'gpt-4-32k': { replacement: 'gpt-4o', sunset: '2025-06' },
|
|
12
|
+
'text-davinci-003': { replacement: 'gpt-4o-mini', sunset: '2024-01' },
|
|
13
|
+
'code-davinci-002': { replacement: 'gpt-4o', sunset: '2024-01' },
|
|
14
|
+
'text-embedding-ada-002': { replacement: 'text-embedding-3-small', sunset: '2025-04' },
|
|
15
|
+
// Anthropic
|
|
16
|
+
'claude-instant-1': { replacement: 'claude-sonnet-4-5', sunset: '2024-08' },
|
|
17
|
+
'claude-2': { replacement: 'claude-sonnet-4-5', sunset: '2024-08' },
|
|
18
|
+
'claude-2.0': { replacement: 'claude-sonnet-4-5', sunset: '2024-08' },
|
|
19
|
+
'claude-2.1': { replacement: 'claude-sonnet-4-5', sunset: '2024-08' },
|
|
20
|
+
'claude-3-haiku-20240307': { replacement: 'claude-haiku-3-5', sunset: '2025-06' },
|
|
21
|
+
'claude-3-sonnet-20240229': { replacement: 'claude-sonnet-4-5', sunset: '2025-03' },
|
|
22
|
+
'claude-3-opus-20240229': { replacement: 'claude-opus-4-0', sunset: '2025-09' },
|
|
23
|
+
// Google
|
|
24
|
+
'gemini-pro': { replacement: 'gemini-2.0-flash', sunset: '2025-02' },
|
|
25
|
+
'gemini-1.0-pro': { replacement: 'gemini-2.0-flash', sunset: '2025-02' },
|
|
26
|
+
'gemini-1.5-pro': { replacement: 'gemini-2.5-pro', sunset: '2025-09' },
|
|
27
|
+
'gemini-1.5-flash': { replacement: 'gemini-2.0-flash', sunset: '2025-09' },
|
|
28
|
+
'text-bison': { replacement: 'gemini-2.0-flash', sunset: '2024-04' },
|
|
29
|
+
'chat-bison': { replacement: 'gemini-2.0-flash', sunset: '2024-04' },
|
|
30
|
+
// Cohere
|
|
31
|
+
'command': { replacement: 'command-r-plus', sunset: '2025-03' },
|
|
32
|
+
'command-light': { replacement: 'command-r', sunset: '2025-03' },
|
|
33
|
+
'command-nightly': { replacement: 'command-r-plus', sunset: '2025-03' },
|
|
34
|
+
};
|
|
35
|
+
const SCAN_EXTS = ['.ts', '.js', '.tsx', '.jsx', '.py', '.rs', '.go', '.java', '.rb', '.php',
|
|
36
|
+
'.yaml', '.yml', '.json', '.toml', '.env', '.env.example', '.env.local', '.cfg', '.ini', '.conf'];
|
|
37
|
+
// Files that contain model registries should not trigger false positives
|
|
38
|
+
const SELF_IGNORE = ['models.ts', 'models.js', 'model-graveyard', 'model-registry', 'sunset'];
|
|
39
|
+
// Short model names that need context to avoid false positives (e.g. npm "command" field)
|
|
40
|
+
const CONTEXT_REQUIRED = new Set(['command', 'command-light', 'command-nightly']);
|
|
41
|
+
function hasModelContext(content, model) {
|
|
42
|
+
// Require the model name to appear in a string-like context: quotes, assignment, or near "model"/"engine"
|
|
43
|
+
const escaped = model.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
44
|
+
const contextPatterns = [
|
|
45
|
+
new RegExp(`['"\`]${escaped}['"\`]`), // quoted
|
|
46
|
+
new RegExp(`model[_\\s]*[:=].*${escaped}`, 'i'), // model assignment
|
|
47
|
+
new RegExp(`engine[_\\s]*[:=].*${escaped}`, 'i'), // engine assignment
|
|
48
|
+
new RegExp(`${escaped}.*(?:api|llm|chat|completion)`, 'i'), // near API terms
|
|
49
|
+
];
|
|
50
|
+
return contextPatterns.some(p => p.test(content));
|
|
51
|
+
}
|
|
52
|
+
export function checkModels(cwd, ignore) {
|
|
53
|
+
const issues = [];
|
|
54
|
+
const files = walkFiles(cwd, ignore);
|
|
55
|
+
const found = new Map();
|
|
56
|
+
for (const f of files) {
|
|
57
|
+
if (!SCAN_EXTS.some(ext => f.endsWith(ext)))
|
|
58
|
+
continue;
|
|
59
|
+
// Skip files that are model registries themselves
|
|
60
|
+
if (SELF_IGNORE.some(s => f.toLowerCase().includes(s)))
|
|
61
|
+
continue;
|
|
62
|
+
const content = readFile(join(cwd, f));
|
|
63
|
+
if (!content)
|
|
64
|
+
continue;
|
|
65
|
+
for (const [model, info] of Object.entries(SUNSET_MODELS)) {
|
|
66
|
+
if (!content.includes(model))
|
|
67
|
+
continue;
|
|
68
|
+
// For short/ambiguous names, require contextual evidence
|
|
69
|
+
if (CONTEXT_REQUIRED.has(model) && !hasModelContext(content, model))
|
|
70
|
+
continue;
|
|
71
|
+
const existing = found.get(model) || [];
|
|
72
|
+
existing.push(f);
|
|
73
|
+
found.set(model, existing);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const [model, files] of found) {
|
|
77
|
+
const info = SUNSET_MODELS[model];
|
|
78
|
+
const fileList = files.length <= 2 ? files.join(', ') : `${files[0]} +${files.length - 1} more`;
|
|
79
|
+
issues.push({
|
|
80
|
+
severity: 'error',
|
|
81
|
+
message: `deprecated model "${model}" in ${fileList} — use "${info.replacement}"${info.sunset ? ` (sunset ${info.sunset})` : ''}`,
|
|
82
|
+
file: files[0],
|
|
83
|
+
fixable: true,
|
|
84
|
+
fixHint: `replace "${model}" with "${info.replacement}"`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const score = Math.max(0, 10 - issues.length * 2);
|
|
88
|
+
return {
|
|
89
|
+
name: 'models',
|
|
90
|
+
score: Math.min(10, score),
|
|
91
|
+
maxScore: 10,
|
|
92
|
+
issues,
|
|
93
|
+
summary: issues.length === 0 ? 'all model references current' : `${issues.length} deprecated model${issues.length > 1 ? 's' : ''} found`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readFile, walkFiles } from '../util.js';
|
|
3
|
+
// Codebase AI-readiness: structure, complexity, documentation
|
|
4
|
+
export function checkReady(cwd, ignore) {
|
|
5
|
+
const issues = [];
|
|
6
|
+
const files = walkFiles(cwd, ignore);
|
|
7
|
+
// 1. README exists
|
|
8
|
+
const hasReadme = files.some(f => /^readme\.(md|txt|rst)$/i.test(f));
|
|
9
|
+
if (!hasReadme) {
|
|
10
|
+
issues.push({ severity: 'warning', message: 'no README found — AI agents work better with project context', fixable: true, fixHint: 'create a README.md' });
|
|
11
|
+
}
|
|
12
|
+
// 2. Project manifest
|
|
13
|
+
const manifests = ['package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'pom.xml', 'build.gradle', 'Gemfile', 'composer.json'];
|
|
14
|
+
const hasManifest = manifests.some(m => files.includes(m));
|
|
15
|
+
if (!hasManifest) {
|
|
16
|
+
issues.push({ severity: 'warning', message: 'no package manifest found — agents need dependency context', fixable: false });
|
|
17
|
+
}
|
|
18
|
+
// 3. Check for overly large files (>500 lines is harder for AI to reason about)
|
|
19
|
+
let largeFileCount = 0;
|
|
20
|
+
const codeExts = ['.ts', '.js', '.tsx', '.jsx', '.py', '.rs', '.go', '.java', '.rb', '.php', '.cs', '.swift', '.kt'];
|
|
21
|
+
for (const f of files) {
|
|
22
|
+
if (!codeExts.some(ext => f.endsWith(ext)))
|
|
23
|
+
continue;
|
|
24
|
+
const content = readFile(join(cwd, f));
|
|
25
|
+
if (content) {
|
|
26
|
+
const lines = content.split('\n').length;
|
|
27
|
+
if (lines > 500) {
|
|
28
|
+
largeFileCount++;
|
|
29
|
+
if (largeFileCount <= 3) {
|
|
30
|
+
issues.push({ severity: 'info', message: `${f} is ${lines} lines — consider splitting for better AI comprehension`, fixable: false });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (largeFileCount > 3) {
|
|
36
|
+
issues.push({ severity: 'info', message: `...and ${largeFileCount - 3} more large files`, fixable: false });
|
|
37
|
+
}
|
|
38
|
+
// 4. Check for .env.example (helps AI understand required env vars)
|
|
39
|
+
const hasEnv = files.some(f => f === '.env' || f === '.env.local');
|
|
40
|
+
const hasEnvExample = files.some(f => f === '.env.example' || f === '.env.template');
|
|
41
|
+
if (hasEnv && !hasEnvExample) {
|
|
42
|
+
issues.push({ severity: 'warning', message: '.env exists but no .env.example — AI agents can\'t see your env structure', fixable: false });
|
|
43
|
+
}
|
|
44
|
+
// 5. TypeScript/Python type coverage
|
|
45
|
+
const tsFiles = files.filter(f => f.endsWith('.ts') || f.endsWith('.tsx'));
|
|
46
|
+
const jsFiles = files.filter(f => f.endsWith('.js') || f.endsWith('.jsx'));
|
|
47
|
+
if (jsFiles.length > 10 && tsFiles.length === 0 && files.includes('package.json')) {
|
|
48
|
+
issues.push({ severity: 'info', message: `${jsFiles.length} JS files with no TypeScript — typed code gives AI agents better context`, fixable: false });
|
|
49
|
+
}
|
|
50
|
+
// 6. Test coverage indicator
|
|
51
|
+
const testFiles = files.filter(f => /\.(test|spec)\.(ts|js|tsx|jsx|py)$/.test(f) || f.includes('__tests__/') || f.startsWith('tests/') || f.startsWith('test/'));
|
|
52
|
+
const codeFiles = files.filter(f => codeExts.some(ext => f.endsWith(ext)));
|
|
53
|
+
if (codeFiles.length > 5 && testFiles.length === 0) {
|
|
54
|
+
issues.push({ severity: 'warning', message: 'no test files found — AI agents produce better code when tests exist to validate against', fixable: false });
|
|
55
|
+
}
|
|
56
|
+
// Score: start at 10, deduct
|
|
57
|
+
const errors = issues.filter(i => i.severity === 'error').length;
|
|
58
|
+
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
59
|
+
const infos = issues.filter(i => i.severity === 'info').length;
|
|
60
|
+
const score = Math.max(0, Math.min(10, 10 - errors * 2 - warnings * 1 - infos * 0.3));
|
|
61
|
+
return {
|
|
62
|
+
name: 'ready',
|
|
63
|
+
score: Math.round(score * 10) / 10,
|
|
64
|
+
maxScore: 10,
|
|
65
|
+
issues,
|
|
66
|
+
summary: issues.length === 0 ? 'codebase is well-structured for AI' : `${issues.length} suggestions for better AI readiness`,
|
|
67
|
+
};
|
|
68
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { isGitRepo, readFile } from './util.js';
|
|
5
|
+
import { checkReady } from './checks/ready.js';
|
|
6
|
+
import { checkDiff } from './checks/diff.js';
|
|
7
|
+
import { checkModels } from './checks/models.js';
|
|
8
|
+
import { checkLinks } from './checks/links.js';
|
|
9
|
+
import { checkConfig } from './checks/config.js';
|
|
10
|
+
import { checkHistory } from './checks/history.js';
|
|
11
|
+
import { score } from './scorer.js';
|
|
12
|
+
import { reportPretty, reportJSON } from './reporter.js';
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
const flags = new Set(args.filter(a => a.startsWith('-')));
|
|
15
|
+
const positional = args.filter(a => !a.startsWith('-'));
|
|
16
|
+
if (flags.has('--help') || flags.has('-h')) {
|
|
17
|
+
console.log(`
|
|
18
|
+
vet — vet your AI-generated code
|
|
19
|
+
|
|
20
|
+
usage:
|
|
21
|
+
npx @safetnsr/vet [dir] run all checks (default: cwd)
|
|
22
|
+
npx @safetnsr/vet --ci exit code 1 if score < threshold
|
|
23
|
+
npx @safetnsr/vet --fix auto-repair fixable issues
|
|
24
|
+
npx @safetnsr/vet --json output JSON
|
|
25
|
+
npx @safetnsr/vet init generate .vetrc + agent config
|
|
26
|
+
|
|
27
|
+
options:
|
|
28
|
+
--ci CI mode (exit 1 if below threshold)
|
|
29
|
+
--fix auto-fix what we can
|
|
30
|
+
--json JSON output
|
|
31
|
+
--no-color disable colors
|
|
32
|
+
-h, --help show this help
|
|
33
|
+
-v, --version show version
|
|
34
|
+
`);
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
if (flags.has('--version') || flags.has('-v')) {
|
|
38
|
+
try {
|
|
39
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
40
|
+
console.log(pkg.version);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
console.log('0.1.0');
|
|
44
|
+
}
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
const COMMANDS = ['init'];
|
|
48
|
+
const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
|
|
49
|
+
const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
|
|
50
|
+
const isCI = flags.has('--ci');
|
|
51
|
+
const isFix = flags.has('--fix');
|
|
52
|
+
const isJSON = flags.has('--json') || (!process.stdout.isTTY && !flags.has('--pretty'));
|
|
53
|
+
// Load config
|
|
54
|
+
let config = {};
|
|
55
|
+
const configContent = readFile(resolve(cwd, '.vetrc'));
|
|
56
|
+
if (configContent) {
|
|
57
|
+
try {
|
|
58
|
+
config = JSON.parse(configContent);
|
|
59
|
+
}
|
|
60
|
+
catch { /* ignore bad config */ }
|
|
61
|
+
}
|
|
62
|
+
const ignore = config.ignore || [];
|
|
63
|
+
if (command === 'init') {
|
|
64
|
+
const { init } = await import('./init.js');
|
|
65
|
+
await init(cwd);
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
// Run checks
|
|
69
|
+
if (!isGitRepo(cwd)) {
|
|
70
|
+
console.error('not a git repository. vet operates on git repos.');
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
const allChecks = ['ready', 'diff', 'models', 'links', 'config', 'history'];
|
|
74
|
+
const enabledChecks = config.checks || allChecks;
|
|
75
|
+
const results = [];
|
|
76
|
+
if (enabledChecks.includes('ready'))
|
|
77
|
+
results.push(checkReady(cwd, ignore));
|
|
78
|
+
if (enabledChecks.includes('diff'))
|
|
79
|
+
results.push(checkDiff(cwd));
|
|
80
|
+
if (enabledChecks.includes('models'))
|
|
81
|
+
results.push(checkModels(cwd, ignore));
|
|
82
|
+
if (enabledChecks.includes('links'))
|
|
83
|
+
results.push(checkLinks(cwd, ignore));
|
|
84
|
+
if (enabledChecks.includes('config'))
|
|
85
|
+
results.push(checkConfig(cwd, ignore));
|
|
86
|
+
if (enabledChecks.includes('history'))
|
|
87
|
+
results.push(checkHistory(cwd));
|
|
88
|
+
const result = score(cwd, results);
|
|
89
|
+
if (isJSON) {
|
|
90
|
+
console.log(reportJSON(result));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
console.log(reportPretty(result));
|
|
94
|
+
}
|
|
95
|
+
// CI exit code
|
|
96
|
+
if (isCI) {
|
|
97
|
+
const threshold = config.thresholds?.min ?? 6;
|
|
98
|
+
process.exit(result.score >= threshold ? 0 : 1);
|
|
99
|
+
}
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function init(cwd: string): Promise<void>;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { c, readFile } from './util.js';
|
|
4
|
+
export async function init(cwd) {
|
|
5
|
+
let created = 0;
|
|
6
|
+
// 1. Create .vetrc
|
|
7
|
+
const vetrcPath = join(cwd, '.vetrc');
|
|
8
|
+
if (!existsSync(vetrcPath)) {
|
|
9
|
+
writeFileSync(vetrcPath, JSON.stringify({
|
|
10
|
+
checks: ['ready', 'diff', 'models', 'links', 'config', 'history'],
|
|
11
|
+
ignore: [],
|
|
12
|
+
thresholds: { min: 6 },
|
|
13
|
+
}, null, 2) + '\n');
|
|
14
|
+
console.log(` ${c.green}+${c.reset} .vetrc`);
|
|
15
|
+
created++;
|
|
16
|
+
}
|
|
17
|
+
// 2. Detect project type and create CLAUDE.md if missing
|
|
18
|
+
const claudeMd = join(cwd, 'CLAUDE.md');
|
|
19
|
+
if (!existsSync(claudeMd)) {
|
|
20
|
+
const projectContext = detectProject(cwd);
|
|
21
|
+
writeFileSync(claudeMd, generateClaudeMd(projectContext));
|
|
22
|
+
console.log(` ${c.green}+${c.reset} CLAUDE.md`);
|
|
23
|
+
created++;
|
|
24
|
+
}
|
|
25
|
+
// 3. Create .cursorrules if missing
|
|
26
|
+
const cursorRules = join(cwd, '.cursorrules');
|
|
27
|
+
if (!existsSync(cursorRules)) {
|
|
28
|
+
const projectContext = detectProject(cwd);
|
|
29
|
+
writeFileSync(cursorRules, generateCursorRules(projectContext));
|
|
30
|
+
console.log(` ${c.green}+${c.reset} .cursorrules`);
|
|
31
|
+
created++;
|
|
32
|
+
}
|
|
33
|
+
// 4. Add pre-commit hook if .git exists
|
|
34
|
+
const hooksDir = join(cwd, '.git', 'hooks');
|
|
35
|
+
const preCommit = join(hooksDir, 'pre-commit');
|
|
36
|
+
if (existsSync(join(cwd, '.git')) && !existsSync(preCommit)) {
|
|
37
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
38
|
+
writeFileSync(preCommit, `#!/bin/sh
|
|
39
|
+
# vet pre-commit hook — checks AI-generated code before committing
|
|
40
|
+
npx @safetnsr/vet --ci --json > /dev/null 2>&1
|
|
41
|
+
if [ $? -ne 0 ]; then
|
|
42
|
+
echo "vet: score below threshold. run 'npx @safetnsr/vet' to see details."
|
|
43
|
+
exit 1
|
|
44
|
+
fi
|
|
45
|
+
`);
|
|
46
|
+
const { chmodSync } = await import('node:fs');
|
|
47
|
+
chmodSync(preCommit, 0o755);
|
|
48
|
+
console.log(` ${c.green}+${c.reset} .git/hooks/pre-commit`);
|
|
49
|
+
created++;
|
|
50
|
+
}
|
|
51
|
+
if (created === 0) {
|
|
52
|
+
console.log(` ${c.dim}everything already set up${c.reset}`);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
console.log(`\n ${c.green}initialized ${created} file${created > 1 ? 's' : ''}${c.reset}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function detectProject(cwd) {
|
|
59
|
+
const ctx = { name: 'project', language: 'unknown', hasTests: false };
|
|
60
|
+
const pkgJson = readFile(join(cwd, 'package.json'));
|
|
61
|
+
if (pkgJson) {
|
|
62
|
+
try {
|
|
63
|
+
const pkg = JSON.parse(pkgJson);
|
|
64
|
+
ctx.name = pkg.name || 'project';
|
|
65
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
66
|
+
ctx.language = deps.typescript ? 'typescript' : 'javascript';
|
|
67
|
+
if (deps.react)
|
|
68
|
+
ctx.framework = 'react';
|
|
69
|
+
if (deps.next)
|
|
70
|
+
ctx.framework = 'next.js';
|
|
71
|
+
if (deps.vue)
|
|
72
|
+
ctx.framework = 'vue';
|
|
73
|
+
if (deps.svelte)
|
|
74
|
+
ctx.framework = 'svelte';
|
|
75
|
+
if (deps.express)
|
|
76
|
+
ctx.framework = 'express';
|
|
77
|
+
if (deps.hono)
|
|
78
|
+
ctx.framework = 'hono';
|
|
79
|
+
if (deps.fastify)
|
|
80
|
+
ctx.framework = 'fastify';
|
|
81
|
+
if (pkg.scripts?.test)
|
|
82
|
+
ctx.hasTests = true;
|
|
83
|
+
}
|
|
84
|
+
catch { /* */ }
|
|
85
|
+
}
|
|
86
|
+
if (existsSync(join(cwd, 'pyproject.toml')) || existsSync(join(cwd, 'setup.py')))
|
|
87
|
+
ctx.language = 'python';
|
|
88
|
+
if (existsSync(join(cwd, 'Cargo.toml')))
|
|
89
|
+
ctx.language = 'rust';
|
|
90
|
+
if (existsSync(join(cwd, 'go.mod')))
|
|
91
|
+
ctx.language = 'go';
|
|
92
|
+
return ctx;
|
|
93
|
+
}
|
|
94
|
+
function generateClaudeMd(ctx) {
|
|
95
|
+
const lines = [`# ${ctx.name}\n`];
|
|
96
|
+
lines.push(`## Project`);
|
|
97
|
+
lines.push(`- Language: ${ctx.language}`);
|
|
98
|
+
if (ctx.framework)
|
|
99
|
+
lines.push(`- Framework: ${ctx.framework}`);
|
|
100
|
+
lines.push('');
|
|
101
|
+
lines.push(`## Rules`);
|
|
102
|
+
lines.push(`- Keep functions small and focused`);
|
|
103
|
+
lines.push(`- Handle errors explicitly — no empty catch blocks`);
|
|
104
|
+
lines.push(`- No hardcoded secrets or API keys`);
|
|
105
|
+
if (ctx.hasTests)
|
|
106
|
+
lines.push(`- Write tests for new functionality`);
|
|
107
|
+
if (ctx.language === 'typescript')
|
|
108
|
+
lines.push(`- Use strict TypeScript — no \`any\` types`);
|
|
109
|
+
lines.push('');
|
|
110
|
+
return lines.join('\n');
|
|
111
|
+
}
|
|
112
|
+
function generateCursorRules(ctx) {
|
|
113
|
+
const lines = [`# ${ctx.name}\n`];
|
|
114
|
+
if (ctx.framework)
|
|
115
|
+
lines.push(`This is a ${ctx.framework} project using ${ctx.language}.`);
|
|
116
|
+
else
|
|
117
|
+
lines.push(`This is a ${ctx.language} project.`);
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push('## Guidelines');
|
|
120
|
+
lines.push('- Keep functions small and focused');
|
|
121
|
+
lines.push('- Handle errors explicitly');
|
|
122
|
+
lines.push('- No hardcoded secrets');
|
|
123
|
+
if (ctx.hasTests)
|
|
124
|
+
lines.push('- Maintain test coverage');
|
|
125
|
+
lines.push('');
|
|
126
|
+
return lines.join('\n');
|
|
127
|
+
}
|
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { c } from './util.js';
|
|
2
|
+
const BAR_WIDTH = 10;
|
|
3
|
+
function bar(score, max) {
|
|
4
|
+
const filled = Math.round((score / max) * BAR_WIDTH);
|
|
5
|
+
const empty = BAR_WIDTH - filled;
|
|
6
|
+
const color = score >= 8 ? c.green : score >= 5 ? c.yellow : c.red;
|
|
7
|
+
return `${color}${'█'.repeat(filled)}${c.dim}${'░'.repeat(empty)}${c.reset}`;
|
|
8
|
+
}
|
|
9
|
+
function severityIcon(s) {
|
|
10
|
+
switch (s) {
|
|
11
|
+
case 'error': return `${c.red}✗${c.reset}`;
|
|
12
|
+
case 'warning': return `${c.yellow}!${c.reset}`;
|
|
13
|
+
case 'info': return `${c.blue}·${c.reset}`;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function reportPretty(result) {
|
|
17
|
+
const lines = [];
|
|
18
|
+
const scoreColor = result.score >= 8 ? c.green : result.score >= 5 ? c.yellow : c.red;
|
|
19
|
+
lines.push('');
|
|
20
|
+
lines.push(` ${c.bold}${result.project}${c.reset} ${scoreColor}${result.score.toFixed(1)}/10${c.reset}`);
|
|
21
|
+
lines.push('');
|
|
22
|
+
for (const check of result.checks) {
|
|
23
|
+
const pad = ' '.repeat(Math.max(0, 10 - check.name.length));
|
|
24
|
+
lines.push(` ${check.name}${pad}${bar(check.score, check.maxScore)} ${check.score.toFixed(0).padStart(2)} ${c.dim}${check.summary}${c.reset}`);
|
|
25
|
+
}
|
|
26
|
+
// Issues
|
|
27
|
+
const allIssues = result.checks.flatMap(ch => ch.issues);
|
|
28
|
+
const errors = allIssues.filter(i => i.severity === 'error');
|
|
29
|
+
const warnings = allIssues.filter(i => i.severity === 'warning');
|
|
30
|
+
if (errors.length > 0 || warnings.length > 0) {
|
|
31
|
+
lines.push('');
|
|
32
|
+
const issueList = [...errors, ...warnings].slice(0, 10); // top 10
|
|
33
|
+
for (const issue of issueList) {
|
|
34
|
+
const loc = issue.file ? `${c.dim}${issue.file}${issue.line ? ':' + issue.line : ''}${c.reset} ` : '';
|
|
35
|
+
lines.push(` ${severityIcon(issue.severity)} ${loc}${issue.message}`);
|
|
36
|
+
}
|
|
37
|
+
const remaining = errors.length + warnings.length - issueList.length;
|
|
38
|
+
if (remaining > 0) {
|
|
39
|
+
lines.push(` ${c.dim}...and ${remaining} more${c.reset}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (result.fixableIssues > 0) {
|
|
43
|
+
lines.push('');
|
|
44
|
+
lines.push(` ${c.cyan}run with --fix to auto-repair ${result.fixableIssues} issue${result.fixableIssues > 1 ? 's' : ''}${c.reset}`);
|
|
45
|
+
}
|
|
46
|
+
lines.push('');
|
|
47
|
+
return lines.join('\n');
|
|
48
|
+
}
|
|
49
|
+
export function reportJSON(result) {
|
|
50
|
+
return JSON.stringify(result, null, 2);
|
|
51
|
+
}
|
package/dist/scorer.d.ts
ADDED
package/dist/scorer.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
export function score(project, checks) {
|
|
3
|
+
const totalScore = checks.reduce((sum, ch) => sum + ch.score, 0);
|
|
4
|
+
const maxTotal = checks.reduce((sum, ch) => sum + ch.maxScore, 0);
|
|
5
|
+
const normalized = maxTotal > 0 ? (totalScore / maxTotal) * 10 : 10;
|
|
6
|
+
const allIssues = checks.flatMap(ch => ch.issues);
|
|
7
|
+
return {
|
|
8
|
+
project: basename(project),
|
|
9
|
+
score: Math.round(normalized * 10) / 10,
|
|
10
|
+
checks,
|
|
11
|
+
totalIssues: allIssues.length,
|
|
12
|
+
fixableIssues: allIssues.filter(i => i.fixable).length,
|
|
13
|
+
timestamp: new Date().toISOString(),
|
|
14
|
+
};
|
|
15
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface CheckResult {
|
|
2
|
+
name: string;
|
|
3
|
+
score: number;
|
|
4
|
+
maxScore: number;
|
|
5
|
+
issues: Issue[];
|
|
6
|
+
summary: string;
|
|
7
|
+
}
|
|
8
|
+
export interface Issue {
|
|
9
|
+
severity: 'error' | 'warning' | 'info';
|
|
10
|
+
message: string;
|
|
11
|
+
file?: string;
|
|
12
|
+
line?: number;
|
|
13
|
+
fixable: boolean;
|
|
14
|
+
fixHint?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface VetResult {
|
|
17
|
+
project: string;
|
|
18
|
+
score: number;
|
|
19
|
+
checks: CheckResult[];
|
|
20
|
+
totalIssues: number;
|
|
21
|
+
fixableIssues: number;
|
|
22
|
+
timestamp: string;
|
|
23
|
+
}
|
|
24
|
+
export interface VetConfig {
|
|
25
|
+
checks?: string[];
|
|
26
|
+
ignore?: string[];
|
|
27
|
+
thresholds?: {
|
|
28
|
+
min?: number;
|
|
29
|
+
};
|
|
30
|
+
agents?: string[];
|
|
31
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const c: {
|
|
2
|
+
reset: string;
|
|
3
|
+
bold: string;
|
|
4
|
+
dim: string;
|
|
5
|
+
red: string;
|
|
6
|
+
green: string;
|
|
7
|
+
yellow: string;
|
|
8
|
+
blue: string;
|
|
9
|
+
cyan: string;
|
|
10
|
+
gray: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function git(cmd: string, cwd: string): string;
|
|
13
|
+
export declare function gitExec(args: string[], cwd: string): string;
|
|
14
|
+
export declare function isGitRepo(cwd: string): boolean;
|
|
15
|
+
export declare function readFile(path: string): string | null;
|
|
16
|
+
export declare function fileExists(path: string): boolean;
|
|
17
|
+
export declare function walkFiles(dir: string, ignore?: string[]): string[];
|
|
18
|
+
export declare function matchesAny(file: string, patterns: string[]): boolean;
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
// ANSI colors — zero deps
|
|
5
|
+
export const c = {
|
|
6
|
+
reset: '\x1b[0m',
|
|
7
|
+
bold: '\x1b[1m',
|
|
8
|
+
dim: '\x1b[2m',
|
|
9
|
+
red: '\x1b[31m',
|
|
10
|
+
green: '\x1b[32m',
|
|
11
|
+
yellow: '\x1b[33m',
|
|
12
|
+
blue: '\x1b[34m',
|
|
13
|
+
cyan: '\x1b[36m',
|
|
14
|
+
gray: '\x1b[90m',
|
|
15
|
+
};
|
|
16
|
+
export function git(cmd, cwd) {
|
|
17
|
+
try {
|
|
18
|
+
return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function gitExec(args, cwd) {
|
|
25
|
+
try {
|
|
26
|
+
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function isGitRepo(cwd) {
|
|
33
|
+
return git('rev-parse --is-inside-work-tree', cwd) === 'true';
|
|
34
|
+
}
|
|
35
|
+
export function readFile(path) {
|
|
36
|
+
try {
|
|
37
|
+
return readFileSync(path, 'utf-8');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function fileExists(path) {
|
|
44
|
+
return existsSync(path);
|
|
45
|
+
}
|
|
46
|
+
export function walkFiles(dir, ignore = []) {
|
|
47
|
+
const results = [];
|
|
48
|
+
const defaultIgnore = ['node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'vendor', '__pycache__', '.venv', 'venv'];
|
|
49
|
+
const allIgnore = [...defaultIgnore, ...ignore];
|
|
50
|
+
function walk(d) {
|
|
51
|
+
let entries;
|
|
52
|
+
try {
|
|
53
|
+
entries = readdirSync(d);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
if (allIgnore.includes(entry))
|
|
60
|
+
continue;
|
|
61
|
+
const full = join(d, entry);
|
|
62
|
+
try {
|
|
63
|
+
const stat = statSync(full);
|
|
64
|
+
if (stat.isDirectory())
|
|
65
|
+
walk(full);
|
|
66
|
+
else
|
|
67
|
+
results.push(relative(dir, full));
|
|
68
|
+
}
|
|
69
|
+
catch { /* skip */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
walk(dir);
|
|
73
|
+
return results;
|
|
74
|
+
}
|
|
75
|
+
export function matchesAny(file, patterns) {
|
|
76
|
+
return patterns.some(p => {
|
|
77
|
+
if (p.endsWith('/'))
|
|
78
|
+
return file.startsWith(p) || file.includes('/' + p);
|
|
79
|
+
if (p.startsWith('*.'))
|
|
80
|
+
return file.endsWith(p.slice(1));
|
|
81
|
+
return file === p || file.includes('/' + p);
|
|
82
|
+
});
|
|
83
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@safetnsr/vet",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "vet your AI-generated code — one command, six checks, zero config",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"vet": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"dev": "tsx src/cli.ts"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ai",
|
|
19
|
+
"code-quality",
|
|
20
|
+
"vibe-coding",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"cursor",
|
|
23
|
+
"copilot",
|
|
24
|
+
"lint",
|
|
25
|
+
"agent",
|
|
26
|
+
"git"
|
|
27
|
+
],
|
|
28
|
+
"author": "safetnsr",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/safetnsr/vet"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^20.0.0",
|
|
36
|
+
"tsx": "^4.21.0",
|
|
37
|
+
"typescript": "^5.7.0"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
}
|
|
42
|
+
}
|