@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 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, 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
+ }
@@ -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>;
@@ -1,8 +1,44 @@
1
1
  import { join } from 'node:path';
2
2
  import { readFile, walkFiles } from '../util.js';
3
- // Model sunset/deprecation registry kept inline for zero deps
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}['"\`]`), // quoted
46
- new RegExp(`model[_\\s]*[:=].*${escaped}`, 'i'), // model assignment
47
- new RegExp(`engine[_\\s]*[:=].*${escaped}`, 'i'), // engine assignment
48
- new RegExp(`${escaped}.*(?:api|llm|chat|completion)`, 'i'), // near API terms
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
- export function checkModels(cwd, ignore) {
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
+ }
@@ -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>;
@@ -1,28 +1,64 @@
1
1
  import { join } from 'node:path';
2
2
  import { readFile, walkFiles } from '../util.js';
3
- // Codebase AI-readiness: structure, complexity, documentation
4
- export function checkReady(cwd, ignore) {
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 {};