ucn 3.7.23 → 3.7.25
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/cli/index.js +285 -1054
- package/core/cache.js +193 -0
- package/core/callers.js +817 -0
- package/core/deadcode.js +320 -0
- package/core/discovery.js +1 -1
- package/core/execute.js +207 -10
- package/core/expand-cache.js +16 -5
- package/core/imports.js +21 -15
- package/core/output.js +380 -38
- package/core/project.js +365 -2259
- package/core/shared.js +11 -1
- package/core/stacktrace.js +313 -0
- package/core/verify.js +533 -0
- package/languages/go.js +57 -21
- package/languages/html.js +14 -3
- package/languages/java.js +4 -2
- package/languages/javascript.js +36 -9
- package/languages/rust.js +49 -17
- package/mcp/server.js +39 -172
- package/package.json +1 -1
package/core/deadcode.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/deadcode.js - Dead code detection (unused functions/classes)
|
|
3
|
+
*
|
|
4
|
+
* Extracted from project.js. All functions take an `index` (ProjectIndex)
|
|
5
|
+
* as the first argument instead of using `this`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { detectLanguage, getParser, getLanguageModule, safeParse } = require('../languages');
|
|
9
|
+
const { isTestFile } = require('./discovery');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a usage index for all identifiers in the codebase (optimized for deadcode)
|
|
13
|
+
* Scans all files ONCE and builds a reverse index: name -> [usages]
|
|
14
|
+
* @param {object} index - ProjectIndex instance
|
|
15
|
+
* @returns {Map<string, Array>} Usage index
|
|
16
|
+
*/
|
|
17
|
+
function buildUsageIndex(index) {
|
|
18
|
+
const usageIndex = new Map(); // name -> [{file, line}]
|
|
19
|
+
|
|
20
|
+
for (const [filePath, fileEntry] of index.files) {
|
|
21
|
+
try {
|
|
22
|
+
const language = detectLanguage(filePath);
|
|
23
|
+
if (!language) continue;
|
|
24
|
+
|
|
25
|
+
const content = index._readFile(filePath);
|
|
26
|
+
|
|
27
|
+
// For HTML files, parse the virtual JS content instead of raw HTML
|
|
28
|
+
// (HTML tree-sitter sees script content as raw_text, not JS identifiers)
|
|
29
|
+
let tree;
|
|
30
|
+
if (language === 'html') {
|
|
31
|
+
const htmlModule = getLanguageModule('html');
|
|
32
|
+
const htmlParser = getParser('html');
|
|
33
|
+
const jsParser = getParser('javascript');
|
|
34
|
+
const blocks = htmlModule.extractScriptBlocks(content, htmlParser);
|
|
35
|
+
if (blocks.length === 0 && !htmlModule.extractEventHandlerCalls) continue;
|
|
36
|
+
if (blocks.length > 0) {
|
|
37
|
+
const virtualJS = htmlModule.buildVirtualJSContent(content, blocks);
|
|
38
|
+
tree = safeParse(jsParser, virtualJS);
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
const parser = getParser(language);
|
|
42
|
+
if (!parser) continue;
|
|
43
|
+
tree = safeParse(parser, content);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Collect all identifiers from this file in one pass
|
|
47
|
+
const traverse = (node) => {
|
|
48
|
+
// Match all identifier-like nodes across languages
|
|
49
|
+
if (node.type === 'identifier' ||
|
|
50
|
+
node.type === 'property_identifier' ||
|
|
51
|
+
node.type === 'type_identifier' ||
|
|
52
|
+
node.type === 'shorthand_property_identifier' ||
|
|
53
|
+
node.type === 'shorthand_property_identifier_pattern' ||
|
|
54
|
+
node.type === 'field_identifier') {
|
|
55
|
+
const name = node.text;
|
|
56
|
+
if (!usageIndex.has(name)) {
|
|
57
|
+
usageIndex.set(name, []);
|
|
58
|
+
}
|
|
59
|
+
usageIndex.get(name).push({
|
|
60
|
+
file: filePath,
|
|
61
|
+
line: node.startPosition.row + 1,
|
|
62
|
+
relativePath: fileEntry.relativePath
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
66
|
+
traverse(node.child(i));
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
if (tree) traverse(tree.rootNode);
|
|
70
|
+
|
|
71
|
+
// For HTML files, also extract identifiers from event handler attributes
|
|
72
|
+
// (onclick="foo()" etc. — these are in HTML, not in <script> blocks)
|
|
73
|
+
if (language === 'html') {
|
|
74
|
+
const htmlModule = getLanguageModule('html');
|
|
75
|
+
const htmlParser = getParser('html');
|
|
76
|
+
const handlerCalls = htmlModule.extractEventHandlerCalls(content, htmlParser);
|
|
77
|
+
for (const call of handlerCalls) {
|
|
78
|
+
if (!usageIndex.has(call.name)) {
|
|
79
|
+
usageIndex.set(call.name, []);
|
|
80
|
+
}
|
|
81
|
+
usageIndex.get(call.name).push({
|
|
82
|
+
file: filePath,
|
|
83
|
+
line: call.line,
|
|
84
|
+
relativePath: fileEntry.relativePath
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// Skip files that can't be processed
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return usageIndex;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Find dead code (unused functions/classes)
|
|
98
|
+
* @param {object} index - ProjectIndex instance
|
|
99
|
+
* @param {object} options - { includeExported, includeTests }
|
|
100
|
+
* @returns {Array} Unused symbols
|
|
101
|
+
*/
|
|
102
|
+
function deadcode(index, options = {}) {
|
|
103
|
+
index._beginOp();
|
|
104
|
+
try {
|
|
105
|
+
const results = [];
|
|
106
|
+
let excludedDecorated = 0;
|
|
107
|
+
let excludedExported = 0;
|
|
108
|
+
|
|
109
|
+
// Build usage index once (instead of per-symbol)
|
|
110
|
+
const usageIndex = buildUsageIndex(index);
|
|
111
|
+
|
|
112
|
+
for (const [name, symbols] of index.symbols) {
|
|
113
|
+
for (const symbol of symbols) {
|
|
114
|
+
// Skip non-function/class types
|
|
115
|
+
// Include various method types from different languages:
|
|
116
|
+
// - function: standalone functions
|
|
117
|
+
// - class, struct, interface: type definitions (skip them in deadcode)
|
|
118
|
+
// - method: class methods
|
|
119
|
+
// - static, public, abstract: Java method modifiers used as types
|
|
120
|
+
// - constructor: constructors
|
|
121
|
+
const callableTypes = ['function', 'method', 'static', 'public', 'abstract', 'constructor'];
|
|
122
|
+
if (!callableTypes.includes(symbol.type)) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const fileEntry = index.files.get(symbol.file);
|
|
127
|
+
const lang = fileEntry?.language;
|
|
128
|
+
|
|
129
|
+
// Skip bundled/minified files (webpack bundles, build artifacts)
|
|
130
|
+
if (fileEntry?.isBundled) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Skip test files unless requested
|
|
135
|
+
if (!options.includeTests && isTestFile(symbol.relativePath, lang)) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Apply exclude and in filters
|
|
140
|
+
if ((options.exclude && options.exclude.length > 0) || options.in) {
|
|
141
|
+
if (!index.matchesFilters(symbol.relativePath, { exclude: options.exclude, in: options.in })) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const mods = symbol.modifiers || [];
|
|
147
|
+
|
|
148
|
+
// Language-specific entry points (called by runtime, no AST-visible callers)
|
|
149
|
+
// Go: main() and init() are called by runtime
|
|
150
|
+
const isGoEntryPoint = lang === 'go' && (name === 'main' || name === 'init');
|
|
151
|
+
|
|
152
|
+
// Java: public static void main(String[] args) is the entry point
|
|
153
|
+
const isJavaEntryPoint = lang === 'java' && name === 'main' &&
|
|
154
|
+
mods.includes('public') && mods.includes('static');
|
|
155
|
+
|
|
156
|
+
// Python: Magic/dunder methods are called by the interpreter, not user code
|
|
157
|
+
// test_* functions/methods are called by pytest/unittest via reflection
|
|
158
|
+
// setUp/tearDown are unittest.TestCase framework methods called by test runner
|
|
159
|
+
// pytest_* are pytest plugin hooks called by the framework
|
|
160
|
+
const isPythonEntryPoint = lang === 'python' &&
|
|
161
|
+
(/^__\w+__$/.test(name) || /^test_/.test(name) ||
|
|
162
|
+
/^(setUp|tearDown)(Class|Module)?$/.test(name) ||
|
|
163
|
+
/^pytest_/.test(name));
|
|
164
|
+
|
|
165
|
+
// Rust: main() is entry point, #[test] and #[bench] functions are called by test/bench runner
|
|
166
|
+
const isRustEntryPoint = lang === 'rust' &&
|
|
167
|
+
(name === 'main' || mods.includes('test') || mods.includes('bench'));
|
|
168
|
+
|
|
169
|
+
// Rust: trait impl methods are invoked via trait dispatch, not direct calls
|
|
170
|
+
// They can never be "dead" - the trait contract requires them to exist
|
|
171
|
+
// className for trait impls contains " for " (e.g., "PartialEq for Glob")
|
|
172
|
+
const isRustTraitImpl = lang === 'rust' && symbol.isMethod &&
|
|
173
|
+
symbol.className && symbol.className.includes(' for ');
|
|
174
|
+
|
|
175
|
+
// Go: Test*, Benchmark*, Example* functions are called by go test
|
|
176
|
+
const isGoTestFunc = lang === 'go' &&
|
|
177
|
+
/^(Test|Benchmark|Example)[A-Z]/.test(name);
|
|
178
|
+
|
|
179
|
+
// Java: @Test annotated methods are called by JUnit
|
|
180
|
+
const isJavaTestMethod = lang === 'java' && mods.includes('test');
|
|
181
|
+
|
|
182
|
+
// Java: @Override methods are invoked via polymorphic dispatch
|
|
183
|
+
// They implement interface/superclass contracts and can't be dead
|
|
184
|
+
const isJavaOverride = lang === 'java' && mods.includes('override');
|
|
185
|
+
|
|
186
|
+
// Skip trait impl / @Override methods entirely - they're required by the type system
|
|
187
|
+
if (isRustTraitImpl || isJavaOverride) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// JavaScript/TypeScript: framework lifecycle methods called by runtime
|
|
192
|
+
// React class components, Web Components, Angular, Vue
|
|
193
|
+
const jsLifecycleMethods = new Set([
|
|
194
|
+
// React class component lifecycle
|
|
195
|
+
'render', 'componentDidMount', 'componentDidUpdate', 'componentWillUnmount',
|
|
196
|
+
'getDerivedStateFromProps', 'getDerivedStateFromError', 'componentDidCatch',
|
|
197
|
+
'getSnapshotBeforeUpdate', 'shouldComponentUpdate',
|
|
198
|
+
// Web Components lifecycle
|
|
199
|
+
'connectedCallback', 'disconnectedCallback', 'attributeChangedCallback', 'adoptedCallback'
|
|
200
|
+
]);
|
|
201
|
+
const isJsEntryPoint = (lang === 'javascript' || lang === 'typescript' || lang === 'tsx') &&
|
|
202
|
+
symbol.isMethod && jsLifecycleMethods.has(name);
|
|
203
|
+
|
|
204
|
+
const isEntryPoint = isGoEntryPoint || isGoTestFunc ||
|
|
205
|
+
isJavaEntryPoint || isJavaTestMethod ||
|
|
206
|
+
isPythonEntryPoint || isRustEntryPoint || isJsEntryPoint;
|
|
207
|
+
|
|
208
|
+
// Entry points are always excluded — they're invoked by the runtime, not user code
|
|
209
|
+
if (isEntryPoint) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Framework registration decorators — excluded by default to reduce noise
|
|
214
|
+
// Python: decorators with '.' (attribute access) like @router.get, @app.route, @celery.task
|
|
215
|
+
// Java: non-standard annotations like @Bean, @Scheduled, @GetMapping
|
|
216
|
+
// These functions are invoked by frameworks, not by user code — AST can't see the call path
|
|
217
|
+
const javaKeywords = new Set(['public', 'private', 'protected', 'static', 'final', 'abstract', 'synchronized', 'native', 'default']);
|
|
218
|
+
const hasRegistrationDecorator = (() => {
|
|
219
|
+
if (lang === 'python') {
|
|
220
|
+
const decorators = symbol.decorators || [];
|
|
221
|
+
return decorators.some(d => d.includes('.'));
|
|
222
|
+
}
|
|
223
|
+
if (lang === 'java') {
|
|
224
|
+
return mods.some(m => !javaKeywords.has(m));
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
})();
|
|
228
|
+
|
|
229
|
+
if (hasRegistrationDecorator && !options.includeDecorated) {
|
|
230
|
+
excludedDecorated++;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const isExported = fileEntry && (
|
|
235
|
+
fileEntry.exports.includes(name) ||
|
|
236
|
+
mods.includes('export') ||
|
|
237
|
+
mods.includes('public') ||
|
|
238
|
+
(lang === 'go' && /^[A-Z]/.test(name))
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// Skip exported unless requested
|
|
242
|
+
if (isExported && !options.includeExported) {
|
|
243
|
+
excludedExported++;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Use pre-built index for O(1) lookup instead of O(files) scan
|
|
248
|
+
const allUsages = usageIndex.get(name) || [];
|
|
249
|
+
|
|
250
|
+
// Filter out usages that are at the definition location
|
|
251
|
+
// nameLine: when decorators/annotations are present, startLine is the decorator line
|
|
252
|
+
// but the name identifier is on a different line (nameLine). Check both.
|
|
253
|
+
let nonDefUsages = allUsages.filter(u =>
|
|
254
|
+
!(u.file === symbol.file && (u.line === symbol.startLine || u.line === symbol.nameLine))
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// For exported symbols in --include-exported mode, also filter out export-site
|
|
258
|
+
// references (e.g., `module.exports = { helperC }` or `export { helperC }`).
|
|
259
|
+
// These are just re-statements of the export, not actual consumption.
|
|
260
|
+
if (isExported && options.includeExported) {
|
|
261
|
+
nonDefUsages = nonDefUsages.filter(u => {
|
|
262
|
+
if (u.file !== symbol.file) return true; // cross-file usage always counts
|
|
263
|
+
// Check if same-file usage is on an export line
|
|
264
|
+
let content;
|
|
265
|
+
try { content = index._readFile(u.file); } catch { return true; }
|
|
266
|
+
if (!content) return true;
|
|
267
|
+
const lines = content.split('\n');
|
|
268
|
+
const line = lines[u.line - 1] || '';
|
|
269
|
+
const trimmed = line.trim();
|
|
270
|
+
// CJS: module.exports = { ... } or exports.name = ...
|
|
271
|
+
if (trimmed.startsWith('module.exports') || /^exports\.\w+\s*=/.test(trimmed)) return false;
|
|
272
|
+
// ESM: export { ... } or export default
|
|
273
|
+
if (/^export\s*\{/.test(trimmed) || /^export\s+default\s/.test(trimmed)) return false;
|
|
274
|
+
return true;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Total includes all usage types (calls, references, callbacks, re-exports)
|
|
279
|
+
const totalUsages = nonDefUsages.length;
|
|
280
|
+
|
|
281
|
+
if (totalUsages === 0) {
|
|
282
|
+
// Collect decorators/annotations for hint display
|
|
283
|
+
// Python: symbol.decorators (e.g., ['app.route("/path")', 'login_required'])
|
|
284
|
+
// Java/Rust/Go: symbol.modifiers may contain annotations (e.g., 'bean', 'scheduled')
|
|
285
|
+
const decorators = symbol.decorators || [];
|
|
286
|
+
// For Java, extract annotation-like modifiers (javaKeywords defined above)
|
|
287
|
+
const annotations = lang === 'java'
|
|
288
|
+
? mods.filter(m => !javaKeywords.has(m))
|
|
289
|
+
: [];
|
|
290
|
+
|
|
291
|
+
results.push({
|
|
292
|
+
name: symbol.name,
|
|
293
|
+
type: symbol.type,
|
|
294
|
+
file: symbol.relativePath,
|
|
295
|
+
startLine: symbol.startLine,
|
|
296
|
+
endLine: symbol.endLine,
|
|
297
|
+
isExported,
|
|
298
|
+
usageCount: 0,
|
|
299
|
+
...(decorators.length > 0 && { decorators }),
|
|
300
|
+
...(annotations.length > 0 && { annotations })
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Sort by file then line
|
|
307
|
+
results.sort((a, b) => {
|
|
308
|
+
if (a.file !== b.file) return a.file.localeCompare(b.file);
|
|
309
|
+
return a.startLine - b.startLine;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Attach exclusion counts as array properties (backwards-compatible)
|
|
313
|
+
results.excludedDecorated = excludedDecorated;
|
|
314
|
+
results.excludedExported = excludedExported;
|
|
315
|
+
|
|
316
|
+
return results;
|
|
317
|
+
} finally { index._endOp(); }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
module.exports = { buildUsageIndex, deadcode };
|
package/core/discovery.js
CHANGED
|
@@ -254,7 +254,7 @@ function parseGlobPattern(pattern, root) {
|
|
|
254
254
|
* Convert a glob pattern to a regular expression
|
|
255
255
|
*/
|
|
256
256
|
function globToRegex(glob) {
|
|
257
|
-
let regex = glob.replace(/[.+^$[\]\\]/g, '\\$&');
|
|
257
|
+
let regex = glob.replace(/[.+^$[\]\\()|]/g, '\\$&');
|
|
258
258
|
|
|
259
259
|
// Handle brace expansion: {js,ts} -> (js|ts)
|
|
260
260
|
regex = regex.replace(/\{([^}]+)\}/g, (_, group) => {
|
package/core/execute.js
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared Command Executor — single dispatch for CLI, MCP, and interactive mode.
|
|
3
3
|
*
|
|
4
|
-
* Handles: input validation, exclude normalization, test exclusion, index calls
|
|
5
|
-
*
|
|
4
|
+
* Handles: input validation, exclude normalization, test exclusion, index calls,
|
|
5
|
+
* and code extraction (fn, class, lines).
|
|
6
6
|
*
|
|
7
7
|
* Each handler returns { ok: true, result } or { ok: false, error }.
|
|
8
|
-
* Adapters handle formatting and surface-specific concerns.
|
|
8
|
+
* Adapters handle formatting, path security (MCP), and surface-specific concerns.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
'use strict';
|
|
12
12
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const ADAPTER_ONLY_COMMANDS = new Set(['fn', 'class', 'lines', 'expand']);
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { addTestExclusions, pickBestDefinition } = require('./shared');
|
|
16
|
+
const { cleanHtmlScriptTags, detectLanguage } = require('./parser');
|
|
17
|
+
const { renderExpandItem } = require('./expand-cache');
|
|
19
18
|
|
|
20
19
|
// ============================================================================
|
|
21
20
|
// HELPERS
|
|
@@ -75,6 +74,14 @@ function num(val, fallback) {
|
|
|
75
74
|
return isNaN(n) ? fallback : n;
|
|
76
75
|
}
|
|
77
76
|
|
|
77
|
+
/** Read a file and extract lines for a symbol match, applying HTML cleanup. */
|
|
78
|
+
function readAndExtract(match) {
|
|
79
|
+
const content = fs.readFileSync(match.file, 'utf-8');
|
|
80
|
+
const lines = content.split('\n');
|
|
81
|
+
const extracted = lines.slice(match.startLine - 1, match.endLine);
|
|
82
|
+
return cleanHtmlScriptTags(extracted, detectLanguage(match.file)).join('\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
78
85
|
// ============================================================================
|
|
79
86
|
// COMMAND HANDLERS
|
|
80
87
|
// ============================================================================
|
|
@@ -96,6 +103,7 @@ const HANDLERS = {
|
|
|
96
103
|
maxCallers: num(p.top, undefined),
|
|
97
104
|
maxCallees: num(p.top, undefined),
|
|
98
105
|
});
|
|
106
|
+
if (!result) return { ok: false, error: `Symbol "${p.name}" not found.` };
|
|
99
107
|
return { ok: true, result };
|
|
100
108
|
},
|
|
101
109
|
|
|
@@ -119,6 +127,7 @@ const HANDLERS = {
|
|
|
119
127
|
file: p.file,
|
|
120
128
|
exclude: toExcludeArray(p.exclude),
|
|
121
129
|
});
|
|
130
|
+
if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
|
|
122
131
|
return { ok: true, result };
|
|
123
132
|
},
|
|
124
133
|
|
|
@@ -146,6 +155,7 @@ const HANDLERS = {
|
|
|
146
155
|
includeMethods: p.includeMethods,
|
|
147
156
|
includeUncertain: p.includeUncertain || false,
|
|
148
157
|
});
|
|
158
|
+
if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
|
|
149
159
|
return { ok: true, result };
|
|
150
160
|
},
|
|
151
161
|
|
|
@@ -153,6 +163,7 @@ const HANDLERS = {
|
|
|
153
163
|
const err = requireName(p.name);
|
|
154
164
|
if (err) return { ok: false, error: err };
|
|
155
165
|
const result = index.example(p.name);
|
|
166
|
+
if (!result) return { ok: false, error: `No examples found for "${p.name}".` };
|
|
156
167
|
return { ok: true, result };
|
|
157
168
|
},
|
|
158
169
|
|
|
@@ -164,6 +175,7 @@ const HANDLERS = {
|
|
|
164
175
|
top: num(p.top, undefined),
|
|
165
176
|
all: p.all,
|
|
166
177
|
});
|
|
178
|
+
if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
|
|
167
179
|
return { ok: true, result };
|
|
168
180
|
},
|
|
169
181
|
|
|
@@ -240,6 +252,173 @@ const HANDLERS = {
|
|
|
240
252
|
return { ok: true, result };
|
|
241
253
|
},
|
|
242
254
|
|
|
255
|
+
// ── Extracting Code ─────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
fn: (index, p) => {
|
|
258
|
+
const err = requireName(p.name);
|
|
259
|
+
if (err) return { ok: false, error: err };
|
|
260
|
+
|
|
261
|
+
const fnNames = p.name.includes(',')
|
|
262
|
+
? p.name.split(',').map(n => n.trim()).filter(Boolean)
|
|
263
|
+
: [p.name];
|
|
264
|
+
|
|
265
|
+
const entries = [];
|
|
266
|
+
const notes = [];
|
|
267
|
+
|
|
268
|
+
for (const fnName of fnNames) {
|
|
269
|
+
const matches = index.find(fnName, { file: p.file, skipCounts: true })
|
|
270
|
+
.filter(m => m.type === 'function' || m.params !== undefined);
|
|
271
|
+
|
|
272
|
+
if (matches.length === 0) {
|
|
273
|
+
notes.push(`Function "${fnName}" not found.`);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (matches.length > 1 && !p.file && p.all) {
|
|
278
|
+
for (const m of matches) {
|
|
279
|
+
const code = readAndExtract(m);
|
|
280
|
+
entries.push({ match: m, code });
|
|
281
|
+
}
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const match = matches.length > 1 && !p.file
|
|
286
|
+
? pickBestDefinition(matches)
|
|
287
|
+
: matches[0];
|
|
288
|
+
|
|
289
|
+
if (matches.length > 1 && !p.file) {
|
|
290
|
+
const others = matches.filter(m => m !== match)
|
|
291
|
+
.map(m => `${m.relativePath}:${m.startLine}`).join(', ');
|
|
292
|
+
notes.push(`Found ${matches.length} definitions for "${fnName}". Showing ${match.relativePath}:${match.startLine}. Also in: ${others}. Use --file to disambiguate or --all to show all.`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const code = readAndExtract(match);
|
|
296
|
+
entries.push({ match, code });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (entries.length === 0 && notes.length > 0) {
|
|
300
|
+
return { ok: false, error: notes.join('\n') };
|
|
301
|
+
}
|
|
302
|
+
return { ok: true, result: { entries, notes } };
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
class: (index, p) => {
|
|
306
|
+
const err = requireName(p.name);
|
|
307
|
+
if (err) return { ok: false, error: err };
|
|
308
|
+
|
|
309
|
+
const CLASS_TYPES = ['class', 'interface', 'type', 'enum', 'struct', 'trait'];
|
|
310
|
+
const matches = index.find(p.name, { file: p.file, skipCounts: true })
|
|
311
|
+
.filter(m => CLASS_TYPES.includes(m.type));
|
|
312
|
+
|
|
313
|
+
if (matches.length === 0) {
|
|
314
|
+
return { ok: false, error: `Class "${p.name}" not found.` };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const entries = [];
|
|
318
|
+
const notes = [];
|
|
319
|
+
const maxLines = num(p.maxLines, null);
|
|
320
|
+
|
|
321
|
+
if (p.maxLines != null && (maxLines === null || !Number.isInteger(maxLines) || maxLines < 1)) {
|
|
322
|
+
return { ok: false, error: '--max-lines must be a positive integer.' };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (matches.length > 1 && !p.file && p.all) {
|
|
326
|
+
for (const m of matches) {
|
|
327
|
+
const code = readAndExtract(m);
|
|
328
|
+
const totalLines = m.endLine - m.startLine + 1;
|
|
329
|
+
entries.push({ match: m, code, totalLines, summaryMode: false, truncated: false });
|
|
330
|
+
}
|
|
331
|
+
return { ok: true, result: { entries, notes } };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const match = matches.length > 1 && !p.file
|
|
335
|
+
? pickBestDefinition(matches)
|
|
336
|
+
: matches[0];
|
|
337
|
+
|
|
338
|
+
if (matches.length > 1 && !p.file) {
|
|
339
|
+
const others = matches.filter(m => m !== match)
|
|
340
|
+
.map(m => `${m.relativePath}:${m.startLine}`).join(', ');
|
|
341
|
+
notes.push(`Found ${matches.length} definitions for "${p.name}". Showing ${match.relativePath}:${match.startLine}. Also in: ${others}. Use --file to disambiguate or --all to show all.`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const totalLines = match.endLine - match.startLine + 1;
|
|
345
|
+
|
|
346
|
+
// Large class summary mode (>200 lines, no maxLines)
|
|
347
|
+
if (totalLines > 200 && !maxLines) {
|
|
348
|
+
const methods = index.findMethodsForType(match.name);
|
|
349
|
+
entries.push({ match, code: null, methods, totalLines, summaryMode: true, truncated: false });
|
|
350
|
+
return { ok: true, result: { entries, notes } };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Truncated mode (maxLines specified and class exceeds it)
|
|
354
|
+
if (maxLines && totalLines > maxLines) {
|
|
355
|
+
const content = fs.readFileSync(match.file, 'utf-8');
|
|
356
|
+
const fileLines = content.split('\n');
|
|
357
|
+
const truncated = fileLines.slice(match.startLine - 1, match.startLine - 1 + maxLines);
|
|
358
|
+
const code = cleanHtmlScriptTags(truncated, detectLanguage(match.file)).join('\n');
|
|
359
|
+
entries.push({ match, code, totalLines, summaryMode: false, truncated: true, maxLines });
|
|
360
|
+
return { ok: true, result: { entries, notes } };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Full extraction
|
|
364
|
+
const code = readAndExtract(match);
|
|
365
|
+
entries.push({ match, code, totalLines, summaryMode: false, truncated: false });
|
|
366
|
+
return { ok: true, result: { entries, notes } };
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
lines: (index, p) => {
|
|
370
|
+
const err = requireFile(p.file);
|
|
371
|
+
if (err) return { ok: false, error: err };
|
|
372
|
+
if (!p.range || (typeof p.range === 'string' && !p.range.trim())) {
|
|
373
|
+
return { ok: false, error: 'Line range is required (e.g. "10-20" or "15").' };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const rangeStr = String(p.range).trim();
|
|
377
|
+
const rangeMatch = rangeStr.match(/^(\d+)(?:-(\d+))?$/);
|
|
378
|
+
if (!rangeMatch) {
|
|
379
|
+
return { ok: false, error: `Invalid line range: "${p.range}". Expected format: <start>-<end> or <line>.` };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const rawStart = parseInt(rangeMatch[1], 10);
|
|
383
|
+
const rawEnd = rangeMatch[2] !== undefined ? parseInt(rangeMatch[2], 10) : rawStart;
|
|
384
|
+
if (rawStart < 1 || rawEnd < 1) {
|
|
385
|
+
return { ok: false, error: 'Invalid line range: line numbers must be >= 1.' };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Auto-swap reversed ranges
|
|
389
|
+
const startLine = Math.min(rawStart, rawEnd);
|
|
390
|
+
const endLine = Math.max(rawStart, rawEnd);
|
|
391
|
+
|
|
392
|
+
const filePath = index.findFile(p.file);
|
|
393
|
+
if (!filePath) {
|
|
394
|
+
return { ok: false, error: `File not found in project: ${p.file}` };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
398
|
+
const fileLines = content.split('\n');
|
|
399
|
+
|
|
400
|
+
if (startLine > fileLines.length) {
|
|
401
|
+
return { ok: false, error: `Line ${startLine} is out of bounds. File has ${fileLines.length} lines.` };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const actualEnd = Math.min(endLine, fileLines.length);
|
|
405
|
+
const extracted = [];
|
|
406
|
+
for (let i = startLine - 1; i < actualEnd; i++) {
|
|
407
|
+
extracted.push(fileLines[i]);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
ok: true,
|
|
412
|
+
result: {
|
|
413
|
+
filePath,
|
|
414
|
+
relativePath: path.relative(index.root, filePath),
|
|
415
|
+
lines: extracted,
|
|
416
|
+
startLine,
|
|
417
|
+
endLine: actualEnd,
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
},
|
|
421
|
+
|
|
243
422
|
// ── File Dependencies ───────────────────────────────────────────────
|
|
244
423
|
|
|
245
424
|
imports: (index, p) => {
|
|
@@ -347,6 +526,24 @@ const HANDLERS = {
|
|
|
347
526
|
});
|
|
348
527
|
return { ok: true, result };
|
|
349
528
|
},
|
|
529
|
+
|
|
530
|
+
// ── Expand (context drill-down) ──────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
expand: (index, p) => {
|
|
533
|
+
if (p.itemNum == null || isNaN(p.itemNum)) {
|
|
534
|
+
return { ok: false, error: 'Item number is required.' };
|
|
535
|
+
}
|
|
536
|
+
if (!p.match) {
|
|
537
|
+
if (p.itemCount > 0) {
|
|
538
|
+
const scopeHint = p.symbolName ? ` (from context for "${p.symbolName}")` : '';
|
|
539
|
+
return { ok: false, error: `Item ${p.itemNum} not found${scopeHint}. Available: 1-${p.itemCount}` };
|
|
540
|
+
}
|
|
541
|
+
return { ok: false, error: 'No expandable items. Run context first.' };
|
|
542
|
+
}
|
|
543
|
+
const rendered = renderExpandItem(p.match, index.root, { validateRoot: p.validateRoot || false });
|
|
544
|
+
if (!rendered.ok) return { ok: false, error: rendered.error };
|
|
545
|
+
return { ok: true, result: rendered };
|
|
546
|
+
},
|
|
350
547
|
};
|
|
351
548
|
|
|
352
549
|
// ============================================================================
|
|
@@ -373,4 +570,4 @@ function execute(index, command, params = {}) {
|
|
|
373
570
|
}
|
|
374
571
|
}
|
|
375
572
|
|
|
376
|
-
module.exports = { execute
|
|
573
|
+
module.exports = { execute };
|
package/core/expand-cache.js
CHANGED
|
@@ -47,7 +47,13 @@ class ExpandCache {
|
|
|
47
47
|
oldestKey = k;
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
-
if (oldestKey)
|
|
50
|
+
if (oldestKey) {
|
|
51
|
+
this.entries.delete(oldestKey);
|
|
52
|
+
// Clean up lastKey if it pointed to the evicted entry
|
|
53
|
+
for (const [r, k] of this.lastKey) {
|
|
54
|
+
if (k === oldestKey) { this.lastKey.delete(r); break; }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
this.entries.set(key, { items, root, symbolName: name, usedAt: Date.now() });
|
|
@@ -68,25 +74,30 @@ class ExpandCache {
|
|
|
68
74
|
const recent = recentKey ? this.entries.get(recentKey) : null;
|
|
69
75
|
|
|
70
76
|
if (recent && recent.items) {
|
|
71
|
-
recent.usedAt = Date.now();
|
|
72
77
|
const match = recent.items.find(i => i.num === itemNum);
|
|
73
78
|
if (match) {
|
|
79
|
+
recent.usedAt = Date.now();
|
|
74
80
|
return { match, itemCount: recent.items.length, symbolName: recent.symbolName };
|
|
75
81
|
}
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
// Fallback: scan all entries for this project
|
|
79
85
|
let maxCount = recent?.items?.length || 0;
|
|
86
|
+
let foundEntry = null;
|
|
80
87
|
for (const [, cached] of this.entries) {
|
|
81
88
|
if (cached.root === root && cached.items) {
|
|
82
|
-
cached.usedAt = Date.now();
|
|
83
89
|
maxCount = Math.max(maxCount, cached.items.length);
|
|
84
90
|
const found = cached.items.find(i => i.num === itemNum);
|
|
85
|
-
if (found) {
|
|
86
|
-
|
|
91
|
+
if (found && !foundEntry) {
|
|
92
|
+
foundEntry = { match: found, cached };
|
|
87
93
|
}
|
|
88
94
|
}
|
|
89
95
|
}
|
|
96
|
+
if (foundEntry) {
|
|
97
|
+
// Only refresh the entry that actually contains the match
|
|
98
|
+
foundEntry.cached.usedAt = Date.now();
|
|
99
|
+
return { match: foundEntry.match, itemCount: maxCount, symbolName: foundEntry.cached.symbolName };
|
|
100
|
+
}
|
|
90
101
|
|
|
91
102
|
return {
|
|
92
103
|
match: null,
|