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/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
- const rawUpperByName = new Map();
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
- if (index.files.get(filePath)?.isBundled) continue;
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
- const key = `${c.name}::${c.line || 0}`;
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
- rawUpperByName.set(c.name, (rawUpperByName.get(c.name) || 0) + 1);
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
- rawUpperByName.set(c.resolvedName,
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 upper = rawUpperByName.get(name) || 0;
164
- if (upper === 0) continue;
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 && require('./shared').isTestPath(symbol.relativePath)) continue;
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))).length;
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
- const nextUpper = candidates[candidateIndex + 1]?.upper ?? -1;
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,
@@ -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
- _declaresName(stmt, name)) return true;
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
  }