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.
- package/README.md +248 -0
- package/bin/tl-api.mjs +515 -0
- package/bin/tl-blame.mjs +345 -0
- package/bin/tl-complexity.mjs +514 -0
- package/bin/tl-component.mjs +274 -0
- package/bin/tl-config.mjs +135 -0
- package/bin/tl-context.mjs +156 -0
- package/bin/tl-coverage.mjs +456 -0
- package/bin/tl-deps.mjs +474 -0
- package/bin/tl-diff.mjs +183 -0
- package/bin/tl-entry.mjs +256 -0
- package/bin/tl-env.mjs +376 -0
- package/bin/tl-exports.mjs +583 -0
- package/bin/tl-flow.mjs +324 -0
- package/bin/tl-history.mjs +289 -0
- package/bin/tl-hotspots.mjs +321 -0
- package/bin/tl-impact.mjs +345 -0
- package/bin/tl-prompt.mjs +175 -0
- package/bin/tl-related.mjs +227 -0
- package/bin/tl-routes.mjs +627 -0
- package/bin/tl-search.mjs +123 -0
- package/bin/tl-structure.mjs +161 -0
- package/bin/tl-symbols.mjs +430 -0
- package/bin/tl-todo.mjs +341 -0
- package/bin/tl-types.mjs +441 -0
- package/bin/tl-unused.mjs +494 -0
- package/package.json +55 -0
- package/src/config.mjs +271 -0
- package/src/output.mjs +251 -0
- package/src/project.mjs +277 -0
|
@@ -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();
|