@safetnsr/vet 0.1.0 → 0.2.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 CHANGED
@@ -13,49 +13,102 @@ works with Claude Code, Cursor, Copilot, Codex, Aider, Windsurf, Cline — anyth
13
13
  | check | what | how |
14
14
  |-------|------|-----|
15
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 |
16
+ | **diff** | did the AI leave anti-patterns? | AI-specific patterns: wholesale rewrites, orphaned imports, catch-alls, over-commenting, plus secrets & stubs |
17
+ | **models** | using deprecated AI models? | scans code for sunset model strings across OpenAI, Anthropic, Google, Cohere |
18
18
  | **links** | broken markdown links? | validates relative links and wikilinks |
19
- | **config** | agent configs in place? | checks for CLAUDE.md, .cursorrules, copilot-instructions, etc. |
19
+ | **config** | agent configs in place? | deep analysis of CLAUDE.md, .cursorrules, copilot-instructions — checks completeness, consistency, and specificity against your actual codebase |
20
20
  | **history** | git patterns healthy? | analyzes commit churn, AI attribution, large changes |
21
21
 
22
22
  ## usage
23
23
 
24
24
  ```bash
25
- # run all checks (default)
25
+ # run all checks
26
26
  npx @safetnsr/vet
27
27
 
28
28
  # check a specific directory
29
29
  npx @safetnsr/vet ./my-project
30
30
 
31
+ # auto-fix: generate CLAUDE.md, .cursorrules, fix deprecated models
32
+ npx @safetnsr/vet --fix
33
+
34
+ # check specific commit range
35
+ npx @safetnsr/vet --since HEAD~5
36
+
37
+ # live monitoring during AI sessions
38
+ npx @safetnsr/vet --watch
39
+
31
40
  # CI mode — exit code 1 if score below threshold
32
41
  npx @safetnsr/vet --ci
33
42
 
34
- # auto-fix what we can
35
- npx @safetnsr/vet --fix
36
-
37
43
  # JSON output
38
44
  npx @safetnsr/vet --json
39
45
 
40
- # set up config + agent files + pre-commit hook
46
+ # generate configs + pre-commit hook
41
47
  npx @safetnsr/vet init
42
48
  ```
43
49
 
44
50
  ## output
45
51
 
46
52
  ```
47
- my-project 8.2/10
53
+ my-project 6.2/10
48
54
 
49
- ready ████████░░ 8 structure + docs look good
50
- diff ██████████ 10 clean diff, no issues
55
+ ready ████░░░░░░ 4 3 readiness issues
56
+ diff ████████░░ 8 3 issues (2 AI-specific) in 5 files
51
57
  models ██████████ 10 all models current
52
58
  links ██████░░░░ 6 3 broken links in docs/
53
- config ████████░░ 8 CLAUDE.md missing react patterns
54
- history ████████░░ 8 2 high-churn files
59
+ config ███░░░░░░░ 3 Cursor needs work (3/10)
60
+ history █████████░ 9 41 commits (~15% AI-attributed)
61
+
62
+ ✗ no README — AI agents have no project context
63
+ ✗ no tests — AI agents produce better code when tests exist
64
+ ! [ai] wholesale rewrite: 40 lines removed, 45 added in utils.ts
65
+ ! [ai] imported "lodash" but never used in new code
55
66
 
56
67
  run --fix to auto-repair 4 issues
57
68
  ```
58
69
 
70
+ ## --fix
71
+
72
+ `vet --fix` doesn't just scaffold — it analyzes your codebase and generates project-specific configs:
73
+
74
+ ```bash
75
+ $ npx @safetnsr/vet --fix
76
+
77
+ vet --fix
78
+
79
+ + CLAUDE.md (generated from codebase: Next.js + React, Vitest, Tailwind CSS, TypeScript)
80
+ + .cursorrules (generated)
81
+ ✓ src/api.ts: "gpt-3.5-turbo" → "gpt-4o-mini"
82
+
83
+ fixed 3 issues
84
+ ```
85
+
86
+ the generated CLAUDE.md includes your actual stack, directory structure, and framework-specific rules — not generic boilerplate.
87
+
88
+ ## AI-specific diff patterns
89
+
90
+ vet catches things that are specific to AI-generated code:
91
+
92
+ | pattern | what it catches |
93
+ |---------|----------------|
94
+ | `[ai] wholesale rewrite` | AI rewrote an entire function when a small edit would suffice |
95
+ | `[ai] orphaned imports` | AI added imports it never uses |
96
+ | `[ai] catch-all handling` | `catch(e) { console.error(e) }` instead of specific error handling |
97
+ | `[ai] comment density` | AI over-commented obvious code |
98
+ | `[ai] empty test body` | AI stubbed a test without implementation |
99
+ | `[ai] trivial assertion` | `expect(true).toBe(true)` — test proves nothing |
100
+
101
+ ## config analysis
102
+
103
+ the config check does deep analysis — not just "does CLAUDE.md exist":
104
+
105
+ ```
106
+ config score breakdown:
107
+ completeness: 4/10 — mentions typescript but not react, vitest
108
+ consistency: 7/10 — "strict TS" but tsconfig.strict is false
109
+ specificity: 3/10 — generic rules, nothing project-specific
110
+ ```
111
+
59
112
  ## config
60
113
 
61
114
  create `.vetrc` in your project root (optional):
@@ -68,14 +121,6 @@ create `.vetrc` in your project root (optional):
68
121
  }
69
122
  ```
70
123
 
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
124
  ## ci
80
125
 
81
126
  ```yaml
@@ -11,92 +11,196 @@ const AGENT_CONFIGS = {
11
11
  windsurf: { files: ['.windsurfrules'], name: 'Windsurf' },
12
12
  cline: { files: ['.clinerules', '.cline/settings.json'], name: 'Cline' },
13
13
  };
14
+ function analyzeConfig(cwd, configFile, agentName, files) {
15
+ const content = readFile(join(cwd, configFile)) || '';
16
+ const contentLower = content.toLowerCase();
17
+ const suggestions = [];
18
+ // Existence: it exists, so 10
19
+ const existence = 10;
20
+ // Completeness: does it cover the project's actual stack?
21
+ let completenessScore = 5; // base
22
+ let completenessChecks = 0;
23
+ let completenessHits = 0;
24
+ const pkgJson = readFile(join(cwd, 'package.json'));
25
+ const deps = {};
26
+ let projectName = '';
27
+ if (pkgJson) {
28
+ try {
29
+ const pkg = JSON.parse(pkgJson);
30
+ projectName = pkg.name || '';
31
+ Object.assign(deps, pkg.dependencies, pkg.devDependencies);
32
+ }
33
+ catch { /* */ }
34
+ }
35
+ // Framework detection + config coverage
36
+ const frameworkMap = {
37
+ react: { keywords: ['react', 'jsx', 'tsx', 'component', 'hook', 'usestate', 'useeffect'], category: 'UI framework' },
38
+ next: { keywords: ['next', 'nextjs', 'app router', 'pages router', 'server component'], category: 'framework' },
39
+ vue: { keywords: ['vue', 'composition api', 'options api', 'ref(', 'reactive'], category: 'UI framework' },
40
+ svelte: { keywords: ['svelte', 'sveltekit', '$:'], category: 'UI framework' },
41
+ express: { keywords: ['express', 'middleware', 'router', 'req, res'], category: 'backend' },
42
+ hono: { keywords: ['hono', 'c.json', 'c.text'], category: 'backend' },
43
+ fastify: { keywords: ['fastify', 'schema', 'route'], category: 'backend' },
44
+ vitest: { keywords: ['vitest', 'describe', 'it(', 'expect', 'test('], category: 'testing' },
45
+ jest: { keywords: ['jest', 'describe', 'it(', 'expect', 'test('], category: 'testing' },
46
+ tailwind: { keywords: ['tailwind', 'className', 'tw-'], category: 'styling' },
47
+ prisma: { keywords: ['prisma', 'schema.prisma', 'prismaClient'], category: 'database' },
48
+ drizzle: { keywords: ['drizzle', 'drizzle-orm'], category: 'database' },
49
+ };
50
+ for (const [dep, info] of Object.entries(frameworkMap)) {
51
+ if (deps[dep] || deps[`@${dep}/core`] || deps[`${dep}-dom`]) {
52
+ completenessChecks++;
53
+ if (info.keywords.some(k => contentLower.includes(k))) {
54
+ completenessHits++;
55
+ }
56
+ else {
57
+ suggestions.push(`add ${dep} conventions (${info.category} detected in dependencies)`);
58
+ }
59
+ }
60
+ }
61
+ if (completenessChecks > 0) {
62
+ completenessScore = Math.round((completenessHits / completenessChecks) * 10);
63
+ }
64
+ // Consistency: cross-reference with actual project config
65
+ let consistencyScore = 10;
66
+ const tsconfig = readFile(join(cwd, 'tsconfig.json'));
67
+ if (tsconfig) {
68
+ try {
69
+ const tc = JSON.parse(tsconfig);
70
+ const strict = tc.compilerOptions?.strict;
71
+ if (contentLower.includes('strict') && strict === false) {
72
+ consistencyScore -= 4;
73
+ suggestions.push('config says "strict" but tsconfig.strict is false — resolve contradiction');
74
+ }
75
+ if (contentLower.includes('esm') && tc.compilerOptions?.module?.toLowerCase()?.includes('commonjs')) {
76
+ consistencyScore -= 3;
77
+ suggestions.push('config mentions ESM but tsconfig uses CommonJS');
78
+ }
79
+ }
80
+ catch { /* */ }
81
+ }
82
+ // Check if config mentions testing but no test framework installed
83
+ if ((contentLower.includes('test') || contentLower.includes('spec')) && !deps.vitest && !deps.jest && !deps.mocha && !deps.ava) {
84
+ consistencyScore -= 2;
85
+ suggestions.push('config mentions tests but no test framework in dependencies');
86
+ }
87
+ // Specificity: generic platitudes vs project-specific rules
88
+ let specificityScore = 5;
89
+ const genericPhrases = [
90
+ 'keep functions small', 'write clean code', 'follow best practices',
91
+ 'use meaningful names', 'handle errors', 'write tests', 'be consistent',
92
+ 'follow conventions', 'keep it simple',
93
+ ];
94
+ let genericCount = 0;
95
+ for (const phrase of genericPhrases) {
96
+ if (contentLower.includes(phrase))
97
+ genericCount++;
98
+ }
99
+ // Specific indicators: file paths, function names, patterns, architecture
100
+ const specificIndicators = [
101
+ /\.(ts|js|py|rs|go)\b/, // mentions specific file types with context
102
+ /src\/|lib\/|app\/|pages\/|components\//, // directory structure
103
+ /import .+ from/, // code examples
104
+ /```/, // code blocks
105
+ /\bapi\/|route|endpoint/i, // API patterns
106
+ /\bmigration|schema|model\b/i, // data patterns
107
+ ];
108
+ let specificCount = 0;
109
+ for (const pattern of specificIndicators) {
110
+ if (pattern.test(content))
111
+ specificCount++;
112
+ }
113
+ if (genericCount > 3 && specificCount < 2) {
114
+ specificityScore = 2;
115
+ suggestions.push('mostly generic rules — add project-specific conventions, file paths, architecture patterns');
116
+ }
117
+ else if (specificCount >= 4) {
118
+ specificityScore = 9;
119
+ }
120
+ else if (specificCount >= 2) {
121
+ specificityScore = 6;
122
+ }
123
+ // Length-based adjustments
124
+ if (content.length < 100) {
125
+ specificityScore = Math.min(specificityScore, 2);
126
+ completenessScore = Math.min(completenessScore, 2);
127
+ suggestions.push('config is very sparse — add project context, conventions, and constraints');
128
+ }
129
+ else if (content.length < 300) {
130
+ specificityScore = Math.min(specificityScore, 5);
131
+ suggestions.push('config could be richer — consider adding architecture decisions and code patterns');
132
+ }
133
+ return {
134
+ file: configFile,
135
+ agent: agentName,
136
+ length: content.length,
137
+ existence,
138
+ completeness: Math.max(0, completenessScore),
139
+ consistency: Math.max(0, consistencyScore),
140
+ specificity: Math.max(0, specificityScore),
141
+ suggestions,
142
+ };
143
+ }
14
144
  export function checkConfig(cwd, ignore) {
15
145
  const issues = [];
16
146
  const files = walkFiles(cwd, ignore);
17
147
  // Detect which agents have config
18
- const detected = [];
19
- const missing = [];
148
+ const analyses = [];
20
149
  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);
150
+ for (const configFile of info.files) {
151
+ if (fileExists(join(cwd, configFile))) {
152
+ analyses.push(analyzeConfig(cwd, configFile, info.name, files));
153
+ }
154
+ }
24
155
  }
25
- if (detected.length === 0) {
156
+ if (analyses.length === 0) {
26
157
  issues.push({
27
- severity: 'warning',
28
- message: 'no AI agent config found — add CLAUDE.md, .cursorrules, or similar to guide AI behavior',
158
+ severity: 'error',
159
+ message: 'no AI agent config found — add CLAUDE.md, .cursorrules, or similar',
29
160
  fixable: true,
30
161
  fixHint: 'run vet init to generate agent config',
31
162
  });
163
+ return {
164
+ name: 'config',
165
+ score: 1,
166
+ maxScore: 10,
167
+ issues,
168
+ summary: 'no agent configs — critically under-configured',
169
+ };
32
170
  }
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
- }
171
+ // Aggregate scores from best config
172
+ const best = analyses.reduce((a, b) => (a.completeness + a.consistency + a.specificity) > (b.completeness + b.consistency + b.specificity) ? a : b);
173
+ // Generate issues from analysis
174
+ if (best.completeness < 5) {
175
+ issues.push({ severity: 'warning', message: `${best.file}: low completeness (${best.completeness}/10) — doesn't mention key dependencies`, fixable: true, fixHint: 'run vet --fix to enrich' });
176
+ }
177
+ if (best.consistency < 7) {
178
+ issues.push({ severity: 'warning', message: `${best.file}: consistency issues (${best.consistency}/10) contradicts project config`, fixable: false });
179
+ }
180
+ if (best.specificity < 5) {
181
+ issues.push({ severity: 'warning', message: `${best.file}: too generic (${best.specificity}/10)add project-specific rules`, fixable: true, fixHint: 'run vet --fix to add specifics' });
81
182
  }
82
- // Check for .gitignore (agents need to know what to ignore)
183
+ if (best.length < 100) {
184
+ issues.push({ severity: 'warning', message: `${best.file}: only ${best.length} chars — too sparse to guide AI`, fixable: true });
185
+ }
186
+ for (const suggestion of best.suggestions.slice(0, 5)) {
187
+ issues.push({ severity: 'info', message: suggestion, file: best.file, fixable: false });
188
+ }
189
+ // Check .gitignore
83
190
  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
- });
191
+ issues.push({ severity: 'warning', message: 'no .gitignore — agents may write to wrong directories', fixable: false });
89
192
  }
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';
193
+ // Score: weighted average of sub-scores
194
+ const subScore = (best.existence * 0.2 + best.completeness * 0.3 + best.consistency * 0.25 + best.specificity * 0.25);
195
+ const gitignorePenalty = fileExists(join(cwd, '.gitignore')) ? 0 : 1;
196
+ const finalScore = Math.max(0, Math.min(10, subScore - gitignorePenalty));
197
+ const agents = analyses.map(a => a.agent);
198
+ const uniqueAgents = [...new Set(agents)];
95
199
  return {
96
200
  name: 'config',
97
- score: Math.round(score * 10) / 10,
201
+ score: Math.round(finalScore * 10) / 10,
98
202
  maxScore: 10,
99
203
  issues,
100
- summary: issues.length === 0 ? `${configSummary} well configured` : `${configSummary} ${issues.length} suggestions`,
204
+ summary: `${uniqueAgents.join(', ')} ${best.completeness >= 7 && best.specificity >= 7 ? 'well configured' : `needs work (${Math.round(finalScore)}/10)`}`,
101
205
  };
102
206
  }
@@ -1,2 +1,2 @@
1
- import type { CheckResult } from '../types.js';
2
- export declare function checkDiff(cwd: string): CheckResult;
1
+ import type { CheckResult, DiffOptions } from '../types.js';
2
+ export declare function checkDiff(cwd: string, opts?: DiffOptions): CheckResult;
@@ -1,49 +1,52 @@
1
1
  import { git } from '../util.js';
2
- const PATTERNS = [
2
+ // Generic patterns (still useful but not the star)
3
+ const GENERIC_PATTERNS = [
3
4
  // Secrets
4
5
  { regex: /(?:api[_-]?key|secret|token|password|credential)\s*[:=]\s*['"][^'"]{8,}['"]/i, message: 'possible hardcoded secret', severity: 'error' },
5
6
  { regex: /sk-[a-zA-Z0-9]{20,}/, message: 'possible OpenAI API key', severity: 'error' },
6
7
  { regex: /AKIA[0-9A-Z]{16}/, message: 'possible AWS access key', severity: 'error' },
7
8
  { 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' },
9
+ { regex: /debugger;/, message: 'debugger statement', severity: 'error' },
10
+ { regex: /\.only\(/, message: '.only() left in test — other tests skipped', severity: 'error' },
21
11
  ];
22
- export function checkDiff(cwd) {
23
- const issues = [];
24
- // Get staged + unstaged diff
12
+ // AI-specific patterns — things AI agents do that humans typically don't
13
+ const AI_PATTERNS = [
14
+ // Empty/trivial tests
15
+ { regex: /(?:it|test)\(\s*['"].*['"]\s*,\s*(?:async\s*)?\(\)\s*=>\s*\{\s*\}\s*\)/, message: '[ai] empty test body — stubbed test', severity: 'error' },
16
+ { regex: /expect\(true\)\.toBe\(true\)/, message: '[ai] trivial assertion — test proves nothing', severity: 'error' },
17
+ { regex: /assert\s+True\s*$/, message: '[ai] trivial assertion', severity: 'error' },
18
+ // Catch-all error handling (AI defaults to generic catches)
19
+ { regex: /catch\s*\([^)]*\)\s*\{\s*\}/, message: '[ai] empty catch block — error silently swallowed', severity: 'error' },
20
+ { regex: /catch\s*\(\w+\)\s*\{\s*console\.(log|error)\(\w+\)\s*;?\s*\}/, message: '[ai] catch-all with just console.log — handle errors specifically', severity: 'warning' },
21
+ // Over-commenting (AI tends to add obvious comments)
22
+ { regex: /\/\/\s*(set|get|return|create|initialize|import|export|define)\s+(the|a)\s+/i, message: '[ai] obvious comment — "// get the value" adds no information', severity: 'warning' },
23
+ ];
24
+ function getDiff(cwd, opts) {
25
+ if (opts.since) {
26
+ return git(`diff ${opts.since}`, cwd);
27
+ }
28
+ // Default: last commit + working changes
25
29
  let diff = git('diff HEAD', cwd);
26
30
  if (!diff)
27
31
  diff = git('diff --cached', cwd);
28
32
  if (!diff)
29
33
  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 = '';
34
+ // If still nothing, diff last commit against its parent
35
+ if (!diff)
36
+ diff = git('diff HEAD~1..HEAD', cwd);
37
+ return diff;
38
+ }
39
+ function parseDiff(diff) {
40
+ const files = [];
41
+ let current = null;
41
42
  let lineNum = 0;
42
43
  for (const line of diff.split('\n')) {
43
44
  if (line.startsWith('diff --git')) {
44
45
  const match = line.match(/b\/(.+)$/);
45
- if (match)
46
- currentFile = match[1];
46
+ if (match) {
47
+ current = { path: match[1], addedLines: [], removedLines: [], addedCount: 0, removedCount: 0 };
48
+ files.push(current);
49
+ }
47
50
  lineNum = 0;
48
51
  continue;
49
52
  }
@@ -53,46 +56,122 @@ export function checkDiff(cwd) {
53
56
  lineNum = parseInt(match[1]) - 1;
54
57
  continue;
55
58
  }
59
+ if (!current)
60
+ continue;
56
61
  if (line.startsWith('+') && !line.startsWith('+++')) {
57
62
  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
63
+ current.addedLines.push({ num: lineNum, text: line.slice(1) });
64
+ current.addedCount++;
65
+ }
66
+ else if (line.startsWith('-') && !line.startsWith('---')) {
67
+ current.removedLines.push(line.slice(1));
68
+ current.removedCount++;
69
+ }
70
+ else {
71
+ lineNum++;
72
+ }
73
+ }
74
+ return files;
75
+ }
76
+ export function checkDiff(cwd, opts = {}) {
77
+ const issues = [];
78
+ const diff = getDiff(cwd, opts);
79
+ if (!diff) {
80
+ return { name: 'diff', score: 10, maxScore: 10, issues: [], summary: 'no changes to check' };
81
+ }
82
+ const files = parseDiff(diff);
83
+ const allPatterns = [...GENERIC_PATTERNS, ...AI_PATTERNS];
84
+ // Pattern matching on added lines
85
+ for (const file of files) {
86
+ for (const { num, text } of file.addedLines) {
87
+ for (const pattern of allPatterns) {
88
+ if (pattern.regex.test(text)) {
89
+ issues.push({ severity: pattern.severity, message: pattern.message, file: file.path, line: num, fixable: false });
90
+ break;
69
91
  }
70
92
  }
71
93
  }
72
- else if (!line.startsWith('-')) {
73
- lineNum++;
94
+ }
95
+ // AI-specific: wholesale function rewrite detection
96
+ for (const file of files) {
97
+ if (file.removedCount > 10 && file.addedCount > 10) {
98
+ const ratio = Math.min(file.removedCount, file.addedCount) / Math.max(file.removedCount, file.addedCount);
99
+ if (ratio > 0.7 && file.removedCount > 20) {
100
+ issues.push({
101
+ severity: 'warning',
102
+ message: `[ai] ${file.path}: ${file.removedCount} lines removed, ${file.addedCount} added — looks like a wholesale rewrite, verify intent`,
103
+ file: file.path,
104
+ fixable: false,
105
+ });
106
+ }
74
107
  }
75
108
  }
76
- // Check for deleted error handling
109
+ // AI-specific: orphaned imports (added import lines without corresponding usage)
110
+ for (const file of files) {
111
+ const addedImports = file.addedLines.filter(l => /^import\s/.test(l.text) || /^from\s/.test(l.text) || /require\(/.test(l.text));
112
+ for (const imp of addedImports) {
113
+ // Extract imported name
114
+ const nameMatch = imp.text.match(/import\s+(?:\{([^}]+)\}|(\w+))/);
115
+ if (nameMatch) {
116
+ const names = (nameMatch[1] || nameMatch[2] || '').split(',').map(n => n.trim().split(' as ').pop()?.trim()).filter(Boolean);
117
+ for (const name of names) {
118
+ if (!name || name.length < 2)
119
+ continue;
120
+ // Check if name is used in any other added line
121
+ const usedElsewhere = file.addedLines.some(l => l !== imp && l.text.includes(name));
122
+ if (!usedElsewhere && file.addedLines.length > 3) {
123
+ issues.push({
124
+ severity: 'warning',
125
+ message: `[ai] imported "${name}" but never used in new code`,
126
+ file: file.path,
127
+ line: imp.num,
128
+ fixable: false,
129
+ });
130
+ break; // one per import line
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ // AI-specific: comment density spike
137
+ for (const file of files) {
138
+ if (file.addedCount < 10)
139
+ continue;
140
+ const commentLines = file.addedLines.filter(l => /^\s*(\/\/|#|\/\*|\*)/.test(l.text)).length;
141
+ const ratio = commentLines / file.addedCount;
142
+ if (ratio > 0.4 && commentLines > 5) {
143
+ issues.push({
144
+ severity: 'info',
145
+ message: `[ai] ${file.path}: ${Math.round(ratio * 100)}% of new lines are comments — AI tends to over-comment`,
146
+ file: file.path,
147
+ fixable: false,
148
+ });
149
+ }
150
+ }
151
+ // Deleted error handling
77
152
  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)) {
153
+ for (const file of files) {
154
+ for (const line of file.removedLines) {
155
+ if (/catch|throw new|\.reject\(|finally\s*\{/.test(line))
81
156
  deletedErrorHandling++;
82
- }
83
157
  }
84
158
  }
85
159
  if (deletedErrorHandling > 3) {
86
- issues.push({ severity: 'warning', message: `${deletedErrorHandling} lines of error handling removed — verify this was intentional`, fixable: false });
160
+ issues.push({ severity: 'warning', message: `${deletedErrorHandling} lines of error handling removed — verify intentional`, fixable: false });
87
161
  }
162
+ // Recalibrated scoring
88
163
  const errors = issues.filter(i => i.severity === 'error').length;
89
164
  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));
165
+ const score = Math.max(0, Math.min(10, 10 - errors * 2 - warnings * 0.75));
166
+ const aiIssues = issues.filter(i => i.message.startsWith('[ai]')).length;
167
+ const totalFiles = files.length;
91
168
  return {
92
169
  name: 'diff',
93
170
  score: Math.round(score * 10) / 10,
94
171
  maxScore: 10,
95
172
  issues,
96
- summary: issues.length === 0 ? 'clean diff, no issues' : `${issues.length} issues in uncommitted changes`,
173
+ summary: issues.length === 0
174
+ ? `${totalFiles} file${totalFiles !== 1 ? 's' : ''} changed, clean`
175
+ : `${issues.length} issues (${aiIssues} AI-specific) in ${totalFiles} files`,
97
176
  };
98
177
  }
@@ -4,65 +4,62 @@ import { readFile, walkFiles } from '../util.js';
4
4
  export function checkReady(cwd, ignore) {
5
5
  const issues = [];
6
6
  const files = walkFiles(cwd, ignore);
7
- // 1. README exists
7
+ // 1. README exists — critical for AI context
8
8
  const hasReadme = files.some(f => /^readme\.(md|txt|rst)$/i.test(f));
9
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' });
10
+ issues.push({ severity: 'error', message: 'no README — AI agents have no project context', fixable: true, fixHint: 'create a README.md' });
11
11
  }
12
12
  // 2. Project manifest
13
13
  const manifests = ['package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'pom.xml', 'build.gradle', 'Gemfile', 'composer.json'];
14
14
  const hasManifest = manifests.some(m => files.includes(m));
15
15
  if (!hasManifest) {
16
- issues.push({ severity: 'warning', message: 'no package manifest found — agents need dependency context', fixable: false });
16
+ issues.push({ severity: 'error', message: 'no package manifest — agents can\'t resolve dependencies', fixable: false });
17
17
  }
18
- // 3. Check for overly large files (>500 lines is harder for AI to reason about)
19
- let largeFileCount = 0;
18
+ // 3. Test coverage
20
19
  const codeExts = ['.ts', '.js', '.tsx', '.jsx', '.py', '.rs', '.go', '.java', '.rb', '.php', '.cs', '.swift', '.kt'];
20
+ const testFiles = files.filter(f => /\.(test|spec)\.(ts|js|tsx|jsx|py)$/.test(f) || f.includes('__tests__/') || f.startsWith('tests/') || f.startsWith('test/'));
21
+ const codeFiles = files.filter(f => codeExts.some(ext => f.endsWith(ext)));
22
+ if (codeFiles.length > 5 && testFiles.length === 0) {
23
+ issues.push({ severity: 'error', message: 'no tests — AI agents produce better code when tests exist to validate against', fixable: false });
24
+ }
25
+ // 4. Overly large files (>500 lines)
26
+ let largeFileCount = 0;
21
27
  for (const f of files) {
22
28
  if (!codeExts.some(ext => f.endsWith(ext)))
23
29
  continue;
24
30
  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
- }
31
+ if (content && content.split('\n').length > 500) {
32
+ largeFileCount++;
33
+ if (largeFileCount <= 3) {
34
+ issues.push({ severity: 'warning', message: `${f} is ${content.split('\n').length} lines — split for better AI comprehension`, fixable: false });
32
35
  }
33
36
  }
34
37
  }
35
38
  if (largeFileCount > 3) {
36
- issues.push({ severity: 'info', message: `...and ${largeFileCount - 3} more large files`, fixable: false });
39
+ issues.push({ severity: 'warning', message: `...and ${largeFileCount - 3} more large files`, fixable: false });
37
40
  }
38
- // 4. Check for .env.example (helps AI understand required env vars)
41
+ // 5. .env without .env.example
39
42
  const hasEnv = files.some(f => f === '.env' || f === '.env.local');
40
43
  const hasEnvExample = files.some(f => f === '.env.example' || f === '.env.template');
41
44
  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 });
45
+ issues.push({ severity: 'warning', message: '.env exists but no .env.example — AI agents can\'t see env structure', fixable: false });
43
46
  }
44
- // 5. TypeScript/Python type coverage
47
+ // 6. No types in JS-heavy project
45
48
  const tsFiles = files.filter(f => f.endsWith('.ts') || f.endsWith('.tsx'));
46
49
  const jsFiles = files.filter(f => f.endsWith('.js') || f.endsWith('.jsx'));
47
50
  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 });
51
+ issues.push({ severity: 'info', message: `${jsFiles.length} JS files, no TypeScript — typed code gives agents better context`, fixable: false });
55
52
  }
56
- // Score: start at 10, deduct
53
+ // Recalibrated scoring: errors = -3, warnings = -1.5, info = -0.3
57
54
  const errors = issues.filter(i => i.severity === 'error').length;
58
55
  const warnings = issues.filter(i => i.severity === 'warning').length;
59
56
  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));
57
+ const score = Math.max(0, Math.min(10, 10 - errors * 3 - warnings * 1.5 - infos * 0.3));
61
58
  return {
62
59
  name: 'ready',
63
60
  score: Math.round(score * 10) / 10,
64
61
  maxScore: 10,
65
62
  issues,
66
- summary: issues.length === 0 ? 'codebase is well-structured for AI' : `${issues.length} suggestions for better AI readiness`,
63
+ summary: issues.length === 0 ? 'codebase is well-structured for AI' : `${issues.length} readiness issues`,
67
64
  };
68
65
  }
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { resolve } from 'node:path';
3
3
  import { readFileSync } from 'node:fs';
4
- import { isGitRepo, readFile } from './util.js';
4
+ import { isGitRepo, readFile, c } from './util.js';
5
5
  import { checkReady } from './checks/ready.js';
6
6
  import { checkDiff } from './checks/diff.js';
7
7
  import { checkModels } from './checks/models.js';
@@ -11,24 +11,38 @@ import { checkHistory } from './checks/history.js';
11
11
  import { score } from './scorer.js';
12
12
  import { reportPretty, reportJSON } from './reporter.js';
13
13
  const args = process.argv.slice(2);
14
- const flags = new Set(args.filter(a => a.startsWith('-')));
14
+ const flags = new Set(args.filter(a => a.startsWith('-') && !a.startsWith('--since')));
15
+ const flagMap = new Map();
16
+ // Parse --since=value or --since value
17
+ for (let i = 0; i < args.length; i++) {
18
+ if (args[i].startsWith('--since=')) {
19
+ flagMap.set('since', args[i].split('=')[1]);
20
+ }
21
+ else if (args[i] === '--since' && args[i + 1]) {
22
+ flagMap.set('since', args[i + 1]);
23
+ i++;
24
+ }
25
+ }
15
26
  const positional = args.filter(a => !a.startsWith('-'));
16
27
  if (flags.has('--help') || flags.has('-h')) {
17
28
  console.log(`
18
- vet — vet your AI-generated code
29
+ ${c.bold}vet${c.reset} — vet your AI-generated code
19
30
 
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
31
+ ${c.dim}usage:${c.reset}
32
+ npx @safetnsr/vet [dir] run all checks
33
+ npx @safetnsr/vet --fix auto-repair fixable issues
34
+ npx @safetnsr/vet --ci exit code 1 if below threshold
35
+ npx @safetnsr/vet --since HEAD~5 check specific commit range
36
+ npx @safetnsr/vet --watch live monitoring during AI sessions
37
+ npx @safetnsr/vet init generate configs + hooks
26
38
 
27
- options:
28
- --ci CI mode (exit 1 if below threshold)
29
- --fix auto-fix what we can
39
+ ${c.dim}options:${c.reset}
40
+ --ci CI mode (exit 1 if score < threshold)
41
+ --fix auto-fix configs, models, links
42
+ --since REF diff against specific commit/range
43
+ --watch re-run on file changes
30
44
  --json JSON output
31
- --no-color disable colors
45
+ --pretty force pretty output (even in pipes)
32
46
  -h, --help show this help
33
47
  -v, --version show version
34
48
  `);
@@ -40,7 +54,7 @@ if (flags.has('--version') || flags.has('-v')) {
40
54
  console.log(pkg.version);
41
55
  }
42
56
  catch {
43
- console.log('0.1.0');
57
+ console.log('0.2.0');
44
58
  }
45
59
  process.exit(0);
46
60
  }
@@ -49,7 +63,9 @@ const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
49
63
  const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
50
64
  const isCI = flags.has('--ci');
51
65
  const isFix = flags.has('--fix');
66
+ const isWatch = flags.has('--watch');
52
67
  const isJSON = flags.has('--json') || (!process.stdout.isTTY && !flags.has('--pretty'));
68
+ const since = flagMap.get('since');
53
69
  // Load config
54
70
  let config = {};
55
71
  const configContent = readFile(resolve(cwd, '.vetrc'));
@@ -65,35 +81,90 @@ if (command === 'init') {
65
81
  await init(cwd);
66
82
  process.exit(0);
67
83
  }
68
- // Run checks
69
84
  if (!isGitRepo(cwd)) {
70
- console.error('not a git repository. vet operates on git repos.');
85
+ console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
71
86
  process.exit(1);
72
87
  }
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));
88
+ // --fix mode
89
+ if (isFix) {
90
+ console.log(`\n ${c.bold}vet --fix${c.reset}\n`);
91
+ const { fixConfig } = await import('./fix/config.js');
92
+ const { fixModels } = await import('./fix/models.js');
93
+ const { fixLinks } = await import('./fix/links.js');
94
+ const configResult = fixConfig(cwd);
95
+ const modelsResult = fixModels(cwd, ignore);
96
+ const linksResult = fixLinks(cwd, ignore);
97
+ const allMessages = [...configResult.messages, ...modelsResult.messages, ...linksResult.messages];
98
+ const totalFixed = configResult.fixed + modelsResult.fixed + linksResult.fixed;
99
+ if (allMessages.length > 0) {
100
+ for (const msg of allMessages)
101
+ console.log(msg);
102
+ }
103
+ console.log(`\n ${totalFixed > 0 ? c.green : c.dim}fixed ${totalFixed} issue${totalFixed !== 1 ? 's' : ''}${c.reset}\n`);
104
+ process.exit(0);
105
+ }
106
+ function runChecks() {
107
+ const allChecks = ['ready', 'diff', 'models', 'links', 'config', 'history'];
108
+ const enabledChecks = config.checks || allChecks;
109
+ const results = [];
110
+ if (enabledChecks.includes('ready'))
111
+ results.push(checkReady(cwd, ignore));
112
+ if (enabledChecks.includes('diff'))
113
+ results.push(checkDiff(cwd, { since }));
114
+ if (enabledChecks.includes('models'))
115
+ results.push(checkModels(cwd, ignore));
116
+ if (enabledChecks.includes('links'))
117
+ results.push(checkLinks(cwd, ignore));
118
+ if (enabledChecks.includes('config'))
119
+ results.push(checkConfig(cwd, ignore));
120
+ if (enabledChecks.includes('history'))
121
+ results.push(checkHistory(cwd));
122
+ return score(cwd, results);
91
123
  }
92
- else {
124
+ // --watch mode
125
+ if (isWatch) {
126
+ console.clear();
127
+ let result = runChecks();
93
128
  console.log(reportPretty(result));
129
+ console.log(` ${c.dim}watching for changes... (ctrl+c to stop)${c.reset}\n`);
130
+ let debounce = null;
131
+ const { watch } = await import('node:fs');
132
+ try {
133
+ const watcher = watch(cwd, { recursive: true }, (event, filename) => {
134
+ if (!filename)
135
+ return;
136
+ if (filename.includes('node_modules') || filename.includes('.git'))
137
+ return;
138
+ if (debounce)
139
+ clearTimeout(debounce);
140
+ debounce = setTimeout(() => {
141
+ console.clear();
142
+ result = runChecks();
143
+ console.log(reportPretty(result));
144
+ console.log(` ${c.dim}watching for changes... (ctrl+c to stop)${c.reset}\n`);
145
+ }, 500);
146
+ });
147
+ process.on('SIGINT', () => {
148
+ watcher.close();
149
+ process.exit(0);
150
+ });
151
+ }
152
+ catch {
153
+ console.error(`${c.yellow}watch mode requires Node 19+ with recursive fs.watch support${c.reset}`);
154
+ process.exit(1);
155
+ }
94
156
  }
95
- // CI exit code
96
- if (isCI) {
97
- const threshold = config.thresholds?.min ?? 6;
98
- process.exit(result.score >= threshold ? 0 : 1);
157
+ else {
158
+ // Normal run
159
+ const result = runChecks();
160
+ if (isJSON) {
161
+ console.log(reportJSON(result));
162
+ }
163
+ else {
164
+ console.log(reportPretty(result));
165
+ }
166
+ if (isCI) {
167
+ const threshold = config.thresholds?.min ?? 6;
168
+ process.exit(result.score >= threshold ? 0 : 1);
169
+ }
99
170
  }
@@ -0,0 +1,4 @@
1
+ export declare function fixConfig(cwd: string): {
2
+ fixed: number;
3
+ messages: string[];
4
+ };
@@ -0,0 +1,151 @@
1
+ import { join } from 'node:path';
2
+ import { writeFileSync, existsSync } from 'node:fs';
3
+ import { readFile, walkFiles, c } from '../util.js';
4
+ // Generate or enrich CLAUDE.md from codebase analysis
5
+ export function fixConfig(cwd) {
6
+ const messages = [];
7
+ let fixed = 0;
8
+ const files = walkFiles(cwd);
9
+ // Detect project context
10
+ const pkgJson = readFile(join(cwd, 'package.json'));
11
+ const deps = {};
12
+ let projectName = 'project';
13
+ let scripts = {};
14
+ if (pkgJson) {
15
+ try {
16
+ const pkg = JSON.parse(pkgJson);
17
+ projectName = pkg.name || 'project';
18
+ Object.assign(deps, pkg.dependencies, pkg.devDependencies);
19
+ scripts = pkg.scripts || {};
20
+ }
21
+ catch { /* */ }
22
+ }
23
+ // Detect frameworks
24
+ const detected = [];
25
+ if (deps.react || deps['react-dom']) {
26
+ const rules = ['use functional components with hooks', 'prefer named exports for components'];
27
+ if (deps.next)
28
+ rules.push('use App Router conventions (layout.tsx, page.tsx, loading.tsx)');
29
+ detected.push({ name: deps.next ? 'Next.js + React' : 'React', rules });
30
+ }
31
+ if (deps.vue)
32
+ detected.push({ name: 'Vue', rules: ['use Composition API', 'keep components in SFC format'] });
33
+ if (deps.svelte)
34
+ detected.push({ name: 'SvelteKit', rules: ['use +page.svelte conventions'] });
35
+ if (deps.hono)
36
+ detected.push({ name: 'Hono', rules: ['use c.json() for responses', 'add specific routes before dynamic routes'] });
37
+ if (deps.express)
38
+ detected.push({ name: 'Express', rules: ['use router.use() for middleware', 'error middleware last'] });
39
+ if (deps.fastify)
40
+ detected.push({ name: 'Fastify', rules: ['use schema validation on routes'] });
41
+ // Testing
42
+ if (deps.vitest)
43
+ detected.push({ name: 'Vitest', rules: ['write tests in *.test.ts files', 'use describe/it/expect pattern'] });
44
+ else if (deps.jest)
45
+ detected.push({ name: 'Jest', rules: ['write tests in *.test.ts files', 'use describe/it/expect pattern'] });
46
+ // Database
47
+ if (deps.prisma || deps['@prisma/client'])
48
+ detected.push({ name: 'Prisma', rules: ['run prisma generate after schema changes', 'use transactions for multi-step mutations'] });
49
+ if (deps['drizzle-orm'])
50
+ detected.push({ name: 'Drizzle', rules: ['define schema in src/db/schema.ts'] });
51
+ // Styling
52
+ if (deps.tailwindcss)
53
+ detected.push({ name: 'Tailwind CSS', rules: ['use utility classes, avoid custom CSS where possible'] });
54
+ // TypeScript
55
+ const tsconfig = readFile(join(cwd, 'tsconfig.json'));
56
+ let tsStrict = false;
57
+ if (tsconfig) {
58
+ try {
59
+ tsStrict = JSON.parse(tsconfig).compilerOptions?.strict === true;
60
+ }
61
+ catch { /* */ }
62
+ }
63
+ if (deps.typescript || tsconfig) {
64
+ const rules = ['use TypeScript for all new files'];
65
+ if (tsStrict)
66
+ rules.push('strict mode enabled — no `any` types, explicit return types on exports');
67
+ detected.push({ name: 'TypeScript', rules });
68
+ }
69
+ // Detect directory structure
70
+ const dirs = new Set();
71
+ for (const f of files.slice(0, 200)) {
72
+ const parts = f.split('/');
73
+ if (parts.length > 1)
74
+ dirs.add(parts[0]);
75
+ }
76
+ // Generate CLAUDE.md
77
+ const claudePath = join(cwd, 'CLAUDE.md');
78
+ const existingContent = readFile(claudePath);
79
+ if (!existingContent) {
80
+ // Generate fresh
81
+ const lines = [`# ${projectName}\n`];
82
+ if (detected.length > 0) {
83
+ lines.push('## Stack');
84
+ lines.push(detected.map(d => `- ${d.name}`).join('\n'));
85
+ lines.push('');
86
+ }
87
+ if (dirs.size > 0) {
88
+ lines.push('## Structure');
89
+ const importantDirs = ['src', 'app', 'pages', 'components', 'lib', 'api', 'server', 'public', 'tests', 'test', 'scripts'];
90
+ const projectDirs = [...dirs].filter(d => importantDirs.includes(d));
91
+ if (projectDirs.length > 0) {
92
+ lines.push(projectDirs.map(d => `- \`${d}/\``).join('\n'));
93
+ lines.push('');
94
+ }
95
+ }
96
+ lines.push('## Rules');
97
+ lines.push('- handle errors explicitly — no empty catch blocks');
98
+ lines.push('- no hardcoded secrets or API keys');
99
+ lines.push('- keep functions focused and under 50 lines');
100
+ for (const d of detected) {
101
+ for (const rule of d.rules) {
102
+ lines.push(`- ${rule}`);
103
+ }
104
+ }
105
+ if (scripts.test)
106
+ lines.push(`- run \`${scripts.test.split('&&')[0].trim()}\` before committing`);
107
+ if (scripts.lint)
108
+ lines.push(`- run \`${scripts.lint.split('&&')[0].trim()}\` to check code style`);
109
+ lines.push('');
110
+ writeFileSync(claudePath, lines.join('\n'));
111
+ messages.push(`${c.green}+${c.reset} CLAUDE.md (generated from codebase: ${detected.map(d => d.name).join(', ')})`);
112
+ fixed++;
113
+ }
114
+ else {
115
+ // Enrich existing — append missing framework mentions
116
+ const contentLower = existingContent.toLowerCase();
117
+ const additions = [];
118
+ for (const d of detected) {
119
+ if (!contentLower.includes(d.name.toLowerCase().split(' ')[0])) {
120
+ additions.push(`\n## ${d.name} (auto-detected)`);
121
+ for (const rule of d.rules) {
122
+ additions.push(`- ${rule}`);
123
+ }
124
+ }
125
+ }
126
+ if (additions.length > 0) {
127
+ writeFileSync(claudePath, existingContent + '\n' + additions.join('\n') + '\n');
128
+ messages.push(`${c.green}+${c.reset} CLAUDE.md enriched with ${additions.length} rules from detected stack`);
129
+ fixed++;
130
+ }
131
+ }
132
+ // Generate .cursorrules if missing
133
+ const cursorPath = join(cwd, '.cursorrules');
134
+ if (!existsSync(cursorPath) && detected.length > 0) {
135
+ const lines = [`# ${projectName}\n`];
136
+ lines.push(`${detected.map(d => d.name).join(' + ')} project.\n`);
137
+ lines.push('## Guidelines');
138
+ lines.push('- handle errors explicitly');
139
+ lines.push('- no hardcoded secrets');
140
+ for (const d of detected) {
141
+ for (const rule of d.rules) {
142
+ lines.push(`- ${rule}`);
143
+ }
144
+ }
145
+ lines.push('');
146
+ writeFileSync(cursorPath, lines.join('\n'));
147
+ messages.push(`${c.green}+${c.reset} .cursorrules (generated)`);
148
+ fixed++;
149
+ }
150
+ return { fixed, messages };
151
+ }
@@ -0,0 +1,4 @@
1
+ export declare function fixLinks(cwd: string, ignore: string[]): {
2
+ fixed: number;
3
+ messages: string[];
4
+ };
@@ -0,0 +1,60 @@
1
+ import { join, dirname } from 'node:path';
2
+ import { writeFileSync } from 'node:fs';
3
+ import { readFile, walkFiles, fileExists, c } from '../util.js';
4
+ export function fixLinks(cwd, ignore) {
5
+ const messages = [];
6
+ let fixed = 0;
7
+ const files = walkFiles(cwd, ignore);
8
+ const mdFiles = files.filter(f => f.endsWith('.md'));
9
+ // Build file index for finding correct targets
10
+ const fileIndex = new Map();
11
+ for (const f of files) {
12
+ const name = f.split('/').pop() || '';
13
+ const nameNoExt = name.replace(/\.[^.]+$/, '');
14
+ fileIndex.set(nameNoExt.toLowerCase(), f);
15
+ fileIndex.set(name.toLowerCase(), f);
16
+ }
17
+ for (const mdFile of mdFiles) {
18
+ const fullPath = join(cwd, mdFile);
19
+ let content = readFile(fullPath);
20
+ if (!content)
21
+ continue;
22
+ const dir = dirname(mdFile);
23
+ let changed = false;
24
+ // Fix broken relative links by finding the target file
25
+ content = content.replace(/\[([^\]]*)\]\(([^)]+)\)/g, (match, text, target) => {
26
+ const cleanTarget = target.split('#')[0].split('?')[0];
27
+ if (!cleanTarget)
28
+ return match;
29
+ if (cleanTarget.startsWith('http://') || cleanTarget.startsWith('https://') || cleanTarget.startsWith('mailto:'))
30
+ return match;
31
+ const resolved = join(dir, cleanTarget);
32
+ if (fileExists(join(cwd, resolved)))
33
+ return match; // link is fine
34
+ // Try to find the target file
35
+ const targetName = cleanTarget.split('/').pop()?.replace(/\.[^.]+$/, '')?.toLowerCase() || '';
36
+ const found = fileIndex.get(targetName);
37
+ if (found) {
38
+ // Calculate relative path from this file to the found file
39
+ const fromDir = dirname(mdFile);
40
+ let newTarget = found;
41
+ if (fromDir !== '.') {
42
+ const fromParts = fromDir.split('/');
43
+ const toParts = found.split('/');
44
+ // Simple relative path
45
+ const ups = fromParts.length;
46
+ newTarget = '../'.repeat(ups) + found;
47
+ }
48
+ changed = true;
49
+ fixed++;
50
+ messages.push(` ${c.green}✓${c.reset} ${mdFile}: "${cleanTarget}" → "${newTarget}"`);
51
+ return `[${text}](${newTarget})`;
52
+ }
53
+ return match;
54
+ });
55
+ if (changed) {
56
+ writeFileSync(fullPath, content);
57
+ }
58
+ }
59
+ return { fixed, messages };
60
+ }
@@ -0,0 +1,4 @@
1
+ export declare function fixModels(cwd: string, ignore: string[]): {
2
+ fixed: number;
3
+ messages: string[];
4
+ };
@@ -0,0 +1,64 @@
1
+ import { join } from 'node:path';
2
+ import { writeFileSync } from 'node:fs';
3
+ import { walkFiles, readFile, c } from '../util.js';
4
+ // Same registry as checks/models.ts — inline to avoid coupling
5
+ const REPLACEMENTS = {
6
+ 'gpt-3.5-turbo': 'gpt-4o-mini',
7
+ 'gpt-4-turbo': 'gpt-4o',
8
+ 'gpt-4-turbo-preview': 'gpt-4o',
9
+ 'gpt-4-0314': 'gpt-4o',
10
+ 'gpt-4-0613': 'gpt-4o',
11
+ 'gpt-4-32k': 'gpt-4o',
12
+ 'text-davinci-003': 'gpt-4o-mini',
13
+ 'code-davinci-002': 'gpt-4o',
14
+ 'text-embedding-ada-002': 'text-embedding-3-small',
15
+ 'claude-instant-1': 'claude-sonnet-4-5',
16
+ 'claude-2': 'claude-sonnet-4-5',
17
+ 'claude-2.0': 'claude-sonnet-4-5',
18
+ 'claude-2.1': 'claude-sonnet-4-5',
19
+ 'claude-3-haiku-20240307': 'claude-haiku-3-5',
20
+ 'claude-3-sonnet-20240229': 'claude-sonnet-4-5',
21
+ 'claude-3-opus-20240229': 'claude-opus-4-0',
22
+ 'gemini-pro': 'gemini-2.0-flash',
23
+ 'gemini-1.0-pro': 'gemini-2.0-flash',
24
+ 'gemini-1.5-pro': 'gemini-2.5-pro',
25
+ 'gemini-1.5-flash': 'gemini-2.0-flash',
26
+ 'text-bison': 'gemini-2.0-flash',
27
+ 'chat-bison': 'gemini-2.0-flash',
28
+ };
29
+ const SCAN_EXTS = ['.ts', '.js', '.tsx', '.jsx', '.py', '.rs', '.go', '.java', '.rb', '.php',
30
+ '.yaml', '.yml', '.json', '.toml', '.cfg', '.ini', '.conf'];
31
+ const SELF_IGNORE = ['models.ts', 'models.js', 'model-graveyard', 'model-registry', 'sunset'];
32
+ export function fixModels(cwd, ignore) {
33
+ const messages = [];
34
+ let fixed = 0;
35
+ const files = walkFiles(cwd, ignore);
36
+ for (const f of files) {
37
+ if (!SCAN_EXTS.some(ext => f.endsWith(ext)))
38
+ continue;
39
+ if (SELF_IGNORE.some(s => f.toLowerCase().includes(s)))
40
+ continue;
41
+ const fullPath = join(cwd, f);
42
+ const raw = readFile(fullPath);
43
+ if (!raw)
44
+ continue;
45
+ let content = raw;
46
+ let changed = false;
47
+ for (const [old, replacement] of Object.entries(REPLACEMENTS)) {
48
+ if (content.includes(old)) {
49
+ const regex = new RegExp(`(['"\`])${old.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\1`, 'g');
50
+ const updated = content.replace(regex, `$1${replacement}$1`);
51
+ if (updated !== content) {
52
+ content = updated;
53
+ changed = true;
54
+ messages.push(` ${c.green}✓${c.reset} ${f}: "${old}" → "${replacement}"`);
55
+ fixed++;
56
+ }
57
+ }
58
+ }
59
+ if (changed) {
60
+ writeFileSync(fullPath, content);
61
+ }
62
+ }
63
+ return { fixed, messages };
64
+ }
package/dist/types.d.ts CHANGED
@@ -29,3 +29,6 @@ export interface VetConfig {
29
29
  };
30
30
  agents?: string[];
31
31
  }
32
+ export interface DiffOptions {
33
+ since?: string;
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "vet your AI-generated code — one command, six checks, zero config",
5
5
  "type": "module",
6
6
  "bin": {