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.
- package/.claude/skills/ucn/SKILL.md +31 -7
- package/README.md +89 -50
- package/cli/index.js +199 -94
- package/core/account.js +3 -1
- package/core/analysis.js +322 -304
- package/core/bridge.js +13 -8
- package/core/cache.js +109 -19
- package/core/callers.js +969 -77
- package/core/check.js +19 -8
- package/core/deadcode.js +368 -40
- package/core/discovery.js +31 -11
- package/core/entrypoints.js +149 -17
- package/core/execute.js +330 -43
- package/core/graph-build.js +61 -10
- package/core/graph.js +282 -61
- package/core/imports.js +72 -3
- package/core/output/analysis-ext.js +70 -10
- package/core/output/analysis.js +52 -33
- package/core/output/check.js +4 -1
- package/core/output/endpoints.js +8 -3
- package/core/output/extraction.js +12 -1
- package/core/output/find.js +23 -9
- package/core/output/graph.js +32 -9
- package/core/output/refactoring.js +147 -49
- package/core/output/reporting.js +104 -5
- package/core/output/search.js +30 -3
- package/core/output/shared.js +31 -4
- package/core/output/tracing.js +22 -11
- package/core/parser.js +8 -6
- package/core/project.js +167 -7
- package/core/registry.js +20 -16
- package/core/reporting.js +131 -13
- package/core/search.js +270 -55
- package/core/shared.js +240 -1
- package/core/stacktrace.js +23 -1
- package/core/tracing.js +278 -36
- package/core/verify.js +352 -349
- package/languages/go.js +29 -17
- package/languages/index.js +56 -0
- package/languages/java.js +133 -16
- package/languages/javascript.js +215 -27
- package/languages/python.js +113 -47
- package/languages/rust.js +89 -26
- package/languages/utils.js +35 -7
- package/mcp/server.js +69 -30
- package/package.json +4 -1
package/core/graph-build.js
CHANGED
|
@@ -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.
|
|
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.
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
156
|
+
const results = [...importers].map(importerPath => {
|
|
125
157
|
const fileEntry = index.files.get(importerPath);
|
|
126
158
|
|
|
127
|
-
//
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
403
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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;
|