ucn 3.7.4 → 3.7.5

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 CHANGED
@@ -1819,14 +1819,21 @@ Commands:
1819
1819
  about <name> Everything about a symbol
1820
1820
  usages <name> All usages grouped by type
1821
1821
  context <name> Callers + callees
1822
+ expand <N> Show code for item N from context
1822
1823
  smart <name> Function + dependencies
1823
1824
  impact <name> What breaks if changed
1824
1825
  trace <name> Call tree
1826
+ example <name> Best usage example
1827
+ related <name> Sibling functions
1825
1828
  imports <file> What file imports
1826
1829
  exporters <file> Who imports file
1827
1830
  tests <name> Find tests
1828
1831
  search <term> Text search
1829
1832
  typedef <name> Find type definitions
1833
+ deadcode Find unused functions/classes
1834
+ verify <name> Check call sites match signature
1835
+ plan <name> Preview refactoring
1836
+ stacktrace <text> Parse a stack trace
1830
1837
  api Show public symbols
1831
1838
  diff-impact What changed and who's affected
1832
1839
  stats Index statistics
@@ -1913,7 +1920,7 @@ function executeInteractiveCommand(index, command, arg) {
1913
1920
  console.log('Usage: context <name>');
1914
1921
  return;
1915
1922
  }
1916
- const ctx = index.context(arg, { includeUncertain: flags.includeUncertain });
1923
+ const ctx = index.context(arg, { includeUncertain: flags.includeUncertain, includeMethods: flags.includeMethods });
1917
1924
  if (!ctx) {
1918
1925
  console.log(`Symbol "${arg}" not found.`);
1919
1926
  } else {
@@ -2036,6 +2043,100 @@ function executeInteractiveCommand(index, command, arg) {
2036
2043
  break;
2037
2044
  }
2038
2045
 
2046
+ case 'expand': {
2047
+ if (!arg) {
2048
+ console.log('Usage: expand <number>');
2049
+ return;
2050
+ }
2051
+ const expandNum = parseInt(arg, 10);
2052
+ if (isNaN(expandNum)) {
2053
+ console.log(`Invalid item number: "${arg}"`);
2054
+ return;
2055
+ }
2056
+ const cached = loadExpandableItems(index.root);
2057
+ if (!cached || !cached.items || cached.items.length === 0) {
2058
+ console.log('No expandable items. Run context first.');
2059
+ return;
2060
+ }
2061
+ const expandMatch = cached.items.find(i => i.num === expandNum);
2062
+ if (!expandMatch) {
2063
+ console.log(`Item ${expandNum} not found. Available: 1-${cached.items.length}`);
2064
+ return;
2065
+ }
2066
+ printExpandedItem(expandMatch, cached.root || index.root);
2067
+ break;
2068
+ }
2069
+
2070
+ case 'deadcode': {
2071
+ const deadResult = index.deadcode({
2072
+ includeExported: flags.includeExported,
2073
+ includeDecorated: flags.includeDecorated,
2074
+ includeTests: flags.includeTests
2075
+ });
2076
+ console.log(output.formatDeadcode(deadResult));
2077
+ break;
2078
+ }
2079
+
2080
+ case 'related': {
2081
+ if (!arg) {
2082
+ console.log('Usage: related <name>');
2083
+ return;
2084
+ }
2085
+ const relResult = index.related(arg, { file: flags.file });
2086
+ console.log(output.formatRelated(relResult));
2087
+ break;
2088
+ }
2089
+
2090
+ case 'example': {
2091
+ if (!arg) {
2092
+ console.log('Usage: example <name>');
2093
+ return;
2094
+ }
2095
+ console.log(output.formatExample(index.example(arg), arg));
2096
+ break;
2097
+ }
2098
+
2099
+ case 'plan': {
2100
+ if (!arg) {
2101
+ console.log('Usage: plan <name> [--add-param=x] [--remove-param=x] [--rename-to=x]');
2102
+ return;
2103
+ }
2104
+ if (!flags.addParam && !flags.removeParam && !flags.renameTo) {
2105
+ console.log('Plan requires an operation: --add-param, --remove-param, or --rename-to');
2106
+ return;
2107
+ }
2108
+ const planResult = index.plan(arg, {
2109
+ addParam: flags.addParam,
2110
+ removeParam: flags.removeParam,
2111
+ renameTo: flags.renameTo,
2112
+ defaultValue: flags.defaultValue,
2113
+ file: flags.file
2114
+ });
2115
+ console.log(output.formatPlan(planResult));
2116
+ break;
2117
+ }
2118
+
2119
+ case 'verify': {
2120
+ if (!arg) {
2121
+ console.log('Usage: verify <name>');
2122
+ return;
2123
+ }
2124
+ const verifyResult = index.verify(arg, { file: flags.file });
2125
+ console.log(output.formatVerify(verifyResult));
2126
+ break;
2127
+ }
2128
+
2129
+ case 'stacktrace':
2130
+ case 'stack': {
2131
+ if (!arg) {
2132
+ console.log('Usage: stacktrace <stack text>');
2133
+ return;
2134
+ }
2135
+ const stackResult = index.parseStackTrace(arg);
2136
+ console.log(output.formatStackTrace(stackResult));
2137
+ break;
2138
+ }
2139
+
2039
2140
  default:
2040
2141
  console.log(`Unknown command: ${command}. Type "help" for available commands.`);
2041
2142
  }
package/core/project.js CHANGED
@@ -1412,7 +1412,8 @@ class ProjectIndex {
1412
1412
  });
1413
1413
  }
1414
1414
  } catch (e) {
1415
- // Skip files that can't be processed
1415
+ // Expected: minified files exceed tree-sitter buffer, binary files fail to parse.
1416
+ // These are not actionable errors — silently skip.
1416
1417
  }
1417
1418
  }
1418
1419
 
@@ -1848,6 +1849,8 @@ class ProjectIndex {
1848
1849
 
1849
1850
  return result;
1850
1851
  } catch (e) {
1852
+ // Expected: file read/parse failures (minified, binary, buffer exceeded).
1853
+ // Return empty callees rather than crashing the entire query.
1851
1854
  return [];
1852
1855
  }
1853
1856
  }
@@ -1956,6 +1959,8 @@ class ProjectIndex {
1956
1959
  cleanHtmlScriptTags(extracted, detectLanguage(symbol.file));
1957
1960
  return extracted.join('\n');
1958
1961
  } catch (e) {
1962
+ // Expected: file may have been deleted or become unreadable since indexing.
1963
+ // Return empty string rather than crashing.
1959
1964
  return '';
1960
1965
  }
1961
1966
  }
@@ -2354,6 +2359,9 @@ class ProjectIndex {
2354
2359
  // Note: no 'g' flag - we only need to test for presence per line
2355
2360
  // The 'i' flag is kept for case-insensitive matching
2356
2361
  const regex = new RegExp('\\b' + escapeRegExp(searchTerm) + '\\b', 'i');
2362
+ // Pre-compile patterns used inside per-line loop
2363
+ const callPattern = new RegExp(escapeRegExp(searchTerm) + '\\s*\\(');
2364
+ const strPattern = new RegExp("['\"`]" + escapeRegExp(searchTerm) + "['\"`]");
2357
2365
 
2358
2366
  for (const { path: testPath, entry } of testFiles) {
2359
2367
  try {
@@ -2368,12 +2376,11 @@ class ProjectIndex {
2368
2376
  matchType = 'test-case';
2369
2377
  } else if (/\b(import|require|from)\b/.test(line)) {
2370
2378
  matchType = 'import';
2371
- } else if (new RegExp(searchTerm + '\\s*\\(').test(line)) {
2379
+ } else if (callPattern.test(line)) {
2372
2380
  matchType = 'call';
2373
2381
  }
2374
2382
  // Detect if the match is inside a string literal (e.g., 'parseFile' or "parseFile")
2375
2383
  if (matchType === 'reference' || matchType === 'call') {
2376
- const strPattern = new RegExp("['\"`]" + escapeRegExp(searchTerm) + "['\"`]");
2377
2384
  if (strPattern.test(line)) {
2378
2385
  matchType = 'string-ref';
2379
2386
  }
@@ -4409,7 +4416,8 @@ class ProjectIndex {
4409
4416
  });
4410
4417
  }
4411
4418
  } catch (e) {
4412
- // Skip unreadable files
4419
+ // Expected: binary/minified files fail to read or parse.
4420
+ // These are not actionable errors — silently skip.
4413
4421
  }
4414
4422
  }
4415
4423
 
package/mcp/server.js CHANGED
@@ -158,6 +158,31 @@ function toolError(message) {
158
158
  return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
159
159
  }
160
160
 
161
+ /**
162
+ * Resolve a file path via index and validate it's within the project root.
163
+ * Returns the resolved absolute path string, or a toolError response.
164
+ */
165
+ function resolveAndValidatePath(index, file) {
166
+ const resolved = index.resolveFilePathForQuery(file);
167
+ if (typeof resolved !== 'string') {
168
+ if (resolved.error === 'file-ambiguous') {
169
+ return toolError(`Ambiguous file "${file}". Candidates:\n${resolved.candidates.map(c => ' ' + c).join('\n')}`);
170
+ }
171
+ return toolError(`File not found: ${file}`);
172
+ }
173
+ // Path boundary check: ensure resolved path is within the project root
174
+ try {
175
+ const realPath = fs.realpathSync(resolved);
176
+ const realRoot = fs.realpathSync(index.root);
177
+ if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
178
+ return toolError(`File is outside project root: ${file}`);
179
+ }
180
+ } catch (e) {
181
+ return toolError(`Cannot resolve file path: ${file}`);
182
+ }
183
+ return resolved;
184
+ }
185
+
161
186
  function requireName(name) {
162
187
  if (!name || !name.trim()) {
163
188
  return toolError('Symbol name is required.');
@@ -465,6 +490,9 @@ server.registerTool(
465
490
  }
466
491
 
467
492
  const match = matches.length > 1 ? pickBestDefinition(matches) : matches[0];
493
+ // Validate file is within project root
494
+ const fnPathCheck = resolveAndValidatePath(index, match.relativePath || path.relative(index.root, match.file));
495
+ if (typeof fnPathCheck !== 'string') return fnPathCheck;
468
496
  const code = fs.readFileSync(match.file, 'utf-8');
469
497
  const codeLines = code.split('\n');
470
498
  const fnCode = codeLines.slice(match.startLine - 1, match.endLine).join('\n');
@@ -490,6 +518,9 @@ server.registerTool(
490
518
  }
491
519
 
492
520
  const match = matches.length > 1 ? pickBestDefinition(matches) : matches[0];
521
+ // Validate file is within project root
522
+ const clsPathCheck = resolveAndValidatePath(index, match.relativePath || path.relative(index.root, match.file));
523
+ if (typeof clsPathCheck !== 'string') return clsPathCheck;
493
524
 
494
525
  const code = fs.readFileSync(match.file, 'utf-8');
495
526
  const codeLines = code.split('\n');
@@ -542,13 +573,8 @@ server.registerTool(
542
573
  return toolError('Line range is required (e.g. "10-20" or "15").');
543
574
  }
544
575
  const index = getIndex(project_dir);
545
- const resolved = index.resolveFilePathForQuery(file);
546
- if (typeof resolved !== 'string') {
547
- if (resolved.error === 'file-ambiguous') {
548
- return toolError(`Ambiguous file "${file}". Candidates:\n${resolved.candidates.map(c => ' ' + c).join('\n')}`);
549
- }
550
- return toolError(`File not found: ${file}`);
551
- }
576
+ const resolved = resolveAndValidatePath(index, file);
577
+ if (typeof resolved !== 'string') return resolved; // toolError response
552
578
  const filePath = resolved;
553
579
 
554
580
  const parts = range.split('-');
@@ -631,6 +657,16 @@ server.registerTool(
631
657
  if (!filePath || !fs.existsSync(filePath)) {
632
658
  return toolError(`Cannot locate file for ${match.name}`);
633
659
  }
660
+ // Validate file is within project root
661
+ try {
662
+ const realPath = fs.realpathSync(filePath);
663
+ const realRoot = fs.realpathSync(index.root);
664
+ if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
665
+ return toolError(`File is outside project root: ${match.name}`);
666
+ }
667
+ } catch (e) {
668
+ return toolError(`Cannot resolve file path for ${match.name}`);
669
+ }
634
670
 
635
671
  const content = fs.readFileSync(filePath, 'utf-8');
636
672
  const fileLines = content.split('\n');
@@ -732,6 +768,10 @@ server.registerTool(
732
768
  }
733
769
 
734
770
  case 'diff_impact': {
771
+ // Validate git ref format to prevent argument injection
772
+ if (base && !/^[a-zA-Z0-9._\-~\/^@{}:]+$/.test(base)) {
773
+ return toolError(`Invalid git ref format: ${base}`);
774
+ }
735
775
  const index = getIndex(project_dir);
736
776
  const result = index.diffImpact({
737
777
  base: base || 'HEAD',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.7.4",
3
+ "version": "3.7.5",
4
4
  "description": "Universal Code Navigator — AST-based call graph analysis for AI agents. Find callers, trace impact, detect dead code across JS/TS, Python, Go, Rust, Java, and HTML. CLI, MCP server, and agent skill.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -411,6 +411,59 @@ const tests = [
411
411
  args: { command: 'imports', project_dir: PROJECT_DIR, file: 'utils.js' },
412
412
  assert: (res, text, isError) => (isError && /ambiguous/i.test(text)) || 'Expected file-ambiguous error for utils.js'
413
413
  },
414
+
415
+ // ========================================================================
416
+ // CATEGORY 3: Security (path traversal, argument injection)
417
+ // ========================================================================
418
+ {
419
+ category: 'Security',
420
+ tool: 'ucn',
421
+ desc: 'lines rejects path traversal (../../../../etc/passwd)',
422
+ args: { command: 'lines', project_dir: PROJECT_DIR, file: '../../../../etc/passwd', range: '1-5' },
423
+ assert: (res, text, isError) => (isError && (/not found/i.test(text) || /outside project/i.test(text))) || 'Expected error for path traversal'
424
+ },
425
+ {
426
+ category: 'Security',
427
+ tool: 'ucn',
428
+ desc: 'lines rejects path traversal (../../other-project/secret.js)',
429
+ args: { command: 'lines', project_dir: PROJECT_DIR, file: '../../other-project/secret.js', range: '1-5' },
430
+ assert: (res, text, isError) => (isError && (/not found/i.test(text) || /outside project/i.test(text))) || 'Expected error for path traversal'
431
+ },
432
+ {
433
+ category: 'Security',
434
+ tool: 'ucn',
435
+ desc: 'lines works with valid file',
436
+ args: { command: 'lines', project_dir: PROJECT_DIR, file: 'core/discovery.js', range: '1-3' },
437
+ assert: (res, text, isError) => (!isError && text.length > 0) || 'Expected valid output for core/discovery.js'
438
+ },
439
+ {
440
+ category: 'Security',
441
+ tool: 'ucn',
442
+ desc: 'diff_impact rejects --config argument injection',
443
+ args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: '--config=malicious' },
444
+ assert: (res, text, isError) => (isError && /invalid git ref/i.test(text)) || 'Expected error for argument injection in base'
445
+ },
446
+ {
447
+ category: 'Security',
448
+ tool: 'ucn',
449
+ desc: 'diff_impact rejects -o flag injection',
450
+ args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: '-o /tmp/evil' },
451
+ assert: (res, text, isError) => (isError && /invalid git ref/i.test(text)) || 'Expected error for flag injection in base'
452
+ },
453
+ {
454
+ category: 'Security',
455
+ tool: 'ucn',
456
+ desc: 'diff_impact accepts valid ref HEAD~3',
457
+ args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: 'HEAD~3' },
458
+ assert: (res, text, isError) => true // Should not error on valid ref format
459
+ },
460
+ {
461
+ category: 'Security',
462
+ tool: 'ucn',
463
+ desc: 'diff_impact accepts valid ref origin/main',
464
+ args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: 'origin/main' },
465
+ assert: (res, text, isError) => true // Should not error on valid ref format
466
+ },
414
467
  ];
415
468
 
416
469
  // ============================================================================
@@ -13025,5 +13025,59 @@ it('FIX 99 — parseDiff handles quoted paths with special characters', () => {
13025
13025
  'Literal backslash-n in filename must be preserved, not converted to newline');
13026
13026
  });
13027
13027
 
13028
+ // ============================================================================
13029
+ // Interactive Mode Tests (Fix #100 — missing commands)
13030
+ // ============================================================================
13031
+
13032
+ describe('Interactive Mode', () => {
13033
+ const { execFileSync } = require('child_process');
13034
+ const cliPath = path.join(__dirname, '..', 'cli', 'index.js');
13035
+
13036
+ it('supports all commands without errors', () => {
13037
+ // Test each previously-missing command by piping into interactive mode
13038
+ // Each should not crash and should produce some output (not "Unknown command")
13039
+ const commands = [
13040
+ 'deadcode',
13041
+ 'related processData',
13042
+ 'example processData',
13043
+ 'verify processData',
13044
+ 'expand 1', // Will say "no expandable items" but won't crash
13045
+ ];
13046
+
13047
+ const input = commands.join('\n') + '\nquit\n';
13048
+
13049
+ const result = execFileSync('node', [cliPath, '--interactive', '.'], {
13050
+ input,
13051
+ encoding: 'utf-8',
13052
+ cwd: path.join(__dirname, '..'),
13053
+ timeout: 30000,
13054
+ stdio: ['pipe', 'pipe', 'pipe']
13055
+ });
13056
+
13057
+ // Verify no "Unknown command" errors for the previously-missing commands
13058
+ assert.ok(!result.includes('Unknown command: deadcode'), 'deadcode should be recognized in interactive mode');
13059
+ assert.ok(!result.includes('Unknown command: related'), 'related should be recognized in interactive mode');
13060
+ assert.ok(!result.includes('Unknown command: example'), 'example should be recognized in interactive mode');
13061
+ assert.ok(!result.includes('Unknown command: verify'), 'verify should be recognized in interactive mode');
13062
+ assert.ok(!result.includes('Unknown command: expand'), 'expand should be recognized in interactive mode');
13063
+ });
13064
+
13065
+ it('help lists all commands', () => {
13066
+ const result = execFileSync('node', [cliPath, '--interactive', '.'], {
13067
+ input: 'help\nquit\n',
13068
+ encoding: 'utf-8',
13069
+ cwd: path.join(__dirname, '..'),
13070
+ timeout: 30000,
13071
+ stdio: ['pipe', 'pipe', 'pipe']
13072
+ });
13073
+
13074
+ // Verify the help text includes all commands (including newly added ones)
13075
+ const expectedCommands = ['expand', 'deadcode', 'related', 'example', 'verify', 'plan', 'stacktrace'];
13076
+ for (const cmd of expectedCommands) {
13077
+ assert.ok(result.includes(cmd), `Interactive help should list "${cmd}"`);
13078
+ }
13079
+ });
13080
+ });
13081
+
13028
13082
  console.log('UCN v3 Test Suite');
13029
13083
  console.log('Run with: node --test test/parser.test.js');