ucn 5.2.2 → 5.3.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 +49 -3
- package/.claude/skills/ucn/references/commands.md +3 -1
- package/README.md +158 -533
- package/cli/index.js +71 -10
- package/core/cache.js +22 -13
- package/core/callers.js +51 -52
- package/core/execute.js +24 -6
- package/core/graph.js +167 -35
- package/core/index-ir.js +12 -9
- package/core/output/graph.js +60 -11
- package/core/output/lines.js +259 -0
- package/core/output/public.js +15 -0
- package/core/output/reporting.js +9 -2
- package/core/output-budget.js +7 -4
- package/core/project.js +15 -2
- package/core/registry.js +7 -6
- package/core/reporting.js +159 -14
- package/languages/javascript.js +213 -6
- package/languages/python.js +115 -12
- package/mcp/server.js +3 -1
- package/package.json +2 -2
- package/assets/demo.svg +0 -31
package/core/reporting.js
CHANGED
|
@@ -133,41 +133,155 @@ function getStats(index, options = {}) {
|
|
|
133
133
|
// decide which definitions are capable of entering the requested top
|
|
134
134
|
// N. Exact pinned caller resolution is then run only until no unseen
|
|
135
135
|
// candidate can beat the current Nth result.
|
|
136
|
-
|
|
136
|
+
// A record whose receiver is provably EXTERNAL (fix #340) cannot
|
|
137
|
+
// confirm a project definition — the engine routes such calls
|
|
138
|
+
// external-package / possible-dispatch, never confirmed — so it leaves
|
|
139
|
+
// the bound. `t.Fatalf(...)` (receiverType T, qualifier `testing`)
|
|
140
|
+
// used to hand every project `Fatalf`/`Fatal`/`Errorf`/`String` a
|
|
141
|
+
// four-digit upper bound, so the early stop never fired and grpc-go's
|
|
142
|
+
// `repo` refined 1375 candidates. Resolution is the engine's own
|
|
143
|
+
// module physics: an import naming the qualifier that resolves to a
|
|
144
|
+
// project file keeps the record; a resolver gap keeps it; only an
|
|
145
|
+
// import that is non-relative, non-project, and unresolved drops it.
|
|
146
|
+
const { _unresolvedModuleIsGap } = require('./callers');
|
|
147
|
+
const externalQualifierMemo = new Map(); // filePath + qualifier -> boolean
|
|
148
|
+
const receiverProvablyExternal = (filePath, fileEntry, c) => {
|
|
149
|
+
if (!c.isMethod || !fileEntry) return false;
|
|
150
|
+
const qualifier = c.receiverTypeQualifier ||
|
|
151
|
+
(c.receiverIsModule && c.receiver) || null;
|
|
152
|
+
if (!qualifier || typeof qualifier !== 'string') return false;
|
|
153
|
+
const key = filePath + '\u0000' + qualifier;
|
|
154
|
+
const memo = externalQualifierMemo.get(key);
|
|
155
|
+
if (memo !== undefined) return memo;
|
|
156
|
+
const head = qualifier.split('.')[0];
|
|
157
|
+
const modules = new Set();
|
|
158
|
+
for (const binding of fileEntry.importBindings || []) {
|
|
159
|
+
if (binding.name === head || binding.alias === head) modules.add(binding.module);
|
|
160
|
+
}
|
|
161
|
+
for (const mod of fileEntry.imports || []) {
|
|
162
|
+
const text = String(mod || '');
|
|
163
|
+
if (text === head || text.split('/').pop() === head ||
|
|
164
|
+
text.split('.').pop() === head) modules.add(text);
|
|
165
|
+
}
|
|
166
|
+
let verdict = false;
|
|
167
|
+
if (modules.size > 0) {
|
|
168
|
+
verdict = true;
|
|
169
|
+
for (const mod of modules) {
|
|
170
|
+
if (fileEntry.moduleResolved?.[mod] || _unresolvedModuleIsGap(index, mod)) {
|
|
171
|
+
verdict = false;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
externalQualifierMemo.set(key, verdict);
|
|
177
|
+
return verdict;
|
|
178
|
+
};
|
|
179
|
+
// Per-DEFINITION bound (fix #340). A record with a trusted parser
|
|
180
|
+
// receiver type can confirm only that type's own same-name method,
|
|
181
|
+
// or an inherited/promoted one when that type defines none itself —
|
|
182
|
+
// so it is charged to that class alone (or to every definition when
|
|
183
|
+
// the class is not a definer). Untyped and convention-guessed
|
|
184
|
+
// receivers (#266: never exclusion evidence) stay charged to every
|
|
185
|
+
// definition. grpc-go: `String` has 278 same-name methods and `Close`
|
|
186
|
+
// 146; under the per-NAME bound every one of them inherited the whole
|
|
187
|
+
// name's ceiling and had to be refined.
|
|
188
|
+
const normalizeTypeName = (text) => String(text || '')
|
|
189
|
+
.replace(/^[*&\s]+/, '').replace(/[<[(].*$/, '').split('.').pop() || null;
|
|
190
|
+
const untypedByName = new Map(); // name -> records with no trusted receiver type
|
|
191
|
+
const typedByName = new Map(); // name -> Map<className, records typed to it>
|
|
192
|
+
const chargeRecord = (name, c) => {
|
|
193
|
+
const typed = c.isMethod && c.receiverType && !c.receiverTypeGuessed
|
|
194
|
+
? normalizeTypeName(c.receiverType) : null;
|
|
195
|
+
if (typed) {
|
|
196
|
+
let byClass = typedByName.get(name);
|
|
197
|
+
if (!byClass) { byClass = new Map(); typedByName.set(name, byClass); }
|
|
198
|
+
byClass.set(typed, (byClass.get(typed) || 0) + 1);
|
|
199
|
+
} else {
|
|
200
|
+
untypedByName.set(name, (untypedByName.get(name) || 0) + 1);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
// Fix #343: symbols and call sites inside inline test modules
|
|
204
|
+
// (`#[cfg(test)] mod tests` / `#[test]` fns, fix #244's ranges) are
|
|
205
|
+
// test code even though their FILE is production — ripgrep's
|
|
206
|
+
// `TempDir.path` (197 calls, all from tests) ranked second among
|
|
207
|
+
// "production functions". Under productionCallsOnly they leave the
|
|
208
|
+
// candidate set, the exact count, AND the upper bound (a bound that
|
|
209
|
+
// still counted them stopped the early exit from firing, so the
|
|
210
|
+
// orientation fell back to its approximate refinement budget).
|
|
211
|
+
const { inlineTestRanges, lineInRanges } = require('./shared');
|
|
212
|
+
const inlineTestRangesByFile = new Map();
|
|
213
|
+
const inInlineTest = (file, line) => {
|
|
214
|
+
if (!file || !line) return false;
|
|
215
|
+
let ranges = inlineTestRangesByFile.get(file);
|
|
216
|
+
if (!ranges) {
|
|
217
|
+
ranges = inlineTestRanges(index.files.get(file) || {});
|
|
218
|
+
inlineTestRangesByFile.set(file, ranges);
|
|
219
|
+
}
|
|
220
|
+
return ranges.length > 0 && lineInRanges(line, ranges);
|
|
221
|
+
};
|
|
137
222
|
for (const [filePath, entry] of index.callsCache) {
|
|
138
223
|
if (!scopedPaths.has(filePath)) continue;
|
|
139
|
-
|
|
224
|
+
const fileEntry = index.files.get(filePath);
|
|
225
|
+
if (fileEntry?.isBundled) continue;
|
|
140
226
|
if (!entry || !Array.isArray(entry.calls)) continue;
|
|
141
227
|
const seenInFile = new Set();
|
|
142
228
|
for (const c of entry.calls) {
|
|
143
229
|
if (!c || !c.name) continue;
|
|
144
|
-
|
|
230
|
+
if (receiverProvablyExternal(filePath, fileEntry, c)) continue;
|
|
231
|
+
if (options.productionCallsOnly && inInlineTest(filePath, c.line)) continue;
|
|
232
|
+
// Distinct receiver types on one line can confirm DIFFERENT
|
|
233
|
+
// definitions. Dedup within a type bucket, never across them.
|
|
234
|
+
// Mixed typed/untyped buckets may overcount, which is safe for
|
|
235
|
+
// an upper bound; dropping a bucket can erase a true HOT item.
|
|
236
|
+
const bucket = c.isMethod && c.receiverType && !c.receiverTypeGuessed
|
|
237
|
+
? normalizeTypeName(c.receiverType) : '';
|
|
238
|
+
const key = `${c.name}::${c.line || 0}::${bucket}`;
|
|
145
239
|
if (!seenInFile.has(key)) {
|
|
146
240
|
seenInFile.add(key);
|
|
147
|
-
|
|
241
|
+
chargeRecord(c.name, c);
|
|
148
242
|
}
|
|
149
243
|
if (c.resolvedName && c.resolvedName !== c.name) {
|
|
150
|
-
const rkey = `${c.resolvedName}::${c.line || 0}`;
|
|
244
|
+
const rkey = `${c.resolvedName}::${c.line || 0}::${bucket}`;
|
|
151
245
|
if (!seenInFile.has(rkey)) {
|
|
152
246
|
seenInFile.add(rkey);
|
|
153
|
-
|
|
154
|
-
(rawUpperByName.get(c.resolvedName) || 0) + 1);
|
|
247
|
+
chargeRecord(c.resolvedName, c);
|
|
155
248
|
}
|
|
156
249
|
}
|
|
157
250
|
}
|
|
158
251
|
}
|
|
252
|
+
const ownerOf = (symbol) => normalizeTypeName(symbol.className || symbol.receiver || '');
|
|
159
253
|
|
|
160
254
|
const candidates = [];
|
|
161
255
|
const seenDefinitions = new Set();
|
|
162
256
|
for (const [name, symbols] of index.symbols) {
|
|
163
|
-
const
|
|
164
|
-
|
|
257
|
+
const untyped = untypedByName.get(name) || 0;
|
|
258
|
+
const typedByClass = typedByName.get(name);
|
|
259
|
+
if (untyped === 0 && !typedByClass) continue;
|
|
165
260
|
const callable = symbols.filter(symbol =>
|
|
166
261
|
FUNCTION_TYPES.has(symbol.type) &&
|
|
167
262
|
matchesReportingScope(index, symbol.relativePath, options));
|
|
263
|
+
const definers = new Set(callable.map(ownerOf).filter(Boolean));
|
|
168
264
|
for (const symbol of callable) {
|
|
265
|
+
let upper = untyped;
|
|
266
|
+
if (typedByClass) {
|
|
267
|
+
const owner = ownerOf(symbol);
|
|
268
|
+
for (const [cls, count] of typedByClass) {
|
|
269
|
+
if (cls === owner || !definers.has(cls)) upper += count;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (upper === 0) continue;
|
|
273
|
+
// Fair share of the name's ceiling: a name shared by 278
|
|
274
|
+
// methods cannot make all 278 hot, so a definition's likely
|
|
275
|
+
// count is nearer upper/definers than upper. Ordering by the
|
|
276
|
+
// share puts the genuinely hot definitions first; the early
|
|
277
|
+
// stop below still uses the exact remaining ceiling, so the
|
|
278
|
+
// exact answer is unchanged and only a bounded refinement
|
|
279
|
+
// (`maxRefine`) benefits from the order.
|
|
280
|
+
const share = upper / Math.max(1, callable.length);
|
|
169
281
|
if (index.files.get(symbol.file)?.isBundled) continue;
|
|
170
|
-
if (options.productionCallsOnly &&
|
|
282
|
+
if (options.productionCallsOnly &&
|
|
283
|
+
(require('./shared').isTestPath(symbol.relativePath) ||
|
|
284
|
+
inInlineTest(symbol.file, symbol.startLine))) continue;
|
|
171
285
|
const identity = `${symbol.file}:${symbol.startLine}:${name}:` +
|
|
172
286
|
`${symbol.className || symbol.receiver || ''}:${symbol.params || ''}`;
|
|
173
287
|
if (seenDefinitions.has(identity)) continue;
|
|
@@ -183,21 +297,39 @@ function getStats(index, options = {}) {
|
|
|
183
297
|
index.importGraph.get(symbol.file)?.has(candidate.file)))) {
|
|
184
298
|
continue;
|
|
185
299
|
}
|
|
186
|
-
candidates.push({ name, symbol, upper });
|
|
300
|
+
candidates.push({ name, symbol, upper, share });
|
|
187
301
|
}
|
|
188
302
|
}
|
|
303
|
+
const maxRefine = Number.isInteger(options.maxRefine) && options.maxRefine > 0
|
|
304
|
+
? options.maxRefine : Infinity;
|
|
305
|
+
// Exact mode walks the ceilings in descending order so the early stop
|
|
306
|
+
// fires as soon as possible; a bounded refinement walks fair shares so
|
|
307
|
+
// the budget lands on the definitions most likely to be hot.
|
|
189
308
|
candidates.sort((a, b) =>
|
|
309
|
+
(maxRefine !== Infinity ? (b.share - a.share) : 0) ||
|
|
190
310
|
(b.upper - a.upper) ||
|
|
191
311
|
codeUnitCompare(a.symbol.relativePath, b.symbol.relativePath) ||
|
|
192
312
|
(a.symbol.startLine || 0) - (b.symbol.startLine || 0));
|
|
313
|
+
// Suffix maximum of the exact ceilings: once no unseen candidate can
|
|
314
|
+
// beat the current Nth result, the answer is exact regardless of order.
|
|
315
|
+
const remainingUpper = new Array(candidates.length + 1).fill(-1);
|
|
316
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
317
|
+
remainingUpper[i] = Math.max(candidates[i].upper, remainingUpper[i + 1]);
|
|
318
|
+
}
|
|
193
319
|
|
|
194
320
|
const hotList = [];
|
|
195
321
|
const { findCallers } = require('./callers');
|
|
196
322
|
const scopedCallerQuery = !!(options.file || options.in ||
|
|
197
323
|
(options.exclude && options.exclude.length > 0));
|
|
198
324
|
let refined = 0;
|
|
325
|
+
let budgetExhausted = false;
|
|
326
|
+
// One operation scope for the whole refinement loop (fix #340): the
|
|
327
|
+
// per-file derivations findCallers builds are shared across candidates.
|
|
328
|
+
if (top > 0) index._beginOp();
|
|
329
|
+
try {
|
|
199
330
|
if (top > 0) {
|
|
200
331
|
for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) {
|
|
332
|
+
if (refined >= maxRefine) { budgetExhausted = true; break; }
|
|
201
333
|
const { name, symbol } = candidates[candidateIndex];
|
|
202
334
|
const exact = findCallers(index, name, {
|
|
203
335
|
targetDefinitions: [symbol],
|
|
@@ -210,7 +342,8 @@ function getStats(index, options = {}) {
|
|
|
210
342
|
(!scopedCallerQuery || scopedPaths.has(caller.file)) &&
|
|
211
343
|
!index.files.get(caller.file)?.isBundled &&
|
|
212
344
|
(!options.productionCallsOnly ||
|
|
213
|
-
!require('./shared').isTestPath(caller.relativePath || caller.file)
|
|
345
|
+
(!require('./shared').isTestPath(caller.relativePath || caller.file) &&
|
|
346
|
+
!inInlineTest(caller.file, caller.line)))).length;
|
|
214
347
|
if (count > 0) {
|
|
215
348
|
const owner = symbol.className ||
|
|
216
349
|
(symbol.receiver || '').replace(/^\*/, '');
|
|
@@ -229,11 +362,11 @@ function getStats(index, options = {}) {
|
|
|
229
362
|
(a.startLine || 0) - (b.startLine || 0));
|
|
230
363
|
if (hotList.length >= top) {
|
|
231
364
|
const threshold = hotList[top - 1].callCount;
|
|
232
|
-
|
|
233
|
-
if (nextUpper < threshold) break;
|
|
365
|
+
if (remainingUpper[candidateIndex + 1] < threshold) break;
|
|
234
366
|
}
|
|
235
367
|
}
|
|
236
368
|
}
|
|
369
|
+
} finally { if (top > 0) index._endOp(); }
|
|
237
370
|
|
|
238
371
|
// Stable order: callCount desc, then (relativePath, startLine) asc.
|
|
239
372
|
hotList.sort((a, b) =>
|
|
@@ -248,6 +381,7 @@ function getStats(index, options = {}) {
|
|
|
248
381
|
totalKind: refined === candidates.length ? 'confirmed' : 'raw-call-candidates',
|
|
249
382
|
refined,
|
|
250
383
|
items: hotList.slice(0, top),
|
|
384
|
+
...(budgetExhausted && { budgetExhausted: true, maxRefine }),
|
|
251
385
|
note: refined === candidates.length
|
|
252
386
|
? 'Counts are confirmed caller-engine edges pinned to each displayed definition; unverified dispatch is excluded.'
|
|
253
387
|
: `Displayed counts are exact confirmed caller-engine edges; ${candidates.length} raw candidates were bounded and ${refined} required exact refinement.`,
|
|
@@ -805,6 +939,12 @@ function computeEvidenceProfile(index, { sampleSize, matchInFilter }) {
|
|
|
805
939
|
* trust verdict. Composes existing engine reads; counts and pointers only
|
|
806
940
|
* (no caller claims, so no account — the toc/stats category).
|
|
807
941
|
*/
|
|
942
|
+
// Orientation refines at most this many HOT candidates exactly (fix #340).
|
|
943
|
+
// grpc-go (1037 files): exact refinement walks 1089 candidates in ~20s; 400
|
|
944
|
+
// in fair-share order reproduces the exact top 8 in under 5s. When the budget
|
|
945
|
+
// binds, the header says so and points at the exact command.
|
|
946
|
+
const ORIENT_HOT_REFINE_BUDGET = 400;
|
|
947
|
+
|
|
808
948
|
function orient(index, options = {}) {
|
|
809
949
|
const top = options.top || 8;
|
|
810
950
|
const scope = {
|
|
@@ -827,6 +967,8 @@ function orient(index, options = {}) {
|
|
|
827
967
|
// actually contains production files. In an all-test repository it
|
|
828
968
|
// would erase the raw ranking that orient promises as its fallback.
|
|
829
969
|
productionCallsOnly: options.includeTests !== true && hasProductionFiles,
|
|
970
|
+
maxRefine: Number.isInteger(options.hotRefineBudget) && options.hotRefineBudget > 0
|
|
971
|
+
? options.hotRefineBudget : ORIENT_HOT_REFINE_BUDGET,
|
|
830
972
|
});
|
|
831
973
|
const health = doctor(index, scope);
|
|
832
974
|
|
|
@@ -908,6 +1050,9 @@ function orient(index, options = {}) {
|
|
|
908
1050
|
total: stats.hot?.total ?? 0,
|
|
909
1051
|
totalKind: stats.hot?.totalKind || 'confirmed',
|
|
910
1052
|
refined: stats.hot?.refined ?? 0,
|
|
1053
|
+
...(stats.hot?.budgetExhausted && {
|
|
1054
|
+
budgetExhausted: true, maxRefine: stats.hot.maxRefine,
|
|
1055
|
+
}),
|
|
911
1056
|
top,
|
|
912
1057
|
production,
|
|
913
1058
|
items: hotItems,
|
package/languages/javascript.js
CHANGED
|
@@ -2371,6 +2371,40 @@ function findCallsInCode(code, parser) {
|
|
|
2371
2371
|
}
|
|
2372
2372
|
return false;
|
|
2373
2373
|
};
|
|
2374
|
+
// fix #337: a declarator initialized from require()/import() is an
|
|
2375
|
+
// IMPORT binding — the module's own name reaching this scope — not a
|
|
2376
|
+
// local shadow. `function build() { const { Foo } = require('./lib');
|
|
2377
|
+
// new Foo() }` resolves through import ownership exactly like the
|
|
2378
|
+
// top-level require the walk already exempts as the module binding
|
|
2379
|
+
// itself. Unwraps `await import()`, parens, and `require('./x').Foo`.
|
|
2380
|
+
const _isImportBindingInitializer = (value) => {
|
|
2381
|
+
let v = value;
|
|
2382
|
+
for (;;) {
|
|
2383
|
+
if (!v) return false;
|
|
2384
|
+
if (v.type === 'await_expression' || v.type === 'parenthesized_expression') {
|
|
2385
|
+
v = v.namedChild(0);
|
|
2386
|
+
continue;
|
|
2387
|
+
}
|
|
2388
|
+
if (v.type === 'member_expression' || v.type === 'subscript_expression') {
|
|
2389
|
+
v = v.childForFieldName('object');
|
|
2390
|
+
continue;
|
|
2391
|
+
}
|
|
2392
|
+
break;
|
|
2393
|
+
}
|
|
2394
|
+
if (v.type !== 'call_expression') return false;
|
|
2395
|
+
const fn = v.childForFieldName('function');
|
|
2396
|
+
return !!fn && (fn.type === 'import' || (fn.type === 'identifier' && fn.text === 'require'));
|
|
2397
|
+
};
|
|
2398
|
+
const _declaresLocalShadow = (declNode, name) => {
|
|
2399
|
+
for (let i = 0; i < declNode.namedChildCount; i++) {
|
|
2400
|
+
const d = declNode.namedChild(i);
|
|
2401
|
+
if (d.type !== 'variable_declarator') continue;
|
|
2402
|
+
if (!_patternDeclaresName(d.childForFieldName('name'), name)) continue;
|
|
2403
|
+
if (_isImportBindingInitializer(d.childForFieldName('value'))) continue;
|
|
2404
|
+
return true;
|
|
2405
|
+
}
|
|
2406
|
+
return false;
|
|
2407
|
+
};
|
|
2374
2408
|
|
|
2375
2409
|
// Bare callback references need to distinguish a module-owned VALUE from
|
|
2376
2410
|
// an unbound name. File-level import reachability cannot prove the value's
|
|
@@ -2447,7 +2481,7 @@ function findCallsInCode(code, parser) {
|
|
|
2447
2481
|
stmt.childForFieldName('name')?.text === name) return true;
|
|
2448
2482
|
if (stmt.startIndex >= refNode.startIndex) continue; // declaration-before-use
|
|
2449
2483
|
if ((stmt.type === 'lexical_declaration' || stmt.type === 'variable_declaration') &&
|
|
2450
|
-
|
|
2484
|
+
_declaresLocalShadow(stmt, name)) return true;
|
|
2451
2485
|
}
|
|
2452
2486
|
} else if (p.type === 'for_statement') {
|
|
2453
2487
|
const init = p.childForFieldName('initializer');
|
|
@@ -3438,6 +3472,144 @@ function findImportsInCode(code, parser) {
|
|
|
3438
3472
|
const imports = [];
|
|
3439
3473
|
let importAliases = null; // {original, local}[] — tracks renamed imports
|
|
3440
3474
|
|
|
3475
|
+
// fix #338: classify edges that do not execute during module
|
|
3476
|
+
// initialization so dependency-cycle reporting can separate an eager
|
|
3477
|
+
// import-time loop from a deliberate lazy one. `require()`/`import()`
|
|
3478
|
+
// nested in any function body (incl. `() => require('./x')` thunks) runs
|
|
3479
|
+
// only when that function is called; TS `import type` / `export type`
|
|
3480
|
+
// re-exports and all-`type` specifier lists are erased at compile time.
|
|
3481
|
+
const FUNCTION_LIKE = new Set(['function_declaration', 'function_expression', 'arrow_function',
|
|
3482
|
+
'method_definition', 'generator_function_declaration', 'generator_function', 'function']);
|
|
3483
|
+
const importDeferral = (node) => {
|
|
3484
|
+
for (let p = node.parent; p; p = p.parent) {
|
|
3485
|
+
if (FUNCTION_LIKE.has(p.type)) return 'function-local';
|
|
3486
|
+
}
|
|
3487
|
+
return null;
|
|
3488
|
+
};
|
|
3489
|
+
// Static path folding is positive identity evidence. A method merely
|
|
3490
|
+
// named join/resolve need not be Node's path utility. Keep an ambiguous
|
|
3491
|
+
// or shadowed binding dynamic rather than inventing a module edge.
|
|
3492
|
+
let pathBindings = null;
|
|
3493
|
+
const isPathModuleCall = node => {
|
|
3494
|
+
if (node?.type !== 'call_expression') return false;
|
|
3495
|
+
const fn = node.childForFieldName('function');
|
|
3496
|
+
const args = node.childForFieldName('arguments');
|
|
3497
|
+
const arg = args?.namedChild(0);
|
|
3498
|
+
return fn?.type === 'identifier' && fn.text === 'require' &&
|
|
3499
|
+
args.namedChildCount === 1 && arg?.type === 'string' &&
|
|
3500
|
+
['path', 'node:path'].includes(arg.text.slice(1, -1));
|
|
3501
|
+
};
|
|
3502
|
+
const collectPathBindings = () => {
|
|
3503
|
+
if (pathBindings) return;
|
|
3504
|
+
pathBindings = new Map();
|
|
3505
|
+
const add = (pattern, value) => {
|
|
3506
|
+
if (!pattern) return;
|
|
3507
|
+
traverseTree(pattern, id => {
|
|
3508
|
+
if (id.type === 'identifier' || id.type === 'shorthand_property_identifier_pattern') {
|
|
3509
|
+
const entries = pathBindings.get(id.text) || [];
|
|
3510
|
+
entries.push({ pattern, value });
|
|
3511
|
+
pathBindings.set(id.text, entries);
|
|
3512
|
+
}
|
|
3513
|
+
return true;
|
|
3514
|
+
});
|
|
3515
|
+
};
|
|
3516
|
+
// File-wide ambiguity is deliberately conservative, including writes
|
|
3517
|
+
// and parameters in unrelated scopes. This rare syntax needs proof,
|
|
3518
|
+
// while ordinary literal require specifiers keep their existing path.
|
|
3519
|
+
traverseTree(tree.rootNode, n => {
|
|
3520
|
+
if (n.type === 'variable_declarator') add(n.childForFieldName('name'), n.childForFieldName('value'));
|
|
3521
|
+
else if (n.type === 'formal_parameters' || n.type === 'import_clause') add(n, null);
|
|
3522
|
+
else if (n.type === 'assignment_expression' || n.type === 'augmented_assignment_expression') add(n.childForFieldName('left'), null);
|
|
3523
|
+
else if (n.type === 'update_expression') add(n.childForFieldName('argument'), null);
|
|
3524
|
+
else if (n.type === 'class_declaration' || n.type === 'class') add(n.childForFieldName('name'), null);
|
|
3525
|
+
else if (n.type === 'catch_clause') add(n.childForFieldName('parameter'), null);
|
|
3526
|
+
else if (FUNCTION_LIKE.has(n.type)) {
|
|
3527
|
+
add(n.childForFieldName('name'), null);
|
|
3528
|
+
add(n.childForFieldName('parameter'), null); // unparenthesized arrow
|
|
3529
|
+
}
|
|
3530
|
+
return true;
|
|
3531
|
+
});
|
|
3532
|
+
};
|
|
3533
|
+
const isPathUtility = fn => {
|
|
3534
|
+
if (pathBindings.has('require')) return false;
|
|
3535
|
+
if (fn?.type !== 'member_expression' ||
|
|
3536
|
+
!['join', 'resolve'].includes(fn.childForFieldName('property')?.text)) return false;
|
|
3537
|
+
const object = fn.childForFieldName('object');
|
|
3538
|
+
if (isPathModuleCall(object)) return true;
|
|
3539
|
+
if (object?.type !== 'identifier') return false;
|
|
3540
|
+
const bindings = pathBindings.get(object.text) || [];
|
|
3541
|
+
return bindings.length === 1 && bindings[0].pattern.type === 'identifier' &&
|
|
3542
|
+
isPathModuleCall(bindings[0].value);
|
|
3543
|
+
};
|
|
3544
|
+
// Static composition of `__dirname`-rooted require paths (fix #337b).
|
|
3545
|
+
// Returns a relative specifier ('./x' / '../x') or null when any piece is
|
|
3546
|
+
// not a string literal.
|
|
3547
|
+
const unquote = (n) => (n.type === 'string' &&
|
|
3548
|
+
!n.namedChildren.some(child => child.type === 'escape_sequence') ? n.text.slice(1, -1) : null);
|
|
3549
|
+
const staticDirnamePath = (arg) => {
|
|
3550
|
+
collectPathBindings();
|
|
3551
|
+
if (pathBindings.has('__dirname') || pathBindings.has('require')) return null;
|
|
3552
|
+
let parts = null;
|
|
3553
|
+
if (arg.type === 'call_expression') {
|
|
3554
|
+
const fn = arg.childForFieldName('function');
|
|
3555
|
+
if (!isPathUtility(fn)) return null;
|
|
3556
|
+
const args = arg.childForFieldName('arguments');
|
|
3557
|
+
if (!args || args.namedChildCount < 2) return null;
|
|
3558
|
+
if (args.namedChild(0).type !== 'identifier' || args.namedChild(0).text !== '__dirname') return null;
|
|
3559
|
+
parts = [];
|
|
3560
|
+
for (let i = 1; i < args.namedChildCount; i++) {
|
|
3561
|
+
const piece = unquote(args.namedChild(i));
|
|
3562
|
+
if (piece == null || piece.startsWith('/')) return null;
|
|
3563
|
+
parts.push(piece);
|
|
3564
|
+
}
|
|
3565
|
+
} else if (arg.type === 'binary_expression') {
|
|
3566
|
+
const operands = [];
|
|
3567
|
+
const flatten = (n) => {
|
|
3568
|
+
if (n.type === 'binary_expression' && n.childForFieldName('operator')?.text === '+') {
|
|
3569
|
+
flatten(n.childForFieldName('left'));
|
|
3570
|
+
flatten(n.childForFieldName('right'));
|
|
3571
|
+
} else operands.push(n);
|
|
3572
|
+
};
|
|
3573
|
+
flatten(arg);
|
|
3574
|
+
if (operands.length < 2 || operands[0].type !== 'identifier' || operands[0].text !== '__dirname') return null;
|
|
3575
|
+
let tail = '';
|
|
3576
|
+
for (let i = 1; i < operands.length; i++) {
|
|
3577
|
+
const piece = unquote(operands[i]);
|
|
3578
|
+
if (piece == null) return null;
|
|
3579
|
+
tail += piece;
|
|
3580
|
+
}
|
|
3581
|
+
if (!tail.startsWith('/')) return null;
|
|
3582
|
+
parts = [tail.slice(1)];
|
|
3583
|
+
} else if (arg.type === 'template_string') {
|
|
3584
|
+
let tail = '';
|
|
3585
|
+
let sawDirname = false;
|
|
3586
|
+
for (let i = 0; i < arg.childCount; i++) {
|
|
3587
|
+
const c = arg.child(i);
|
|
3588
|
+
if (c.type === 'template_substitution') {
|
|
3589
|
+
if (sawDirname || c.namedChildCount !== 1 || c.namedChild(0).text !== '__dirname') return null;
|
|
3590
|
+
sawDirname = true;
|
|
3591
|
+
} else if (c.type === 'string_fragment') {
|
|
3592
|
+
if (!sawDirname) return null;
|
|
3593
|
+
tail += c.text;
|
|
3594
|
+
} else if (c.type !== '`') return null;
|
|
3595
|
+
}
|
|
3596
|
+
if (!sawDirname || !tail.startsWith('/')) return null;
|
|
3597
|
+
parts = [tail.slice(1)];
|
|
3598
|
+
}
|
|
3599
|
+
if (!parts || parts.length === 0) return null;
|
|
3600
|
+
const joined = parts.join('/').replace(/\\/g, '/');
|
|
3601
|
+
if (!joined || joined.includes('${')) return null;
|
|
3602
|
+
const normalized = require('path').posix.normalize(joined);
|
|
3603
|
+
if (normalized.startsWith('/') || normalized === '.') return null;
|
|
3604
|
+
return normalized.startsWith('.') ? normalized : `./${normalized}`;
|
|
3605
|
+
};
|
|
3606
|
+
const hasTypeKeyword = (node) => {
|
|
3607
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
3608
|
+
if (node.child(i).type === 'type') return true;
|
|
3609
|
+
}
|
|
3610
|
+
return false;
|
|
3611
|
+
};
|
|
3612
|
+
|
|
3441
3613
|
traverseTreeCached(tree.rootNode, (node) => {
|
|
3442
3614
|
// ES6 import statements
|
|
3443
3615
|
if (node.type === 'import_statement') {
|
|
@@ -3446,6 +3618,10 @@ function findImportsInCode(code, parser) {
|
|
|
3446
3618
|
const names = [];
|
|
3447
3619
|
const esmRenames = [];
|
|
3448
3620
|
let importType = 'named';
|
|
3621
|
+
let typeOnly = hasTypeKeyword(node);
|
|
3622
|
+
let specifierCount = 0;
|
|
3623
|
+
let typeSpecifierCount = 0;
|
|
3624
|
+
let hasValueBinding = false;
|
|
3449
3625
|
|
|
3450
3626
|
// Find the module path (string node)
|
|
3451
3627
|
for (let i = 0; i < node.namedChildCount; i++) {
|
|
@@ -3466,7 +3642,8 @@ function findImportsInCode(code, parser) {
|
|
|
3466
3642
|
if (c.type === 'string') src = c.text.slice(1, -1);
|
|
3467
3643
|
}
|
|
3468
3644
|
if (src) {
|
|
3469
|
-
imports.push({ module: src, names: alias ? [alias] : [], type: 'require', line
|
|
3645
|
+
imports.push({ module: src, names: alias ? [alias] : [], type: 'require', line,
|
|
3646
|
+
...(typeOnly && { deferred: true, deferredReason: 'type-only' }) });
|
|
3470
3647
|
}
|
|
3471
3648
|
return true;
|
|
3472
3649
|
}
|
|
@@ -3478,6 +3655,7 @@ function findImportsInCode(code, parser) {
|
|
|
3478
3655
|
// Default import: import foo from 'x'
|
|
3479
3656
|
names.push(clauseChild.text);
|
|
3480
3657
|
importType = 'default';
|
|
3658
|
+
hasValueBinding = true;
|
|
3481
3659
|
} else if (clauseChild.type === 'named_imports') {
|
|
3482
3660
|
// Named imports: import { a, b } from 'x'
|
|
3483
3661
|
for (let k = 0; k < clauseChild.namedChildCount; k++) {
|
|
@@ -3485,6 +3663,8 @@ function findImportsInCode(code, parser) {
|
|
|
3485
3663
|
if (specifier.type === 'import_specifier') {
|
|
3486
3664
|
const nameNode = specifier.namedChild(0);
|
|
3487
3665
|
const aliasNode = specifier.namedChild(1);
|
|
3666
|
+
specifierCount++;
|
|
3667
|
+
if (hasTypeKeyword(specifier)) typeSpecifierCount++;
|
|
3488
3668
|
if (nameNode) names.push(nameNode.text);
|
|
3489
3669
|
// Track renamed imports: import { X as Y }
|
|
3490
3670
|
if (nameNode && aliasNode && aliasNode.text !== nameNode.text) {
|
|
@@ -3501,6 +3681,7 @@ function findImportsInCode(code, parser) {
|
|
|
3501
3681
|
clauseChild.namedChild(0);
|
|
3502
3682
|
if (nsName) names.push(nsName.text);
|
|
3503
3683
|
importType = 'namespace';
|
|
3684
|
+
hasValueBinding = true;
|
|
3504
3685
|
}
|
|
3505
3686
|
}
|
|
3506
3687
|
}
|
|
@@ -3511,8 +3692,13 @@ function findImportsInCode(code, parser) {
|
|
|
3511
3692
|
// Side-effect import: import 'x'
|
|
3512
3693
|
importType = 'side-effect';
|
|
3513
3694
|
}
|
|
3695
|
+
if (!typeOnly && specifierCount > 0 && typeSpecifierCount === specifierCount &&
|
|
3696
|
+
importType === 'named' && !hasValueBinding) {
|
|
3697
|
+
typeOnly = true;
|
|
3698
|
+
}
|
|
3514
3699
|
imports.push({ module: modulePath, names, type: importType, line,
|
|
3515
|
-
...(esmRenames.length > 0 && { renames: esmRenames })
|
|
3700
|
+
...(esmRenames.length > 0 && { renames: esmRenames }),
|
|
3701
|
+
...(typeOnly && { deferred: true, deferredReason: 'type-only' }) });
|
|
3516
3702
|
}
|
|
3517
3703
|
return true;
|
|
3518
3704
|
}
|
|
@@ -3522,6 +3708,8 @@ function findImportsInCode(code, parser) {
|
|
|
3522
3708
|
if (node.type === 'export_statement') {
|
|
3523
3709
|
let source = null;
|
|
3524
3710
|
const names = [];
|
|
3711
|
+
let specifierCount = 0;
|
|
3712
|
+
let typeSpecifierCount = 0;
|
|
3525
3713
|
|
|
3526
3714
|
// Find the source module (string node with 'from')
|
|
3527
3715
|
for (let i = 0; i < node.namedChildCount; i++) {
|
|
@@ -3533,6 +3721,8 @@ function findImportsInCode(code, parser) {
|
|
|
3533
3721
|
for (let j = 0; j < child.namedChildCount; j++) {
|
|
3534
3722
|
const specifier = child.namedChild(j);
|
|
3535
3723
|
if (specifier.type === 'export_specifier') {
|
|
3724
|
+
specifierCount++;
|
|
3725
|
+
if (hasTypeKeyword(specifier)) typeSpecifierCount++;
|
|
3536
3726
|
const nameNode = specifier.namedChild(0);
|
|
3537
3727
|
if (nameNode) names.push(nameNode.text);
|
|
3538
3728
|
}
|
|
@@ -3544,7 +3734,9 @@ function findImportsInCode(code, parser) {
|
|
|
3544
3734
|
const line = node.startPosition.row + 1;
|
|
3545
3735
|
const isStarReExport = node.text.includes('export *');
|
|
3546
3736
|
const importType = isStarReExport ? 'namespace' : 'named';
|
|
3547
|
-
imports.push({ module: source, names, type: importType, line, isReExport: true
|
|
3737
|
+
imports.push({ module: source, names, type: importType, line, isReExport: true,
|
|
3738
|
+
...((hasTypeKeyword(node) || (specifierCount > 0 && specifierCount === typeSpecifierCount)) &&
|
|
3739
|
+
{ deferred: true, deferredReason: 'type-only' }) });
|
|
3548
3740
|
}
|
|
3549
3741
|
return true;
|
|
3550
3742
|
}
|
|
@@ -3562,8 +3754,17 @@ function findImportsInCode(code, parser) {
|
|
|
3562
3754
|
let modulePath;
|
|
3563
3755
|
let dynamic = false;
|
|
3564
3756
|
|
|
3757
|
+
const composedPath = firstArg && firstArg.type !== 'string' ? staticDirnamePath(firstArg) : null;
|
|
3565
3758
|
if (firstArg && firstArg.type === 'string') {
|
|
3566
3759
|
modulePath = firstArg.text.slice(1, -1);
|
|
3760
|
+
} else if (composedPath) {
|
|
3761
|
+
// fix #337b: `require(path.join(__dirname, '..', 'x'))`,
|
|
3762
|
+
// `require(__dirname + '/x')`, `require(\`${__dirname}/x\`)`
|
|
3763
|
+
// compose to an exact relative specifier — the CJS
|
|
3764
|
+
// test-suite idiom that used to be an unresolvable
|
|
3765
|
+
// dynamic module (excluding every constructor call it
|
|
3766
|
+
// bound as other-definition-import).
|
|
3767
|
+
modulePath = composedPath;
|
|
3567
3768
|
} else {
|
|
3568
3769
|
dynamic = true;
|
|
3569
3770
|
modulePath = firstArg ? firstArg.text : null;
|
|
@@ -3605,7 +3806,9 @@ function findImportsInCode(code, parser) {
|
|
|
3605
3806
|
}
|
|
3606
3807
|
|
|
3607
3808
|
if (modulePath) {
|
|
3809
|
+
const deferral = importDeferral(node);
|
|
3608
3810
|
imports.push({ module: modulePath, names, type: 'require', line, dynamic,
|
|
3811
|
+
...(deferral && { deferred: true, deferredReason: deferral }),
|
|
3609
3812
|
...(defaultLike && { defaultLike: true }),
|
|
3610
3813
|
// Per-import rename pairing (fix #269): the flat
|
|
3611
3814
|
// importAliases list loses WHICH module a renamed
|
|
@@ -3623,11 +3826,15 @@ function findImportsInCode(code, parser) {
|
|
|
3623
3826
|
if (argsNode && argsNode.namedChildCount > 0) {
|
|
3624
3827
|
const firstArg = argsNode.namedChild(0);
|
|
3625
3828
|
const line = node.startPosition.row + 1;
|
|
3829
|
+
const deferral = importDeferral(node);
|
|
3830
|
+
const deferredFields = deferral ? { deferred: true, deferredReason: deferral } : {};
|
|
3626
3831
|
if (firstArg && firstArg.type === 'string') {
|
|
3627
3832
|
const modulePath = firstArg.text.slice(1, -1);
|
|
3628
|
-
imports.push({ module: modulePath, names: [], type: 'dynamic', line, dynamic: false
|
|
3833
|
+
imports.push({ module: modulePath, names: [], type: 'dynamic', line, dynamic: false,
|
|
3834
|
+
...deferredFields });
|
|
3629
3835
|
} else if (firstArg) {
|
|
3630
|
-
imports.push({ module: firstArg.text, names: [], type: 'dynamic', line, dynamic: true
|
|
3836
|
+
imports.push({ module: firstArg.text, names: [], type: 'dynamic', line, dynamic: true,
|
|
3837
|
+
...deferredFields });
|
|
3631
3838
|
}
|
|
3632
3839
|
}
|
|
3633
3840
|
}
|