@safetnsr/vet 0.2.0 → 0.3.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 +58 -2
- package/dist/checks/edge.d.ts +30 -0
- package/dist/checks/edge.js +230 -0
- 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 +48 -9
- package/dist/checks/receipt.d.ts +48 -0
- package/dist/checks/receipt.js +306 -0
- package/dist/checks/scan.d.ts +2 -0
- package/dist/checks/scan.js +225 -0
- package/dist/checks/secrets.d.ts +2 -0
- package/dist/checks/secrets.js +280 -0
- package/dist/cli.js +48 -10
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# vet
|
|
2
2
|
|
|
3
|
-
vet your AI-generated code. one command,
|
|
3
|
+
vet your AI-generated code. one command, ten checks, zero config.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx @safetnsr/vet
|
|
@@ -18,6 +18,10 @@ works with Claude Code, Cursor, Copilot, Codex, Aider, Windsurf, Cline — anyth
|
|
|
18
18
|
| **links** | broken markdown links? | validates relative links and wikilinks |
|
|
19
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
|
+
| **scan** | malicious patterns in agent configs? | scans .claude/, .cursorrules, CLAUDE.md, .mcp/ for prompt injection, shell injection, exfiltration endpoints |
|
|
22
|
+
| **secrets** | leaked secrets in build output? | scans dist/, build/, .next/ + .env files for API keys, tokens, connection strings using pattern + entropy analysis |
|
|
23
|
+
| **receipt** | what did the last agent session do? | parses ~/.claude/projects/ JSONL session logs — files changed, commands run, packages installed, SHA256 integrity hash |
|
|
24
|
+
| **edge** | how replaceable is your git history? | classifies commits by human-edge score: architecture (90) → debugging (85) → integration (80) → feature (60) → boilerplate (20) → cosmetic (10) |
|
|
21
25
|
|
|
22
26
|
## usage
|
|
23
27
|
|
|
@@ -45,6 +49,14 @@ npx @safetnsr/vet --json
|
|
|
45
49
|
|
|
46
50
|
# generate configs + pre-commit hook
|
|
47
51
|
npx @safetnsr/vet init
|
|
52
|
+
|
|
53
|
+
# show last agent session receipt (ASCII or JSON)
|
|
54
|
+
npx @safetnsr/vet receipt
|
|
55
|
+
npx @safetnsr/vet receipt --json
|
|
56
|
+
|
|
57
|
+
# show human-edge score for git history
|
|
58
|
+
npx @safetnsr/vet edge
|
|
59
|
+
npx @safetnsr/vet edge --explain
|
|
48
60
|
```
|
|
49
61
|
|
|
50
62
|
## output
|
|
@@ -109,13 +121,57 @@ config score breakdown:
|
|
|
109
121
|
specificity: 3/10 — generic rules, nothing project-specific
|
|
110
122
|
```
|
|
111
123
|
|
|
124
|
+
## subcommands
|
|
125
|
+
|
|
126
|
+
### `vet receipt`
|
|
127
|
+
|
|
128
|
+
Shows a receipt for the last Claude Code agent session — what files it touched, what commands it ran, what packages it installed, plus a SHA256 integrity hash:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
╔══════════════════════════════════════════════╗
|
|
132
|
+
║ AGENT SESSION RECEIPT ║
|
|
133
|
+
╠══════════════════════════════════════════════╣
|
|
134
|
+
║ Session: abc123def456 ║
|
|
135
|
+
║ Date: 2024-01-15 14:32:11 UTC ║
|
|
136
|
+
║ Duration: 12m 34s ║
|
|
137
|
+
╠══════════════════════════════════════════════╣
|
|
138
|
+
║ FILES CREATED (3) ║
|
|
139
|
+
║ src/checks/scan.ts ║
|
|
140
|
+
║ src/checks/secrets.ts ║
|
|
141
|
+
║ test/scan.test.mjs ║
|
|
142
|
+
╠══════════════════════════════════════════════╣
|
|
143
|
+
║ SHA256: 3a7f9c2e... ║
|
|
144
|
+
╚══════════════════════════════════════════════╝
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### `vet edge`
|
|
148
|
+
|
|
149
|
+
Analyzes git history and scores how AI-replaceable your contributions are:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
Human Edge Report 72/100
|
|
153
|
+
|
|
154
|
+
🏗️ Architecture 12 (28%) ████████████
|
|
155
|
+
🔍 Debugging 8 (19%) ████████
|
|
156
|
+
🔗 Integration 6 (14%) ██████
|
|
157
|
+
⚡ Feature 10 (24%) ██████████
|
|
158
|
+
📋 Boilerplate 5 (12%) █████
|
|
159
|
+
🎨 Cosmetic 1 (2%) █
|
|
160
|
+
|
|
161
|
+
Top commits
|
|
162
|
+
90 a3f2b1c refactor: extract auth middleware across all routes
|
|
163
|
+
85 7e8d9f1 fix: resolve race condition in session cleanup
|
|
164
|
+
|
|
165
|
+
→ Strong position. Your work is deeply contextual and hard to automate.
|
|
166
|
+
```
|
|
167
|
+
|
|
112
168
|
## config
|
|
113
169
|
|
|
114
170
|
create `.vetrc` in your project root (optional):
|
|
115
171
|
|
|
116
172
|
```json
|
|
117
173
|
{
|
|
118
|
-
"checks": ["ready", "diff", "models", "links", "config", "history"],
|
|
174
|
+
"checks": ["ready", "diff", "models", "links", "config", "history", "scan", "secrets", "receipt", "edge"],
|
|
119
175
|
"ignore": ["vendor/", "generated/"],
|
|
120
176
|
"thresholds": { "min": 6 }
|
|
121
177
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
type CommitCategory = 'architecture' | 'debugging' | 'integration' | 'feature' | 'boilerplate' | 'cosmetic';
|
|
3
|
+
interface DiffStats {
|
|
4
|
+
filesChanged: number;
|
|
5
|
+
insertions: number;
|
|
6
|
+
deletions: number;
|
|
7
|
+
dirsChanged: number;
|
|
8
|
+
files: string[];
|
|
9
|
+
}
|
|
10
|
+
interface ClassifiedCommit {
|
|
11
|
+
hash: string;
|
|
12
|
+
date: string;
|
|
13
|
+
message: string;
|
|
14
|
+
author: string;
|
|
15
|
+
category: CommitCategory;
|
|
16
|
+
score: number;
|
|
17
|
+
reasoning: string;
|
|
18
|
+
stats: DiffStats;
|
|
19
|
+
}
|
|
20
|
+
export interface EdgeAnalysis {
|
|
21
|
+
score: number;
|
|
22
|
+
totalCommits: number;
|
|
23
|
+
distribution: Record<CommitCategory, number>;
|
|
24
|
+
topCommits: ClassifiedCommit[];
|
|
25
|
+
recommendation: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function analyzeEdge(cwd: string, maxCommits?: number): EdgeAnalysis;
|
|
28
|
+
export declare function checkEdge(cwd: string): CheckResult;
|
|
29
|
+
export declare function runEdgeCommand(cwd: string, explain?: boolean): void;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
const CATEGORY_SCORES = {
|
|
3
|
+
architecture: 90,
|
|
4
|
+
debugging: 85,
|
|
5
|
+
integration: 80,
|
|
6
|
+
feature: 60,
|
|
7
|
+
boilerplate: 20,
|
|
8
|
+
cosmetic: 10,
|
|
9
|
+
};
|
|
10
|
+
const BOILERPLATE_RE = [/\bcrud\b/i, /\bscaffold/i, /\bgenerat(e|ed|ing)\b/i, /\binit(ial)?\b/i, /\bboilerplate\b/i, /\btemplate\b/i, /\bsetup\b/i];
|
|
11
|
+
const COSMETIC_RE = [/\brenam(e|ed|ing)\b/i, /\bformat(ting)?\b/i, /\blint(ing)?\b/i, /\bprettier\b/i, /\bstyle\b/i, /\bwhitespace\b/i, /\btypo\b/i];
|
|
12
|
+
const DEBUG_RE = [/\bfix(e[ds])?\b/i, /\bbug\b/i, /\bdebug/i, /\bpatch\b/i, /\bhotfix\b/i, /\bresolv(e|ed|ing)\b/i, /\berror\b/i, /\bissue\b/i];
|
|
13
|
+
const INTEGRATION_RE = [/\bintegrat(e|ion|ing)\b/i, /\bapi\b/i, /\bwebhook\b/i, /\bmigrat(e|ion|ing)\b/i, /\bdatabase\b/i, /\bqueue\b/i, /\bconnect/i, /\bpipeline\b/i, /\bauth(entication|orization)?\b/i];
|
|
14
|
+
function getUniqueDirs(files) {
|
|
15
|
+
const dirs = new Set();
|
|
16
|
+
for (const f of files) {
|
|
17
|
+
const parts = f.split('/');
|
|
18
|
+
dirs.add(parts.length > 1 ? parts.slice(0, -1).join('/') : '.');
|
|
19
|
+
}
|
|
20
|
+
return [...dirs];
|
|
21
|
+
}
|
|
22
|
+
function hasMultiSystemFiles(files) {
|
|
23
|
+
const layers = new Set();
|
|
24
|
+
for (const f of files) {
|
|
25
|
+
const lower = f.toLowerCase();
|
|
26
|
+
if (/route|controller|handler/.test(lower))
|
|
27
|
+
layers.add('api');
|
|
28
|
+
if (/model|schema|migration|db/.test(lower))
|
|
29
|
+
layers.add('db');
|
|
30
|
+
if (/test|spec/.test(lower))
|
|
31
|
+
layers.add('test');
|
|
32
|
+
if (/config|\.env|yaml|yml/.test(lower))
|
|
33
|
+
layers.add('config');
|
|
34
|
+
if (/middleware|auth/.test(lower))
|
|
35
|
+
layers.add('middleware');
|
|
36
|
+
if (/service|worker/.test(lower))
|
|
37
|
+
layers.add('service');
|
|
38
|
+
}
|
|
39
|
+
return layers.size >= 2;
|
|
40
|
+
}
|
|
41
|
+
function isCosmetic(stats, message) {
|
|
42
|
+
const total = stats.insertions + stats.deletions;
|
|
43
|
+
if (total < 10 && COSMETIC_RE.some(p => p.test(message)))
|
|
44
|
+
return true;
|
|
45
|
+
if (stats.insertions > 0 && stats.deletions > 0 &&
|
|
46
|
+
Math.abs(stats.insertions - stats.deletions) <= 2 &&
|
|
47
|
+
total < 20 && COSMETIC_RE.some(p => p.test(message)))
|
|
48
|
+
return true;
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
function classifyCommit(hash, date, message, author, stats) {
|
|
52
|
+
const { filesChanged, insertions, deletions, dirsChanged } = stats;
|
|
53
|
+
const total = insertions + deletions;
|
|
54
|
+
let category;
|
|
55
|
+
let reasoning;
|
|
56
|
+
if (isCosmetic(stats, message)) {
|
|
57
|
+
category = 'cosmetic';
|
|
58
|
+
reasoning = `Small change (${total} lines) matching cosmetic patterns`;
|
|
59
|
+
}
|
|
60
|
+
else if (BOILERPLATE_RE.some(p => p.test(message)) && (insertions > deletions * 3 || deletions === 0)) {
|
|
61
|
+
category = 'boilerplate';
|
|
62
|
+
reasoning = `Scaffolding pattern in commit message (${insertions} insertions, ${deletions} deletions)`;
|
|
63
|
+
}
|
|
64
|
+
else if (dirsChanged >= 3 && filesChanged >= 4) {
|
|
65
|
+
category = 'architecture';
|
|
66
|
+
reasoning = `Cross-directory changes (${dirsChanged} dirs, ${filesChanged} files) — structural refactoring`;
|
|
67
|
+
}
|
|
68
|
+
else if (filesChanged <= 2 && total <= 30 && DEBUG_RE.some(p => p.test(message))) {
|
|
69
|
+
category = 'debugging';
|
|
70
|
+
reasoning = `Targeted fix (${total} lines in ${filesChanged} file${filesChanged > 1 ? 's' : ''}) — context-heavy debugging`;
|
|
71
|
+
}
|
|
72
|
+
else if (INTEGRATION_RE.some(p => p.test(message)) || (dirsChanged >= 2 && hasMultiSystemFiles(stats.files))) {
|
|
73
|
+
category = 'integration';
|
|
74
|
+
reasoning = 'Multi-system wiring — connecting different parts of the stack';
|
|
75
|
+
}
|
|
76
|
+
else if (insertions > 100 && deletions < 10 && filesChanged >= 3 && deletions < insertions * 0.1) {
|
|
77
|
+
category = 'boilerplate';
|
|
78
|
+
reasoning = `Uniform additions (${insertions} insertions, ${deletions} deletions)`;
|
|
79
|
+
}
|
|
80
|
+
else if (total > 0) {
|
|
81
|
+
if (dirsChanged >= 2 && filesChanged >= 3 && total > 50) {
|
|
82
|
+
category = 'architecture';
|
|
83
|
+
reasoning = `Multi-directory changes (${dirsChanged} dirs, ${filesChanged} files, ${total} lines)`;
|
|
84
|
+
}
|
|
85
|
+
else if (DEBUG_RE.some(p => p.test(message)) && filesChanged <= 3) {
|
|
86
|
+
category = 'debugging';
|
|
87
|
+
reasoning = `Bug fix across ${filesChanged} file${filesChanged > 1 ? 's' : ''}`;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
category = 'feature';
|
|
91
|
+
reasoning = `Feature work (${filesChanged} file${filesChanged > 1 ? 's' : ''}, ${total} lines)`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
category = 'cosmetic';
|
|
96
|
+
reasoning = 'Empty or metadata-only commit';
|
|
97
|
+
}
|
|
98
|
+
return { hash: hash.slice(0, 8), date, message: message.slice(0, 80), author, category, score: CATEGORY_SCORES[category], reasoning, stats };
|
|
99
|
+
}
|
|
100
|
+
// ── Git log parsing (no simple-git, uses execSync) ───────────────────────────
|
|
101
|
+
function gitRaw(args, cwd) {
|
|
102
|
+
try {
|
|
103
|
+
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function getCommitStats(hash, cwd) {
|
|
110
|
+
let raw = gitRaw(['diff', '--numstat', `${hash}^`, hash, '--'], cwd);
|
|
111
|
+
if (!raw) {
|
|
112
|
+
raw = gitRaw(['diff-tree', '--numstat', '--root', hash, '--'], cwd);
|
|
113
|
+
}
|
|
114
|
+
const files = [];
|
|
115
|
+
let insertions = 0;
|
|
116
|
+
let deletions = 0;
|
|
117
|
+
for (const line of raw.split('\n').filter(Boolean)) {
|
|
118
|
+
const parts = line.split('\t');
|
|
119
|
+
if (parts.length >= 3) {
|
|
120
|
+
insertions += parts[0] === '-' ? 0 : (parseInt(parts[0] ?? '0', 10) || 0);
|
|
121
|
+
deletions += parts[1] === '-' ? 0 : (parseInt(parts[1] ?? '0', 10) || 0);
|
|
122
|
+
if (parts[2])
|
|
123
|
+
files.push(parts[2]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { filesChanged: files.length, insertions, deletions, dirsChanged: getUniqueDirs(files).length, files };
|
|
127
|
+
}
|
|
128
|
+
function getRecommendation(dist, total) {
|
|
129
|
+
if (total === 0)
|
|
130
|
+
return 'no commits to analyze';
|
|
131
|
+
const boilerPct = ((dist.boilerplate + dist.cosmetic) / total) * 100;
|
|
132
|
+
const archPct = ((dist.architecture + dist.integration) / total) * 100;
|
|
133
|
+
if (boilerPct > 40)
|
|
134
|
+
return 'Focus more on cross-system architecture and targeted debugging. Delegate scaffolding to AI.';
|
|
135
|
+
if (archPct > 50)
|
|
136
|
+
return 'Strong position. Your work is deeply contextual and hard to automate.';
|
|
137
|
+
if (dist.debugging > dist.architecture)
|
|
138
|
+
return 'Good debugging instinct. Level up by taking on more cross-system architecture.';
|
|
139
|
+
return 'Balanced mix. Push toward more integration and architecture work to increase your irreplaceability.';
|
|
140
|
+
}
|
|
141
|
+
export function analyzeEdge(cwd, maxCommits = 50) {
|
|
142
|
+
const raw = gitRaw(['log', '--format=%H|%aI|%an|%s', '--no-merges', `-n`, String(maxCommits)], cwd);
|
|
143
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
144
|
+
const commits = [];
|
|
145
|
+
for (const line of lines) {
|
|
146
|
+
const pipeIdx = line.indexOf('|');
|
|
147
|
+
const rest = line.slice(pipeIdx + 1);
|
|
148
|
+
const hash = line.slice(0, pipeIdx);
|
|
149
|
+
const [date, author, ...msgParts] = rest.split('|');
|
|
150
|
+
const message = msgParts.join('|');
|
|
151
|
+
if (!hash)
|
|
152
|
+
continue;
|
|
153
|
+
const stats = getCommitStats(hash, cwd);
|
|
154
|
+
commits.push(classifyCommit(hash, date ?? '', message ?? '', author ?? '', stats));
|
|
155
|
+
}
|
|
156
|
+
const dist = { architecture: 0, debugging: 0, integration: 0, feature: 0, boilerplate: 0, cosmetic: 0 };
|
|
157
|
+
for (const c of commits)
|
|
158
|
+
dist[c.category]++;
|
|
159
|
+
const overallScore = commits.length > 0
|
|
160
|
+
? Math.round(commits.reduce((s, c) => s + c.score, 0) / commits.length)
|
|
161
|
+
: 0;
|
|
162
|
+
const topCommits = [...commits].sort((a, b) => b.score - a.score).slice(0, 3);
|
|
163
|
+
return { score: overallScore, totalCommits: commits.length, distribution: dist, topCommits, recommendation: getRecommendation(dist, commits.length) };
|
|
164
|
+
}
|
|
165
|
+
// ── CheckResult adapter ──────────────────────────────────────────────────────
|
|
166
|
+
export function checkEdge(cwd) {
|
|
167
|
+
const analysis = analyzeEdge(cwd);
|
|
168
|
+
const issues = [];
|
|
169
|
+
if (analysis.totalCommits === 0) {
|
|
170
|
+
return { name: 'edge', score: 5, maxScore: 10, issues: [{ severity: 'info', message: 'no commits to analyze', fixable: false }], summary: 'no git history' };
|
|
171
|
+
}
|
|
172
|
+
const { distribution: dist, totalCommits: total } = analysis;
|
|
173
|
+
const boilerPct = Math.round(((dist.boilerplate + dist.cosmetic) / total) * 100);
|
|
174
|
+
const archPct = Math.round(((dist.architecture + dist.integration) / total) * 100);
|
|
175
|
+
if (boilerPct > 50) {
|
|
176
|
+
issues.push({ severity: 'warning', message: `${boilerPct}% of commits are boilerplate/cosmetic — high automation risk`, fixable: false });
|
|
177
|
+
}
|
|
178
|
+
if (archPct > 40) {
|
|
179
|
+
issues.push({ severity: 'info', message: `${archPct}% architecture/integration work — strong human edge`, fixable: false });
|
|
180
|
+
}
|
|
181
|
+
if (analysis.topCommits.length > 0) {
|
|
182
|
+
const top = analysis.topCommits[0];
|
|
183
|
+
issues.push({ severity: 'info', message: `top commit: ${top.hash} (${top.category}, ${top.score}/100) — ${top.message.slice(0, 50)}`, fixable: false });
|
|
184
|
+
}
|
|
185
|
+
// Map 0-100 score to 0-10
|
|
186
|
+
const vetScore = Math.round(analysis.score / 10);
|
|
187
|
+
return {
|
|
188
|
+
name: 'edge',
|
|
189
|
+
score: vetScore,
|
|
190
|
+
maxScore: 10,
|
|
191
|
+
issues,
|
|
192
|
+
summary: `${total} commits — human edge score ${analysis.score}/100 — ${analysis.recommendation.slice(0, 60)}`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// ── Standalone subcommand output ─────────────────────────────────────────────
|
|
196
|
+
export function runEdgeCommand(cwd, explain = false) {
|
|
197
|
+
const analysis = analyzeEdge(cwd, explain ? 100 : 50);
|
|
198
|
+
if (analysis.totalCommits === 0) {
|
|
199
|
+
console.log('\n no commits to analyze\n');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const { distribution: dist, totalCommits: total, score, topCommits, recommendation } = analysis;
|
|
203
|
+
const scoreColor = score >= 70 ? '\x1b[32m' : score >= 40 ? '\x1b[33m' : '\x1b[31m';
|
|
204
|
+
const RESET = '\x1b[0m';
|
|
205
|
+
const BOLD = '\x1b[1m';
|
|
206
|
+
const DIM = '\x1b[2m';
|
|
207
|
+
console.log('');
|
|
208
|
+
console.log(` ${BOLD}Human Edge Report${RESET} ${scoreColor}${score}/100${RESET}`);
|
|
209
|
+
console.log(` ${DIM}${total} commits analyzed${RESET}`);
|
|
210
|
+
console.log('');
|
|
211
|
+
const cats = ['architecture', 'debugging', 'integration', 'feature', 'boilerplate', 'cosmetic'];
|
|
212
|
+
const catEmoji = { architecture: '🏗️', debugging: '🔍', integration: '🔗', feature: '⚡', boilerplate: '📋', cosmetic: '🎨' };
|
|
213
|
+
for (const cat of cats) {
|
|
214
|
+
if (dist[cat] === 0)
|
|
215
|
+
continue;
|
|
216
|
+
const pct = Math.round((dist[cat] / total) * 100);
|
|
217
|
+
const bar = '█'.repeat(Math.max(1, Math.round(pct / 5)));
|
|
218
|
+
const catScore = CATEGORY_SCORES[cat];
|
|
219
|
+
const color = catScore >= 70 ? '\x1b[32m' : catScore >= 40 ? '\x1b[33m' : '\x1b[31m';
|
|
220
|
+
console.log(` ${catEmoji[cat]} ${cat.padEnd(14)} ${color}${String(dist[cat]).padStart(3)}${RESET} ${DIM}(${String(pct).padStart(2)}%)${RESET} ${color}${bar}${RESET}`);
|
|
221
|
+
}
|
|
222
|
+
console.log('');
|
|
223
|
+
console.log(` ${BOLD}Top commits${RESET}`);
|
|
224
|
+
for (const c of topCommits) {
|
|
225
|
+
console.log(` ${scoreColor}${c.score}${RESET} ${c.hash} ${c.message.slice(0, 55)}`);
|
|
226
|
+
}
|
|
227
|
+
console.log('');
|
|
228
|
+
console.log(` → ${recommendation}`);
|
|
229
|
+
console.log('');
|
|
230
|
+
}
|
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>;
|
package/dist/checks/models.js
CHANGED
|
@@ -1,8 +1,44 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { readFile, walkFiles } from '../util.js';
|
|
3
|
-
//
|
|
3
|
+
// Try to use @safetnsr/model-graveyard if installed (248 models, alias matching, YAML registry)
|
|
4
|
+
async function tryModelGraveyard(cwd) {
|
|
5
|
+
try {
|
|
6
|
+
const mod = await import(/* webpackIgnore: true */ '@safetnsr/model-graveyard');
|
|
7
|
+
if (typeof mod.scan !== 'function')
|
|
8
|
+
return null;
|
|
9
|
+
const report = await mod.scan(cwd);
|
|
10
|
+
const issues = [];
|
|
11
|
+
for (const match of report.matches) {
|
|
12
|
+
if (!match.model)
|
|
13
|
+
continue;
|
|
14
|
+
if (match.model.status === 'deprecated' || match.model.status === 'eol') {
|
|
15
|
+
issues.push({
|
|
16
|
+
severity: 'error',
|
|
17
|
+
message: `${match.model.status} model "${match.raw}" in ${match.file}:${match.line}${match.model.successor ? ` — use "${match.model.successor}"` : ''}`,
|
|
18
|
+
file: match.file,
|
|
19
|
+
line: match.line,
|
|
20
|
+
fixable: !!match.model.successor,
|
|
21
|
+
fixHint: match.model.successor ? `replace "${match.raw}" with "${match.model.successor}"` : undefined,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const score = Math.max(0, 10 - issues.length * 2);
|
|
26
|
+
return {
|
|
27
|
+
name: 'models',
|
|
28
|
+
score: Math.min(10, score),
|
|
29
|
+
maxScore: 10,
|
|
30
|
+
issues,
|
|
31
|
+
summary: issues.length === 0
|
|
32
|
+
? `${report.filesScanned} files scanned (via model-graveyard) — all current`
|
|
33
|
+
: `${issues.length} deprecated model${issues.length > 1 ? 's' : ''} (via model-graveyard)`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Built-in fallback: inline registry, basic string matching
|
|
4
41
|
const SUNSET_MODELS = {
|
|
5
|
-
// OpenAI
|
|
6
42
|
'gpt-3.5-turbo': { replacement: 'gpt-4o-mini', sunset: '2025-06' },
|
|
7
43
|
'gpt-4-turbo': { replacement: 'gpt-4o', sunset: '2025-04' },
|
|
8
44
|
'gpt-4-turbo-preview': { replacement: 'gpt-4o', sunset: '2025-04' },
|
|
@@ -12,7 +48,6 @@ const SUNSET_MODELS = {
|
|
|
12
48
|
'text-davinci-003': { replacement: 'gpt-4o-mini', sunset: '2024-01' },
|
|
13
49
|
'code-davinci-002': { replacement: 'gpt-4o', sunset: '2024-01' },
|
|
14
50
|
'text-embedding-ada-002': { replacement: 'text-embedding-3-small', sunset: '2025-04' },
|
|
15
|
-
// Anthropic
|
|
16
51
|
'claude-instant-1': { replacement: 'claude-sonnet-4-5', sunset: '2024-08' },
|
|
17
52
|
'claude-2': { replacement: 'claude-sonnet-4-5', sunset: '2024-08' },
|
|
18
53
|
'claude-2.0': { replacement: 'claude-sonnet-4-5', sunset: '2024-08' },
|
|
@@ -20,43 +55,36 @@ const SUNSET_MODELS = {
|
|
|
20
55
|
'claude-3-haiku-20240307': { replacement: 'claude-haiku-3-5', sunset: '2025-06' },
|
|
21
56
|
'claude-3-sonnet-20240229': { replacement: 'claude-sonnet-4-5', sunset: '2025-03' },
|
|
22
57
|
'claude-3-opus-20240229': { replacement: 'claude-opus-4-0', sunset: '2025-09' },
|
|
23
|
-
// Google
|
|
24
58
|
'gemini-pro': { replacement: 'gemini-2.0-flash', sunset: '2025-02' },
|
|
25
59
|
'gemini-1.0-pro': { replacement: 'gemini-2.0-flash', sunset: '2025-02' },
|
|
26
60
|
'gemini-1.5-pro': { replacement: 'gemini-2.5-pro', sunset: '2025-09' },
|
|
27
61
|
'gemini-1.5-flash': { replacement: 'gemini-2.0-flash', sunset: '2025-09' },
|
|
28
62
|
'text-bison': { replacement: 'gemini-2.0-flash', sunset: '2024-04' },
|
|
29
63
|
'chat-bison': { replacement: 'gemini-2.0-flash', sunset: '2024-04' },
|
|
30
|
-
// Cohere
|
|
31
|
-
'command': { replacement: 'command-r-plus', sunset: '2025-03' },
|
|
32
64
|
'command-light': { replacement: 'command-r', sunset: '2025-03' },
|
|
33
65
|
'command-nightly': { replacement: 'command-r-plus', sunset: '2025-03' },
|
|
34
66
|
};
|
|
35
67
|
const SCAN_EXTS = ['.ts', '.js', '.tsx', '.jsx', '.py', '.rs', '.go', '.java', '.rb', '.php',
|
|
36
68
|
'.yaml', '.yml', '.json', '.toml', '.env', '.env.example', '.env.local', '.cfg', '.ini', '.conf'];
|
|
37
|
-
// Files that contain model registries should not trigger false positives
|
|
38
69
|
const SELF_IGNORE = ['models.ts', 'models.js', 'model-graveyard', 'model-registry', 'sunset'];
|
|
39
|
-
// Short model names that need context to avoid false positives (e.g. npm "command" field)
|
|
40
70
|
const CONTEXT_REQUIRED = new Set(['command', 'command-light', 'command-nightly']);
|
|
41
71
|
function hasModelContext(content, model) {
|
|
42
|
-
// Require the model name to appear in a string-like context: quotes, assignment, or near "model"/"engine"
|
|
43
72
|
const escaped = model.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
44
73
|
const contextPatterns = [
|
|
45
|
-
new RegExp(`['"\`]${escaped}['"\`]`),
|
|
46
|
-
new RegExp(`model[_\\s]*[:=].*${escaped}`, 'i'),
|
|
47
|
-
new RegExp(`engine[_\\s]*[:=].*${escaped}`, 'i'),
|
|
48
|
-
new RegExp(`${escaped}.*(?:api|llm|chat|completion)`, 'i'),
|
|
74
|
+
new RegExp(`['"\`]${escaped}['"\`]`),
|
|
75
|
+
new RegExp(`model[_\\s]*[:=].*${escaped}`, 'i'),
|
|
76
|
+
new RegExp(`engine[_\\s]*[:=].*${escaped}`, 'i'),
|
|
77
|
+
new RegExp(`${escaped}.*(?:api|llm|chat|completion)`, 'i'),
|
|
49
78
|
];
|
|
50
79
|
return contextPatterns.some(p => p.test(content));
|
|
51
80
|
}
|
|
52
|
-
|
|
81
|
+
function builtinModels(cwd, ignore) {
|
|
53
82
|
const issues = [];
|
|
54
83
|
const files = walkFiles(cwd, ignore);
|
|
55
84
|
const found = new Map();
|
|
56
85
|
for (const f of files) {
|
|
57
86
|
if (!SCAN_EXTS.some(ext => f.endsWith(ext)))
|
|
58
87
|
continue;
|
|
59
|
-
// Skip files that are model registries themselves
|
|
60
88
|
if (SELF_IGNORE.some(s => f.toLowerCase().includes(s)))
|
|
61
89
|
continue;
|
|
62
90
|
const content = readFile(join(cwd, f));
|
|
@@ -65,7 +93,6 @@ export function checkModels(cwd, ignore) {
|
|
|
65
93
|
for (const [model, info] of Object.entries(SUNSET_MODELS)) {
|
|
66
94
|
if (!content.includes(model))
|
|
67
95
|
continue;
|
|
68
|
-
// For short/ambiguous names, require contextual evidence
|
|
69
96
|
if (CONTEXT_REQUIRED.has(model) && !hasModelContext(content, model))
|
|
70
97
|
continue;
|
|
71
98
|
const existing = found.get(model) || [];
|
|
@@ -93,3 +120,9 @@ export function checkModels(cwd, ignore) {
|
|
|
93
120
|
summary: issues.length === 0 ? 'all model references current' : `${issues.length} deprecated model${issues.length > 1 ? 's' : ''} found`,
|
|
94
121
|
};
|
|
95
122
|
}
|
|
123
|
+
export async function checkModels(cwd, ignore) {
|
|
124
|
+
const rich = await tryModelGraveyard(cwd);
|
|
125
|
+
if (rich)
|
|
126
|
+
return rich;
|
|
127
|
+
return builtinModels(cwd, ignore);
|
|
128
|
+
}
|
package/dist/checks/ready.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { CheckResult } from '../types.js';
|
|
2
|
-
export declare function checkReady(cwd: string, ignore: string[]): CheckResult
|
|
2
|
+
export declare function checkReady(cwd: string, ignore: string[]): Promise<CheckResult>;
|
package/dist/checks/ready.js
CHANGED
|
@@ -1,28 +1,64 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { readFile, walkFiles } from '../util.js';
|
|
3
|
-
//
|
|
4
|
-
|
|
3
|
+
// Try to use @safetnsr/ai-ready if installed (richer per-file analysis)
|
|
4
|
+
async function tryAiReady(cwd) {
|
|
5
|
+
try {
|
|
6
|
+
const mod = await import(/* webpackIgnore: true */ '@safetnsr/ai-ready');
|
|
7
|
+
if (typeof mod.main !== 'function')
|
|
8
|
+
return null;
|
|
9
|
+
const result = mod.main(['--json', cwd]);
|
|
10
|
+
if (!result || result.exitCode !== 0)
|
|
11
|
+
return null;
|
|
12
|
+
const data = JSON.parse(result.output);
|
|
13
|
+
const issues = [];
|
|
14
|
+
// Convert ai-ready's per-file results to vet issues
|
|
15
|
+
if (data.files) {
|
|
16
|
+
const lowScoreFiles = data.files.filter((f) => f.score < 5);
|
|
17
|
+
for (const f of lowScoreFiles.slice(0, 5)) {
|
|
18
|
+
issues.push({
|
|
19
|
+
severity: 'warning',
|
|
20
|
+
message: `${f.file}: readiness ${f.score}/10 — ${f.reasons?.join(', ') || 'low score'}`,
|
|
21
|
+
file: f.file,
|
|
22
|
+
fixable: false,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if (lowScoreFiles.length > 5) {
|
|
26
|
+
issues.push({ severity: 'info', message: `...and ${lowScoreFiles.length - 5} more low-readiness files`, fixable: false });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Map ai-ready score to vet format
|
|
30
|
+
const score = typeof data.score === 'number' ? data.score : 5;
|
|
31
|
+
return {
|
|
32
|
+
name: 'ready',
|
|
33
|
+
score: Math.round(Math.min(10, score) * 10) / 10,
|
|
34
|
+
maxScore: 10,
|
|
35
|
+
issues,
|
|
36
|
+
summary: `${data.files?.length || 0} files analyzed (via ai-ready) — ${issues.length} issues`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Built-in fallback: simpler project-level checks
|
|
44
|
+
function builtinReady(cwd, ignore) {
|
|
5
45
|
const issues = [];
|
|
6
46
|
const files = walkFiles(cwd, ignore);
|
|
7
|
-
// 1. README exists — critical for AI context
|
|
8
47
|
const hasReadme = files.some(f => /^readme\.(md|txt|rst)$/i.test(f));
|
|
9
48
|
if (!hasReadme) {
|
|
10
49
|
issues.push({ severity: 'error', message: 'no README — AI agents have no project context', fixable: true, fixHint: 'create a README.md' });
|
|
11
50
|
}
|
|
12
|
-
// 2. Project manifest
|
|
13
51
|
const manifests = ['package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'pom.xml', 'build.gradle', 'Gemfile', 'composer.json'];
|
|
14
52
|
const hasManifest = manifests.some(m => files.includes(m));
|
|
15
53
|
if (!hasManifest) {
|
|
16
54
|
issues.push({ severity: 'error', message: 'no package manifest — agents can\'t resolve dependencies', fixable: false });
|
|
17
55
|
}
|
|
18
|
-
// 3. Test coverage
|
|
19
56
|
const codeExts = ['.ts', '.js', '.tsx', '.jsx', '.py', '.rs', '.go', '.java', '.rb', '.php', '.cs', '.swift', '.kt'];
|
|
20
57
|
const testFiles = files.filter(f => /\.(test|spec)\.(ts|js|tsx|jsx|py)$/.test(f) || f.includes('__tests__/') || f.startsWith('tests/') || f.startsWith('test/'));
|
|
21
58
|
const codeFiles = files.filter(f => codeExts.some(ext => f.endsWith(ext)));
|
|
22
59
|
if (codeFiles.length > 5 && testFiles.length === 0) {
|
|
23
60
|
issues.push({ severity: 'error', message: 'no tests — AI agents produce better code when tests exist to validate against', fixable: false });
|
|
24
61
|
}
|
|
25
|
-
// 4. Overly large files (>500 lines)
|
|
26
62
|
let largeFileCount = 0;
|
|
27
63
|
for (const f of files) {
|
|
28
64
|
if (!codeExts.some(ext => f.endsWith(ext)))
|
|
@@ -38,19 +74,16 @@ export function checkReady(cwd, ignore) {
|
|
|
38
74
|
if (largeFileCount > 3) {
|
|
39
75
|
issues.push({ severity: 'warning', message: `...and ${largeFileCount - 3} more large files`, fixable: false });
|
|
40
76
|
}
|
|
41
|
-
// 5. .env without .env.example
|
|
42
77
|
const hasEnv = files.some(f => f === '.env' || f === '.env.local');
|
|
43
78
|
const hasEnvExample = files.some(f => f === '.env.example' || f === '.env.template');
|
|
44
79
|
if (hasEnv && !hasEnvExample) {
|
|
45
80
|
issues.push({ severity: 'warning', message: '.env exists but no .env.example — AI agents can\'t see env structure', fixable: false });
|
|
46
81
|
}
|
|
47
|
-
// 6. No types in JS-heavy project
|
|
48
82
|
const tsFiles = files.filter(f => f.endsWith('.ts') || f.endsWith('.tsx'));
|
|
49
83
|
const jsFiles = files.filter(f => f.endsWith('.js') || f.endsWith('.jsx'));
|
|
50
84
|
if (jsFiles.length > 10 && tsFiles.length === 0 && files.includes('package.json')) {
|
|
51
85
|
issues.push({ severity: 'info', message: `${jsFiles.length} JS files, no TypeScript — typed code gives agents better context`, fixable: false });
|
|
52
86
|
}
|
|
53
|
-
// Recalibrated scoring: errors = -3, warnings = -1.5, info = -0.3
|
|
54
87
|
const errors = issues.filter(i => i.severity === 'error').length;
|
|
55
88
|
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
56
89
|
const infos = issues.filter(i => i.severity === 'info').length;
|
|
@@ -63,3 +96,9 @@ export function checkReady(cwd, ignore) {
|
|
|
63
96
|
summary: issues.length === 0 ? 'codebase is well-structured for AI' : `${issues.length} readiness issues`,
|
|
64
97
|
};
|
|
65
98
|
}
|
|
99
|
+
export async function checkReady(cwd, ignore) {
|
|
100
|
+
const rich = await tryAiReady(cwd);
|
|
101
|
+
if (rich)
|
|
102
|
+
return rich;
|
|
103
|
+
return builtinReady(cwd, ignore);
|
|
104
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
interface ToolUseBlock {
|
|
3
|
+
type: 'tool_use';
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
input: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
interface SessionEntry {
|
|
9
|
+
type?: string;
|
|
10
|
+
role?: string;
|
|
11
|
+
content?: unknown;
|
|
12
|
+
timestamp?: string;
|
|
13
|
+
message?: {
|
|
14
|
+
role?: string;
|
|
15
|
+
content?: unknown;
|
|
16
|
+
};
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
export interface GroupedActions {
|
|
20
|
+
session_id: string;
|
|
21
|
+
start_time: string | null;
|
|
22
|
+
end_time: string | null;
|
|
23
|
+
duration_seconds: number | null;
|
|
24
|
+
files_modified: string[];
|
|
25
|
+
files_deleted: string[];
|
|
26
|
+
files_created: string[];
|
|
27
|
+
commands_run: string[];
|
|
28
|
+
urls_fetched: string[];
|
|
29
|
+
packages_installed: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface SessionReceipt {
|
|
32
|
+
actions: GroupedActions;
|
|
33
|
+
sha256: string;
|
|
34
|
+
generated_at: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function findSessionFiles(baseDir?: string): string[];
|
|
37
|
+
export declare function findLatestSession(baseDir?: string): string | null;
|
|
38
|
+
export declare function parseSessionFile(filePath: string): Promise<{
|
|
39
|
+
toolUses: ToolUseBlock[];
|
|
40
|
+
entries: SessionEntry[];
|
|
41
|
+
}>;
|
|
42
|
+
export declare function groupActions(toolUses: ToolUseBlock[], entries: SessionEntry[], sessionPath: string): GroupedActions;
|
|
43
|
+
export declare function computeSha256(content: string): string;
|
|
44
|
+
export declare function renderReceiptText(actions: GroupedActions): string;
|
|
45
|
+
export declare function renderReceiptJson(actions: GroupedActions): SessionReceipt;
|
|
46
|
+
export declare function checkReceipt(cwd: string): Promise<CheckResult>;
|
|
47
|
+
export declare function runReceiptCommand(format?: 'ascii' | 'json'): Promise<void>;
|
|
48
|
+
export {};
|