@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 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,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkConfig(cwd: string, ignore: string[]): CheckResult;
@@ -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,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkDiff(cwd: string): CheckResult;
@@ -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,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkHistory(cwd: string): CheckResult;
@@ -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,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkLinks(cwd: string, ignore: string[]): CheckResult;
@@ -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,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkModels(cwd: string, ignore: string[]): CheckResult;
@@ -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,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkReady(cwd: string, ignore: string[]): CheckResult;
@@ -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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ }
@@ -0,0 +1,3 @@
1
+ import type { VetResult } from './types.js';
2
+ export declare function reportPretty(result: VetResult): string;
3
+ export declare function reportJSON(result: VetResult): string;
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ import type { CheckResult, VetResult } from './types.js';
2
+ export declare function score(project: string, checks: CheckResult[]): VetResult;
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
+ }
@@ -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
+ }