@safetnsr/vet 1.20.1 → 1.22.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/dist/checks/context.d.ts +3 -0
- package/dist/checks/context.js +359 -0
- package/dist/checks/split.d.ts +3 -0
- package/dist/checks/split.js +325 -0
- package/dist/cli.js +32 -4
- package/package.json +3 -2
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { c } from '../util.js';
|
|
5
|
+
import { cachedReadFile } from '../file-cache.js';
|
|
6
|
+
// ── Tiktoken lazy init ───────────────────────────────────────────────────────
|
|
7
|
+
import { encodingForModel } from 'js-tiktoken';
|
|
8
|
+
let _encoder = null;
|
|
9
|
+
function getEncoder() {
|
|
10
|
+
if (!_encoder) {
|
|
11
|
+
_encoder = encodingForModel('gpt-4');
|
|
12
|
+
}
|
|
13
|
+
return _encoder;
|
|
14
|
+
}
|
|
15
|
+
function countTokens(text) {
|
|
16
|
+
return getEncoder().encode(text).length;
|
|
17
|
+
}
|
|
18
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
19
|
+
const CONTEXT_FILES = ['CLAUDE.md', 'AGENTS.md', 'SOUL.md', '.cursorrules', 'codex.md'];
|
|
20
|
+
const CURSOR_RULES_DIR = join('.cursor', 'rules');
|
|
21
|
+
const MEMORY_DIR = 'memory';
|
|
22
|
+
const DAILY_DIR = join(MEMORY_DIR, 'daily');
|
|
23
|
+
const MODEL_COSTS = {
|
|
24
|
+
opus: 15, // $15 per MTok input
|
|
25
|
+
sonnet: 3, // $3 per MTok input
|
|
26
|
+
haiku: 0.25, // $0.25 per MTok input
|
|
27
|
+
};
|
|
28
|
+
const TOKEN_THRESHOLD = 8000;
|
|
29
|
+
const BLOATED_FILE_THRESHOLD = 10000;
|
|
30
|
+
function splitIntoSections(content, file) {
|
|
31
|
+
const lines = content.split('\n');
|
|
32
|
+
const sections = [];
|
|
33
|
+
let currentTitle = '(intro)';
|
|
34
|
+
let currentLines = [];
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
const headerMatch = line.match(/^(#{2,3})\s+(.+)/);
|
|
37
|
+
if (headerMatch) {
|
|
38
|
+
// flush previous
|
|
39
|
+
if (currentLines.length > 0) {
|
|
40
|
+
const text = currentLines.join('\n');
|
|
41
|
+
sections.push({ title: currentTitle, content: text, tokens: countTokens(text), file });
|
|
42
|
+
}
|
|
43
|
+
currentTitle = headerMatch[2].trim();
|
|
44
|
+
currentLines = [line];
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
currentLines.push(line);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// flush last
|
|
51
|
+
if (currentLines.length > 0) {
|
|
52
|
+
const text = currentLines.join('\n');
|
|
53
|
+
sections.push({ title: currentTitle, content: text, tokens: countTokens(text), file });
|
|
54
|
+
}
|
|
55
|
+
return sections;
|
|
56
|
+
}
|
|
57
|
+
// ── File discovery ───────────────────────────────────────────────────────────
|
|
58
|
+
function discoverContextFiles(cwd) {
|
|
59
|
+
const files = [];
|
|
60
|
+
for (const name of CONTEXT_FILES) {
|
|
61
|
+
const full = join(cwd, name);
|
|
62
|
+
if (existsSync(full))
|
|
63
|
+
files.push(full);
|
|
64
|
+
}
|
|
65
|
+
// memory/*.md (not daily/)
|
|
66
|
+
const memDir = join(cwd, MEMORY_DIR);
|
|
67
|
+
if (existsSync(memDir) && statSync(memDir).isDirectory()) {
|
|
68
|
+
try {
|
|
69
|
+
for (const entry of readdirSync(memDir)) {
|
|
70
|
+
if (!entry.endsWith('.md'))
|
|
71
|
+
continue;
|
|
72
|
+
const full = join(memDir, entry);
|
|
73
|
+
try {
|
|
74
|
+
if (statSync(full).isFile())
|
|
75
|
+
files.push(full);
|
|
76
|
+
}
|
|
77
|
+
catch { /* skip */ }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch { /* skip */ }
|
|
81
|
+
}
|
|
82
|
+
// .cursor/rules
|
|
83
|
+
const cursorDir = join(cwd, CURSOR_RULES_DIR);
|
|
84
|
+
if (existsSync(cursorDir) && statSync(cursorDir).isDirectory()) {
|
|
85
|
+
try {
|
|
86
|
+
for (const entry of readdirSync(cursorDir)) {
|
|
87
|
+
const full = join(cursorDir, entry);
|
|
88
|
+
try {
|
|
89
|
+
if (statSync(full).isFile())
|
|
90
|
+
files.push(full);
|
|
91
|
+
}
|
|
92
|
+
catch { /* skip */ }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch { /* skip */ }
|
|
96
|
+
}
|
|
97
|
+
return files;
|
|
98
|
+
}
|
|
99
|
+
// ── Stale detection ──────────────────────────────────────────────────────────
|
|
100
|
+
function detectStaleSections(sections) {
|
|
101
|
+
const stale = new Set();
|
|
102
|
+
const claudeDir = join(homedir(), '.claude', 'projects');
|
|
103
|
+
if (!existsSync(claudeDir))
|
|
104
|
+
return stale;
|
|
105
|
+
// Collect recent session log content
|
|
106
|
+
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
107
|
+
let sessionContent = '';
|
|
108
|
+
try {
|
|
109
|
+
const projects = readdirSync(claudeDir);
|
|
110
|
+
for (const project of projects) {
|
|
111
|
+
const projectDir = join(claudeDir, project);
|
|
112
|
+
try {
|
|
113
|
+
if (!statSync(projectDir).isDirectory())
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const files = readdirSync(projectDir);
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
if (!file.endsWith('.jsonl'))
|
|
123
|
+
continue;
|
|
124
|
+
const full = join(projectDir, file);
|
|
125
|
+
try {
|
|
126
|
+
const stat = statSync(full);
|
|
127
|
+
if (stat.mtimeMs < sevenDaysAgo)
|
|
128
|
+
continue;
|
|
129
|
+
sessionContent += readFileSync(full, 'utf-8') + '\n';
|
|
130
|
+
}
|
|
131
|
+
catch { /* skip */ }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch { /* skip */ }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return stale;
|
|
139
|
+
}
|
|
140
|
+
if (!sessionContent)
|
|
141
|
+
return stale;
|
|
142
|
+
// Check each section: extract significant phrases and grep
|
|
143
|
+
for (const section of sections) {
|
|
144
|
+
if (section.title === '(intro)')
|
|
145
|
+
continue;
|
|
146
|
+
// Extract significant words from section content (skip short/generic)
|
|
147
|
+
const words = section.content
|
|
148
|
+
.replace(/[#`*_\-\[\](){}|]/g, ' ')
|
|
149
|
+
.split(/\s+/)
|
|
150
|
+
.filter(w => w.length > 4);
|
|
151
|
+
// Take a sample of phrases to check
|
|
152
|
+
const phrases = words.slice(0, 20);
|
|
153
|
+
if (phrases.length === 0)
|
|
154
|
+
continue;
|
|
155
|
+
const found = phrases.some(phrase => sessionContent.includes(phrase));
|
|
156
|
+
if (!found) {
|
|
157
|
+
stale.add(`${section.file}::${section.title}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return stale;
|
|
161
|
+
}
|
|
162
|
+
// ── Cost calculation ─────────────────────────────────────────────────────────
|
|
163
|
+
function calculateCost(tokens, model) {
|
|
164
|
+
const rate = MODEL_COSTS[model] || 3;
|
|
165
|
+
return (tokens / 1_000_000) * rate;
|
|
166
|
+
}
|
|
167
|
+
function formatCost(cost) {
|
|
168
|
+
if (cost < 0.001)
|
|
169
|
+
return `$${cost.toFixed(6)}`;
|
|
170
|
+
if (cost < 0.01)
|
|
171
|
+
return `$${cost.toFixed(4)}`;
|
|
172
|
+
return `$${cost.toFixed(3)}`;
|
|
173
|
+
}
|
|
174
|
+
// ── CheckResult (for full vet scan) ──────────────────────────────────────────
|
|
175
|
+
export function checkContext(cwd) {
|
|
176
|
+
const files = discoverContextFiles(cwd);
|
|
177
|
+
const issues = [];
|
|
178
|
+
let score = 100;
|
|
179
|
+
if (files.length === 0) {
|
|
180
|
+
score -= 15;
|
|
181
|
+
issues.push({
|
|
182
|
+
severity: 'error',
|
|
183
|
+
message: 'No agent context files found',
|
|
184
|
+
fixable: false,
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
name: 'context',
|
|
188
|
+
score: Math.max(0, score),
|
|
189
|
+
maxScore: 100,
|
|
190
|
+
issues,
|
|
191
|
+
summary: 'no agent context files found',
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const allSections = [];
|
|
195
|
+
let totalTokens = 0;
|
|
196
|
+
for (const filePath of files) {
|
|
197
|
+
const content = cachedReadFile(filePath);
|
|
198
|
+
if (!content)
|
|
199
|
+
continue;
|
|
200
|
+
const relPath = filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath;
|
|
201
|
+
const sections = splitIntoSections(content, relPath);
|
|
202
|
+
allSections.push(...sections);
|
|
203
|
+
const fileTokens = sections.reduce((sum, s) => sum + s.tokens, 0);
|
|
204
|
+
totalTokens += fileTokens;
|
|
205
|
+
if (fileTokens > BLOATED_FILE_THRESHOLD) {
|
|
206
|
+
issues.push({
|
|
207
|
+
severity: 'warning',
|
|
208
|
+
message: `Context file exceeds 10K tokens: ${relPath} (${fileTokens} tokens)`,
|
|
209
|
+
file: relPath,
|
|
210
|
+
fixable: false,
|
|
211
|
+
fixHint: 'Split or trim this file to reduce token cost',
|
|
212
|
+
});
|
|
213
|
+
score -= 5;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Stale detection
|
|
217
|
+
const staleSections = detectStaleSections(allSections);
|
|
218
|
+
let staleDeduction = 0;
|
|
219
|
+
let staleSavings = 0;
|
|
220
|
+
for (const key of staleSections) {
|
|
221
|
+
const [file, title] = key.split('::');
|
|
222
|
+
const section = allSections.find(s => s.file === file && s.title === title);
|
|
223
|
+
if (section)
|
|
224
|
+
staleSavings += section.tokens;
|
|
225
|
+
if (staleDeduction < 40) {
|
|
226
|
+
issues.push({
|
|
227
|
+
severity: 'warning',
|
|
228
|
+
message: `Stale section: "${title}" in ${file}`,
|
|
229
|
+
file,
|
|
230
|
+
fixable: false,
|
|
231
|
+
fixHint: 'Section not referenced in recent sessions — consider removing',
|
|
232
|
+
});
|
|
233
|
+
staleDeduction += 10;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
score -= Math.min(staleDeduction, 40);
|
|
237
|
+
// Token threshold penalty
|
|
238
|
+
if (totalTokens > TOKEN_THRESHOLD) {
|
|
239
|
+
const over = totalTokens - TOKEN_THRESHOLD;
|
|
240
|
+
const penalty = Math.min(Math.floor(over / 2000) * 5, 30);
|
|
241
|
+
score -= penalty;
|
|
242
|
+
}
|
|
243
|
+
// Info issues
|
|
244
|
+
issues.push({
|
|
245
|
+
severity: 'info',
|
|
246
|
+
message: `Total context: ${totalTokens} tokens across ${files.length} file${files.length !== 1 ? 's' : ''}`,
|
|
247
|
+
fixable: false,
|
|
248
|
+
});
|
|
249
|
+
if (staleSavings > 0) {
|
|
250
|
+
const savings = formatCost(calculateCost(staleSavings, 'sonnet'));
|
|
251
|
+
issues.push({
|
|
252
|
+
severity: 'info',
|
|
253
|
+
message: `Potential savings: ${staleSavings} tokens (${savings}/call at sonnet rates) from removing stale sections`,
|
|
254
|
+
fixable: false,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
name: 'context',
|
|
259
|
+
score: Math.max(0, score),
|
|
260
|
+
maxScore: 100,
|
|
261
|
+
issues,
|
|
262
|
+
summary: `${totalTokens} tokens in ${files.length} context file${files.length !== 1 ? 's' : ''}${staleSections.size > 0 ? `, ${staleSections.size} stale section${staleSections.size !== 1 ? 's' : ''}` : ''}`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// ── Subcommand output ────────────────────────────────────────────────────────
|
|
266
|
+
export async function runContextCommand(format, cwd) {
|
|
267
|
+
const files = discoverContextFiles(cwd);
|
|
268
|
+
if (files.length === 0) {
|
|
269
|
+
if (format === 'json') {
|
|
270
|
+
console.log(JSON.stringify({ files: [], sections: [], totalTokens: 0, costs: {}, stale: [], score: 0 }, null, 2));
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
console.log(`\n ${c.bold}vet context${c.reset} — no agent context files found\n`);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const allSections = [];
|
|
278
|
+
let totalTokens = 0;
|
|
279
|
+
for (const filePath of files) {
|
|
280
|
+
const content = cachedReadFile(filePath);
|
|
281
|
+
if (!content)
|
|
282
|
+
continue;
|
|
283
|
+
const relPath = filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath;
|
|
284
|
+
const sections = splitIntoSections(content, relPath);
|
|
285
|
+
allSections.push(...sections);
|
|
286
|
+
totalTokens += sections.reduce((sum, s) => sum + s.tokens, 0);
|
|
287
|
+
}
|
|
288
|
+
const staleSections = detectStaleSections(allSections);
|
|
289
|
+
if (format === 'json') {
|
|
290
|
+
const result = {
|
|
291
|
+
files: files.map(f => f.startsWith(cwd) ? f.slice(cwd.length + 1) : f),
|
|
292
|
+
sections: allSections.map(s => ({
|
|
293
|
+
file: s.file,
|
|
294
|
+
title: s.title,
|
|
295
|
+
tokens: s.tokens,
|
|
296
|
+
stale: staleSections.has(`${s.file}::${s.title}`),
|
|
297
|
+
costs: {
|
|
298
|
+
opus: calculateCost(s.tokens, 'opus'),
|
|
299
|
+
sonnet: calculateCost(s.tokens, 'sonnet'),
|
|
300
|
+
haiku: calculateCost(s.tokens, 'haiku'),
|
|
301
|
+
},
|
|
302
|
+
})),
|
|
303
|
+
totalTokens,
|
|
304
|
+
costs: {
|
|
305
|
+
opus: calculateCost(totalTokens, 'opus'),
|
|
306
|
+
sonnet: calculateCost(totalTokens, 'sonnet'),
|
|
307
|
+
haiku: calculateCost(totalTokens, 'haiku'),
|
|
308
|
+
},
|
|
309
|
+
stale: [...staleSections],
|
|
310
|
+
staleSavingsTokens: allSections
|
|
311
|
+
.filter(s => staleSections.has(`${s.file}::${s.title}`))
|
|
312
|
+
.reduce((sum, s) => sum + s.tokens, 0),
|
|
313
|
+
};
|
|
314
|
+
console.log(JSON.stringify(result, null, 2));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// ASCII table output
|
|
318
|
+
console.log(`\n ${c.bold}vet context${c.reset} — agent context cost audit\n`);
|
|
319
|
+
// Header
|
|
320
|
+
const fileW = 30;
|
|
321
|
+
const sectionW = 25;
|
|
322
|
+
const tokenW = 8;
|
|
323
|
+
const costW = 12;
|
|
324
|
+
console.log(` ${c.dim}${'─'.repeat(fileW + sectionW + tokenW + costW * 3 + 10)}${c.reset}`);
|
|
325
|
+
console.log(` ${pad('File', fileW)} ${pad('Section', sectionW)} ${padR('Tokens', tokenW)} ${padR('Opus', costW)} ${padR('Sonnet', costW)} ${padR('Haiku', costW)}`);
|
|
326
|
+
console.log(` ${c.dim}${'─'.repeat(fileW + sectionW + tokenW + costW * 3 + 10)}${c.reset}`);
|
|
327
|
+
for (const s of allSections) {
|
|
328
|
+
const isStale = staleSections.has(`${s.file}::${s.title}`);
|
|
329
|
+
const staleMarker = isStale ? ` ${c.yellow}⚠ stale${c.reset}` : '';
|
|
330
|
+
const file = truncate(s.file, fileW);
|
|
331
|
+
const title = truncate(s.title, sectionW);
|
|
332
|
+
console.log(` ${pad(file, fileW)} ${pad(title, sectionW)} ${padR(String(s.tokens), tokenW)} ${padR(formatCost(calculateCost(s.tokens, 'opus')), costW)} ${padR(formatCost(calculateCost(s.tokens, 'sonnet')), costW)} ${padR(formatCost(calculateCost(s.tokens, 'haiku')), costW)}${staleMarker}`);
|
|
333
|
+
}
|
|
334
|
+
console.log(` ${c.dim}${'─'.repeat(fileW + sectionW + tokenW + costW * 3 + 10)}${c.reset}`);
|
|
335
|
+
console.log(` ${pad(c.bold + 'Total' + c.reset, fileW)} ${pad('', sectionW)} ${padR(String(totalTokens), tokenW)} ${padR(formatCost(calculateCost(totalTokens, 'opus')), costW)} ${padR(formatCost(calculateCost(totalTokens, 'sonnet')), costW)} ${padR(formatCost(calculateCost(totalTokens, 'haiku')), costW)}`);
|
|
336
|
+
console.log('');
|
|
337
|
+
if (staleSections.size > 0) {
|
|
338
|
+
const staleToks = allSections
|
|
339
|
+
.filter(s => staleSections.has(`${s.file}::${s.title}`))
|
|
340
|
+
.reduce((sum, s) => sum + s.tokens, 0);
|
|
341
|
+
console.log(` ${c.yellow}⚠ ${staleSections.size} stale section${staleSections.size !== 1 ? 's' : ''} detected${c.reset} — ${staleToks} tokens (${formatCost(calculateCost(staleToks, 'sonnet'))}/call at sonnet rates)`);
|
|
342
|
+
console.log(` ${c.dim}These sections weren't referenced in recent Claude sessions${c.reset}\n`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// ── String helpers ───────────────────────────────────────────────────────────
|
|
346
|
+
function pad(s, w) {
|
|
347
|
+
// Strip ANSI for length calculation
|
|
348
|
+
const clean = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
349
|
+
return s + ' '.repeat(Math.max(0, w - clean.length));
|
|
350
|
+
}
|
|
351
|
+
function padR(s, w) {
|
|
352
|
+
const clean = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
353
|
+
return ' '.repeat(Math.max(0, w - clean.length)) + s;
|
|
354
|
+
}
|
|
355
|
+
function truncate(s, max) {
|
|
356
|
+
if (s.length <= max)
|
|
357
|
+
return s;
|
|
358
|
+
return s.slice(0, max - 1) + '…';
|
|
359
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { gitExec, c } from '../util.js';
|
|
2
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
3
|
+
const CONFIG_FILES = new Set([
|
|
4
|
+
'package.json', 'package-lock.json', 'tsconfig.json', 'tsconfig.build.json',
|
|
5
|
+
'.eslintrc', '.eslintrc.json', '.eslintrc.js', '.prettierrc', '.prettierrc.json',
|
|
6
|
+
'.env', '.env.example', '.env.local', '.gitignore', '.npmignore',
|
|
7
|
+
'jest.config.js', 'jest.config.ts', 'vitest.config.ts', 'vite.config.ts',
|
|
8
|
+
'webpack.config.js', 'rollup.config.js', 'esbuild.config.js',
|
|
9
|
+
'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
|
|
10
|
+
'.dockerignore', 'Makefile', '.editorconfig',
|
|
11
|
+
]);
|
|
12
|
+
const TEST_PATTERNS = [/^test\//, /^tests\//, /^__tests__\//, /\.test\./, /\.spec\./];
|
|
13
|
+
const FIX_INDICATORS = /\bfix(es|ed)?\b|\bbug\b|\berror\b|\bcrash\b|\bpatch\b/i;
|
|
14
|
+
// ── Diff parsing ─────────────────────────────────────────────────────────────
|
|
15
|
+
function parseDiff(diffOutput) {
|
|
16
|
+
if (!diffOutput.trim())
|
|
17
|
+
return [];
|
|
18
|
+
const hunks = [];
|
|
19
|
+
const fileDiffs = diffOutput.split(/^diff --git /m).filter(Boolean);
|
|
20
|
+
for (const fileDiff of fileDiffs) {
|
|
21
|
+
const lines = fileDiff.split('\n');
|
|
22
|
+
// Parse file paths
|
|
23
|
+
const headerMatch = lines[0]?.match(/a\/(.+?) b\/(.+)/);
|
|
24
|
+
if (!headerMatch)
|
|
25
|
+
continue;
|
|
26
|
+
const file = headerMatch[2];
|
|
27
|
+
// Detect binary
|
|
28
|
+
if (fileDiff.includes('Binary files')) {
|
|
29
|
+
hunks.push({
|
|
30
|
+
file, oldStart: 0, oldCount: 0, newStart: 0, newCount: 0,
|
|
31
|
+
content: '', isNew: false, isDeleted: false, isRenamed: false, isBinary: true,
|
|
32
|
+
});
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const isNew = fileDiff.includes('new file mode');
|
|
36
|
+
const isDeleted = fileDiff.includes('deleted file mode');
|
|
37
|
+
const isRenamed = fileDiff.includes('rename from') || fileDiff.includes('similarity index');
|
|
38
|
+
// Parse individual hunks within the file
|
|
39
|
+
const hunkHeaderRE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
40
|
+
let currentHunkLines = [];
|
|
41
|
+
let currentMatch = null;
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
const match = line.match(hunkHeaderRE);
|
|
44
|
+
if (match) {
|
|
45
|
+
// Save previous hunk
|
|
46
|
+
if (currentMatch) {
|
|
47
|
+
hunks.push({
|
|
48
|
+
file,
|
|
49
|
+
oldStart: parseInt(currentMatch[1], 10),
|
|
50
|
+
oldCount: parseInt(currentMatch[2] || '1', 10),
|
|
51
|
+
newStart: parseInt(currentMatch[3], 10),
|
|
52
|
+
newCount: parseInt(currentMatch[4] || '1', 10),
|
|
53
|
+
content: currentHunkLines.join('\n'),
|
|
54
|
+
isNew, isDeleted, isRenamed, isBinary: false,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
currentMatch = match;
|
|
58
|
+
currentHunkLines = [];
|
|
59
|
+
}
|
|
60
|
+
else if (currentMatch) {
|
|
61
|
+
currentHunkLines.push(line);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Save last hunk
|
|
65
|
+
if (currentMatch) {
|
|
66
|
+
hunks.push({
|
|
67
|
+
file,
|
|
68
|
+
oldStart: parseInt(currentMatch[1], 10),
|
|
69
|
+
oldCount: parseInt(currentMatch[2] || '1', 10),
|
|
70
|
+
newStart: parseInt(currentMatch[3], 10),
|
|
71
|
+
newCount: parseInt(currentMatch[4] || '1', 10),
|
|
72
|
+
content: currentHunkLines.join('\n'),
|
|
73
|
+
isNew, isDeleted, isRenamed, isBinary: false,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
else if (isNew || isDeleted) {
|
|
77
|
+
// File with no hunks (e.g., empty new file)
|
|
78
|
+
hunks.push({
|
|
79
|
+
file, oldStart: 0, oldCount: 0, newStart: 0, newCount: 0,
|
|
80
|
+
content: '', isNew, isDeleted, isRenamed, isBinary: false,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return hunks;
|
|
85
|
+
}
|
|
86
|
+
// ── Clustering ───────────────────────────────────────────────────────────────
|
|
87
|
+
function isTestFile(file) {
|
|
88
|
+
return TEST_PATTERNS.some(p => p.test(file));
|
|
89
|
+
}
|
|
90
|
+
function isConfigFile(file) {
|
|
91
|
+
const basename = file.split('/').pop() || file;
|
|
92
|
+
return CONFIG_FILES.has(basename) || basename.startsWith('.');
|
|
93
|
+
}
|
|
94
|
+
function getClusterKey(file) {
|
|
95
|
+
if (isTestFile(file))
|
|
96
|
+
return 'test';
|
|
97
|
+
if (isConfigFile(file))
|
|
98
|
+
return 'config';
|
|
99
|
+
// Group by first directory
|
|
100
|
+
const parts = file.split('/');
|
|
101
|
+
if (parts.length > 1)
|
|
102
|
+
return `src:${parts[0]}`;
|
|
103
|
+
return 'src:root';
|
|
104
|
+
}
|
|
105
|
+
function generateCommitMessage(cluster) {
|
|
106
|
+
const fileList = cluster.files.length <= 3
|
|
107
|
+
? cluster.files.map(f => f.split('/').pop()).join(', ')
|
|
108
|
+
: `${cluster.files.length} files`;
|
|
109
|
+
// Test cluster
|
|
110
|
+
if (cluster.prefix === 'test') {
|
|
111
|
+
return `test: update ${fileList}`;
|
|
112
|
+
}
|
|
113
|
+
// Config cluster
|
|
114
|
+
if (cluster.prefix === 'config') {
|
|
115
|
+
return `chore: update ${fileList}`;
|
|
116
|
+
}
|
|
117
|
+
// Check if all files are new
|
|
118
|
+
const allNew = cluster.hunks.every(h => h.isNew);
|
|
119
|
+
if (allNew) {
|
|
120
|
+
return `feat: add ${fileList}`;
|
|
121
|
+
}
|
|
122
|
+
// Check if all files are deleted
|
|
123
|
+
const allDeleted = cluster.hunks.every(h => h.isDeleted);
|
|
124
|
+
if (allDeleted) {
|
|
125
|
+
return `refactor: remove ${fileList}`;
|
|
126
|
+
}
|
|
127
|
+
// Check hunk content for fix indicators
|
|
128
|
+
const allContent = cluster.hunks.map(h => h.content).join('\n');
|
|
129
|
+
if (FIX_INDICATORS.test(allContent)) {
|
|
130
|
+
return `fix: update ${fileList}`;
|
|
131
|
+
}
|
|
132
|
+
return `refactor: update ${fileList}`;
|
|
133
|
+
}
|
|
134
|
+
function clusterHunks(hunks) {
|
|
135
|
+
// Filter out binary files
|
|
136
|
+
const nonBinary = hunks.filter(h => !h.isBinary);
|
|
137
|
+
if (nonBinary.length === 0)
|
|
138
|
+
return [];
|
|
139
|
+
const groups = new Map();
|
|
140
|
+
for (const hunk of nonBinary) {
|
|
141
|
+
const key = getClusterKey(hunk.file);
|
|
142
|
+
if (!groups.has(key))
|
|
143
|
+
groups.set(key, []);
|
|
144
|
+
groups.get(key).push(hunk);
|
|
145
|
+
}
|
|
146
|
+
const clusters = [];
|
|
147
|
+
for (const [key, groupHunks] of groups) {
|
|
148
|
+
const files = [...new Set(groupHunks.map(h => h.file))];
|
|
149
|
+
const cluster = {
|
|
150
|
+
name: key,
|
|
151
|
+
prefix: key.startsWith('src:') ? 'src' : key,
|
|
152
|
+
files,
|
|
153
|
+
hunks: groupHunks,
|
|
154
|
+
commitMessage: '',
|
|
155
|
+
};
|
|
156
|
+
cluster.commitMessage = generateCommitMessage(cluster);
|
|
157
|
+
clusters.push(cluster);
|
|
158
|
+
}
|
|
159
|
+
// Sort: config first, then src, then test
|
|
160
|
+
clusters.sort((a, b) => {
|
|
161
|
+
const order = (c) => c.prefix === 'config' ? 0 : c.prefix === 'src' ? 1 : 2;
|
|
162
|
+
return order(a) - order(b);
|
|
163
|
+
});
|
|
164
|
+
return clusters;
|
|
165
|
+
}
|
|
166
|
+
// ── Score calculation ────────────────────────────────────────────────────────
|
|
167
|
+
function analyzeCommit(cwd, sha) {
|
|
168
|
+
const diff = gitExec(['diff', `${sha}~1`, sha], cwd);
|
|
169
|
+
if (!diff)
|
|
170
|
+
return { fileCount: 0, clusterCount: 0, totalHunks: 0 };
|
|
171
|
+
const hunks = parseDiff(diff);
|
|
172
|
+
const nonBinary = hunks.filter(h => !h.isBinary);
|
|
173
|
+
const clusters = clusterHunks(nonBinary);
|
|
174
|
+
const files = new Set(nonBinary.map(h => h.file));
|
|
175
|
+
return { fileCount: files.size, clusterCount: clusters.length, totalHunks: nonBinary.length };
|
|
176
|
+
}
|
|
177
|
+
// ── Main check (for scorecard) ───────────────────────────────────────────────
|
|
178
|
+
export function checkSplit(cwd) {
|
|
179
|
+
const issues = [];
|
|
180
|
+
// Get recent commits (last 10)
|
|
181
|
+
const log = gitExec(['log', '--oneline', '-10', '--format=%H'], cwd);
|
|
182
|
+
if (!log) {
|
|
183
|
+
return {
|
|
184
|
+
name: 'split',
|
|
185
|
+
score: 100,
|
|
186
|
+
maxScore: 100,
|
|
187
|
+
issues: [{ severity: 'info', message: 'no commits to analyze', fixable: false }],
|
|
188
|
+
summary: 'no commits',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const shas = log.split('\n').filter(Boolean);
|
|
192
|
+
let totalPenalty = 0;
|
|
193
|
+
let analyzedCount = 0;
|
|
194
|
+
for (const sha of shas) {
|
|
195
|
+
// Check if commit has a parent
|
|
196
|
+
const parent = gitExec(['rev-parse', `${sha}~1`], cwd);
|
|
197
|
+
if (!parent)
|
|
198
|
+
continue;
|
|
199
|
+
const analysis = analyzeCommit(cwd, sha);
|
|
200
|
+
analyzedCount++;
|
|
201
|
+
if (analysis.fileCount === 0)
|
|
202
|
+
continue;
|
|
203
|
+
// Penalty for large multi-concern commits
|
|
204
|
+
if (analysis.clusterCount > 1 && analysis.fileCount > 5) {
|
|
205
|
+
const severity = analysis.clusterCount > 3 ? 'warning' : 'info';
|
|
206
|
+
const shortSha = sha.substring(0, 7);
|
|
207
|
+
const penalty = Math.min(20, (analysis.clusterCount - 1) * 5);
|
|
208
|
+
totalPenalty += penalty;
|
|
209
|
+
issues.push({
|
|
210
|
+
severity,
|
|
211
|
+
message: `commit ${shortSha} touches ${analysis.fileCount} files across ${analysis.clusterCount} concerns`,
|
|
212
|
+
fixable: true,
|
|
213
|
+
fixHint: `run: vet split --since ${shortSha}~1`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (analysis.fileCount > 20) {
|
|
217
|
+
totalPenalty += 15;
|
|
218
|
+
issues.push({
|
|
219
|
+
severity: 'warning',
|
|
220
|
+
message: `commit ${sha.substring(0, 7)} modifies ${analysis.fileCount} files — likely needs splitting`,
|
|
221
|
+
fixable: true,
|
|
222
|
+
fixHint: 'run: vet split',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const score = Math.max(0, 100 - totalPenalty);
|
|
227
|
+
const summary = issues.length === 0
|
|
228
|
+
? 'all recent commits are atomic'
|
|
229
|
+
: `${issues.length} commit(s) could be split into smaller atomic commits`;
|
|
230
|
+
return { name: 'split', score, maxScore: 100, issues, summary };
|
|
231
|
+
}
|
|
232
|
+
// ── Subcommand ───────────────────────────────────────────────────────────────
|
|
233
|
+
export async function runSplitCommand(format, cwd, since, apply, force) {
|
|
234
|
+
const ref = since || 'HEAD~1';
|
|
235
|
+
// Get the diff
|
|
236
|
+
const diff = gitExec(['diff', ref, 'HEAD'], cwd);
|
|
237
|
+
if (!diff.trim()) {
|
|
238
|
+
if (format === 'json') {
|
|
239
|
+
console.log(JSON.stringify({ clusters: [], message: 'no changes to split' }));
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
console.log(`\n ${c.bold}vet split${c.reset} — commit surgery\n`);
|
|
243
|
+
console.log(` ${c.dim}no changes between ${ref} and HEAD${c.reset}\n`);
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const hunks = parseDiff(diff);
|
|
248
|
+
const clusters = clusterHunks(hunks);
|
|
249
|
+
if (clusters.length <= 1) {
|
|
250
|
+
if (format === 'json') {
|
|
251
|
+
console.log(JSON.stringify({
|
|
252
|
+
clusters: clusters.map(cl => ({
|
|
253
|
+
name: cl.name, prefix: cl.prefix, files: cl.files,
|
|
254
|
+
hunkCount: cl.hunks.length, commitMessage: cl.commitMessage,
|
|
255
|
+
})),
|
|
256
|
+
message: 'commit is already atomic',
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
console.log(`\n ${c.bold}vet split${c.reset} — commit surgery\n`);
|
|
261
|
+
console.log(` ${c.green}commit is already atomic — no split needed${c.reset}\n`);
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// JSON output
|
|
266
|
+
if (format === 'json') {
|
|
267
|
+
const output = {
|
|
268
|
+
ref,
|
|
269
|
+
clusterCount: clusters.length,
|
|
270
|
+
clusters: clusters.map(cl => ({
|
|
271
|
+
name: cl.name,
|
|
272
|
+
prefix: cl.prefix,
|
|
273
|
+
files: cl.files,
|
|
274
|
+
hunkCount: cl.hunks.length,
|
|
275
|
+
commitMessage: cl.commitMessage,
|
|
276
|
+
})),
|
|
277
|
+
};
|
|
278
|
+
console.log(JSON.stringify(output, null, 2));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// ASCII table output
|
|
282
|
+
console.log(`\n ${c.bold}vet split${c.reset} — commit surgery\n`);
|
|
283
|
+
console.log(` analyzing changes since ${c.cyan}${ref}${c.reset}\n`);
|
|
284
|
+
console.log(` ${c.dim}# commit message${' '.repeat(35)}files hunks${c.reset}`);
|
|
285
|
+
for (let i = 0; i < clusters.length; i++) {
|
|
286
|
+
const cl = clusters[i];
|
|
287
|
+
const num = String(i + 1).padStart(2);
|
|
288
|
+
const msg = cl.commitMessage.padEnd(50).substring(0, 50);
|
|
289
|
+
const files = String(cl.files.length).padStart(5);
|
|
290
|
+
const hunkCount = String(cl.hunks.length).padStart(6);
|
|
291
|
+
console.log(` ${num} ${msg}${files}${hunkCount}`);
|
|
292
|
+
for (const file of cl.files) {
|
|
293
|
+
console.log(` ${c.dim}${file}${c.reset}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
console.log(`\n ${c.bold}${clusters.length} atomic commits${c.reset} proposed\n`);
|
|
297
|
+
if (!apply) {
|
|
298
|
+
console.log(` ${c.dim}dry run — use --apply to execute${c.reset}\n`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// Apply mode: safety checks
|
|
302
|
+
const currentBranch = gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
303
|
+
if ((currentBranch === 'main' || currentBranch === 'master') && !force) {
|
|
304
|
+
console.log(` ${c.red}refusing to rewrite history on ${currentBranch}${c.reset}`);
|
|
305
|
+
console.log(` ${c.dim}use --force to override${c.reset}\n`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
// Create backup branch
|
|
309
|
+
const backupBranch = `vet-split-backup-${Date.now()}`;
|
|
310
|
+
gitExec(['branch', backupBranch], cwd);
|
|
311
|
+
console.log(` ${c.dim}backup branch: ${backupBranch}${c.reset}`);
|
|
312
|
+
// Soft reset to the ref point
|
|
313
|
+
gitExec(['reset', '--soft', ref], cwd);
|
|
314
|
+
gitExec(['reset', 'HEAD'], cwd);
|
|
315
|
+
// Apply each cluster as a separate commit
|
|
316
|
+
for (const cl of clusters) {
|
|
317
|
+
for (const file of cl.files) {
|
|
318
|
+
gitExec(['add', file], cwd);
|
|
319
|
+
}
|
|
320
|
+
gitExec(['commit', '-m', cl.commitMessage], cwd);
|
|
321
|
+
console.log(` ${c.green}committed:${c.reset} ${cl.commitMessage}`);
|
|
322
|
+
}
|
|
323
|
+
console.log(`\n ${c.green}split complete${c.reset} — ${clusters.length} atomic commits created`);
|
|
324
|
+
console.log(` ${c.dim}backup: ${backupBranch}${c.reset}\n`);
|
|
325
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -31,6 +31,8 @@ import { checkLoop, runLoopCommand } from './checks/loop.js';
|
|
|
31
31
|
import { checkBloat, runBloatCommand } from './checks/bloat.js';
|
|
32
32
|
import { checkGuard, runGuardCommand } from './checks/guard.js';
|
|
33
33
|
import { checkExplain, runExplainCommand } from './checks/explain.js';
|
|
34
|
+
import { checkContext, runContextCommand } from './checks/context.js';
|
|
35
|
+
import { checkSplit, runSplitCommand } from './checks/split.js';
|
|
34
36
|
import { checkCompleteness } from './checks/completeness.js';
|
|
35
37
|
import { score } from './scorer.js';
|
|
36
38
|
import { reportPretty, reportJSON, reportBadge } from './reporter.js';
|
|
@@ -86,6 +88,8 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
86
88
|
npx @safetnsr/vet bloat detect agent-generated code bloat
|
|
87
89
|
npx @safetnsr/vet guard [dir] scan for destructive operation bomb sites
|
|
88
90
|
npx @safetnsr/vet explain [--since REF] [--verbose] [--json] risk-tier agent changes
|
|
91
|
+
npx @safetnsr/vet context [dir] audit agent context files for token cost + stale sections
|
|
92
|
+
npx @safetnsr/vet split [--since HEAD~1] [--apply] [--force] [--json] split AI mega-commits into atomic commits
|
|
89
93
|
|
|
90
94
|
${c.dim}categories:${c.reset}
|
|
91
95
|
security (30%) scan, secrets, config, model usage
|
|
@@ -121,7 +125,7 @@ if (flags.has('--version') || flags.has('-v')) {
|
|
|
121
125
|
}
|
|
122
126
|
process.exit(0);
|
|
123
127
|
}
|
|
124
|
-
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain'];
|
|
128
|
+
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split'];
|
|
125
129
|
const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
|
|
126
130
|
const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
|
|
127
131
|
const isCI = flags.has('--ci');
|
|
@@ -263,6 +267,28 @@ if (command === 'guard') {
|
|
|
263
267
|
}
|
|
264
268
|
process.exit(0);
|
|
265
269
|
}
|
|
270
|
+
if (command === 'context') {
|
|
271
|
+
try {
|
|
272
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
273
|
+
await runContextCommand(format, cwd);
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
console.error(`${c.red}context failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
process.exit(0);
|
|
280
|
+
}
|
|
281
|
+
if (command === 'split') {
|
|
282
|
+
try {
|
|
283
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
284
|
+
await runSplitCommand(format, cwd, since, flags.has('--apply'), flags.has('--force'));
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
console.error(`${c.red}split failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
process.exit(0);
|
|
291
|
+
}
|
|
266
292
|
if (command === 'explain') {
|
|
267
293
|
try {
|
|
268
294
|
const format = isJSON ? 'json' : 'ascii';
|
|
@@ -327,7 +353,7 @@ async function runChecks() {
|
|
|
327
353
|
}
|
|
328
354
|
}
|
|
329
355
|
// Run ALL independent checks in parallel
|
|
330
|
-
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult, deepResult, semanticResult, hotspotsResult, clonesResult,] = await Promise.all([
|
|
356
|
+
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult, deepResult, semanticResult, hotspotsResult, clonesResult, contextResult, splitResult,] = await Promise.all([
|
|
331
357
|
withTimeout('scan', () => checkScan(cwd)),
|
|
332
358
|
withTimeout('secrets', () => checkSecrets(cwd)),
|
|
333
359
|
withTimeout('config', () => checkConfig(cwd, ignore)),
|
|
@@ -355,6 +381,8 @@ async function runChecks() {
|
|
|
355
381
|
withTimeout('semantic', () => checkSemantic(cwd), 60_000),
|
|
356
382
|
withTimeout('hotspots', () => checkHotspots(cwd), 30_000),
|
|
357
383
|
withTimeout('clones', () => checkClones(cwd), 60_000),
|
|
384
|
+
withTimeout('context', () => checkContext(cwd)),
|
|
385
|
+
withTimeout('split', () => checkSplit(cwd)),
|
|
358
386
|
]);
|
|
359
387
|
// Git-dependent checks (diff + history) — parallel with each other
|
|
360
388
|
const [diffResult, historyResult] = await Promise.all([
|
|
@@ -366,10 +394,10 @@ async function runChecks() {
|
|
|
366
394
|
return score(cwd, {
|
|
367
395
|
security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult, guardResult],
|
|
368
396
|
integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, explainResult],
|
|
369
|
-
debt: [readyResult, historyResult, debtResult, bloatResult, clonesResult],
|
|
397
|
+
debt: [readyResult, historyResult, debtResult, bloatResult, clonesResult, splitResult],
|
|
370
398
|
deps: [depsResult],
|
|
371
399
|
architecture: [architectureResult],
|
|
372
|
-
aiready: [aireadyResult, deepResult, semanticResult],
|
|
400
|
+
aiready: [aireadyResult, deepResult, semanticResult, contextResult],
|
|
373
401
|
history: [hotspotsResult],
|
|
374
402
|
});
|
|
375
403
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@safetnsr/vet",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"description": "vet your AI-generated code — one command, one score card, one letter grade",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"@safetnsr/model-graveyard": "^0.2.0"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@huggingface/transformers": "^3.8.1"
|
|
47
|
+
"@huggingface/transformers": "^3.8.1",
|
|
48
|
+
"js-tiktoken": "^1.0.21"
|
|
48
49
|
}
|
|
49
50
|
}
|