ucn 3.8.17 → 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.
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');
@@ -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
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/search.js CHANGED
@@ -865,6 +865,64 @@ function tests(index, nameOrFile, options = {}) {
865
865
  }
866
866
 
867
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
+
868
926
  const matches = [];
869
927
 
870
928
  lines.forEach((line, idx) => {
@@ -885,23 +943,23 @@ function tests(index, nameOrFile, options = {}) {
885
943
  }
886
944
 
887
945
  // Match-level className scoping: for call matches,
888
- // the class name must appear on the SAME line as receiver
889
- // (e.g., "new ClassName().method()" or "className.method()").
946
+ // the class name or a bound instance variable must appear
947
+ // on the same line as the method call.
890
948
  if (classReceiverPattern && matchType === 'call') {
891
- if (!classNameFilter.test(line)) return; // skip — different receiver
949
+ if (!lineHasClassReceiver(line, idx)) return; // skip — different receiver
892
950
  }
893
951
 
894
952
  // For reference matches, check same line or ±1 line.
895
953
  if (classReceiverPattern && matchType === 'reference') {
896
- let classNearby = classNameFilter.test(line);
897
- if (!classNearby && idx > 0) classNearby = classNameFilter.test(lines[idx - 1]);
898
- if (!classNearby && idx + 1 < lines.length) classNearby = classNameFilter.test(lines[idx + 1]);
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);
899
957
  if (!classNearby) return; // skip this match
900
958
  }
901
959
 
902
960
  // For test-case matches with className, keep if the test
903
- // description mentions the class or the test body (next few
904
- // lines) references the class as a receiver.
961
+ // description mentions the class or the test body
962
+ // references the class (directly or via bound instance).
905
963
  if (classNameFilter && matchType === 'test-case') {
906
964
  let classInContext = classNameFilter.test(line);
907
965
  if (!classInContext) {
@@ -911,7 +969,7 @@ function tests(index, nameOrFile, options = {}) {
911
969
  const bodyLine = lines[idx + d];
912
970
  // Stop at next test-case boundary
913
971
  if (/\b(describe|it|test|spec)\s*\(/.test(bodyLine)) break;
914
- if (classNameFilter.test(bodyLine)) classInContext = true;
972
+ if (lineHasClassReceiver(bodyLine, idx + d)) classInContext = true;
915
973
  }
916
974
  }
917
975
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.8.17",
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",