ucn 4.0.2 → 4.1.0

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.
Files changed (46) hide show
  1. package/.claude/skills/ucn/SKILL.md +31 -7
  2. package/README.md +89 -50
  3. package/cli/index.js +199 -94
  4. package/core/account.js +3 -1
  5. package/core/analysis.js +322 -304
  6. package/core/bridge.js +13 -8
  7. package/core/cache.js +109 -19
  8. package/core/callers.js +969 -77
  9. package/core/check.js +19 -8
  10. package/core/deadcode.js +368 -40
  11. package/core/discovery.js +31 -11
  12. package/core/entrypoints.js +149 -17
  13. package/core/execute.js +330 -43
  14. package/core/graph-build.js +61 -10
  15. package/core/graph.js +282 -61
  16. package/core/imports.js +72 -3
  17. package/core/output/analysis-ext.js +70 -10
  18. package/core/output/analysis.js +52 -33
  19. package/core/output/check.js +4 -1
  20. package/core/output/endpoints.js +8 -3
  21. package/core/output/extraction.js +12 -1
  22. package/core/output/find.js +23 -9
  23. package/core/output/graph.js +32 -9
  24. package/core/output/refactoring.js +147 -49
  25. package/core/output/reporting.js +104 -5
  26. package/core/output/search.js +30 -3
  27. package/core/output/shared.js +31 -4
  28. package/core/output/tracing.js +22 -11
  29. package/core/parser.js +8 -6
  30. package/core/project.js +167 -7
  31. package/core/registry.js +20 -16
  32. package/core/reporting.js +131 -13
  33. package/core/search.js +270 -55
  34. package/core/shared.js +240 -1
  35. package/core/stacktrace.js +23 -1
  36. package/core/tracing.js +278 -36
  37. package/core/verify.js +352 -349
  38. package/languages/go.js +29 -17
  39. package/languages/index.js +56 -0
  40. package/languages/java.js +133 -16
  41. package/languages/javascript.js +215 -27
  42. package/languages/python.js +113 -47
  43. package/languages/rust.js +89 -26
  44. package/languages/utils.js +35 -7
  45. package/mcp/server.js +69 -30
  46. package/package.json +4 -1
@@ -30,8 +30,11 @@ function buildDirIndex(index) {
30
30
  * Resolve a Java package import to a project file.
31
31
  * Handles regular imports, static imports (strips member name), and wildcards (strips .*).
32
32
  * Progressively strips trailing segments to find the class file.
33
+ * With `opts.all`, returns an ARRAY of files: for a package wildcard
34
+ * (com.pkg.*) that's every file directly in the package — Java wildcard
35
+ * imports pull in the whole package, and they are NOT recursive.
33
36
  */
34
- function _resolveJavaPackageImport(index, importModule, javaFileIndex) {
37
+ function _resolveJavaPackageImport(index, importModule, javaFileIndex, opts = {}) {
35
38
  const isWildcard = importModule.endsWith('.*');
36
39
  // Strip wildcard suffix (e.g., "com.pkg.Class.*" -> "com.pkg.Class")
37
40
  const mod = isWildcard ? importModule.slice(0, -2) : importModule;
@@ -48,7 +51,7 @@ function _resolveJavaPackageImport(index, importModule, javaFileIndex) {
48
51
  const fileSuffix = '/' + segments.slice(0, i).join('/') + '.java';
49
52
  for (const absPath of candidates) {
50
53
  if (absPath.endsWith(fileSuffix)) {
51
- return absPath;
54
+ return opts.all ? [absPath] : absPath;
52
55
  }
53
56
  }
54
57
  }
@@ -59,24 +62,31 @@ function _resolveJavaPackageImport(index, importModule, javaFileIndex) {
59
62
  const fileSuffix = '/' + segments.slice(0, i).join('/') + '.java';
60
63
  for (const absPath of index.files.keys()) {
61
64
  if (absPath.endsWith(fileSuffix)) {
62
- return absPath;
65
+ return opts.all ? [absPath] : absPath;
63
66
  }
64
67
  }
65
68
  }
66
69
  }
67
70
 
68
71
  // For wildcard imports (com.pkg.model.*), the package may be a directory
69
- // containing .java files. Check if any file lives under this package path.
72
+ // containing .java files. Match files DIRECTLY in the package directory
73
+ // a bare `includes()` also matched subpackage files, but Java wildcards
74
+ // are not recursive.
70
75
  if (isWildcard) {
71
- const dirSuffix = '/' + segments.join('/') + '/';
76
+ const dirSuffix = '/' + segments.join('/');
77
+ const matches = [];
72
78
  for (const absPath of index.files.keys()) {
73
- if (absPath.includes(dirSuffix)) {
74
- return absPath;
79
+ if (absPath.endsWith('.java') && path.dirname(absPath).endsWith(dirSuffix)) {
80
+ matches.push(absPath);
81
+ if (!opts.all) break;
75
82
  }
76
83
  }
84
+ if (matches.length > 0) {
85
+ return opts.all ? matches : matches[0];
86
+ }
77
87
  }
78
88
 
79
- return null;
89
+ return opts.all ? [] : null;
80
90
  }
81
91
 
82
92
  /**
@@ -130,15 +140,27 @@ function buildImportGraph(index) {
130
140
 
131
141
  // Java package imports: resolve by progressive suffix matching
132
142
  // Handles regular, static (com.pkg.Class.method), and wildcard (com.pkg.Class.*) imports
143
+ let javaWildcardFiles = null;
133
144
  if (!resolved && fileEntry.language === 'java' && !importModule.startsWith('.')) {
134
- resolved = _resolveJavaPackageImport(index, importModule, javaFileIndex);
145
+ if (importModule.endsWith('.*')) {
146
+ // A package wildcard depends on EVERY file in the package
147
+ // (the Go filesToLink analog) — linking only the first
148
+ // dropped dependency edges for the rest of the package.
149
+ const all = _resolveJavaPackageImport(index, importModule, javaFileIndex, { all: true });
150
+ if (all.length > 0) {
151
+ resolved = all[0];
152
+ if (all.length > 1) javaWildcardFiles = all;
153
+ }
154
+ } else {
155
+ resolved = _resolveJavaPackageImport(index, importModule, javaFileIndex);
156
+ }
135
157
  }
136
158
 
137
159
  if (resolved && index.files.has(resolved)) {
138
160
  moduleResolved[importModule] = path.relative(index.root, resolved);
139
161
  // For Go, a package import means all files in that directory are dependencies
140
162
  // (Go packages span multiple files in the same directory)
141
- const filesToLink = [resolved];
163
+ const filesToLink = javaWildcardFiles ? [...javaWildcardFiles] : [resolved];
142
164
  if (langTraits(fileEntry.language)?.packageScope === 'directory') {
143
165
  const pkgDir = path.dirname(resolved);
144
166
  const dirFiles = dirToGoFiles.get(pkgDir) || [];
@@ -161,6 +183,35 @@ function buildImportGraph(index) {
161
183
  }
162
184
  }
163
185
 
186
+ // From-import submodules (fix #224): `from . import jobs` binds
187
+ // jobs.py as a plain NAME — the parser can't know (a from-import name
188
+ // may be a symbol), the resolver can. Resolve the composed dotted
189
+ // specifier; a project-file hit records it in moduleResolved AND adds
190
+ // the import edge, so scope resolution and module-receiver ownership
191
+ // see the submodule exactly like `import jobs`.
192
+ if (langTraits(fileEntry.language)?.submoduleImports) {
193
+ for (const b of (fileEntry.importBindings || [])) {
194
+ if (!b || !b.name || b.module == null) continue;
195
+ const mod = String(b.module);
196
+ const spec = mod.endsWith('.') ? mod + b.name : mod + '.' + b.name;
197
+ if (moduleResolved[spec] || seenModules.has(spec)) continue;
198
+ seenModules.add(spec);
199
+ const resolved = resolveImport(spec, filePath, {
200
+ aliases: index.config.aliases,
201
+ language: fileEntry.language,
202
+ root: index.root
203
+ });
204
+ if (resolved && index.files.has(resolved)) {
205
+ moduleResolved[spec] = path.relative(index.root, resolved);
206
+ importedFiles.add(resolved);
207
+ if (!index.exportGraph.has(resolved)) {
208
+ index.exportGraph.set(resolved, new Set());
209
+ }
210
+ index.exportGraph.get(resolved).add(filePath);
211
+ }
212
+ }
213
+ }
214
+
164
215
  index.importGraph.set(filePath, importedFiles);
165
216
  fileEntry.moduleResolved = moduleResolved;
166
217
  }
package/core/graph.js CHANGED
@@ -8,6 +8,7 @@
8
8
  'use strict';
9
9
 
10
10
  const path = require('path');
11
+ const { codeUnitCompare } = require('./shared');
11
12
  const { extractImports, resolveImport } = require('./imports');
12
13
  const { langTraits } = require('../languages');
13
14
  const { isTestFile } = require('./discovery');
@@ -32,9 +33,12 @@ function imports(index, filePath) {
32
33
  const content = index._readFile(normalizedPath);
33
34
  const { imports: rawImports } = extractImports(content, fileEntry.language);
34
35
 
35
- const contentLines = content.split('\n');
36
-
37
36
  return rawImports.map(imp => {
37
+ // Every parser records the import's AST line; use it directly.
38
+ // (The old substring re-derivation matched 'os' inside 'osmosis',
39
+ // comments, and collapsed repeated modules to the first line.)
40
+ const line = imp.line ?? null;
41
+
38
42
  // Skip imports with null module (e.g. Rust include! with dynamic path)
39
43
  if (!imp.module) {
40
44
  return {
@@ -44,7 +48,7 @@ function imports(index, filePath) {
44
48
  resolved: null,
45
49
  isExternal: false,
46
50
  isDynamic: true,
47
- line: null
51
+ line
48
52
  };
49
53
  }
50
54
 
@@ -53,13 +57,6 @@ function imports(index, filePath) {
53
57
  // Go side-effect/dot imports and Rust glob uses also set dynamic=true but have valid module paths.
54
58
  const isUnresolvableDynamic = imp.dynamic && (imp.type === 'require' || imp.type === 'dynamic');
55
59
  if (isUnresolvableDynamic) {
56
- let line = null;
57
- for (let i = 0; i < contentLines.length; i++) {
58
- if (contentLines[i].includes(imp.module || 'require')) {
59
- line = i + 1;
60
- break;
61
- }
62
- }
63
60
  return {
64
61
  module: imp.module,
65
62
  names: imp.names,
@@ -83,22 +80,16 @@ function imports(index, filePath) {
83
80
  resolvedPath = index._resolveJavaPackageImport(imp.module);
84
81
  }
85
82
 
86
- // Find line number of import
87
- let line = null;
88
- for (let i = 0; i < contentLines.length; i++) {
89
- if (contentLines[i].includes(imp.module)) {
90
- line = i + 1;
91
- break;
92
- }
93
- }
94
-
95
83
  return {
96
84
  module: imp.module,
97
85
  names: imp.names,
98
86
  type: imp.type,
99
87
  resolved: resolvedPath ? path.relative(index.root, resolvedPath) : null,
100
88
  isExternal: !resolvedPath,
101
- isDynamic: false,
89
+ // A string-literal dynamic import (import('./x'), importlib.import_module("x"))
90
+ // is still mechanically dynamic even when the path resolves —
91
+ // `type: 'dynamic'` with `isDynamic: false` was a contradiction.
92
+ isDynamic: imp.type === 'dynamic',
102
93
  line
103
94
  };
104
95
  });
@@ -107,6 +98,45 @@ function imports(index, filePath) {
107
98
  }
108
99
  }
109
100
 
101
+ /**
102
+ * Decide whether a symbol is exported, using evidence at the right scope:
103
+ * the file-level export list speaks for TOP-LEVEL names only — a struct
104
+ * field or method sharing an exported function's name is not itself exported.
105
+ * Member symbols (className set) are judged by their OWN visibility marker:
106
+ * export/public modifiers, Rust `pub`/`pub(crate)`, or Go capitalization.
107
+ */
108
+ function symbolIsExported(symbol, fileEntry, exportedNames) {
109
+ const modifiers = symbol.modifiers || [];
110
+ if (modifiers.includes('export') || modifiers.includes('public')) return true;
111
+ if (modifiers.some(m => typeof m === 'string' && /^pub\b/.test(m))) return true;
112
+ if (langTraits(fileEntry.language)?.exportVisibility === 'capitalization' &&
113
+ /^[A-Z]/.test(symbol.name || '')) return true;
114
+ // Rust trait-impl members cannot carry `pub` (the compiler forbids it) —
115
+ // their visibility IS the implementing type's (fix #251:
116
+ // `impl Default for Config` methods of a pub Config are publicly
117
+ // callable but were listed nowhere).
118
+ if (symbol.traitImpl && symbol.className && fileEntry.language === 'rust') {
119
+ for (const s of fileEntry.symbols || []) {
120
+ if (s.name === symbol.className &&
121
+ ['struct', 'enum', 'class', 'type'].includes(s.type)) {
122
+ return (s.modifiers || []).some(m => typeof m === 'string' && /^pub\b/.test(m));
123
+ }
124
+ }
125
+ return exportedNames.has(symbol.className);
126
+ }
127
+ if (symbol.className) return false;
128
+ return exportedNames.has(symbol.name);
129
+ }
130
+
131
+ /**
132
+ * Signature for export listings: members render as `Class.name(...)` so a
133
+ * Go/Rust/Java method is distinguishable from a free function of the same name.
134
+ */
135
+ function formatExportSignature(index, symbol) {
136
+ if (!symbol.className) return index.formatSignature(symbol);
137
+ return index.formatSignature({ ...symbol, name: `${symbol.className}.${symbol.name}` });
138
+ }
139
+
110
140
  /**
111
141
  * Get files that import a given file
112
142
  * @param {object} index - ProjectIndex instance
@@ -120,39 +150,72 @@ function exporters(index, filePath) {
120
150
  const targetPath = resolved;
121
151
 
122
152
  const importers = index.exportGraph.get(targetPath) || new Set();
153
+ const targetRel = path.relative(index.root, targetPath);
154
+ const targetDir = path.dirname(targetRel);
123
155
 
124
- return [...importers].map(importerPath => {
156
+ const results = [...importers].map(importerPath => {
125
157
  const fileEntry = index.files.get(importerPath);
126
158
 
127
- // Find the import line
159
+ // Locate the import statement via the importer's own parsed import
160
+ // records: fileEntry.moduleResolved (built with the import graph) maps
161
+ // each module string to the project file it resolved to, and the
162
+ // parser records each import's AST line. (The old basename+'import'
163
+ // substring heuristic matched comments, returned null for Rust
164
+ // use/mod, and misfired on prose like 'important'.)
128
165
  let importLine = null;
129
- try {
130
- const content = index._readFile(importerPath);
131
- const lines = content.split('\n');
132
- let targetBasename = path.basename(targetPath, path.extname(targetPath));
133
-
134
- // For __init__.py, search for the package name (parent dir)
135
- // e.g., "from tools import X" search for "tools" not "__init__"
136
- if (targetBasename === '__init__') {
137
- targetBasename = path.basename(path.dirname(targetPath));
138
- }
139
-
140
- for (let i = 0; i < lines.length; i++) {
141
- if (lines[i].includes(targetBasename) &&
142
- (lines[i].includes('import') || lines[i].includes('require') || lines[i].includes('from'))) {
143
- importLine = i + 1;
144
- break;
166
+ let importModule = null;
167
+ const mr = (fileEntry && fileEntry.moduleResolved) || {};
168
+ let matchModules = Object.keys(mr).filter(m => mr[m] === targetRel);
169
+ if (matchModules.length === 0) {
170
+ // Directory-level links: a Go package import (or Java wildcard)
171
+ // links every file in the target's directory the module string
172
+ // resolved to a sibling, but the statement still covers the target.
173
+ matchModules = Object.keys(mr).filter(m => {
174
+ if (path.dirname(mr[m]) !== targetDir) return false;
175
+ const lang = fileEntry.language;
176
+ if (langTraits(lang)?.packageScope === 'directory') return true;
177
+ if (lang === 'java' && m.endsWith('.*')) return true;
178
+ return false;
179
+ });
180
+ }
181
+ if (matchModules.length > 0 && fileEntry) {
182
+ try {
183
+ const content = index._readFile(importerPath);
184
+ const { imports: rawImports } = extractImports(content, fileEntry.language);
185
+ const wanted = new Set(matchModules);
186
+ const submodules = langTraits(fileEntry.language)?.submoduleImports;
187
+ for (const imp of rawImports) {
188
+ if (!imp.module) continue;
189
+ let matched = wanted.has(imp.module) ? imp.module : null;
190
+ // Python from-import submodules resolve under a composed
191
+ // spec (`from . import jobs` → '.jobs', fix #224) that the
192
+ // raw record stores as module '.' + name 'jobs'.
193
+ if (!matched && submodules && imp.names) {
194
+ for (const n of imp.names) {
195
+ const spec = imp.module.endsWith('.') ? imp.module + n : imp.module + '.' + n;
196
+ if (wanted.has(spec)) { matched = spec; break; }
197
+ }
198
+ }
199
+ if (matched && imp.line != null &&
200
+ (importLine === null || imp.line < importLine)) {
201
+ importLine = imp.line;
202
+ importModule = matched;
203
+ }
145
204
  }
205
+ } catch (e) {
206
+ // Skip — file unreadable, leave importLine null
146
207
  }
147
- } catch (e) {
148
- // Skip
149
208
  }
150
209
 
151
210
  return {
152
211
  file: fileEntry ? fileEntry.relativePath : path.relative(index.root, importerPath),
153
- importLine
212
+ importLine,
213
+ ...(importModule !== null && { module: importModule })
154
214
  };
155
215
  });
216
+
217
+ results.sort((a, b) => codeUnitCompare(a.file, b.file));
218
+ return results;
156
219
  }
157
220
 
158
221
  /**
@@ -179,6 +242,21 @@ function fileExports(index, filePath, _visited) {
179
242
  const results = [];
180
243
  const exportedNames = new Set(fileEntry.exports);
181
244
 
245
+ // Names exported ONLY under an alias (`export { foo as myFoo }`, no
246
+ // plain export) are importable only AS the alias — the alias entry is
247
+ // added from exportDetails below; the bare name would be a lie (fix #245).
248
+ const aliasedAway = new Set();
249
+ if (fileEntry.exportDetails) {
250
+ const plainClauseNames = new Set(fileEntry.exportDetails
251
+ .filter(e => e.type === 'named' && !e.source && !e.alias)
252
+ .map(e => e.name));
253
+ for (const e of fileEntry.exportDetails) {
254
+ if (e.type === 'named' && !e.source && e.alias && !plainClauseNames.has(e.name)) {
255
+ aliasedAway.add(e.name);
256
+ }
257
+ }
258
+ }
259
+
182
260
  // Python convention: when a module declares no `__all__`, every top-level
183
261
  // non-`_` name is considered public. We don't want this in the underlying
184
262
  // export list (deadcode would think everything is exported), so fileExports
@@ -186,10 +264,11 @@ function fileExports(index, filePath, _visited) {
186
264
  const isPythonImplicit = fileEntry.language === 'python' && exportedNames.size === 0;
187
265
 
188
266
  for (const symbol of fileEntry.symbols) {
189
- const isExported = exportedNames.has(symbol.name) ||
190
- (symbol.modifiers && symbol.modifiers.includes('export')) ||
191
- (symbol.modifiers && symbol.modifiers.includes('public')) ||
192
- (langTraits(fileEntry.language)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(symbol.name)) ||
267
+ // impl blocks group members; the block itself is not an exportable
268
+ // symbol (its name collides with the struct's, which IS listed).
269
+ if (symbol.type === 'impl') continue;
270
+ if (aliasedAway.has(symbol.name) && !(symbol.modifiers || []).includes('export')) continue;
271
+ const isExported = symbolIsExported(symbol, fileEntry, exportedNames) ||
193
272
  (isPythonImplicit && symbol.name && !symbol.name.startsWith('_') && !symbol.className && !symbol.isMethod);
194
273
 
195
274
  if (isExported) {
@@ -199,9 +278,10 @@ function fileExports(index, filePath, _visited) {
199
278
  file: fileEntry.relativePath,
200
279
  startLine: symbol.startLine,
201
280
  endLine: symbol.endLine,
281
+ ...(symbol.className && { className: symbol.className }),
202
282
  params: symbol.params,
203
283
  returnType: symbol.returnType,
204
- signature: index.formatSignature(symbol)
284
+ signature: formatExportSignature(index, symbol)
205
285
  });
206
286
  }
207
287
  }
@@ -243,16 +323,32 @@ function fileExports(index, filePath, _visited) {
243
323
  for (const srcExp of sourceExportsResult) {
244
324
  if (!matchedNames.has(srcExp.name)) {
245
325
  matchedNames.add(srcExp.name);
246
- results.push({ ...srcExp, file: fileEntry.relativePath, reExportedFrom: srcExp.file });
326
+ // The entry belongs to the BARREL file
327
+ // its line is the `export *` statement,
328
+ // never the source's line numbers (fix
329
+ // #245: barrel.ts:9-11 on a 1-line file).
330
+ results.push({
331
+ ...srcExp,
332
+ file: fileEntry.relativePath,
333
+ startLine: exp.line,
334
+ endLine: exp.line,
335
+ reExportedFrom: srcExp.file,
336
+ });
247
337
  }
248
338
  }
249
339
  } else {
250
- // Named re-export: find the specific symbol
340
+ // Named re-export: find the specific symbol.
341
+ // Consumers import the ALIAS when one exists —
342
+ // `export { foo as myFoo } from './lib'` is
343
+ // importable only as myFoo (fix #245); the
344
+ // source-side name stays in sourceName.
345
+ const displayName = exp.alias || exp.name;
251
346
  const srcSymbol = sourceEntry.symbols.find(s => s.name === exp.name);
252
347
  if (srcSymbol) {
253
348
  matchedNames.add(exp.name);
254
349
  results.push({
255
- name: exp.name,
350
+ name: displayName,
351
+ ...(exp.alias && { sourceName: exp.name }),
256
352
  type: srcSymbol.type,
257
353
  file: fileEntry.relativePath,
258
354
  startLine: exp.line,
@@ -266,7 +362,8 @@ function fileExports(index, filePath, _visited) {
266
362
  // Symbol not found in source — still list it as a re-export
267
363
  matchedNames.add(exp.name);
268
364
  results.push({
269
- name: exp.name,
365
+ name: displayName,
366
+ ...(exp.alias && { sourceName: exp.name }),
270
367
  type: 're-export',
271
368
  file: fileEntry.relativePath,
272
369
  startLine: exp.line,
@@ -282,6 +379,61 @@ function fileExports(index, filePath, _visited) {
282
379
  }
283
380
  }
284
381
  }
382
+
383
+ // Local export clauses: `export { foo, bar }` / `export { foo as
384
+ // myFoo }`. A clause-exported CONST has no isVariable flag and no
385
+ // symbol entry, and a two-step barrel (`import { foo } from './lib';
386
+ // export { foo };`) matched nothing — both rendered empty (fix
387
+ // #245). Aliased local exports list under the ALIAS.
388
+ for (const exp of fileEntry.exportDetails) {
389
+ if (exp.type !== 'named' || exp.source || exp.isVariable) continue;
390
+ const displayName = exp.alias || exp.name;
391
+ if (matchedNames.has(displayName)) continue;
392
+ if (!exp.alias && matchedNames.has(exp.name)) continue;
393
+ const localSym = fileEntry.symbols.find(s => s.name === exp.name && !s.className);
394
+ if (localSym) {
395
+ matchedNames.add(displayName);
396
+ results.push({
397
+ name: displayName,
398
+ ...(exp.alias && { sourceName: exp.name }),
399
+ type: localSym.type,
400
+ file: fileEntry.relativePath,
401
+ startLine: localSym.startLine,
402
+ endLine: localSym.endLine,
403
+ params: localSym.params,
404
+ returnType: localSym.returnType,
405
+ signature: index.formatSignature(localSym)
406
+ });
407
+ continue;
408
+ }
409
+ // Two-step barrel: the name is an import binding — resolve it
410
+ const binding = (fileEntry.importBindings || []).find(b => b.name === exp.name);
411
+ const resolvedRel = binding && fileEntry.moduleResolved && fileEntry.moduleResolved[binding.module];
412
+ let srcSymbol = null, srcRel = null;
413
+ if (resolvedRel) {
414
+ for (const [, fe2] of index.files) {
415
+ if (fe2.relativePath === resolvedRel) {
416
+ srcSymbol = fe2.symbols.find(s => s.name === exp.name && !s.className) || null;
417
+ srcRel = fe2.relativePath;
418
+ break;
419
+ }
420
+ }
421
+ }
422
+ matchedNames.add(displayName);
423
+ results.push({
424
+ name: displayName,
425
+ ...(exp.alias && { sourceName: exp.name }),
426
+ type: srcSymbol ? srcSymbol.type : 'variable',
427
+ file: fileEntry.relativePath,
428
+ startLine: exp.line,
429
+ endLine: exp.line,
430
+ params: srcSymbol ? srcSymbol.params : undefined,
431
+ returnType: srcSymbol ? srcSymbol.returnType : null,
432
+ signature: srcSymbol ? index.formatSignature(srcSymbol)
433
+ : `export ${exp.name}${exp.alias ? ' as ' + exp.alias : ''}`,
434
+ ...(srcRel && { reExportedFrom: srcRel })
435
+ });
436
+ }
285
437
  }
286
438
 
287
439
  // Python __all__ re-exports: names listed in __all__ that come from imports
@@ -399,21 +551,18 @@ function api(index, filePath, options = {}) {
399
551
  const exportedNames = new Set(fileEntry.exports);
400
552
 
401
553
  for (const symbol of fileEntry.symbols) {
402
- const isExported = exportedNames.has(symbol.name) ||
403
- (symbol.modifiers && symbol.modifiers.includes('export')) ||
404
- (symbol.modifiers && symbol.modifiers.includes('public')) ||
405
- (langTraits(fileEntry.language)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(symbol.name));
406
-
407
- if (isExported) {
554
+ if (symbol.type === 'impl') continue;
555
+ if (symbolIsExported(symbol, fileEntry, exportedNames)) {
408
556
  results.push({
409
557
  name: symbol.name,
410
558
  type: symbol.type,
411
559
  file: fileEntry.relativePath,
412
560
  startLine: symbol.startLine,
413
561
  endLine: symbol.endLine,
562
+ ...(symbol.className && { className: symbol.className }),
414
563
  params: symbol.params,
415
564
  returnType: symbol.returnType,
416
- signature: index.formatSignature(symbol)
565
+ signature: formatExportSignature(index, symbol)
417
566
  });
418
567
  }
419
568
  }
@@ -434,11 +583,52 @@ function api(index, filePath, options = {}) {
434
583
  returnType: exp.typeAnnotation || null,
435
584
  signature: sig
436
585
  });
586
+ matchedNames.add(exp.name);
587
+ }
588
+ }
589
+ // The fix #245 fileExports discipline, api side (fix #251 — the
590
+ // two commands diverged on the same file): consumers import the
591
+ // ALIAS, and clause-exported names with no indexed symbol
592
+ // (class/function expressions) are still API surface.
593
+ for (const exp of fileEntry.exportDetails) {
594
+ if (!exp || !exp.name || exp.module) continue;
595
+ if (exp.alias && exp.alias !== exp.name) {
596
+ const entry = results.find(r =>
597
+ r.file === fileEntry.relativePath && r.name === exp.name && !r.sourceName);
598
+ if (entry) {
599
+ entry.sourceName = exp.name;
600
+ entry.name = exp.alias;
601
+ if (entry.signature) {
602
+ entry.signature = entry.signature.replace(exp.name, exp.alias);
603
+ }
604
+ matchedNames.add(exp.alias);
605
+ continue;
606
+ }
607
+ }
608
+ const shown = exp.alias || exp.name;
609
+ if (!matchedNames.has(shown) && !matchedNames.has(exp.name) &&
610
+ exportedNames.has(exp.name)) {
611
+ results.push({
612
+ name: shown,
613
+ ...(exp.alias && exp.alias !== exp.name && { sourceName: exp.name }),
614
+ type: 'export',
615
+ file: fileEntry.relativePath,
616
+ startLine: exp.line || 1,
617
+ endLine: exp.line || 1,
618
+ params: undefined,
619
+ returnType: null,
620
+ signature: shown,
621
+ });
622
+ matchedNames.add(shown);
437
623
  }
438
624
  }
439
625
  }
440
626
  }
441
627
 
628
+ // Rule 11: (file, line) ordering regardless of parse order — file mode
629
+ // used to emit symbols in extraction order (fix #251).
630
+ results.sort((a, b) => codeUnitCompare(a.file, b.file) ||
631
+ (a.startLine - b.startLine) || codeUnitCompare(a.name, b.name));
442
632
  return results;
443
633
  }
444
634
 
@@ -479,6 +669,9 @@ function graph(index, filePath, options = {}) {
479
669
  const visited = new Set();
480
670
  const nodes = [];
481
671
  const edges = [];
672
+ let truncated = false;
673
+
674
+ const cutNodes = [];
482
675
 
483
676
  const traverse = (file, depth) => {
484
677
  if (visited.has(file)) return;
@@ -488,8 +681,13 @@ function graph(index, filePath, options = {}) {
488
681
  const relPath = fileEntry ? fileEntry.relativePath : path.relative(index.root, file);
489
682
  nodes.push({ file, relativePath: relPath, depth });
490
683
 
491
- // Stop traversal at max depth but still register the node above
492
- if (depth >= maxDepth) return;
684
+ // Stop traversal at max depth but still register the node above.
685
+ // Edges from cut nodes are resolved in a post-pass (below) so the
686
+ // outcome never depends on visit order.
687
+ if (depth >= maxDepth) {
688
+ cutNodes.push(file);
689
+ return;
690
+ }
493
691
 
494
692
  const neighbors = dir === 'imports'
495
693
  ? (index.importGraph.get(file) || new Set())
@@ -502,7 +700,26 @@ function graph(index, filePath, options = {}) {
502
700
  };
503
701
 
504
702
  traverse(targetPath, 0);
505
- return { nodes, edges };
703
+
704
+ // Deterministic cut-frontier pass (fix #245): a cut node's edge to a
705
+ // node ALREADY in the result needs no deeper traversal — emit it
706
+ // (the diamond b→a edge used to vanish); only neighbors genuinely
707
+ // outside the result mark the graph depth-truncated. The in-traversal
708
+ // peek made both outcomes depend on import order.
709
+ for (const file of cutNodes) {
710
+ const neighbors = dir === 'imports'
711
+ ? (index.importGraph.get(file) || new Set())
712
+ : (index.exportGraph.get(file) || new Set());
713
+ for (const neighbor of neighbors) {
714
+ if (visited.has(neighbor)) {
715
+ edges.push({ from: file, to: neighbor });
716
+ } else {
717
+ truncated = true;
718
+ }
719
+ }
720
+ }
721
+
722
+ return { nodes, edges, truncated };
506
723
  };
507
724
 
508
725
  if (direction === 'both') {
@@ -513,6 +730,8 @@ function graph(index, filePath, options = {}) {
513
730
  return {
514
731
  root: targetPath,
515
732
  direction: 'both',
733
+ maxDepth,
734
+ depthTruncated: importsGraph.truncated || importersGraph.truncated,
516
735
  imports: { nodes: importsGraph.nodes, edges: importsGraph.edges },
517
736
  importers: { nodes: importersGraph.nodes, edges: importersGraph.edges },
518
737
  // Keep combined for backward compat
@@ -526,6 +745,8 @@ function graph(index, filePath, options = {}) {
526
745
  return {
527
746
  root: targetPath,
528
747
  direction,
748
+ maxDepth,
749
+ depthTruncated: subgraph.truncated,
529
750
  nodes: subgraph.nodes,
530
751
  edges: subgraph.edges
531
752
  };
@@ -608,7 +829,7 @@ function circularDeps(index, options = {}) {
608
829
  result = uniqueCycles.filter(c => c.files.some(f => f.includes(fileFilter)));
609
830
  }
610
831
 
611
- result.sort((a, b) => a.length - b.length || a.files[0].localeCompare(b.files[0]));
832
+ result.sort((a, b) => a.length - b.length || codeUnitCompare(a.files[0], b.files[0]));
612
833
 
613
834
  // Count files that participate in import graph (have edges)
614
835
  let filesWithImports = 0;