@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 +66 -21
- package/dist/checks/config.js +173 -69
- package/dist/checks/diff.d.ts +2 -2
- package/dist/checks/diff.js +130 -51
- package/dist/checks/ready.js +23 -26
- package/dist/cli.js +110 -39
- package/dist/fix/config.d.ts +4 -0
- package/dist/fix/config.js +151 -0
- package/dist/fix/links.d.ts +4 -0
- package/dist/fix/links.js +60 -0
- package/dist/fix/models.d.ts +4 -0
- package/dist/fix/models.js +64 -0
- package/dist/types.d.ts +3 -0
- package/package.json +1 -1
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? |
|
|
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? |
|
|
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
|
|
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
|
-
#
|
|
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
|
|
53
|
+
my-project 6.2/10
|
|
48
54
|
|
|
49
|
-
ready
|
|
50
|
-
diff
|
|
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
|
|
54
|
-
history
|
|
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
|
package/dist/checks/config.js
CHANGED
|
@@ -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
|
|
19
|
-
const missing = [];
|
|
148
|
+
const analyses = [];
|
|
20
149
|
for (const [agent, info] of Object.entries(AGENT_CONFIGS)) {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
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 (
|
|
156
|
+
if (analyses.length === 0) {
|
|
26
157
|
issues.push({
|
|
27
|
-
severity: '
|
|
28
|
-
message: 'no AI agent config found — add CLAUDE.md, .cursorrules, or similar
|
|
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
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const
|
|
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(
|
|
201
|
+
score: Math.round(finalScore * 10) / 10,
|
|
98
202
|
maxScore: 10,
|
|
99
203
|
issues,
|
|
100
|
-
summary:
|
|
204
|
+
summary: `${uniqueAgents.join(', ')} — ${best.completeness >= 7 && best.specificity >= 7 ? 'well configured' : `needs work (${Math.round(finalScore)}/10)`}`,
|
|
101
205
|
};
|
|
102
206
|
}
|
package/dist/checks/diff.d.ts
CHANGED
|
@@ -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;
|
package/dist/checks/diff.js
CHANGED
|
@@ -1,49 +1,52 @@
|
|
|
1
1
|
import { git } from '../util.js';
|
|
2
|
-
|
|
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
|
-
|
|
9
|
-
{ regex:
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
//
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
//
|
|
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
|
|
79
|
-
|
|
80
|
-
if (/catch|throw
|
|
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
|
|
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 *
|
|
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
|
|
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
|
}
|
package/dist/checks/ready.js
CHANGED
|
@@ -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: '
|
|
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: '
|
|
16
|
+
issues.push({ severity: 'error', message: 'no package manifest — agents can\'t resolve dependencies', fixable: false });
|
|
17
17
|
}
|
|
18
|
-
// 3.
|
|
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
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
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: '
|
|
39
|
+
issues.push({ severity: 'warning', message: `...and ${largeFileCount - 3} more large files`, fixable: false });
|
|
37
40
|
}
|
|
38
|
-
//
|
|
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
|
|
45
|
+
issues.push({ severity: 'warning', message: '.env exists but no .env.example — AI agents can\'t see env structure', fixable: false });
|
|
43
46
|
}
|
|
44
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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 *
|
|
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}
|
|
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]
|
|
22
|
-
npx @safetnsr/vet --
|
|
23
|
-
npx @safetnsr/vet --
|
|
24
|
-
npx @safetnsr/vet --
|
|
25
|
-
npx @safetnsr/vet
|
|
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
|
|
29
|
-
--fix auto-fix
|
|
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
|
-
--
|
|
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.
|
|
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(
|
|
85
|
+
console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
|
|
71
86
|
process.exit(1);
|
|
72
87
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
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,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,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,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