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.
@@ -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
- * Does NOT handle: output formatting, expand caching, file I/O commands.
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 { addTestExclusions } = require('./shared');
14
-
15
- // Commands handled directly by adapters (not in HANDLERS below).
16
- // fn, class, lines need raw file content / line-range logic.
17
- // expand needs per-session cache state that differs by surface.
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, ADAPTER_ONLY_COMMANDS };
573
+ module.exports = { execute };
@@ -47,7 +47,13 @@ class ExpandCache {
47
47
  oldestKey = k;
48
48
  }
49
49
  }
50
- if (oldestKey) this.entries.delete(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
- return { match: found, itemCount: maxCount, symbolName: cached.symbolName };
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,