tokenlean 0.1.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.
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * tl-hotspots - Find frequently changed files (git churn analysis)
5
+ *
6
+ * Identifies files that change often - these are usually the most
7
+ * important to understand when working on a codebase. High churn
8
+ * files often indicate core logic, bugs, or areas needing refactoring.
9
+ *
10
+ * Usage: tl-hotspots [path] [--days N]
11
+ */
12
+
13
+ // Prompt info for tl-prompt
14
+ if (process.argv.includes('--prompt')) {
15
+ console.log(JSON.stringify({
16
+ name: 'tl-hotspots',
17
+ desc: 'Find frequently changed files (git churn)',
18
+ when: 'before-modify',
19
+ example: 'tl-hotspots --days 30'
20
+ }));
21
+ process.exit(0);
22
+ }
23
+
24
+ import { execSync } from 'child_process';
25
+ import { existsSync, readFileSync } from 'fs';
26
+ import { basename, relative, resolve } from 'path';
27
+ import {
28
+ createOutput,
29
+ parseCommonArgs,
30
+ estimateTokens,
31
+ formatTokens,
32
+ shellEscape,
33
+ COMMON_OPTIONS_HELP
34
+ } from '../src/output.mjs';
35
+ import { findProjectRoot, shouldSkip, isCodeFile } from '../src/project.mjs';
36
+ import { getConfig } from '../src/config.mjs';
37
+
38
+ const HELP = `
39
+ tl-hotspots - Find frequently changed files (git churn analysis)
40
+
41
+ Usage: tl-hotspots [path] [options]
42
+
43
+ Options:
44
+ --days N, -d N Analyze last N days (default: 90)
45
+ --top N, -n N Show top N files (default: 20)
46
+ --authors, -a Group by author
47
+ --code-only, -c Only show code files (no config/docs)
48
+ ${COMMON_OPTIONS_HELP}
49
+
50
+ Examples:
51
+ tl-hotspots # Top 20 hotspots in last 90 days
52
+ tl-hotspots src/ -d 30 # Hotspots in src/ from last 30 days
53
+ tl-hotspots -n 10 -c # Top 10 code files only
54
+ tl-hotspots -a # Show who changes what most
55
+
56
+ Output shows:
57
+ • Files sorted by change frequency
58
+ • Number of commits touching each file
59
+ • Lines added/removed
60
+ • Token cost to read the file
61
+ `;
62
+
63
+ // ─────────────────────────────────────────────────────────────
64
+ // Git Analysis
65
+ // ─────────────────────────────────────────────────────────────
66
+
67
+ function getGitLog(path, days, projectRoot) {
68
+ try {
69
+ // Single quotes around format to prevent shell interpretation of %
70
+ const cmd = `git -C "${shellEscape(projectRoot)}" log --since="${days} days ago" --format='%H|%an|%ad|%s' --date=short --name-only -- "${shellEscape(path)}"`;
71
+
72
+ const output = execSync(cmd, {
73
+ encoding: 'utf-8',
74
+ maxBuffer: 50 * 1024 * 1024
75
+ });
76
+
77
+ return parseGitLog(output);
78
+ } catch (e) {
79
+ return { commits: [], fileChanges: new Map(), authorChanges: new Map() };
80
+ }
81
+ }
82
+
83
+ function parseGitLog(output) {
84
+ const commits = [];
85
+ const fileChanges = new Map(); // file -> { commits, additions, deletions, authors }
86
+ const authorChanges = new Map(); // author -> { commits, files }
87
+
88
+ const lines = output.trim().split('\n');
89
+ let currentCommit = null;
90
+
91
+ for (const line of lines) {
92
+ if (line.includes('|')) {
93
+ // Commit line: hash|author|date|subject
94
+ const [hash, author, date, ...subjectParts] = line.split('|');
95
+ currentCommit = {
96
+ hash,
97
+ author,
98
+ date,
99
+ subject: subjectParts.join('|'),
100
+ files: []
101
+ };
102
+ commits.push(currentCommit);
103
+
104
+ // Track author
105
+ if (!authorChanges.has(author)) {
106
+ authorChanges.set(author, { commits: 0, files: new Set() });
107
+ }
108
+ authorChanges.get(author).commits++;
109
+ } else if (line.trim() && currentCommit) {
110
+ // File line
111
+ const file = line.trim();
112
+ currentCommit.files.push(file);
113
+
114
+ // Track file changes
115
+ if (!fileChanges.has(file)) {
116
+ fileChanges.set(file, { commits: 0, authors: new Set() });
117
+ }
118
+ const fc = fileChanges.get(file);
119
+ fc.commits++;
120
+ fc.authors.add(currentCommit.author);
121
+
122
+ // Track author's files
123
+ authorChanges.get(currentCommit.author).files.add(file);
124
+ }
125
+ }
126
+
127
+ return { commits, fileChanges, authorChanges };
128
+ }
129
+
130
+ function getFileStats(files, projectRoot) {
131
+ const stats = [];
132
+
133
+ for (const [file, data] of files) {
134
+ const fullPath = resolve(projectRoot, file);
135
+
136
+ // Skip if file doesn't exist (deleted) or should be skipped
137
+ if (!existsSync(fullPath)) {
138
+ continue;
139
+ }
140
+
141
+ const name = basename(file);
142
+ if (shouldSkip(name, false)) {
143
+ continue;
144
+ }
145
+
146
+ try {
147
+ const content = readFileSync(fullPath, 'utf-8');
148
+ const tokens = estimateTokens(content);
149
+ const lines = content.split('\n').length;
150
+
151
+ stats.push({
152
+ file,
153
+ commits: data.commits,
154
+ authors: [...data.authors],
155
+ authorCount: data.authors.size,
156
+ tokens,
157
+ lines
158
+ });
159
+ } catch {
160
+ // Can't read file
161
+ }
162
+ }
163
+
164
+ return stats;
165
+ }
166
+
167
+ // ─────────────────────────────────────────────────────────────
168
+ // Output Formatting
169
+ // ─────────────────────────────────────────────────────────────
170
+
171
+ function formatHotspots(stats, out, showAuthors, topN) {
172
+ // Sort by commit count descending
173
+ stats.sort((a, b) => b.commits - a.commits);
174
+
175
+ const top = stats.slice(0, topN);
176
+ let totalTokens = 0;
177
+
178
+ for (const item of top) {
179
+ totalTokens += item.tokens;
180
+
181
+ let line = ` ${item.commits.toString().padStart(3)} commits`;
182
+ line += ` ${item.authorCount.toString().padStart(2)} authors`;
183
+ line += ` ~${formatTokens(item.tokens).padStart(5)}`;
184
+ line += ` ${item.file}`;
185
+
186
+ out.add(line);
187
+
188
+ if (showAuthors && item.authors.length > 0) {
189
+ out.add(` └─ ${item.authors.slice(0, 3).join(', ')}${item.authors.length > 3 ? '...' : ''}`);
190
+ }
191
+ }
192
+
193
+ return { count: top.length, totalTokens };
194
+ }
195
+
196
+ function formatAuthorSummary(authorChanges, out, topN) {
197
+ const authors = [...authorChanges.entries()]
198
+ .map(([name, data]) => ({
199
+ name,
200
+ commits: data.commits,
201
+ fileCount: data.files.size
202
+ }))
203
+ .sort((a, b) => b.commits - a.commits)
204
+ .slice(0, topN);
205
+
206
+ out.blank();
207
+ out.add('👥 Top contributors:');
208
+
209
+ for (const author of authors) {
210
+ out.add(` ${author.commits.toString().padStart(3)} commits ${author.fileCount.toString().padStart(3)} files ${author.name}`);
211
+ }
212
+ }
213
+
214
+ // ─────────────────────────────────────────────────────────────
215
+ // Main
216
+ // ─────────────────────────────────────────────────────────────
217
+
218
+ const args = process.argv.slice(2);
219
+ const options = parseCommonArgs(args);
220
+
221
+ // Get config defaults
222
+ const hotspotsConfig = getConfig('hotspots') || {};
223
+
224
+ // Parse tool-specific options (CLI overrides config)
225
+ let days = hotspotsConfig.days || 90;
226
+ let topN = hotspotsConfig.top || 20;
227
+ let showAuthors = false;
228
+ let codeOnly = false;
229
+
230
+ const consumedIndices = new Set();
231
+
232
+ for (let i = 0; i < options.remaining.length; i++) {
233
+ const arg = options.remaining[i];
234
+ if ((arg === '--days' || arg === '-d') && options.remaining[i + 1]) {
235
+ days = parseInt(options.remaining[i + 1], 10);
236
+ consumedIndices.add(i);
237
+ consumedIndices.add(i + 1);
238
+ i++;
239
+ } else if ((arg === '--top' || arg === '-n') && options.remaining[i + 1]) {
240
+ topN = parseInt(options.remaining[i + 1], 10);
241
+ consumedIndices.add(i);
242
+ consumedIndices.add(i + 1);
243
+ i++;
244
+ } else if (arg === '--authors' || arg === '-a') {
245
+ showAuthors = true;
246
+ consumedIndices.add(i);
247
+ } else if (arg === '--code-only' || arg === '-c') {
248
+ codeOnly = true;
249
+ consumedIndices.add(i);
250
+ }
251
+ }
252
+
253
+ const targetPath = options.remaining.find((a, i) => !a.startsWith('-') && !consumedIndices.has(i)) || '.';
254
+
255
+ if (options.help) {
256
+ console.log(HELP);
257
+ process.exit(0);
258
+ }
259
+
260
+ const projectRoot = findProjectRoot();
261
+ const resolvedPath = resolve(targetPath);
262
+ const relPath = relative(projectRoot, resolvedPath) || '.';
263
+
264
+ // Check if we're in a git repo
265
+ try {
266
+ execSync(`git -C "${shellEscape(projectRoot)}" rev-parse --git-dir`, { encoding: 'utf-8', stdio: 'pipe' });
267
+ } catch {
268
+ console.error('Error: Not in a git repository');
269
+ process.exit(1);
270
+ }
271
+
272
+ const out = createOutput(options);
273
+
274
+ out.header(`\n🔥 Hotspots: ${relPath === '.' ? basename(projectRoot) : relPath}`);
275
+ out.header(` Last ${days} days, top ${topN} files`);
276
+ out.blank();
277
+
278
+ const { commits, fileChanges, authorChanges } = getGitLog(relPath, days, projectRoot);
279
+
280
+ if (commits.length === 0) {
281
+ out.add('No commits found in the specified time range.');
282
+ out.print();
283
+ process.exit(0);
284
+ }
285
+
286
+ // Filter to code files if requested
287
+ let filteredChanges = fileChanges;
288
+ if (codeOnly) {
289
+ filteredChanges = new Map(
290
+ [...fileChanges.entries()].filter(([file]) => isCodeFile(file))
291
+ );
292
+ }
293
+
294
+ const stats = getFileStats(filteredChanges, projectRoot);
295
+
296
+ if (stats.length === 0) {
297
+ out.add('No matching files found.');
298
+ out.print();
299
+ process.exit(0);
300
+ }
301
+
302
+ const { count, totalTokens } = formatHotspots(stats, out, showAuthors, topN);
303
+
304
+ if (showAuthors && authorChanges.size > 0) {
305
+ formatAuthorSummary(authorChanges, out, 5);
306
+ }
307
+
308
+ out.blank();
309
+ out.stats('─'.repeat(50));
310
+ out.stats(`📊 ${commits.length} commits, ${stats.length} files changed`);
311
+ out.stats(` Top ${count} files: ~${formatTokens(totalTokens)} tokens to review`);
312
+ out.blank();
313
+
314
+ // JSON data
315
+ out.setData('path', relPath);
316
+ out.setData('days', days);
317
+ out.setData('totalCommits', commits.length);
318
+ out.setData('totalFiles', stats.length);
319
+ out.setData('hotspots', stats.slice(0, topN));
320
+
321
+ out.print();
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * tl-impact - Analyze the blast radius of changing a file
5
+ *
6
+ * Shows which files import/depend on the target file, helping you
7
+ * understand the impact of changes before you make them.
8
+ *
9
+ * Usage: tl-impact <file> [--depth N]
10
+ */
11
+
12
+ // Prompt info for tl-prompt
13
+ if (process.argv.includes('--prompt')) {
14
+ console.log(JSON.stringify({
15
+ name: 'tl-impact',
16
+ desc: 'Blast radius - what depends on this file',
17
+ when: 'before-modify',
18
+ example: 'tl-impact src/utils.ts'
19
+ }));
20
+ process.exit(0);
21
+ }
22
+
23
+ import { execSync } from 'child_process';
24
+ import { existsSync, readFileSync } from 'fs';
25
+ import { basename, dirname, extname, relative, resolve } from 'path';
26
+ import {
27
+ createOutput,
28
+ parseCommonArgs,
29
+ estimateTokens,
30
+ formatTokens,
31
+ shellEscape,
32
+ rgEscape,
33
+ COMMON_OPTIONS_HELP
34
+ } from '../src/output.mjs';
35
+ import { findProjectRoot, categorizeFile } from '../src/project.mjs';
36
+
37
+ const HELP = `
38
+ tl-impact - Analyze the blast radius of changing a file
39
+
40
+ Usage: tl-impact <file> [options]
41
+
42
+ Options:
43
+ --depth N, -d N Include transitive importers up to N levels (default: 1)
44
+ ${COMMON_OPTIONS_HELP}
45
+
46
+ Examples:
47
+ tl-impact src/utils/api.ts # Direct importers only
48
+ tl-impact src/utils/api.ts -d 2 # Include files that import the importers
49
+ tl-impact src/utils/api.ts -j # JSON output
50
+
51
+ Output shows:
52
+ • Which files import the target
53
+ • Token cost of each importer
54
+ • Line number of the import
55
+ • Categorized by source/test/story/mock
56
+ `;
57
+
58
+ // ─────────────────────────────────────────────────────────────
59
+ // Import Detection
60
+ // ─────────────────────────────────────────────────────────────
61
+
62
+ // Use rgEscape from output.mjs for shell-safe regex patterns
63
+
64
+ function findDirectImporters(filePath, projectRoot) {
65
+ const ext = extname(filePath);
66
+ const baseName = basename(filePath, ext);
67
+ const importers = new Map();
68
+
69
+ // Search for the baseName in import/require statements
70
+ // Use simple pattern to find candidates, then verify in JS
71
+ const searchTerms = [baseName];
72
+
73
+ if (baseName === 'index') {
74
+ const parentDir = basename(dirname(filePath));
75
+ searchTerms.push(parentDir);
76
+ }
77
+
78
+ // Use -e for multiple patterns, simpler matching
79
+ const patterns = searchTerms.map(t => `-e "${rgEscape(t)}"`).join(' ');
80
+
81
+ try {
82
+ const rgCommand = `rg -l --type-add 'code:*.{js,jsx,ts,tsx,mjs,mts,cjs}' -t code ${patterns} "${projectRoot}" 2>/dev/null || true`;
83
+ const result = execSync(rgCommand, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
84
+ const candidates = result.trim().split('\n').filter(Boolean);
85
+
86
+ for (const candidate of candidates) {
87
+ if (candidate === filePath) continue;
88
+ if (!existsSync(candidate)) continue;
89
+
90
+ const verification = verifyImport(candidate, filePath, projectRoot);
91
+ if (verification) {
92
+ importers.set(candidate, verification);
93
+ }
94
+ }
95
+ } catch (e) {
96
+ // ripgrep error
97
+ }
98
+
99
+ return importers;
100
+ }
101
+
102
+ function verifyImport(importerPath, targetPath, projectRoot) {
103
+ try {
104
+ const content = readFileSync(importerPath, 'utf-8');
105
+ const lines = content.split('\n');
106
+ const targetDir = dirname(targetPath);
107
+ const targetName = basename(targetPath).replace(/\.[^.]+$/, '');
108
+ const importerDir = dirname(importerPath);
109
+
110
+ for (let i = 0; i < lines.length; i++) {
111
+ const line = lines[i];
112
+
113
+ const importMatches = [
114
+ ...line.matchAll(/from\s+['"]([^'"]+)['"]/g),
115
+ ...line.matchAll(/import\s+['"]([^'"]+)['"]/g),
116
+ ...line.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g),
117
+ ...line.matchAll(/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g),
118
+ ];
119
+
120
+ for (const match of importMatches) {
121
+ const importPath = match[1];
122
+
123
+ if (resolveImportPath(importPath, importerDir, targetPath, projectRoot)) {
124
+ let importType = 'import';
125
+ if (line.includes('require(')) importType = 'require';
126
+ if (line.includes('import(')) importType = 'dynamic import';
127
+ if (line.match(/import\s+type/)) importType = 'type import';
128
+
129
+ return { line: i + 1, importType, statement: line.trim().substring(0, 80) };
130
+ }
131
+ }
132
+ }
133
+ } catch (e) {
134
+ // File read error
135
+ }
136
+
137
+ return null;
138
+ }
139
+
140
+ function resolveImportPath(importPath, importerDir, targetPath, projectRoot) {
141
+ const targetExt = extname(targetPath);
142
+ const targetName = basename(targetPath, targetExt);
143
+ const targetDir = dirname(targetPath);
144
+
145
+ if (!importPath.startsWith('.') && !importPath.startsWith('/')) {
146
+ return false;
147
+ }
148
+
149
+ let resolvedPath = resolve(importerDir, importPath);
150
+ const extensions = ['', '.js', '.jsx', '.ts', '.tsx', '.mjs', '.mts', '/index.js', '/index.ts', '/index.tsx'];
151
+
152
+ for (const ext of extensions) {
153
+ const tryPath = resolvedPath + ext;
154
+ if (tryPath === targetPath || resolve(tryPath) === resolve(targetPath)) {
155
+ return true;
156
+ }
157
+ }
158
+
159
+ if (targetName === 'index') {
160
+ if (resolvedPath === targetDir || resolve(resolvedPath) === resolve(targetDir)) {
161
+ return true;
162
+ }
163
+ }
164
+
165
+ return false;
166
+ }
167
+
168
+ function findTransitiveImporters(directImporters, targetPath, projectRoot, maxDepth = 2) {
169
+ const allImporters = new Map(directImporters);
170
+ const processed = new Set([targetPath]);
171
+ let currentLevel = [...directImporters.keys()];
172
+
173
+ for (let depth = 1; depth < maxDepth && currentLevel.length > 0; depth++) {
174
+ const nextLevel = [];
175
+
176
+ for (const filePath of currentLevel) {
177
+ if (processed.has(filePath)) continue;
178
+ processed.add(filePath);
179
+
180
+ const importers = findDirectImporters(filePath, projectRoot);
181
+
182
+ for (const [path, info] of importers) {
183
+ if (!allImporters.has(path) && !processed.has(path)) {
184
+ allImporters.set(path, { ...info, depth, via: basename(filePath) });
185
+ nextLevel.push(path);
186
+ }
187
+ }
188
+ }
189
+
190
+ currentLevel = nextLevel;
191
+ }
192
+
193
+ return allImporters;
194
+ }
195
+
196
+
197
+ // ─────────────────────────────────────────────────────────────
198
+ // Output
199
+ // ─────────────────────────────────────────────────────────────
200
+
201
+ function buildResults(importers, projectRoot) {
202
+ const categories = { source: [], test: [], story: [], mock: [] };
203
+
204
+ for (const [path, info] of importers) {
205
+ const category = categorizeFile(path, projectRoot);
206
+ const tokens = estimateTokens(readFileSync(path, 'utf-8'));
207
+ categories[category].push({
208
+ path,
209
+ relPath: relative(projectRoot, path),
210
+ tokens,
211
+ ...info
212
+ });
213
+ }
214
+
215
+ for (const cat of Object.keys(categories)) {
216
+ categories[cat].sort((a, b) => {
217
+ if ((a.depth || 0) !== (b.depth || 0)) return (a.depth || 0) - (b.depth || 0);
218
+ return a.path.localeCompare(b.path);
219
+ });
220
+ }
221
+
222
+ return categories;
223
+ }
224
+
225
+ function printCategory(out, title, files, emoji) {
226
+ if (files.length === 0) return { totalFiles: 0, totalTokens: 0 };
227
+
228
+ out.add(`${emoji} ${title} (${files.length}):`);
229
+
230
+ let totalTokens = 0;
231
+ for (const file of files) {
232
+ totalTokens += file.tokens;
233
+ let line = ` ${file.relPath} (~${formatTokens(file.tokens)}) L${file.line}`;
234
+ if (file.depth && file.depth > 0) {
235
+ line += ` [via ${file.via}]`;
236
+ }
237
+ out.add(line);
238
+ }
239
+ out.blank();
240
+
241
+ return { totalFiles: files.length, totalTokens };
242
+ }
243
+
244
+ // ─────────────────────────────────────────────────────────────
245
+ // Main
246
+ // ─────────────────────────────────────────────────────────────
247
+
248
+ const args = process.argv.slice(2);
249
+ const options = parseCommonArgs(args);
250
+
251
+ // Parse tool-specific options
252
+ let maxDepth = 1;
253
+ for (let i = 0; i < options.remaining.length; i++) {
254
+ const arg = options.remaining[i];
255
+ if ((arg === '--depth' || arg === '-d') && options.remaining[i + 1]) {
256
+ maxDepth = parseInt(options.remaining[i + 1], 10);
257
+ i++;
258
+ }
259
+ }
260
+
261
+ const filePath = options.remaining.find(a => !a.startsWith('-'));
262
+
263
+ if (options.help || !filePath) {
264
+ console.log(HELP);
265
+ process.exit(options.help ? 0 : 1);
266
+ }
267
+
268
+ const resolvedPath = resolve(filePath);
269
+
270
+ if (!existsSync(resolvedPath)) {
271
+ console.error(`File not found: ${filePath}`);
272
+ process.exit(1);
273
+ }
274
+
275
+ const projectRoot = findProjectRoot();
276
+ const relPath = relative(projectRoot, resolvedPath);
277
+ const targetTokens = estimateTokens(readFileSync(resolvedPath, 'utf-8'));
278
+
279
+ const out = createOutput(options);
280
+
281
+ out.header(`\n🎯 Impact analysis: ${relPath}`);
282
+ out.header(` Target file: ~${formatTokens(targetTokens)} tokens`);
283
+
284
+ if (maxDepth > 1) {
285
+ out.header(` Analyzing ${maxDepth} levels of dependencies...`);
286
+ }
287
+ out.blank();
288
+
289
+ const directImporters = findDirectImporters(resolvedPath, projectRoot);
290
+ let importers = directImporters;
291
+ if (maxDepth > 1) {
292
+ importers = findTransitiveImporters(directImporters, resolvedPath, projectRoot, maxDepth);
293
+ }
294
+
295
+ // Set JSON data
296
+ out.setData('target', relPath);
297
+ out.setData('targetTokens', targetTokens);
298
+ out.setData('maxDepth', maxDepth);
299
+
300
+ if (importers.size === 0) {
301
+ out.add('✨ No importers found - this file has no dependents!');
302
+ out.blank();
303
+ out.add('This could mean:');
304
+ out.add(' • It\'s an entry point (main, index)');
305
+ out.add(' • It\'s a standalone script');
306
+ out.add(' • It\'s unused and can be safely deleted');
307
+ out.blank();
308
+
309
+ out.setData('importers', []);
310
+ out.setData('totalFiles', 0);
311
+ out.setData('totalTokens', 0);
312
+
313
+ out.print();
314
+ process.exit(0);
315
+ }
316
+
317
+ const categories = buildResults(importers, projectRoot);
318
+
319
+ // Set JSON data
320
+ out.setData('importers', categories);
321
+
322
+ let totalFiles = 0;
323
+ let totalTokens = 0;
324
+
325
+ const s = printCategory(out, 'Source files', categories.source, '📦');
326
+ totalFiles += s.totalFiles; totalTokens += s.totalTokens;
327
+
328
+ const t = printCategory(out, 'Test files', categories.test, '🧪');
329
+ totalFiles += t.totalFiles; totalTokens += t.totalTokens;
330
+
331
+ const st = printCategory(out, 'Stories', categories.story, '📖');
332
+ totalFiles += st.totalFiles; totalTokens += st.totalTokens;
333
+
334
+ const m = printCategory(out, 'Mocks/Fixtures', categories.mock, '🎭');
335
+ totalFiles += m.totalFiles; totalTokens += m.totalTokens;
336
+
337
+ out.setData('totalFiles', totalFiles);
338
+ out.setData('totalTokens', totalTokens);
339
+
340
+ out.stats('─'.repeat(50));
341
+ out.stats(`📊 Total impact: ${totalFiles} files, ~${formatTokens(totalTokens)} tokens`);
342
+ out.stats(` Changing ${basename(resolvedPath)} may affect all listed files.`);
343
+ out.blank();
344
+
345
+ out.print();