@safetnsr/vet 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # vet
2
2
 
3
- vet your AI-generated code. one command, six checks, zero config.
3
+ vet your AI-generated code. one command, eight checks, zero config.
4
4
 
5
5
  ```bash
6
6
  npx @safetnsr/vet
@@ -15,9 +15,11 @@ works with Claude Code, Cursor, Copilot, Codex, Aider, Windsurf, Cline — anyth
15
15
  | **ready** | is your codebase AI-friendly? | scans structure, docs, types, tests |
16
16
  | **diff** | did the AI leave anti-patterns? | AI-specific patterns: wholesale rewrites, orphaned imports, catch-alls, over-commenting, plus secrets & stubs |
17
17
  | **models** | using deprecated AI models? | scans code for sunset model strings across OpenAI, Anthropic, Google, Cohere |
18
- | **links** | broken markdown links? | validates relative links and wikilinks |
19
18
  | **config** | agent configs in place? | deep analysis of CLAUDE.md, .cursorrules, copilot-instructions — checks completeness, consistency, and specificity against your actual codebase |
20
19
  | **history** | git patterns healthy? | analyzes commit churn, AI attribution, large changes |
20
+ | **scan** | malicious patterns in agent configs? | scans .claude/, .cursorrules, CLAUDE.md, .mcp/ for prompt injection, shell injection, exfiltration endpoints |
21
+ | **secrets** | leaked secrets in build output? | scans dist/, build/, .next/ + .env files for API keys, tokens, connection strings using pattern + entropy analysis |
22
+ | **receipt** | what did the last agent session do? | parses ~/.claude/projects/ JSONL session logs — files changed, commands run, packages installed, SHA256 integrity hash |
21
23
 
22
24
  ## usage
23
25
 
@@ -45,19 +47,25 @@ npx @safetnsr/vet --json
45
47
 
46
48
  # generate configs + pre-commit hook
47
49
  npx @safetnsr/vet init
50
+
51
+ # show last agent session receipt (ASCII or JSON)
52
+ npx @safetnsr/vet receipt
53
+ npx @safetnsr/vet receipt --json
48
54
  ```
49
55
 
50
56
  ## output
51
57
 
52
58
  ```
53
- my-project 6.2/10
59
+ my-project 7.5/10
54
60
 
55
61
  ready ████░░░░░░ 4 3 readiness issues
56
62
  diff ████████░░ 8 3 issues (2 AI-specific) in 5 files
57
63
  models ██████████ 10 all models current
58
- links ██████░░░░ 6 3 broken links in docs/
59
64
  config ███░░░░░░░ 3 Cursor — needs work (3/10)
60
65
  history █████████░ 9 41 commits (~15% AI-attributed)
66
+ scan ██████████ 10 no malicious patterns found
67
+ secrets ██████████ 10 no leaked secrets
68
+ receipt ██████████ 10 last session: 3 files, 2 commands
61
69
 
62
70
  ✗ no README — AI agents have no project context
63
71
  ✗ no tests — AI agents produce better code when tests exist
@@ -69,7 +77,7 @@ npx @safetnsr/vet init
69
77
 
70
78
  ## --fix
71
79
 
72
- `vet --fix` doesn't just scaffold — it analyzes your codebase and generates project-specific configs:
80
+ `vet --fix` analyzes your codebase and generates project-specific configs:
73
81
 
74
82
  ```bash
75
83
  $ npx @safetnsr/vet --fix
@@ -83,12 +91,10 @@ $ npx @safetnsr/vet --fix
83
91
  fixed 3 issues
84
92
  ```
85
93
 
86
- the generated CLAUDE.md includes your actual stack, directory structure, and framework-specific rules — not generic boilerplate.
94
+ the generated CLAUDE.md includes your actual stack, directory structure, and framework-specific rules.
87
95
 
88
96
  ## AI-specific diff patterns
89
97
 
90
- vet catches things that are specific to AI-generated code:
91
-
92
98
  | pattern | what it catches |
93
99
  |---------|----------------|
94
100
  | `[ai] wholesale rewrite` | AI rewrote an entire function when a small edit would suffice |
@@ -109,13 +115,36 @@ config score breakdown:
109
115
  specificity: 3/10 — generic rules, nothing project-specific
110
116
  ```
111
117
 
118
+ ## subcommands
119
+
120
+ ### `vet receipt`
121
+
122
+ 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:
123
+
124
+ ```
125
+ ╔══════════════════════════════════════════════╗
126
+ ║ AGENT SESSION RECEIPT ║
127
+ ╠══════════════════════════════════════════════╣
128
+ ║ Session: abc123def456 ║
129
+ ║ Date: 2024-01-15 14:32:11 UTC ║
130
+ ║ Duration: 12m 34s ║
131
+ ╠══════════════════════════════════════════════╣
132
+ ║ FILES CREATED (3) ║
133
+ ║ src/checks/scan.ts ║
134
+ ║ src/checks/secrets.ts ║
135
+ ║ test/scan.test.mjs ║
136
+ ╠══════════════════════════════════════════════╣
137
+ ║ SHA256: 3a7f9c2e... ║
138
+ ╚══════════════════════════════════════════════╝
139
+ ```
140
+
112
141
  ## config
113
142
 
114
143
  create `.vetrc` in your project root (optional):
115
144
 
116
145
  ```json
117
146
  {
118
- "checks": ["ready", "diff", "models", "links", "config", "history"],
147
+ "checks": ["ready", "diff", "models", "config", "history", "scan", "secrets", "receipt"],
119
148
  "ignore": ["vendor/", "generated/"],
120
149
  "thresholds": { "min": 6 }
121
150
  }
@@ -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
+ }
@@ -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 {};