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/src/config.mjs ADDED
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Shared configuration system for tokenlean CLI tools
3
+ *
4
+ * Config files (in order of priority, higher overrides lower):
5
+ * 1. .tokenleanrc.json in project root (or parent directories)
6
+ * 2. ~/.tokenleanrc.json (global defaults)
7
+ *
8
+ * Example config:
9
+ * {
10
+ * "output": { "maxLines": 100, "format": "text" },
11
+ * "ignore": ["node_modules", "dist"],
12
+ * "searchPatterns": { ... },
13
+ * "hotspots": { "days": 90 },
14
+ * "structure": { "depth": 3 }
15
+ * }
16
+ */
17
+
18
+ import { existsSync, readFileSync } from 'fs';
19
+ import { dirname, join } from 'path';
20
+ import { homedir } from 'os';
21
+
22
+ // ─────────────────────────────────────────────────────────────
23
+ // Constants
24
+ // ─────────────────────────────────────────────────────────────
25
+
26
+ const CONFIG_FILENAME = '.tokenleanrc.json';
27
+ const GLOBAL_CONFIG_PATH = join(homedir(), CONFIG_FILENAME);
28
+
29
+ // Default configuration values
30
+ const DEFAULT_CONFIG = {
31
+ output: {
32
+ maxLines: null,
33
+ maxTokens: null,
34
+ format: 'text' // 'text' | 'json'
35
+ },
36
+ // Extensions to built-in skip/important lists (always extend, never replace)
37
+ skipDirs: [],
38
+ skipExtensions: [],
39
+ importantDirs: [],
40
+ importantFiles: [],
41
+ searchPatterns: {
42
+ hooks: {
43
+ description: 'React hooks usage',
44
+ pattern: 'use[A-Z]\\w+\\(',
45
+ glob: '**/*.{ts,tsx,js,jsx}'
46
+ },
47
+ errors: {
48
+ description: 'Error handling (throw, catch, Error)',
49
+ pattern: '(throw |catch\\s*\\(|new Error)',
50
+ glob: '**/*.{ts,tsx,js,jsx,mjs}'
51
+ },
52
+ env: {
53
+ description: 'Environment variables',
54
+ pattern: 'process\\.env\\.|import\\.meta\\.env',
55
+ glob: '**/*.{ts,tsx,js,jsx,mjs}'
56
+ },
57
+ routes: {
58
+ description: 'Route definitions',
59
+ pattern: '(app|router)\\.(get|post|put|delete|patch|use)\\(|path:\\s*[\'"]/',
60
+ glob: '**/*.{ts,tsx,js,jsx,mjs}'
61
+ },
62
+ exports: {
63
+ description: 'Exported functions and classes',
64
+ pattern: '^export (function|class|const|default)',
65
+ glob: '**/*.{ts,tsx,js,jsx,mjs}'
66
+ },
67
+ async: {
68
+ description: 'Async patterns (async/await, Promise)',
69
+ pattern: '(async |await |Promise\\.|\\. then\\()',
70
+ glob: '**/*.{ts,tsx,js,jsx,mjs}'
71
+ }
72
+ },
73
+ hotspots: {
74
+ days: 90
75
+ },
76
+ structure: {
77
+ depth: 3
78
+ },
79
+ symbols: {
80
+ includePrivate: false
81
+ },
82
+ impact: {
83
+ depth: 2
84
+ }
85
+ };
86
+
87
+ // ─────────────────────────────────────────────────────────────
88
+ // Config Loading
89
+ // ─────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Deep merge two objects (source into target)
93
+ * Arrays are replaced, not merged
94
+ */
95
+ function deepMerge(target, source) {
96
+ const result = { ...target };
97
+
98
+ for (const key of Object.keys(source)) {
99
+ if (
100
+ source[key] !== null &&
101
+ typeof source[key] === 'object' &&
102
+ !Array.isArray(source[key]) &&
103
+ target[key] !== null &&
104
+ typeof target[key] === 'object' &&
105
+ !Array.isArray(target[key])
106
+ ) {
107
+ result[key] = deepMerge(target[key], source[key]);
108
+ } else {
109
+ result[key] = source[key];
110
+ }
111
+ }
112
+
113
+ return result;
114
+ }
115
+
116
+ /**
117
+ * Load and parse a JSON config file
118
+ * Returns null if file doesn't exist or is invalid
119
+ */
120
+ function loadConfigFile(path) {
121
+ if (!existsSync(path)) {
122
+ return null;
123
+ }
124
+
125
+ try {
126
+ const content = readFileSync(path, 'utf-8');
127
+ return JSON.parse(content);
128
+ } catch (e) {
129
+ // Silently ignore invalid JSON - tools can warn if needed
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Find project config by walking up directory tree
136
+ * Returns { config, path, root } or null if not found
137
+ */
138
+ function findProjectConfig(startDir = process.cwd()) {
139
+ let dir = startDir;
140
+
141
+ while (dir !== '/') {
142
+ const configPath = join(dir, CONFIG_FILENAME);
143
+
144
+ if (existsSync(configPath)) {
145
+ const config = loadConfigFile(configPath);
146
+ if (config) {
147
+ return { config, path: configPath, root: dir };
148
+ }
149
+ }
150
+
151
+ dir = dirname(dir);
152
+ }
153
+
154
+ return null;
155
+ }
156
+
157
+ // ─────────────────────────────────────────────────────────────
158
+ // Main API
159
+ // ─────────────────────────────────────────────────────────────
160
+
161
+ // Cached merged config
162
+ let cachedConfig = null;
163
+ let cachedProjectRoot = null;
164
+
165
+ /**
166
+ * Load and merge all config sources
167
+ * Returns the merged configuration object
168
+ */
169
+ export function loadConfig(options = {}) {
170
+ const { reload = false, startDir = process.cwd() } = options;
171
+
172
+ // Return cached config if available
173
+ if (cachedConfig && !reload) {
174
+ return { config: cachedConfig, projectRoot: cachedProjectRoot };
175
+ }
176
+
177
+ // Start with defaults
178
+ let merged = { ...DEFAULT_CONFIG };
179
+ let projectRoot = startDir;
180
+
181
+ // Load global config
182
+ const globalConfig = loadConfigFile(GLOBAL_CONFIG_PATH);
183
+ if (globalConfig) {
184
+ merged = deepMerge(merged, globalConfig);
185
+ }
186
+
187
+ // Load project config (overrides global)
188
+ const projectResult = findProjectConfig(startDir);
189
+ if (projectResult) {
190
+ merged = deepMerge(merged, projectResult.config);
191
+ projectRoot = projectResult.root;
192
+ }
193
+
194
+ // Cache the result
195
+ cachedConfig = merged;
196
+ cachedProjectRoot = projectRoot;
197
+
198
+ return { config: merged, projectRoot };
199
+ }
200
+
201
+ /**
202
+ * Get a specific config section
203
+ */
204
+ export function getConfig(section) {
205
+ const { config } = loadConfig();
206
+ return section ? config[section] : config;
207
+ }
208
+
209
+ /**
210
+ * Get search patterns from config
211
+ */
212
+ export function getSearchPatterns() {
213
+ const { config } = loadConfig();
214
+ return config.searchPatterns || {};
215
+ }
216
+
217
+ /**
218
+ * Get output defaults from config
219
+ */
220
+ export function getOutputDefaults() {
221
+ const { config } = loadConfig();
222
+ return config.output || {};
223
+ }
224
+
225
+ /**
226
+ * Get ignore patterns from config
227
+ */
228
+ export function getIgnorePatterns() {
229
+ const { config } = loadConfig();
230
+ return config.ignore || [];
231
+ }
232
+
233
+ /**
234
+ * Check if config file exists (either global or project)
235
+ */
236
+ export function hasConfig() {
237
+ if (existsSync(GLOBAL_CONFIG_PATH)) return true;
238
+ return findProjectConfig() !== null;
239
+ }
240
+
241
+ /**
242
+ * Get paths to config files that would be loaded
243
+ */
244
+ export function getConfigPaths() {
245
+ const paths = [];
246
+
247
+ if (existsSync(GLOBAL_CONFIG_PATH)) {
248
+ paths.push({ type: 'global', path: GLOBAL_CONFIG_PATH });
249
+ }
250
+
251
+ const projectResult = findProjectConfig();
252
+ if (projectResult) {
253
+ paths.push({ type: 'project', path: projectResult.path });
254
+ }
255
+
256
+ return paths;
257
+ }
258
+
259
+ /**
260
+ * Clear the config cache (useful for testing)
261
+ */
262
+ export function clearConfigCache() {
263
+ cachedConfig = null;
264
+ cachedProjectRoot = null;
265
+ }
266
+
267
+ // ─────────────────────────────────────────────────────────────
268
+ // Exports for direct access
269
+ // ─────────────────────────────────────────────────────────────
270
+
271
+ export { CONFIG_FILENAME, GLOBAL_CONFIG_PATH, DEFAULT_CONFIG };
package/src/output.mjs ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Shared output utilities for tokenlean CLI tools
3
+ *
4
+ * Centralizes output formatting, truncation, and common options.
5
+ */
6
+
7
+ // ─────────────────────────────────────────────────────────────
8
+ // Shell Escaping
9
+ // ─────────────────────────────────────────────────────────────
10
+
11
+ /**
12
+ * Escape a string for safe use in shell double-quoted strings
13
+ * Handles: $ ` \ " !
14
+ */
15
+ export function shellEscape(str) {
16
+ return str.replace(/[`$"\\!]/g, '\\$&');
17
+ }
18
+
19
+ /**
20
+ * Escape a string for use as a ripgrep pattern (regex escaping + shell escaping)
21
+ */
22
+ export function rgEscape(str) {
23
+ // First escape regex special chars
24
+ const regexEscaped = str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
25
+ // Then escape shell special chars
26
+ return shellEscape(regexEscaped);
27
+ }
28
+
29
+ // ─────────────────────────────────────────────────────────────
30
+ // Token Estimation
31
+ // ─────────────────────────────────────────────────────────────
32
+
33
+ export function estimateTokens(content) {
34
+ if (typeof content !== 'string') return 0;
35
+ return Math.ceil(content.length / 4);
36
+ }
37
+
38
+ export function formatTokens(tokens) {
39
+ if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
40
+ if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
41
+ return String(tokens);
42
+ }
43
+
44
+ // ─────────────────────────────────────────────────────────────
45
+ // Argument Parsing
46
+ // ─────────────────────────────────────────────────────────────
47
+
48
+ export function parseCommonArgs(args) {
49
+ const options = {
50
+ maxLines: Infinity,
51
+ maxTokens: Infinity,
52
+ json: false,
53
+ quiet: false,
54
+ help: false,
55
+ remaining: []
56
+ };
57
+
58
+ for (let i = 0; i < args.length; i++) {
59
+ const arg = args[i];
60
+
61
+ if (arg === '--max-lines' || arg === '-l') {
62
+ options.maxLines = parseInt(args[++i], 10) || Infinity;
63
+ } else if (arg === '--max-tokens' || arg === '-t') {
64
+ options.maxTokens = parseInt(args[++i], 10) || Infinity;
65
+ } else if (arg === '--json' || arg === '-j') {
66
+ options.json = true;
67
+ } else if (arg === '--quiet' || arg === '-q') {
68
+ options.quiet = true;
69
+ } else if (arg === '--help' || arg === '-h') {
70
+ options.help = true;
71
+ } else {
72
+ options.remaining.push(arg);
73
+ }
74
+ }
75
+
76
+ return options;
77
+ }
78
+
79
+ export const COMMON_OPTIONS_HELP = `
80
+ Common options:
81
+ --max-lines N, -l N Limit output to N lines
82
+ --max-tokens N, -t N Limit output to ~N tokens
83
+ --json, -j Output as JSON (for piping)
84
+ --quiet, -q Minimal output (no headers/stats)
85
+ --help, -h Show help`;
86
+
87
+ // ─────────────────────────────────────────────────────────────
88
+ // Output Builder
89
+ // ─────────────────────────────────────────────────────────────
90
+
91
+ export class Output {
92
+ constructor(options = {}) {
93
+ this.options = {
94
+ maxLines: options.maxLines ?? Infinity,
95
+ maxTokens: options.maxTokens ?? Infinity,
96
+ json: options.json ?? false,
97
+ quiet: options.quiet ?? false
98
+ };
99
+
100
+ this.lines = [];
101
+ this.data = {}; // For JSON output
102
+ this.truncated = false;
103
+ this.totalLines = 0;
104
+ }
105
+
106
+ // Add a header line (skipped in quiet mode)
107
+ header(text) {
108
+ if (!this.options.quiet) {
109
+ this.lines.push(text);
110
+ }
111
+ return this;
112
+ }
113
+
114
+ // Add a blank line (skipped in quiet mode)
115
+ blank() {
116
+ if (!this.options.quiet) {
117
+ this.lines.push('');
118
+ }
119
+ return this;
120
+ }
121
+
122
+ // Add content lines (respects limits)
123
+ add(text) {
124
+ this.totalLines++;
125
+
126
+ if (this.truncated) return this;
127
+
128
+ // Check token limit
129
+ const currentTokens = estimateTokens(this.lines.join('\n'));
130
+ const newTokens = estimateTokens(text);
131
+
132
+ if (currentTokens + newTokens > this.options.maxTokens) {
133
+ this.truncated = true;
134
+ return this;
135
+ }
136
+
137
+ // Check line limit
138
+ if (this.lines.length >= this.options.maxLines) {
139
+ this.truncated = true;
140
+ return this;
141
+ }
142
+
143
+ this.lines.push(text);
144
+ return this;
145
+ }
146
+
147
+ // Add multiple lines at once
148
+ addLines(textArray) {
149
+ for (const line of textArray) {
150
+ this.add(line);
151
+ if (this.truncated) break;
152
+ }
153
+ return this;
154
+ }
155
+
156
+ // Add a section with title and items
157
+ section(title, items, formatter = (x) => x) {
158
+ if (items.length === 0) return this;
159
+
160
+ this.add(title);
161
+ for (const item of items) {
162
+ this.add(formatter(item));
163
+ if (this.truncated) break;
164
+ }
165
+ this.blank();
166
+ return this;
167
+ }
168
+
169
+ // Set data for JSON output
170
+ setData(key, value) {
171
+ this.data[key] = value;
172
+ return this;
173
+ }
174
+
175
+ // Add stats footer (skipped in quiet mode)
176
+ stats(text) {
177
+ if (!this.options.quiet && !this.truncated) {
178
+ this.lines.push(text);
179
+ }
180
+ return this;
181
+ }
182
+
183
+ // Render the output
184
+ render() {
185
+ if (this.options.json) {
186
+ return JSON.stringify({
187
+ ...this.data,
188
+ truncated: this.truncated,
189
+ totalItems: this.totalLines
190
+ }, null, 2);
191
+ }
192
+
193
+ let output = this.lines.join('\n');
194
+
195
+ if (this.truncated) {
196
+ const remaining = this.totalLines - this.lines.length;
197
+ if (remaining > 0) {
198
+ output += `\n\n... truncated (${remaining} more lines)`;
199
+ } else {
200
+ output += '\n\n... truncated';
201
+ }
202
+ }
203
+
204
+ return output;
205
+ }
206
+
207
+ // Print to stdout
208
+ print() {
209
+ console.log(this.render());
210
+ }
211
+ }
212
+
213
+ // ─────────────────────────────────────────────────────────────
214
+ // Convenience function for simple outputs
215
+ // ─────────────────────────────────────────────────────────────
216
+
217
+ export function createOutput(options) {
218
+ return new Output(options);
219
+ }
220
+
221
+ // ─────────────────────────────────────────────────────────────
222
+ // Table formatting
223
+ // ─────────────────────────────────────────────────────────────
224
+
225
+ export function formatTable(rows, options = {}) {
226
+ if (rows.length === 0) return [];
227
+
228
+ const { indent = '', separator = ' ' } = options;
229
+
230
+ // Calculate column widths
231
+ const colWidths = [];
232
+ for (const row of rows) {
233
+ row.forEach((cell, i) => {
234
+ const len = String(cell).length;
235
+ colWidths[i] = Math.max(colWidths[i] || 0, len);
236
+ });
237
+ }
238
+
239
+ // Format rows
240
+ return rows.map(row => {
241
+ const cells = row.map((cell, i) => {
242
+ const str = String(cell);
243
+ // Right-align numbers, left-align text
244
+ if (typeof cell === 'number' || /^[\d,.]+[kMG]?$/.test(str)) {
245
+ return str.padStart(colWidths[i]);
246
+ }
247
+ return str.padEnd(colWidths[i]);
248
+ });
249
+ return indent + cells.join(separator);
250
+ });
251
+ }