ucn 3.8.16 → 3.8.18

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.
@@ -157,11 +157,11 @@ ucn [target] <command> [name] [--flags]
157
157
 
158
158
  | Flag | When to use it |
159
159
  |------|---------------|
160
- | `--class-name=X` | Scope to specific class (e.g., `--class-name=Repository` for method `save`) |
160
+ | `--class-name=X` | Scope to specific class (e.g., `--class-name=Repository` for method `save`). Works with `fn`, `about`, `context`, `impact`, `tests`, `example`, `typedef`, `verify`, `plan` |
161
161
  | `--file=<pattern>` | Disambiguate when a name exists in multiple files (e.g., `--file=api`) |
162
162
  | `--exclude=test,mock` | Focus on production code only |
163
163
  | `--in=src/core` | Limit search to a subdirectory |
164
- | `--depth=N` | Control tree depth for `trace` and `graph` (default 3). Also expands all children — no breadth limit |
164
+ | `--depth=N` | Control tree depth for `trace`, `graph`, and detail level for `find` (default 3). Also expands all children — no breadth limit |
165
165
  | `--all` | Expand truncated sections in `about`, `trace`, `graph`, `related` |
166
166
  | `--include-tests` | Include test files in results (excluded by default) |
167
167
  | `--include-methods` | Include `obj.method()` calls in `context`/`smart` (only direct calls shown by default) |
package/cli/index.js CHANGED
@@ -10,11 +10,10 @@
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
 
13
- const { parseFile, detectLanguage } = require('../core/parser');
13
+ const { detectLanguage } = require('../core/parser');
14
14
  const { ProjectIndex } = require('../core/project');
15
15
  const { expandGlob, findProjectRoot } = require('../core/discovery');
16
16
  const output = require('../core/output');
17
- const { escapeRegExp } = require('../core/shared');
18
17
  const { getCliCommandSet, resolveCommand, FLAG_APPLICABILITY, toCliName } = require('../core/registry');
19
18
  const { execute } = require('../core/execute');
20
19
  const { ExpandCache } = require('../core/expand-cache');
@@ -658,7 +657,7 @@ function runProjectCommand(rootDir, command, arg) {
658
657
 
659
658
  case 'class': {
660
659
  requireArg(arg, 'Usage: ucn . class <name>');
661
- const { ok, result, error, note } = execute(index, 'class', { name: arg, file: flags.file, all: flags.all, maxLines: flags.maxLines, className: flags.className });
660
+ const { ok, result, error, note } = execute(index, 'class', { name: arg, file: flags.file, all: flags.all, maxLines: flags.maxLines });
662
661
  if (!ok) fail(error);
663
662
  if (note) console.error(note);
664
663
  printOutput(result, output.formatClassResultJson, output.formatClassResult);
@@ -703,7 +702,7 @@ function runProjectCommand(rootDir, command, arg) {
703
702
  const { ok, result, error } = execute(index, 'fileExports', { file: filePath });
704
703
  if (!ok) fail(error);
705
704
  printOutput(result,
706
- output.formatFileExportsJson,
705
+ r => output.formatFileExportsJson(r, filePath),
707
706
  r => output.formatFileExports(r, filePath)
708
707
  );
709
708
  break;
@@ -904,164 +903,85 @@ function runGlobCommand(pattern, command, arg) {
904
903
  process.exit(1);
905
904
  }
906
905
 
907
- switch (command) {
908
- case 'toc': {
909
- let totalFunctions = 0;
910
- let totalClasses = 0;
911
- let totalState = 0;
912
- let totalLines = 0;
913
- const byFile = [];
914
-
915
- for (const file of files) {
916
- try {
917
- const result = parseFile(file);
918
- let functions = result.functions;
919
- if (flags.topLevel) {
920
- functions = functions.filter(fn => !fn.isNested && (!fn.indent || fn.indent === 0));
921
- }
922
- totalFunctions += functions.length;
923
- totalClasses += result.classes.length;
924
- totalState += result.stateObjects.length;
925
- totalLines += result.totalLines;
926
- byFile.push({
927
- file,
928
- language: result.language,
929
- lines: result.totalLines,
930
- functions,
931
- classes: result.classes,
932
- state: result.stateObjects
933
- });
934
- } catch (e) {
935
- // Skip unparseable files
936
- }
937
- }
938
-
939
- // Convert glob toc to shared formatter format
940
- const toc = {
941
- totals: { files: files.length, lines: totalLines, functions: totalFunctions, classes: totalClasses, state: totalState },
942
- files: byFile.map(f => ({
943
- file: f.file,
944
- lines: f.lines,
945
- functions: f.functions.length,
946
- classes: f.classes.length,
947
- state: f.stateObjects ? f.stateObjects.length : (f.state ? f.state.length : 0)
948
- })),
949
- meta: {}
950
- };
951
- if (flags.json) {
952
- console.log(output.formatTocJson(toc));
953
- } else {
954
- console.log(output.formatToc(toc, {
955
- detailedHint: 'Add --detailed to list all functions, or "ucn . about <name>" for full details on a symbol'
956
- }));
957
- }
958
- break;
959
- }
960
-
961
- case 'find':
962
- if (!arg) {
963
- console.error('Usage: ucn "pattern" find <name>');
964
- process.exit(1);
965
- }
966
- findInGlobFiles(files, arg);
967
- break;
906
+ const canonical = resolveCommand(command, 'cli') || command;
968
907
 
969
- case 'search':
970
- if (!arg) {
971
- console.error('Usage: ucn "pattern" search <term>');
972
- process.exit(1);
973
- }
974
- searchGlobFiles(files, arg);
975
- break;
908
+ // Build a temporary index over the matched files and route through execute().
909
+ // This gives glob mode the same semantics as project mode: test exclusions,
910
+ // limit, all flags — no bespoke logic, no parity drift.
911
+ const rootDir = findProjectRoot(path.dirname(files[0]));
912
+ const index = new ProjectIndex(rootDir);
913
+ index.build(files, { quiet: true });
976
914
 
977
- default:
978
- console.error(`Command "${command}" not supported in glob mode`);
979
- process.exit(1);
915
+ // Supported commands — anything that works with an index
916
+ const supportedGlobCommands = new Set(['toc', 'find', 'search', 'fn', 'class', 'usages', 'deadcode', 'typedef', 'stats']);
917
+ if (!supportedGlobCommands.has(canonical)) {
918
+ console.error(`Command "${command}" not supported in glob mode. Supported: ${[...supportedGlobCommands].join(', ')}`);
919
+ process.exit(1);
980
920
  }
981
- }
982
-
983
- function findInGlobFiles(files, name) {
984
- const allMatches = [];
985
- const lowerName = name.toLowerCase();
986
-
987
- for (const file of files) {
988
- try {
989
- const result = parseFile(file);
990
-
991
- for (const fn of result.functions) {
992
- if (flags.exact ? fn.name === name : fn.name.toLowerCase().includes(lowerName)) {
993
- allMatches.push({ ...fn, type: 'function', relativePath: file });
994
- }
995
- }
996
921
 
997
- for (const cls of result.classes) {
998
- if (flags.exact ? cls.name === name : cls.name.toLowerCase().includes(lowerName)) {
999
- allMatches.push({ ...cls, relativePath: file });
1000
- }
1001
- }
1002
- } catch (e) {
1003
- // Skip
922
+ // Build params same as project mode
923
+ const params = {};
924
+ if (['find', 'usages', 'fn', 'class', 'typedef'].includes(canonical)) {
925
+ if (!arg) {
926
+ console.error(`Usage: ucn "pattern" ${command} <name>`);
927
+ process.exit(1);
1004
928
  }
929
+ params.name = arg;
1005
930
  }
1006
-
1007
- if (flags.json) {
1008
- console.log(output.formatSymbolJson(allMatches, name));
1009
- } else {
1010
- console.log(output.formatFindDetailed(allMatches, name, { depth: flags.depth, top: flags.top, all: flags.all }));
1011
- }
1012
- }
1013
-
1014
- function searchGlobFiles(files, term) {
1015
- const results = [];
1016
- const useRegex = flags.regex !== false; // Default: regex ON
1017
- let regex;
1018
- if (useRegex) {
1019
- try { regex = new RegExp(term, flags.caseSensitive ? '' : 'i'); } catch (e) { regex = new RegExp(escapeRegExp(term), flags.caseSensitive ? '' : 'i'); }
1020
- } else {
1021
- regex = new RegExp(escapeRegExp(term), flags.caseSensitive ? '' : 'i');
931
+ if (canonical === 'search') {
932
+ if (!arg && !flags.type) {
933
+ console.error('Usage: ucn "pattern" search <term>');
934
+ process.exit(1);
935
+ }
936
+ params.term = arg;
1022
937
  }
938
+ Object.assign(params, flags);
1023
939
 
1024
- for (const file of files) {
1025
- try {
1026
- const content = fs.readFileSync(file, 'utf-8');
1027
- const lines = content.split('\n');
1028
- const matches = [];
1029
-
1030
- lines.forEach((line, idx) => {
1031
- if (regex.test(line)) {
1032
- if (flags.codeOnly && isCommentOrString(line)) {
1033
- return;
1034
- }
1035
-
1036
- const match = { line: idx + 1, content: line };
1037
-
1038
- if (flags.context > 0) {
1039
- const before = [];
1040
- const after = [];
1041
- for (let i = 1; i <= flags.context; i++) {
1042
- if (idx - i >= 0) before.unshift(lines[idx - i]);
1043
- if (idx + i < lines.length) after.push(lines[idx + i]);
1044
- }
1045
- match.before = before;
1046
- match.after = after;
1047
- }
1048
-
1049
- matches.push(match);
1050
- }
1051
- });
940
+ const { ok, result, error, note, structural } = execute(index, canonical, params);
941
+ if (!ok) fail(error);
942
+ if (note) console.error(note);
1052
943
 
1053
- if (matches.length > 0) {
1054
- results.push({ file, matches });
944
+ // Format output same formatters as project mode
945
+ switch (canonical) {
946
+ case 'toc':
947
+ printOutput(result, output.formatTocJson, r => output.formatToc(r, {
948
+ detailedHint: 'Add --detailed to list all functions, or "ucn . about <name>" for full details on a symbol'
949
+ }));
950
+ break;
951
+ case 'find':
952
+ printOutput(result,
953
+ r => output.formatSymbolJson(r, arg),
954
+ r => output.formatFindDetailed(r, arg, { depth: flags.depth, top: flags.top, all: flags.all })
955
+ );
956
+ break;
957
+ case 'search':
958
+ if (structural) {
959
+ printOutput(result, output.formatStructuralSearchJson, output.formatStructuralSearch);
960
+ } else {
961
+ printOutput(result,
962
+ r => output.formatSearchJson(r, arg),
963
+ r => output.formatSearch(r, arg)
964
+ );
1055
965
  }
1056
- } catch (e) {
1057
- // Skip
1058
- }
1059
- }
1060
-
1061
- if (flags.json) {
1062
- console.log(output.formatSearchJson(results, term));
1063
- } else {
1064
- console.log(output.formatSearch(results, term));
966
+ break;
967
+ case 'fn':
968
+ printOutput(result, output.formatFnResultJson, output.formatFnResult);
969
+ break;
970
+ case 'class':
971
+ printOutput(result, output.formatClassResultJson, output.formatClassResult);
972
+ break;
973
+ case 'usages':
974
+ printOutput(result, r => output.formatUsagesJson(r, arg), r => output.formatUsages(r, arg));
975
+ break;
976
+ case 'deadcode':
977
+ printOutput(result, output.formatDeadcodeJson, r => output.formatDeadcode(r, { top: flags.top }));
978
+ break;
979
+ case 'typedef':
980
+ printOutput(result, r => output.formatTypedefJson(r, arg), r => output.formatTypedef(r, arg));
981
+ break;
982
+ case 'stats':
983
+ printOutput(result, output.formatStatsJson, r => output.formatStats(r, { top: flags.top }));
984
+ break;
1065
985
  }
1066
986
  }
1067
987
 
@@ -1069,13 +989,6 @@ function searchGlobFiles(files, term) {
1069
989
  // HELPERS
1070
990
  // ============================================================================
1071
991
 
1072
- function isCommentOrString(line) {
1073
- const trimmed = line.trim();
1074
- return trimmed.startsWith('//') ||
1075
- trimmed.startsWith('#') ||
1076
- trimmed.startsWith('*') ||
1077
- trimmed.startsWith('/*');
1078
- }
1079
992
 
1080
993
  function printUsage() {
1081
994
  console.log(`UCN - Universal Code Navigator
@@ -120,14 +120,14 @@ function formatFileExports(exports, filePath) {
120
120
  return lines.join('\n');
121
121
  }
122
122
 
123
- function formatFileExportsJson(result) {
123
+ function formatFileExportsJson(result, filePath) {
124
124
  if (!result) return JSON.stringify({ found: false });
125
+ // result is an array of exported symbols from index.fileExports()
125
126
  return JSON.stringify({
126
- meta: { command: 'fileExports', file: result.file },
127
+ meta: { command: 'fileExports', file: filePath || (Array.isArray(result) ? result[0]?.file : result.file) },
127
128
  data: {
128
- file: result.file,
129
- exports: result.exports || [],
130
- reExports: result.reExports || [],
129
+ file: filePath || (Array.isArray(result) ? result[0]?.file : result.file),
130
+ exports: Array.isArray(result) ? result : (result.exports || []),
131
131
  },
132
132
  }, null, 2);
133
133
  }
package/core/project.js CHANGED
@@ -158,28 +158,35 @@ class ProjectIndex {
158
158
  const startTime = Date.now();
159
159
  const quiet = options.quiet !== false;
160
160
 
161
- if (!pattern) {
162
- pattern = detectProjectPattern(this.root);
163
- }
161
+ // Accept pre-expanded file array (glob mode) or a pattern string
162
+ let files;
163
+ if (Array.isArray(pattern)) {
164
+ files = pattern;
165
+ } else {
166
+ if (!pattern) {
167
+ pattern = detectProjectPattern(this.root);
168
+ }
164
169
 
165
- const globOpts = {
166
- root: this.root,
167
- maxFiles: options.maxFiles || this.config.maxFiles || 50000,
168
- followSymlinks: options.followSymlinks
169
- };
170
+ const globOpts = {
171
+ root: this.root,
172
+ maxFiles: options.maxFiles || this.config.maxFiles || 50000,
173
+ followSymlinks: options.followSymlinks
174
+ };
170
175
 
171
- // Merge .gitignore and .ucn.json exclude into file discovery
172
- const gitignorePatterns = parseGitignore(this.root);
173
- const configExclude = this.config.exclude || [];
174
- if (gitignorePatterns.length > 0 || configExclude.length > 0) {
175
- globOpts.ignores = [...DEFAULT_IGNORES, ...gitignorePatterns, ...configExclude];
176
- }
176
+ // Merge .gitignore and .ucn.json exclude into file discovery
177
+ const gitignorePatterns = parseGitignore(this.root);
178
+ const configExclude = this.config.exclude || [];
179
+ if (gitignorePatterns.length > 0 || configExclude.length > 0) {
180
+ globOpts.ignores = [...DEFAULT_IGNORES, ...gitignorePatterns, ...configExclude];
181
+ }
177
182
 
178
- const files = expandGlob(pattern, globOpts);
183
+ files = expandGlob(pattern, globOpts);
184
+ }
179
185
 
180
186
  // Track if files were truncated by maxFiles limit
181
- if (files.length >= globOpts.maxFiles) {
182
- this.truncated = { indexed: files.length, maxFiles: globOpts.maxFiles };
187
+ const maxFiles = options.maxFiles || this.config.maxFiles || 50000;
188
+ if (!Array.isArray(pattern) && files.length >= maxFiles) {
189
+ this.truncated = { indexed: files.length, maxFiles };
183
190
  } else {
184
191
  this.truncated = null;
185
192
  }
package/core/registry.js CHANGED
@@ -116,7 +116,7 @@ const FLAG_APPLICABILITY = {
116
116
  entrypoints: ['file', 'exclude', 'includeTests', 'limit', 'type', 'framework'],
117
117
  // Extracting code
118
118
  fn: ['file', 'className', 'all'],
119
- class: ['file', 'className', 'all', 'maxLines'],
119
+ class: ['file', 'all', 'maxLines'],
120
120
  lines: ['file', 'range'],
121
121
  expand: [],
122
122
  // File dependencies
package/core/search.js CHANGED
@@ -845,11 +845,15 @@ function tests(index, nameOrFile, options = {}) {
845
845
  const callPattern = new RegExp(escapeRegExp(searchTerm) + '\\s*\\(');
846
846
  const strPattern = new RegExp("['\"`]" + escapeRegExp(searchTerm) + "['\"`]");
847
847
 
848
- // When className is provided, build a pattern to scope matches.
849
- // We require the test file to also reference the class name (import, instantiation, or receiver).
848
+ // When className is provided, build patterns for match-level scoping.
850
849
  const classNameFilter = options.className
851
850
  ? new RegExp('\\b' + escapeRegExp(options.className) + '\\b')
852
851
  : null;
852
+ // Match-level class scoping: for calls, check receiver or nearby context;
853
+ // e.g., "new ClassName().method()" or "instance.method()" where instance is typed.
854
+ const classReceiverPattern = options.className
855
+ ? new RegExp('\\b' + escapeRegExp(options.className) + '\\s*[\\.\\(]')
856
+ : null;
853
857
 
854
858
  for (const { path: testPath, entry } of testFiles) {
855
859
  try {
@@ -861,6 +865,64 @@ function tests(index, nameOrFile, options = {}) {
861
865
  }
862
866
 
863
867
  const lines = content.split('\n');
868
+
869
+ // Build a map of variable names → line ranges where they're bound to the target class.
870
+ // Tracks reassignment: a variable is only an instance of the target class between
871
+ // its assignment to that class and any subsequent reassignment to something else.
872
+ // Pre-compiles receiver regexes for O(1) per-line checks.
873
+ const instanceVarReceiverRegexes = []; // [{regex, fromLine, toLine}]
874
+ if (options.className) {
875
+ const cn = escapeRegExp(options.className);
876
+ const bindingPattern = new RegExp(
877
+ '(?:const|let|var|)\\s+(\\w+)\\s*=\\s*(?:new\\s+' + cn + '|' + cn + '\\.\\w+)\\s*\\(', 'g'
878
+ );
879
+ // Also detect reassignment to a DIFFERENT class/value
880
+ const reassignPattern = /(\w+)\s*=\s*(?:new\s+\w|[^=])/g;
881
+
882
+ // First pass: find all bindings to the target class
883
+ const bindings = []; // [{varName, line}]
884
+ for (let i = 0; i < lines.length; i++) {
885
+ let m;
886
+ bindingPattern.lastIndex = 0;
887
+ while ((m = bindingPattern.exec(lines[i])) !== null) {
888
+ bindings.push({ varName: m[1], line: i });
889
+ }
890
+ }
891
+
892
+ // Second pass: for each binding, find where the var is reassigned (scope end)
893
+ const escapedTerm = escapeRegExp(searchTerm);
894
+ for (const b of bindings) {
895
+ let toLine = lines.length; // default: valid until end of file
896
+ for (let i = b.line + 1; i < lines.length; i++) {
897
+ // Check if this variable is reassigned to something else
898
+ const line = lines[i];
899
+ if (new RegExp('\\b' + escapeRegExp(b.varName) + '\\s*=\\s*(?!\\s*=)').test(line)
900
+ && !bindingPattern.test(line)) {
901
+ toLine = i;
902
+ break;
903
+ }
904
+ }
905
+ instanceVarReceiverRegexes.push({
906
+ regex: new RegExp('\\b' + escapeRegExp(b.varName) + '\\.' + escapedTerm + '\\s*\\('),
907
+ nameRegex: new RegExp('\\b' + escapeRegExp(b.varName) + '\\b'),
908
+ fromLine: b.line,
909
+ toLine,
910
+ });
911
+ }
912
+ }
913
+
914
+ // Check if a line's receiver is the target class (direct or via bound variable).
915
+ function lineHasClassReceiver(line, lineIdx) {
916
+ if (classNameFilter.test(line)) return true;
917
+ // Check pre-compiled instance variable regexes (scoped to assignment range)
918
+ for (const entry of instanceVarReceiverRegexes) {
919
+ if (lineIdx >= entry.fromLine && lineIdx < entry.toLine) {
920
+ if (entry.regex.test(line) || entry.nameRegex.test(line)) return true;
921
+ }
922
+ }
923
+ return false;
924
+ }
925
+
864
926
  const matches = [];
865
927
 
866
928
  lines.forEach((line, idx) => {
@@ -880,6 +942,40 @@ function tests(index, nameOrFile, options = {}) {
880
942
  }
881
943
  }
882
944
 
945
+ // Match-level className scoping: for call matches,
946
+ // the class name or a bound instance variable must appear
947
+ // on the same line as the method call.
948
+ if (classReceiverPattern && matchType === 'call') {
949
+ if (!lineHasClassReceiver(line, idx)) return; // skip — different receiver
950
+ }
951
+
952
+ // For reference matches, check same line or ±1 line.
953
+ if (classReceiverPattern && matchType === 'reference') {
954
+ let classNearby = lineHasClassReceiver(line, idx);
955
+ if (!classNearby && idx > 0) classNearby = lineHasClassReceiver(lines[idx - 1], idx - 1);
956
+ if (!classNearby && idx + 1 < lines.length) classNearby = lineHasClassReceiver(lines[idx + 1], idx + 1);
957
+ if (!classNearby) return; // skip this match
958
+ }
959
+
960
+ // For test-case matches with className, keep if the test
961
+ // description mentions the class or the test body
962
+ // references the class (directly or via bound instance).
963
+ if (classNameFilter && matchType === 'test-case') {
964
+ let classInContext = classNameFilter.test(line);
965
+ if (!classInContext) {
966
+ // Look forward into the test body for class usage
967
+ for (let d = 1; d <= 5 && !classInContext; d++) {
968
+ if (idx + d < lines.length) {
969
+ const bodyLine = lines[idx + d];
970
+ // Stop at next test-case boundary
971
+ if (/\b(describe|it|test|spec)\s*\(/.test(bodyLine)) break;
972
+ if (lineHasClassReceiver(bodyLine, idx + d)) classInContext = true;
973
+ }
974
+ }
975
+ }
976
+ if (!classInContext) return; // skip test-case not related to this class
977
+ }
978
+
883
979
  matches.push({
884
980
  line: idx + 1,
885
981
  content: line.trim(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.8.16",
3
+ "version": "3.8.18",
4
4
  "mcpName": "io.github.mleoca/ucn",
5
5
  "description": "Code intelligence toolkit for AI agents — extract functions, trace call chains, find callers, detect dead code without reading entire files. Works as MCP server, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java.",
6
6
  "main": "index.js",