@safetnsr/vet 0.1.0 → 0.2.1
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/models.d.ts +1 -1
- package/dist/checks/models.js +49 -16
- package/dist/checks/ready.d.ts +1 -1
- package/dist/checks/ready.js +65 -29
- package/dist/cli.js +111 -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 +4 -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/models.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { CheckResult } from '../types.js';
|
|
2
|
-
export declare function checkModels(cwd: string, ignore: string[]): CheckResult
|
|
2
|
+
export declare function checkModels(cwd: string, ignore: string[]): Promise<CheckResult>;
|