@ethlete/query 6.0.0-next.52 → 6.0.0-next.54

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.
@@ -8,6 +8,7 @@ export const CONSUMER_TASK = {
8
8
  UNCONVERTED_ACTION_GROUP: 'NTK-UNCONVERTED-ACTION-GROUP',
9
9
  SELECT_BY_ACTION_ID: 'NTK-SELECT-BY-ACTION-ID',
10
10
  AUTH_INTERCEPTOR: 'NTK-AUTH-INTERCEPTOR',
11
+ FACADE_ON: 'NTK-FACADE-ON',
11
12
  };
12
13
  const defineConsumerTasks = (report) => {
13
14
  report.define(CONSUMER_TASK.UNSUPPORTED_IMPORT, {
@@ -16,12 +17,16 @@ const defineConsumerTasks = (report) => {
16
17
  });
17
18
  report.define(CONSUMER_TASK.UNCONVERTED_ACTION_GROUP, {
18
19
  title: 'Consumer types name an action group that is still on the toolkit',
19
- action: 'The file was left on `@tomtomb/ngrx-toolkit` because a `MappedEntityState` / `ActionCallArgs` type names an action group whose feature was not converted. Resolve that feature’s task, then re-run the generator.',
20
+ action: 'The file was left on `@tomtomb/ngrx-toolkit` because a `MappedEntityState` / `ActionCallArgs` type names an action group whose feature was not converted. Until then the file does not typecheck where it takes handles from facades that were migrated: the interop `MappedEntityState` has no `isPolling$`, `type$` or `entityId$`, and other generics. Resolve that feature’s task, then re-run the generator.',
20
21
  });
21
22
  report.define(CONSUMER_TASK.SELECT_BY_ACTION_ID, {
22
23
  title: 'Handle looked up by action id',
23
24
  action: 'The interop has no action ids. Pass the args instead and look the handle up with `toolkitSelect(creator, args, { injector })` - equal args return the handle `toolkitCall` created.',
24
25
  });
26
+ report.define(CONSUMER_TASK.FACADE_ON, {
27
+ title: 'Facade `on()` / `once()` call on a migrated facade',
28
+ action: 'The migrated facade has no `on()` / `once()`, and the v3 creators have no `.success` / `.failure` actions, so the call does not typecheck. Rewrite it by hand: react to the `response$` / `error$` of the handle the facade method returns, or subscribe to the creator’s result.',
29
+ });
25
30
  report.define(CONSUMER_TASK.AUTH_INTERCEPTOR, {
26
31
  title: 'HTTP interceptor attaches the bearer token',
27
32
  action: 'v3 secure creators already send the token, so this interceptor sends it twice, and it adds it to every other `HttpClient` call too - third-party hosts included. Delete it and its registration. Requests it skipped must use public creators: pass their prefixes as `--publicRoutes` and re-run.',
@@ -416,8 +421,70 @@ const findInterceptors = (tree, inScope, filePath, content) => {
416
421
  visit(sourceFile);
417
422
  return tasks;
418
423
  };
424
+ const migratedFacadeNames = (graph, migratedFacades, filePath, sourceFile) => {
425
+ const names = new Set();
426
+ for (const statement of sourceFile.statements) {
427
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
428
+ continue;
429
+ const bindings = statement.importClause?.namedBindings;
430
+ if (!bindings || !ts.isNamedImports(bindings))
431
+ continue;
432
+ for (const element of bindings.elements) {
433
+ const imported = element.propertyName?.text ?? element.name.text;
434
+ const declaringFile = graph.findDeclaringFile(filePath, statement.moduleSpecifier.text, imported);
435
+ if (declaringFile && migratedFacades.has(declaringFile))
436
+ names.add(element.name.text);
437
+ }
438
+ }
439
+ return names;
440
+ };
441
+ const findFacadeEventCalls = (graph, migratedFacades, filePath, content) => {
442
+ if (!/\.(on|once)\s*\(/.test(content))
443
+ return [];
444
+ const sourceFile = createSourceFile(content, filePath);
445
+ const facadeNames = migratedFacadeNames(graph, migratedFacades, filePath, sourceFile);
446
+ if (facadeNames.size === 0)
447
+ return [];
448
+ const isFacadeType = (type) => !!type && ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName) && facadeNames.has(type.typeName.text);
449
+ const isFacadeInject = (node) => !!node && isCall(node, 'inject') && !!node.arguments[0] && facadeNames.has(node.arguments[0].getText(sourceFile));
450
+ const bound = new Set();
451
+ const collect = (node) => {
452
+ if ((ts.isPropertyDeclaration(node) || ts.isParameter(node) || ts.isVariableDeclaration(node)) &&
453
+ ts.isIdentifier(node.name) &&
454
+ (isFacadeType(node.type) || isFacadeInject(node.initializer))) {
455
+ bound.add(node.name.text);
456
+ }
457
+ ts.forEachChild(node, collect);
458
+ };
459
+ collect(sourceFile);
460
+ const isFacade = (receiver) => {
461
+ if (isFacadeInject(receiver))
462
+ return true;
463
+ if (ts.isIdentifier(receiver))
464
+ return bound.has(receiver.text);
465
+ return (ts.isPropertyAccessExpression(receiver) &&
466
+ receiver.expression.kind === ts.SyntaxKind.ThisKeyword &&
467
+ bound.has(receiver.name.text));
468
+ };
469
+ const tasks = [];
470
+ const visit = (node) => {
471
+ if (ts.isCallExpression(node) &&
472
+ ts.isPropertyAccessExpression(node.expression) &&
473
+ ['on', 'once'].includes(node.expression.name.text) &&
474
+ isFacade(node.expression.expression)) {
475
+ tasks.push({
476
+ id: CONSUMER_TASK.FACADE_ON,
477
+ summary: `${code(node.getText(sourceFile).replace(/\s+/g, ' '))} listens to toolkit actions the migrated facade no longer emits.`,
478
+ locations: [{ filePath, line: lineOf(sourceFile, node.getStart(sourceFile)) }],
479
+ });
480
+ }
481
+ ts.forEachChild(node, visit);
482
+ };
483
+ visit(sourceFile);
484
+ return tasks;
485
+ };
419
486
  /** Runs the consumer side of the migration on every scoped file that still imports `@tomtomb/ngrx-toolkit`. */
420
- export const migrateToolkitConsumers = (tree, graph, inScope, report) => {
487
+ export const migrateToolkitConsumers = (tree, graph, inScope, report, migratedFacades) => {
421
488
  defineConsumerTasks(report);
422
489
  const stats = {
423
490
  filesRewritten: 0,
@@ -434,6 +501,7 @@ export const migrateToolkitConsumers = (tree, graph, inScope, report) => {
434
501
  const interceptors = findInterceptors(tree, inScope, filePath, content);
435
502
  interceptors.forEach((task) => report.add(task));
436
503
  stats.interceptorsFound += interceptors.length;
504
+ findFacadeEventCalls(graph, migratedFacades, filePath, content).forEach((task) => report.add(task));
437
505
  if (!content.includes(TOOLKIT_MODULE))
438
506
  continue;
439
507
  const result = rewriteConsumer(tree, graph, filePath, content);
@@ -112,7 +112,8 @@ export const migrateToolkitStores = (tree, options, report) => {
112
112
  ready.push(feature);
113
113
  }
114
114
  const removal = removeFeatureStores(tree, graph, inScope, ready, report);
115
- const consumers = migrateToolkitConsumers(tree, createModuleGraph(tree), inScope, report);
115
+ const migratedFacades = new Set(ready.map((feature) => feature.files.facade).filter((file) => !!file));
116
+ const consumers = migrateToolkitConsumers(tree, createModuleGraph(tree), inScope, report, migratedFacades);
116
117
  return {
117
118
  featuresFound: discovered.features.length,
118
119
  featuresConverted: ready.length,
@@ -1,5 +1,7 @@
1
1
  import * as ts from 'typescript';
2
- import { REMOVED_EXPERIMENTAL_QUERY_HELPERS, createSourceFile, ensureImportFromQuery, ensureNamedImports, findRemovedExperimentalQueryHelpers, } from './shared.js';
2
+ import { createModuleGraph } from './module-graph.js';
3
+ import { isShadowedInNestedScope } from './rename-symbols.js';
4
+ import { REMOVED_EXPERIMENTAL_QUERY_HELPERS, createSourceFile, ensureImportFromQuery, ensureNamedImports, findRemovedExperimentalQueryHelpers, getLineNumber, printImportDeclaration, } from './shared.js';
3
5
  export const reportRemovedExperimentalQueryHelpers = (tree, scope, report) => {
4
6
  scope.visit(tree, (filePath) => {
5
7
  if (!filePath.endsWith('.ts')) {
@@ -90,8 +92,9 @@ export const replaceAnyQueryWithLegacy = (tree, scope) => {
90
92
  console.log(`\n✅ Replaced legacy AnyV2Query aliases in ${updatedFiles.length} files`);
91
93
  }
92
94
  };
93
- export const migrateEmptyPrepareCalls = (tree, scope) => {
95
+ export const migrateEmptyPrepareCalls = (tree, scope, report) => {
94
96
  const updatedFiles = [];
97
+ const graph = createModuleGraph(tree);
95
98
  scope.visit(tree, (filePath) => {
96
99
  if (!filePath.endsWith('.ts') || filePath.endsWith('.spec.ts')) {
97
100
  return;
@@ -100,7 +103,17 @@ export const migrateEmptyPrepareCalls = (tree, scope) => {
100
103
  if (!content || !content.includes('.prepare()')) {
101
104
  return;
102
105
  }
103
- const nextContent = transformEmptyPrepareCalls(content);
106
+ const { content: nextContent, unresolved } = transformEmptyPrepareCalls(tree, graph, filePath, content);
107
+ unresolved.forEach(({ line, receiver }) => {
108
+ report.addManualReview({
109
+ title: `Check the empty prepare() call on ${receiver}`,
110
+ summary: `The migration could not tell whether \`${receiver}\` is a legacy query creator, so it left \`${receiver}.prepare()\` as is. A legacy creator needs \`.prepare({})\` in v3.`,
111
+ action: `Change the call to \`${receiver}.prepare({})\` if \`${receiver}\` is a legacy query creator, otherwise leave it.`,
112
+ locations: [{ filePath, line }],
113
+ source: 'cleanup-migration',
114
+ dedupeKey: `unresolved-empty-prepare:${filePath}:${line}`,
115
+ });
116
+ });
104
117
  if (nextContent !== content) {
105
118
  tree.write(filePath, nextContent);
106
119
  updatedFiles.push(filePath);
@@ -111,31 +124,36 @@ export const migrateEmptyPrepareCalls = (tree, scope) => {
111
124
  }
112
125
  };
113
126
  const migrateDevtoolsInFile = (content) => {
114
- const importsComponent = importsFromQuery(content, 'QueryDevtoolsComponent');
115
- let result = replaceDevtoolsProviderCalls(content);
127
+ const componentNames = localNamesImportedFromQuery(content, 'QueryDevtoolsComponent');
128
+ const providerNames = new Set([
129
+ 'provideQueryClientForDevtools',
130
+ ...localNamesImportedFromQuery(content, 'provideQueryClientForDevtools'),
131
+ ]);
132
+ let result = replaceDevtoolsProviderCalls(content, providerNames);
116
133
  const hasProvider = result.includes('provideQueryDevtools()');
117
134
  result = dropLegacyDevtoolsImports(result);
118
135
  if (hasProvider) {
119
136
  result = ensureImportFromQuery(result, ['provideQueryDevtools']);
120
137
  }
121
- if (importsComponent) {
138
+ if (componentNames.length > 0) {
122
139
  result = ensureNamedImports({
123
140
  content: result,
124
- importsNeeded: ['QueryDevtoolsComponent'],
141
+ importsNeeded: componentNames.map((name) => name === 'QueryDevtoolsComponent' ? name : `QueryDevtoolsComponent as ${name}`),
125
142
  moduleSpecifier: '@ethlete/query-devtools',
126
143
  });
127
144
  }
128
- return { content: result, importsComponent };
129
- };
130
- const importsFromQuery = (content, name) => {
131
- const sourceFile = createSourceFile(content);
132
- return sourceFile.statements.some((node) => ts.isImportDeclaration(node) &&
133
- ts.isStringLiteral(node.moduleSpecifier) &&
134
- node.moduleSpecifier.text === '@ethlete/query' &&
135
- !!node.importClause?.namedBindings &&
136
- ts.isNamedImports(node.importClause.namedBindings) &&
137
- node.importClause.namedBindings.elements.some((element) => element.name.text === name));
145
+ return { content: result, importsComponent: componentNames.length > 0 };
138
146
  };
147
+ const queryImports = (sourceFile) => sourceFile.statements.filter((node) => ts.isImportDeclaration(node) &&
148
+ ts.isStringLiteral(node.moduleSpecifier) &&
149
+ node.moduleSpecifier.text === '@ethlete/query');
150
+ const namedImportElements = (node) => node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)
151
+ ? node.importClause.namedBindings.elements
152
+ : [];
153
+ const importedName = (element) => (element.propertyName ?? element.name).text;
154
+ const localNamesImportedFromQuery = (content, name) => queryImports(createSourceFile(content)).flatMap((node) => namedImportElements(node)
155
+ .filter((element) => importedName(element) === name)
156
+ .map((element) => element.name.text));
139
157
  /**
140
158
  * Turns every `provideQueryClientForDevtools({ client, displayName })` into a single
141
159
  * `provideQueryDevtools()`.
@@ -144,13 +162,11 @@ const importsFromQuery = (content, name) => {
144
162
  * first call site keeps its position - it is already where the app wanted its devtools - and the
145
163
  * rest are removed along with the comma that separated them.
146
164
  */
147
- const replaceDevtoolsProviderCalls = (content) => {
165
+ const replaceDevtoolsProviderCalls = (content, providerNames) => {
148
166
  const sourceFile = createSourceFile(content);
149
167
  const calls = [];
150
168
  const visit = (node) => {
151
- if (ts.isCallExpression(node) &&
152
- ts.isIdentifier(node.expression) &&
153
- node.expression.text === 'provideQueryClientForDevtools') {
169
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && providerNames.has(node.expression.text)) {
154
170
  calls.push(node);
155
171
  }
156
172
  ts.forEachChild(node, visit);
@@ -190,30 +206,27 @@ const withSurroundingComma = (content, start, end) => {
190
206
  }
191
207
  return { start, end };
192
208
  };
193
- /** Drops the v2 devtools names from the `@ethlete/query` import; both moved or were renamed. */
209
+ const LEGACY_DEVTOOLS_IMPORTS = new Set(['QueryDevtoolsComponent', 'provideQueryClientForDevtools']);
210
+ /** Drops the v2 devtools names from the `@ethlete/query` imports; both moved or were renamed. */
194
211
  const dropLegacyDevtoolsImports = (content) => {
195
212
  const sourceFile = createSourceFile(content);
196
- let importNode;
197
- ts.forEachChild(sourceFile, (node) => {
198
- if (ts.isImportDeclaration(node) &&
199
- ts.isStringLiteral(node.moduleSpecifier) &&
200
- node.moduleSpecifier.text === '@ethlete/query') {
201
- importNode = node;
213
+ const replacements = [];
214
+ for (const node of queryImports(sourceFile)) {
215
+ const elements = namedImportElements(node);
216
+ const nextElements = elements.filter((element) => !LEGACY_DEVTOOLS_IMPORTS.has(importedName(element)));
217
+ if (nextElements.length === elements.length) {
218
+ continue;
202
219
  }
203
- });
204
- if (!importNode?.importClause?.namedBindings || !ts.isNamedImports(importNode.importClause.namedBindings)) {
205
- return content;
220
+ const nextImport = printImportDeclaration(node, sourceFile, nextElements.map((element) => element.getText(sourceFile)));
221
+ const end = !nextImport && content[node.getEnd()] === '\n' ? node.getEnd() + 1 : node.getEnd();
222
+ replacements.push({ start: node.getStart(sourceFile), end, replacement: nextImport });
206
223
  }
207
- const nextElements = importNode.importClause.namedBindings.elements.filter((element) => element.name.text !== 'QueryDevtoolsComponent' && element.name.text !== 'provideQueryClientForDevtools');
208
- if (nextElements.length === importNode.importClause.namedBindings.elements.length) {
209
- return content;
210
- }
211
- if (nextElements.length === 0) {
212
- const nextLineEnd = content[importNode.getEnd()] === '\n' ? importNode.getEnd() + 1 : importNode.getEnd();
213
- return content.slice(0, importNode.getStart(sourceFile)) + content.slice(nextLineEnd);
214
- }
215
- const nextImport = `import { ${nextElements.map((element) => element.getText(sourceFile)).join(', ')} } from '@ethlete/query';`;
216
- return content.slice(0, importNode.getStart(sourceFile)) + nextImport + content.slice(importNode.getEnd());
224
+ let result = content;
225
+ replacements.sort((left, right) => right.start - left.start);
226
+ replacements.forEach(({ start, end, replacement }) => {
227
+ result = result.slice(0, start) + replacement + result.slice(end);
228
+ });
229
+ return result;
217
230
  };
218
231
  const replaceAnyQueryInFile = (content) => {
219
232
  const sourceFile = createSourceFile(content);
@@ -245,20 +258,96 @@ const replaceAnyQueryInFile = (content) => {
245
258
  });
246
259
  return result;
247
260
  };
248
- const transformEmptyPrepareCalls = (content) => {
249
- const sourceFile = createSourceFile(content);
261
+ const findTopLevelVariable = (sourceFile, name) => {
262
+ for (const statement of sourceFile.statements) {
263
+ if (!ts.isVariableStatement(statement))
264
+ continue;
265
+ const declaration = statement.declarationList.declarations.find((candidate) => ts.isIdentifier(candidate.name) && candidate.name.text === name);
266
+ if (declaration)
267
+ return declaration;
268
+ }
269
+ return undefined;
270
+ };
271
+ const findNamedImport = (sourceFile, localName) => {
272
+ for (const statement of sourceFile.statements) {
273
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
274
+ continue;
275
+ const element = namedImportElements(statement).find((candidate) => candidate.name.text === localName);
276
+ if (element)
277
+ return { specifier: statement.moduleSpecifier.text, importedName: importedName(element) };
278
+ }
279
+ return undefined;
280
+ };
281
+ const QUERY_CLIENT_CONSTRUCTORS = new Set(['V2QueryClient', 'QueryClient']);
282
+ /**
283
+ * Classifies what a top-level binding of `sourceFile` holds: a query creator (a call to an
284
+ * `@ethlete/query` export such as `createLegacyQueryCreator`, or a method call on a v2 client), something
285
+ * provably unrelated, or `unknown` when the binding cannot be followed.
286
+ */
287
+ const classifyBinding = (tree, graph, filePath, sourceFile, name, isClient, depth) => {
288
+ if (depth > 4)
289
+ return 'unknown';
290
+ const declaration = findTopLevelVariable(sourceFile, name);
291
+ if (declaration) {
292
+ const initializer = declaration.initializer;
293
+ if (isClient) {
294
+ return initializer &&
295
+ ts.isNewExpression(initializer) &&
296
+ ts.isIdentifier(initializer.expression) &&
297
+ QUERY_CLIENT_CONSTRUCTORS.has(initializer.expression.text) &&
298
+ findNamedImport(sourceFile, initializer.expression.text)?.specifier === '@ethlete/query'
299
+ ? 'query'
300
+ : 'foreign';
301
+ }
302
+ if (!initializer || !ts.isCallExpression(initializer))
303
+ return 'foreign';
304
+ const callee = initializer.expression;
305
+ if (ts.isIdentifier(callee)) {
306
+ return findNamedImport(sourceFile, callee.text)?.specifier === '@ethlete/query' ? 'query' : 'foreign';
307
+ }
308
+ if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) {
309
+ return classifyBinding(tree, graph, filePath, sourceFile, callee.expression.text, true, depth + 1);
310
+ }
311
+ return 'foreign';
312
+ }
313
+ const binding = findNamedImport(sourceFile, name);
314
+ if (!binding || binding.specifier === '@ethlete/query')
315
+ return 'unknown';
316
+ const declaringFile = graph.findDeclaringFile(filePath, binding.specifier, binding.importedName);
317
+ if (!declaringFile)
318
+ return graph.isWorkspaceSpecifier(binding.specifier) ? 'unknown' : 'foreign';
319
+ const declaringContent = tree.read(declaringFile, 'utf-8');
320
+ if (!declaringContent)
321
+ return 'unknown';
322
+ return classifyBinding(tree, graph, declaringFile, createSourceFile(declaringContent, declaringFile), binding.importedName, isClient, depth + 1);
323
+ };
324
+ const classifyPrepareReceiver = (tree, graph, filePath, sourceFile, receiver) => {
325
+ if (!ts.isIdentifier(receiver) || isShadowedInNestedScope(receiver))
326
+ return 'unknown';
327
+ return classifyBinding(tree, graph, filePath, sourceFile, receiver.text, false, 0);
328
+ };
329
+ const transformEmptyPrepareCalls = (tree, graph, filePath, content) => {
330
+ const sourceFile = createSourceFile(content, filePath);
250
331
  const replacements = [];
332
+ const unresolved = [];
251
333
  const visit = (node) => {
252
- if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
253
- const propertyAccess = node.expression;
254
- if (ts.isIdentifier(propertyAccess.name) &&
255
- propertyAccess.name.text === 'prepare' &&
256
- node.arguments.length === 0) {
257
- const callText = content.slice(node.getStart(sourceFile), node.getEnd());
334
+ if (ts.isCallExpression(node) &&
335
+ ts.isPropertyAccessExpression(node.expression) &&
336
+ ts.isIdentifier(node.expression.name) &&
337
+ node.expression.name.text === 'prepare' &&
338
+ node.arguments.length === 0) {
339
+ const kind = classifyPrepareReceiver(tree, graph, filePath, sourceFile, node.expression.expression);
340
+ if (kind === 'query') {
258
341
  replacements.push({
259
- start: node.getStart(sourceFile),
260
- end: node.getEnd(),
261
- replacement: callText.replace(/\.prepare\(\)/, '.prepare({})'),
342
+ start: node.arguments.pos,
343
+ end: node.arguments.end,
344
+ replacement: '{}',
345
+ });
346
+ }
347
+ else if (kind === 'unknown') {
348
+ unresolved.push({
349
+ line: getLineNumber(node, sourceFile),
350
+ receiver: node.expression.expression.getText(sourceFile),
262
351
  });
263
352
  }
264
353
  }
@@ -270,6 +359,6 @@ const transformEmptyPrepareCalls = (content) => {
270
359
  replacements.forEach(({ start, end, replacement }) => {
271
360
  result = result.slice(0, start) + replacement + result.slice(end);
272
361
  });
273
- return result;
362
+ return { content: result, unresolved };
274
363
  };
275
364
  //# sourceMappingURL=cleanup-migration.js.map
@@ -24,6 +24,7 @@ export const createMigrationScope = (tree, options) => {
24
24
  return {
25
25
  visit: (targetTree, callback) => visitNotIgnoredFiles(targetTree, '', callback),
26
26
  describe: () => 'the whole workspace',
27
+ isScoped: false,
27
28
  };
28
29
  }
29
30
  return {
@@ -33,6 +34,7 @@ export const createMigrationScope = (tree, options) => {
33
34
  }
34
35
  },
35
36
  describe: () => roots.join(', '),
37
+ isScoped: true,
36
38
  };
37
39
  };
38
40
  //# sourceMappingURL=migration-scope.js.map
@@ -19,18 +19,18 @@ export default async function migrate(tree, schema) {
19
19
  updateLegacyCreatorImportsAndUsages(tree, scope, report);
20
20
  }
21
21
  if (variableRenames.size > 0) {
22
- updateImportsAcrossWorkspace(tree, variableRenames, scope);
22
+ updateImportsAcrossWorkspace(tree, variableRenames, scope, report);
23
23
  }
24
24
  replaceAnyQueryWithLegacy(tree, scope);
25
25
  migrateDevtoolsUsage(tree, scope, report);
26
- migrateEmptyPrepareCalls(tree, scope);
26
+ migrateEmptyPrepareCalls(tree, scope, report);
27
27
  migrateLegacyPrepareCalls(tree, report, scope);
28
28
  reportRemovedExperimentalQueryHelpers(tree, scope, report);
29
29
  if (queryClientFiles.size > 0) {
30
30
  reportMissingHttpClientProviders(tree, report, scope);
31
31
  reportDefaultHeaderUsages(tree, report, scope);
32
32
  }
33
- report.writeToTree(tree);
33
+ report.writeToTree(tree, scope.isScoped ? scope.describe() : undefined);
34
34
  report.printSummary();
35
35
  if (!schema.skipFormat) {
36
36
  await formatFiles(tree);
@@ -15,8 +15,9 @@ const normalizePath = (path) => {
15
15
  return segments.join('/');
16
16
  };
17
17
  const dirName = (path) => path.slice(0, Math.max(0, path.lastIndexOf('/')));
18
- const readTsConfigPaths = (tree) => {
18
+ const readTsConfig = (tree) => {
19
19
  const paths = new Map();
20
+ let baseUrl = null;
20
21
  for (const configPath of ['tsconfig.base.json', 'tsconfig.json']) {
21
22
  const raw = tree.read(configPath, 'utf-8');
22
23
  if (!raw)
@@ -25,6 +26,9 @@ const readTsConfigPaths = (tree) => {
25
26
  // tsconfig files are JSONC in practice; the TS parser is the only thing that reliably reads
26
27
  // them, comments and trailing commas included.
27
28
  const parsed = ts.parseConfigFileTextToJson(configPath, raw).config;
29
+ const configBaseUrl = parsed?.compilerOptions?.baseUrl;
30
+ if (baseUrl === null && typeof configBaseUrl === 'string')
31
+ baseUrl = normalizePath(configBaseUrl);
28
32
  Object.entries(parsed?.compilerOptions?.paths ?? {}).forEach(([key, targets]) => {
29
33
  if (!paths.has(key))
30
34
  paths.set(key, targets);
@@ -34,45 +38,49 @@ const readTsConfigPaths = (tree) => {
34
38
  // A tsconfig we cannot parse just means fewer resolvable aliases, not a failed migration.
35
39
  }
36
40
  }
37
- return paths;
41
+ return { paths, baseUrl };
38
42
  };
43
+ /** The part of `specifier` the `*` of `pattern` stands for, `''` for an exact match, or `null`. */
44
+ const matchPathAlias = (pattern, specifier) => {
45
+ const wildcardIndex = pattern.indexOf('*');
46
+ if (wildcardIndex === -1)
47
+ return pattern === specifier ? '' : null;
48
+ const prefix = pattern.slice(0, wildcardIndex);
49
+ const patternSuffix = pattern.slice(wildcardIndex + 1);
50
+ if (!specifier.startsWith(prefix) || !specifier.endsWith(patternSuffix))
51
+ return null;
52
+ return specifier.slice(prefix.length, specifier.length - patternSuffix.length);
53
+ };
54
+ const JS_EXTENSION = /\.(m?)js$/;
39
55
  /** The files a specifier could point at, most specific first. */
40
- const candidateFiles = (base) => [
41
- base.endsWith('.ts') ? base : `${base}.ts`,
42
- `${base}/index.ts`,
43
- `${base}.d.ts`,
44
- ];
56
+ const candidateFiles = (base) => {
57
+ if (base.endsWith('.ts'))
58
+ return [base];
59
+ const jsExtension = JS_EXTENSION.exec(base);
60
+ if (jsExtension) {
61
+ const stem = base.slice(0, -jsExtension[0].length);
62
+ return [`${stem}.${jsExtension[1]}ts`, `${stem}.ts`, `${stem}.d.ts`, `${base}/index.ts`];
63
+ }
64
+ return [`${base}.ts`, `${base}/index.ts`, `${base}.d.ts`];
65
+ };
45
66
  export const createModuleGraph = (tree) => {
46
- const tsConfigPaths = readTsConfigPaths(tree);
67
+ const { paths: tsConfigPaths, baseUrl } = readTsConfig(tree);
68
+ const findCandidate = (base) => candidateFiles(base).find((candidate) => tree.exists(candidate)) ?? null;
47
69
  const declaringFileCache = new Map();
48
70
  const resolveEntryFile = (fromFile, specifier) => {
49
- if (specifier.startsWith('.')) {
50
- const base = normalizePath(`${dirName(fromFile)}/${specifier}`);
51
- return candidateFiles(base).find((candidate) => tree.exists(candidate)) ?? null;
52
- }
71
+ if (specifier.startsWith('.'))
72
+ return findCandidate(normalizePath(`${dirName(fromFile)}/${specifier}`));
53
73
  for (const [pattern, targets] of tsConfigPaths.entries()) {
54
- const wildcardIndex = pattern.indexOf('*');
55
- let suffix;
56
- if (wildcardIndex === -1) {
57
- if (pattern !== specifier)
58
- continue;
59
- suffix = '';
60
- }
61
- else {
62
- const prefix = pattern.slice(0, wildcardIndex);
63
- const patternSuffix = pattern.slice(wildcardIndex + 1);
64
- if (!specifier.startsWith(prefix) || !specifier.endsWith(patternSuffix))
65
- continue;
66
- suffix = specifier.slice(prefix.length, specifier.length - patternSuffix.length);
67
- }
74
+ const wildcard = matchPathAlias(pattern, specifier);
75
+ if (wildcard === null)
76
+ continue;
68
77
  for (const target of targets) {
69
- const resolvedTarget = normalizePath(target.replace('*', suffix));
70
- const candidate = candidateFiles(resolvedTarget).find((option) => tree.exists(option));
78
+ const candidate = findCandidate(normalizePath(`${baseUrl ?? ''}/${target.replace('*', wildcard)}`));
71
79
  if (candidate)
72
80
  return candidate;
73
81
  }
74
82
  }
75
- return null;
83
+ return baseUrl === null ? null : findCandidate(normalizePath(`${baseUrl}/${specifier}`));
76
84
  };
77
85
  const declaresSymbol = (sourceFile, symbolName) => {
78
86
  let declares = false;
@@ -140,6 +148,8 @@ export const createModuleGraph = (tree) => {
140
148
  return {
141
149
  resolveFile: resolveEntryFile,
142
150
  pathAliases: () => [...tsConfigPaths.keys()],
151
+ isWorkspaceSpecifier: (specifier) => specifier.startsWith('.') ||
152
+ [...tsConfigPaths.keys()].some((pattern) => matchPathAlias(pattern, specifier) !== null),
143
153
  findDeclaringFile: (fromFile, specifier, symbolName) => {
144
154
  const cacheKey = `${fromFile}|${specifier}|${symbolName}`;
145
155
  const cached = declaringFileCache.get(cacheKey);