@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.
@@ -1,6 +1,8 @@
1
1
  import * as ts from 'typescript';
2
2
  import { CLIENT_FEATURE, reportClientErrorPipelineAdded } from '../migrate-query-opt-in-features/migration.js';
3
- import { capitalizeFirstLetter, createSourceFile, ensureConfigSuffix, ensureImportFromEthleteCore, ensureImportFromQuery, getVariableStatementEnd, } from './shared.js';
3
+ import { createModuleGraph } from './module-graph.js';
4
+ import { isNamePosition, isShadowedInNestedScope, renamedReference } from './rename-symbols.js';
5
+ import { capitalizeFirstLetter, createSourceFile, ensureConfigSuffix, ensureImportFromEthleteCore, ensureImportFromQuery, getVariableStatementEnd, printImportDeclaration, } from './shared.js';
4
6
  export const migrateQueryClients = (tree, report, scope) => {
5
7
  const queryClientFiles = new Map();
6
8
  const variableRenames = new Map();
@@ -14,9 +16,9 @@ export const migrateQueryClients = (tree, report, scope) => {
14
16
  }
15
17
  const renames = new Map();
16
18
  const nextContent = migrateQueryClientToConfig({ content, filePath, renamesOut: renames, report });
17
- renames.forEach((newName, oldName) => {
18
- variableRenames.set(oldName, newName);
19
- });
19
+ if (renames.size > 0) {
20
+ variableRenames.set(filePath, { originalContent: content, renames });
21
+ }
20
22
  const configNames = extractClientConfigNames(nextContent);
21
23
  if (configNames.length > 0) {
22
24
  queryClientFiles.set(filePath, configNames);
@@ -69,10 +71,12 @@ export const generateQueryCreators = (tree, queryClientFiles) => {
69
71
  generatedFiles.forEach((filePath) => console.log(` - ${filePath}`));
70
72
  }
71
73
  };
72
- export const updateImportsAcrossWorkspace = (tree, renames, scope) => {
74
+ export const updateImportsAcrossWorkspace = (tree, renames, scope, report) => {
73
75
  if (renames.size === 0) {
74
76
  return;
75
77
  }
78
+ const originalContents = new Map(Array.from(renames.entries()).map(([filePath, { originalContent }]) => [filePath, originalContent]));
79
+ const graph = createModuleGraph(withOriginalContents(tree, originalContents));
76
80
  scope.visit(tree, (filePath) => {
77
81
  if (!filePath.endsWith('.ts')) {
78
82
  return;
@@ -81,12 +85,42 @@ export const updateImportsAcrossWorkspace = (tree, renames, scope) => {
81
85
  if (!content) {
82
86
  return;
83
87
  }
84
- const nextContent = updateImportsInFile(content, renames);
88
+ const { content: nextContent, renamedByName } = updateImportsInFile(content, filePath, renames, graph);
89
+ if (renamedByName.size > 0) {
90
+ report.addManualReview({
91
+ title: `Check the client rename in ${filePath}`,
92
+ summary: `The migration could not resolve ${[...renamedByName].map((specifier) => `\`${specifier}\``).join(', ')} to a file, so it renamed the imported client by its name alone.`,
93
+ action: 'Confirm the renamed import really is the migrated query client, and revert it if it is not.',
94
+ locations: [{ filePath }],
95
+ source: 'query-client-migration',
96
+ dedupeKey: `client-rename-by-name:${filePath}`,
97
+ });
98
+ }
85
99
  if (nextContent !== content) {
100
+ originalContents.set(filePath, content);
86
101
  tree.write(filePath, nextContent);
87
102
  }
88
103
  });
89
104
  };
105
+ /**
106
+ * A view of `tree` that still reads the pre-migration content of `originalContents`, so the module
107
+ * graph resolves a client import by the name it had when the import was written.
108
+ */
109
+ const withOriginalContents = (tree, originalContents) => new Proxy(tree, {
110
+ get: (target, property) => {
111
+ if (property === 'read') {
112
+ return (filePath, encoding) => {
113
+ const original = originalContents.get(filePath);
114
+ if (original === undefined) {
115
+ return encoding ? target.read(filePath, encoding) : target.read(filePath);
116
+ }
117
+ return encoding ? original : Buffer.from(original);
118
+ };
119
+ }
120
+ const value = Reflect.get(target, property, target);
121
+ return typeof value === 'function' ? value.bind(target) : value;
122
+ },
123
+ });
90
124
  const hasQueryClientInstantiation = (content) => {
91
125
  const sourceFile = createSourceFile(content);
92
126
  let hasQueryClientImport = false;
@@ -202,11 +236,31 @@ const removeQueryClientImport = (content, keepLegacyClient) => {
202
236
  });
203
237
  const nextImports = new Set(requiredImports);
204
238
  existingElements.forEach((element) => nextImports.add(element.getText(sourceFile)));
205
- const nextImportStatement = `import { ${Array.from(nextImports).sort().join(', ')} } from '@ethlete/query';`;
239
+ const nextImportStatement = printImportDeclaration(queryImportNode, sourceFile, Array.from(nextImports).sort());
206
240
  return (content.slice(0, queryImportNode.getStart(sourceFile)) +
207
241
  nextImportStatement +
208
242
  content.slice(queryImportNode.getEnd()));
209
243
  };
244
+ const isTopLevelVariableName = (node) => {
245
+ const declaration = node.parent;
246
+ return (ts.isVariableDeclaration(declaration) &&
247
+ declaration.name === node &&
248
+ ts.isVariableStatement(declaration.parent.parent) &&
249
+ ts.isSourceFile(declaration.parent.parent.parent));
250
+ };
251
+ const renameInDeclaringFile = (node, nextName) => {
252
+ const parent = node.parent;
253
+ if (isTopLevelVariableName(node)) {
254
+ return nextName;
255
+ }
256
+ if (ts.isExportSpecifier(parent) && !parent.parent.parent.moduleSpecifier) {
257
+ return (parent.propertyName ?? parent.name) === node ? nextName : undefined;
258
+ }
259
+ if (isNamePosition(node) || isShadowedInNestedScope(node)) {
260
+ return undefined;
261
+ }
262
+ return renamedReference(node, nextName);
263
+ };
210
264
  const renameVariables = (content, renames) => {
211
265
  if (renames.size === 0) {
212
266
  return content;
@@ -214,44 +268,21 @@ const renameVariables = (content, renames) => {
214
268
  const sourceFile = createSourceFile(content);
215
269
  const replacements = [];
216
270
  const visit = (node) => {
217
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
218
- const nextName = renames.get(node.name.text);
219
- if (nextName) {
220
- replacements.push({
221
- start: node.name.getStart(sourceFile),
222
- end: node.name.getEnd(),
223
- replacement: nextName,
224
- });
225
- }
226
- }
227
- else if (ts.isIdentifier(node)) {
228
- const nextName = renames.get(node.text);
229
- if (nextName) {
230
- const parent = node.parent;
231
- if (!ts.isPropertyAssignment(parent) || parent.name !== node) {
232
- replacements.push({
233
- start: node.getStart(sourceFile),
234
- end: node.getEnd(),
235
- replacement: nextName,
236
- });
237
- }
238
- }
271
+ const nextName = ts.isIdentifier(node) ? renames.get(node.text) : undefined;
272
+ const replacement = nextName && renameInDeclaringFile(node, nextName);
273
+ if (replacement) {
274
+ replacements.push({ start: node.getStart(sourceFile), end: node.getEnd(), replacement });
239
275
  }
240
276
  ts.forEachChild(node, visit);
241
277
  };
242
278
  visit(sourceFile);
243
- const seen = new Set();
244
- const uniqueReplacements = replacements.filter((replacement) => {
245
- const key = `${replacement.start}:${replacement.end}`;
246
- if (seen.has(key)) {
247
- return false;
248
- }
249
- seen.add(key);
250
- return true;
251
- });
279
+ return applyReplacements(content, replacements);
280
+ };
281
+ const applyReplacements = (content, replacements) => {
252
282
  let result = content;
253
- uniqueReplacements.sort((left, right) => right.start - left.start);
254
- uniqueReplacements.forEach(({ start, end, replacement }) => {
283
+ [...replacements]
284
+ .sort((left, right) => right.start - left.start)
285
+ .forEach(({ start, end, replacement }) => {
255
286
  result = result.slice(0, start) + replacement + result.slice(end);
256
287
  });
257
288
  return result;
@@ -539,107 +570,90 @@ const generateCreatorsForConfig = (configName) => {
539
570
  `export const ${creatorBaseName}Delete = createDeleteQuery(${configVariableName});`,
540
571
  ].join('\n');
541
572
  };
542
- const declaresName = (declarations, name) => declarations.some((declaration) => declaration.name && ts.isIdentifier(declaration.name) && declaration.name.text === name);
543
- const isShadowedByLocalDeclaration = (identifier) => {
544
- for (let scope = identifier.parent; scope && !ts.isSourceFile(scope); scope = scope.parent) {
545
- if (ts.isFunctionLike(scope) && declaresName(scope.parameters, identifier.text)) {
546
- return true;
547
- }
548
- if (ts.isBlock(scope) &&
549
- scope.statements.some((statement) => ts.isVariableStatement(statement) && declaresName(statement.declarationList.declarations, identifier.text))) {
550
- return true;
551
- }
552
- }
553
- return false;
554
- };
555
- const updateImportsInFile = (content, renames) => {
556
- const sourceFile = createSourceFile(content);
573
+ /**
574
+ * Points imports and re-exports of a renamed client at its new name, and renames the references to
575
+ * each unaliased import. Only a binding the module graph resolves to the client's declaring file is
576
+ * touched; a same-named symbol from any other module stays as it is. A workspace specifier the graph
577
+ * cannot resolve falls back to the rename by name and is returned in `renamedByName` for the report.
578
+ */
579
+ const updateImportsInFile = (content, filePath, renames, graph) => {
580
+ const sourceFile = createSourceFile(content, filePath);
557
581
  const replacements = [];
558
- const visit = (node) => {
559
- if (ts.isImportDeclaration(node) &&
560
- node.importClause?.namedBindings &&
561
- ts.isNamedImports(node.importClause.namedBindings)) {
562
- const nextElements = [];
582
+ const localRenames = new Map();
583
+ const renamedByName = new Set();
584
+ const resolveRename = (specifier, importedName) => {
585
+ const declaringFile = graph.findDeclaringFile(filePath, specifier, importedName);
586
+ if (declaringFile) {
587
+ return declaringFile !== filePath ? renames.get(declaringFile)?.renames.get(importedName) : undefined;
588
+ }
589
+ if (!graph.isWorkspaceSpecifier(specifier))
590
+ return undefined;
591
+ const nextName = [...renames.values()].find((entry) => entry.renames.has(importedName))?.renames.get(importedName);
592
+ if (nextName)
593
+ renamedByName.add(specifier);
594
+ return nextName;
595
+ };
596
+ for (const statement of sourceFile.statements) {
597
+ if (ts.isImportDeclaration(statement) &&
598
+ ts.isStringLiteral(statement.moduleSpecifier) &&
599
+ statement.importClause?.namedBindings &&
600
+ ts.isNamedImports(statement.importClause.namedBindings)) {
601
+ const specifier = statement.moduleSpecifier.text;
563
602
  let hasChanges = false;
564
- node.importClause.namedBindings.elements.forEach((element) => {
565
- const nextName = renames.get(element.propertyName?.text ?? element.name.text);
603
+ const nextElements = statement.importClause.namedBindings.elements.map((element) => {
604
+ const nextName = resolveRename(specifier, (element.propertyName ?? element.name).text);
566
605
  if (!nextName) {
567
- nextElements.push(element.getText(sourceFile));
568
- return;
606
+ return element.getText(sourceFile);
569
607
  }
570
608
  hasChanges = true;
571
- nextElements.push(element.propertyName ? `${nextName} as ${element.name.text}` : nextName);
609
+ const typePrefix = element.isTypeOnly ? 'type ' : '';
610
+ if (element.propertyName) {
611
+ return `${typePrefix}${nextName} as ${element.name.text}`;
612
+ }
613
+ localRenames.set(element.name.text, nextName);
614
+ return `${typePrefix}${nextName}`;
572
615
  });
573
616
  if (hasChanges) {
574
617
  replacements.push({
575
- start: node.getStart(sourceFile),
576
- end: node.getEnd(),
577
- replacement: `import { ${nextElements.join(', ')} } from '${node.moduleSpecifier.text}';`,
618
+ start: statement.getStart(sourceFile),
619
+ end: statement.getEnd(),
620
+ replacement: printImportDeclaration(statement, sourceFile, nextElements),
578
621
  });
579
622
  }
580
623
  }
581
- if (ts.isIdentifier(node)) {
582
- const nextName = renames.get(node.text);
583
- if (!nextName) {
584
- ts.forEachChild(node, visit);
585
- return;
586
- }
587
- const parent = node.parent;
588
- if (ts.isImportSpecifier(parent)) {
589
- ts.forEachChild(node, visit);
590
- return;
591
- }
592
- if (ts.isVariableDeclaration(parent) && parent.name === node) {
593
- ts.forEachChild(node, visit);
594
- return;
595
- }
596
- if (ts.isFunctionDeclaration(parent) && parent.name === node) {
597
- ts.forEachChild(node, visit);
598
- return;
599
- }
600
- if (ts.isMethodDeclaration(parent) && parent.name === node) {
601
- ts.forEachChild(node, visit);
602
- return;
603
- }
604
- if (ts.isParameter(parent) && parent.name === node) {
605
- ts.forEachChild(node, visit);
606
- return;
607
- }
608
- if (ts.isPropertyDeclaration(parent) && parent.name === node) {
609
- ts.forEachChild(node, visit);
610
- return;
611
- }
612
- if (ts.isPropertyAccessExpression(parent) && parent.name === node) {
613
- ts.forEachChild(node, visit);
614
- return;
615
- }
616
- if ((ts.isPropertyAssignment(parent) && parent.name === node) || isShadowedByLocalDeclaration(node)) {
617
- ts.forEachChild(node, visit);
618
- return;
619
- }
624
+ if (ts.isExportDeclaration(statement) &&
625
+ statement.moduleSpecifier &&
626
+ ts.isStringLiteral(statement.moduleSpecifier) &&
627
+ statement.exportClause &&
628
+ ts.isNamedExports(statement.exportClause)) {
629
+ const specifier = statement.moduleSpecifier.text;
630
+ statement.exportClause.elements.forEach((element) => {
631
+ const exportedFrom = element.propertyName ?? element.name;
632
+ const nextName = ts.isIdentifier(exportedFrom) ? resolveRename(specifier, exportedFrom.text) : undefined;
633
+ if (nextName) {
634
+ replacements.push({
635
+ start: exportedFrom.getStart(sourceFile),
636
+ end: exportedFrom.getEnd(),
637
+ replacement: nextName,
638
+ });
639
+ }
640
+ });
641
+ }
642
+ }
643
+ const visit = (node) => {
644
+ const nextName = ts.isIdentifier(node) ? localRenames.get(node.text) : undefined;
645
+ if (nextName && !isNamePosition(node) && !isShadowedInNestedScope(node)) {
620
646
  replacements.push({
621
647
  start: node.getStart(sourceFile),
622
648
  end: node.getEnd(),
623
- replacement: nextName,
649
+ replacement: renamedReference(node, nextName),
624
650
  });
625
651
  }
626
652
  ts.forEachChild(node, visit);
627
653
  };
628
- visit(sourceFile);
629
- let result = content;
630
- const seen = new Set();
631
- const uniqueReplacements = replacements.filter((replacement) => {
632
- const key = `${replacement.start}:${replacement.end}`;
633
- if (seen.has(key)) {
634
- return false;
635
- }
636
- seen.add(key);
637
- return true;
638
- });
639
- uniqueReplacements.sort((left, right) => right.start - left.start);
640
- uniqueReplacements.forEach(({ start, end, replacement }) => {
641
- result = result.slice(0, start) + replacement + result.slice(end);
642
- });
643
- return result;
654
+ if (localRenames.size > 0) {
655
+ visit(sourceFile);
656
+ }
657
+ return { content: applyReplacements(content, replacements), renamedByName };
644
658
  };
645
659
  //# sourceMappingURL=query-client-migration.js.map
@@ -1,11 +1,11 @@
1
1
  import * as ts from 'typescript';
2
- import { createSourceFile } from './shared.js';
2
+ import { createSourceFile, printImportDeclaration } from './shared.js';
3
3
  /**
4
4
  * Whether an identifier is a *name* rather than a *reference* - a declaration, a property key, the
5
5
  * right-hand side of a member access. Renaming those is what turned `PeopleDetailDataSource.getPerson`
6
6
  * and the `postLogin` property of a config type into `legacy…` during the first migration run.
7
7
  */
8
- const isNamePosition = (node) => {
8
+ export const isNamePosition = (node) => {
9
9
  const parent = node.parent;
10
10
  if (!parent)
11
11
  return false;
@@ -65,7 +65,7 @@ const isNamePosition = (node) => {
65
65
  return true;
66
66
  return false;
67
67
  };
68
- const renamedReference = (node, nextName) => {
68
+ export const renamedReference = (node, nextName) => {
69
69
  const parent = node.parent;
70
70
  if (ts.isShorthandPropertyAssignment(parent))
71
71
  return `${node.text}: ${nextName}`;
@@ -73,6 +73,38 @@ const renamedReference = (node, nextName) => {
73
73
  return `${nextName} as ${node.text}`;
74
74
  return nextName;
75
75
  };
76
+ const bindingDeclares = (name, text) => ts.isIdentifier(name)
77
+ ? name.text === text
78
+ : name.elements.some((element) => !ts.isOmittedExpression(element) && bindingDeclares(element.name, text));
79
+ const statementDeclares = (statement, text) => (ts.isVariableStatement(statement) &&
80
+ statement.declarationList.declarations.some((declaration) => bindingDeclares(declaration.name, text))) ||
81
+ ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement)) &&
82
+ statement.name?.text === text);
83
+ export const isShadowedInNestedScope = (identifier) => {
84
+ const text = identifier.text;
85
+ for (let scope = identifier.parent; scope && !ts.isSourceFile(scope); scope = scope.parent) {
86
+ if (ts.isFunctionLike(scope) && scope.parameters.some((parameter) => bindingDeclares(parameter.name, text))) {
87
+ return true;
88
+ }
89
+ if ((ts.isFunctionExpression(scope) || ts.isClassExpression(scope)) && scope.name?.text === text) {
90
+ return true;
91
+ }
92
+ if ((ts.isBlock(scope) || ts.isModuleBlock(scope) || ts.isCaseClause(scope) || ts.isDefaultClause(scope)) &&
93
+ scope.statements.some((statement) => statementDeclares(statement, text))) {
94
+ return true;
95
+ }
96
+ if ((ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
97
+ scope.initializer &&
98
+ ts.isVariableDeclarationList(scope.initializer) &&
99
+ scope.initializer.declarations.some((declaration) => bindingDeclares(declaration.name, text))) {
100
+ return true;
101
+ }
102
+ if (ts.isCatchClause(scope) && scope.variableDeclaration && bindingDeclares(scope.variableDeclaration.name, text)) {
103
+ return true;
104
+ }
105
+ }
106
+ return false;
107
+ };
76
108
  /** Every name the file declares itself - a local by the same name means the import is shadowed. */
77
109
  const collectLocalDeclarations = (sourceFile) => {
78
110
  const declared = new Set();
@@ -183,18 +215,13 @@ export const pruneUnusedNamedImports = (content) => {
183
215
  const kept = node.importClause.namedBindings.elements.filter((element) => used.has(element.name.text));
184
216
  if (kept.length === node.importClause.namedBindings.elements.length)
185
217
  continue;
186
- const hasDefaultImport = !!node.importClause.name;
187
- if (kept.length === 0 && !hasDefaultImport) {
218
+ const nextImport = printImportDeclaration(node, sourceFile, kept.map((element) => element.getText(sourceFile)));
219
+ if (!nextImport) {
188
220
  const end = content[node.getEnd()] === '\n' ? node.getEnd() + 1 : node.getEnd();
189
221
  replacements.push({ start: node.getStart(sourceFile), end, replacement: '' });
190
222
  continue;
191
223
  }
192
- const defaultPart = hasDefaultImport ? `${node.importClause.name.text}, ` : '';
193
- replacements.push({
194
- start: node.getStart(sourceFile),
195
- end: node.getEnd(),
196
- replacement: `import ${defaultPart}{ ${kept.map((element) => element.getText(sourceFile)).join(', ')} } from '${node.moduleSpecifier.text}';`,
197
- });
224
+ replacements.push({ start: node.getStart(sourceFile), end: node.getEnd(), replacement: nextImport });
198
225
  }
199
226
  let result = content;
200
227
  replacements.sort((left, right) => right.start - left.start);
@@ -25,7 +25,7 @@ export class QueryV3MigrationReport {
25
25
  return;
26
26
  }
27
27
  const nextTask = {
28
- id: `QV3-${String(this.tasks.length + 1).padStart(3, '0')}`,
28
+ id: formatTaskId(this.tasks.length + 1),
29
29
  kind: input.kind,
30
30
  title: input.title,
31
31
  summary: input.summary,
@@ -58,11 +58,37 @@ export class QueryV3MigrationReport {
58
58
  });
59
59
  console.warn(`\nšŸ“„ See ${QUERY_V3_MIGRATION_REPORT_PATH} for the AI-ready follow-up list.`);
60
60
  }
61
- writeToTree(tree) {
62
- tree.write(QUERY_V3_MIGRATION_REPORT_PATH, renderMigrationReportMarkdown(this.tasks));
61
+ /**
62
+ * Writes the report. A scoped run (`scopeLabel` set) replaces its own section of an existing report
63
+ * or appends one, so the tasks of earlier runs over other projects survive.
64
+ */
65
+ writeToTree(tree, scopeLabel) {
66
+ const existing = scopeLabel ? tree.read(QUERY_V3_MIGRATION_REPORT_PATH, 'utf-8') : null;
67
+ if (!scopeLabel || !existing) {
68
+ tree.write(QUERY_V3_MIGRATION_REPORT_PATH, renderMigrationReportMarkdown(this.tasks, scopeLabel));
69
+ return;
70
+ }
71
+ tree.write(QUERY_V3_MIGRATION_REPORT_PATH, mergeScopedSection(existing, this.tasks, scopeLabel));
63
72
  }
64
73
  }
65
- export const renderMigrationReportMarkdown = (tasks) => {
74
+ const sectionStart = (scopeLabel) => `<!-- migrate-to-query-v3 scope: ${scopeLabel} -->`;
75
+ const SECTION_END = '<!-- /migrate-to-query-v3 scope -->';
76
+ const mergeScopedSection = (existing, tasks, scopeLabel) => {
77
+ const start = sectionStart(scopeLabel);
78
+ const startIndex = existing.indexOf(start);
79
+ const endIndex = startIndex === -1 ? -1 : existing.indexOf(SECTION_END, startIndex);
80
+ const [before, after] = startIndex === -1 || endIndex === -1
81
+ ? [existing.trimEnd(), '']
82
+ : [existing.slice(0, startIndex).trimEnd(), existing.slice(endIndex + SECTION_END.length).trimStart()];
83
+ const highestId = Math.max(0, ...Array.from(`${before}\n${after}`.matchAll(/QV3-(\d+)/g), (match) => Number(match[1])));
84
+ const renumbered = tasks.map((task, index) => ({ ...task, id: formatTaskId(highestId + index + 1) }));
85
+ return [before, renderTasksSection(renumbered, scopeLabel), after]
86
+ .filter((part) => part.length > 0)
87
+ .join('\n\n')
88
+ .concat('\n');
89
+ };
90
+ const formatTaskId = (index) => `QV3-${String(index).padStart(3, '0')}`;
91
+ export const renderMigrationReportMarkdown = (tasks, scopeLabel) => {
66
92
  const header = [
67
93
  '# Query V3 Migration Follow-Up',
68
94
  '',
@@ -83,8 +109,16 @@ export const renderMigrationReportMarkdown = (tasks) => {
83
109
  '- `Source`: which migration phase raised the task.',
84
110
  '',
85
111
  ].join('\n');
112
+ return `${header}${renderTasksSection(tasks, scopeLabel)}\n`;
113
+ };
114
+ const renderTasksSection = (tasks, scopeLabel) => {
115
+ const heading = scopeLabel ? `## Tasks (scope: ${scopeLabel})` : '## Tasks';
116
+ const body = renderTasksBody(tasks);
117
+ return scopeLabel ? `${sectionStart(scopeLabel)}\n${heading}\n\n${body}\n${SECTION_END}` : `${heading}\n\n${body}`;
118
+ };
119
+ const renderTasksBody = (tasks) => {
86
120
  if (tasks.length === 0) {
87
- return `${header}## Tasks\n\nNo open follow-up tasks were recorded during this migration run.\n`;
121
+ return 'No open follow-up tasks were recorded during this migration run.';
88
122
  }
89
123
  const tasksSection = tasks
90
124
  .map((task) => {
@@ -129,8 +163,7 @@ export const renderMigrationReportMarkdown = (tasks) => {
129
163
  ];
130
164
  }),
131
165
  '```',
132
- '',
133
166
  ].join('\n');
134
- return `${header}## Tasks\n\n${tasksSection}${machineSection}`;
167
+ return `${tasksSection}${machineSection}`;
135
168
  };
136
169
  //# sourceMappingURL=report.js.map
@@ -13,6 +13,23 @@ export const ensureConfigSuffix = (name) => {
13
13
  }
14
14
  return `${name}Config`;
15
15
  };
16
+ const importSpecifierText = (element) => element.propertyName ? `${element.propertyName.text} as ${element.name.text}` : element.name.text;
17
+ /**
18
+ * Prints `node` with `namedElements` as its named bindings, keeping the default import, the
19
+ * `import type` modifier, the quote style and any import attributes. Returns an empty string when
20
+ * nothing would be left to import.
21
+ */
22
+ export const printImportDeclaration = (node, sourceFile, namedElements) => {
23
+ const clause = node.importClause;
24
+ const defaultName = clause?.name?.text;
25
+ const bindings = [defaultName, namedElements.length > 0 ? `{ ${namedElements.join(', ')} }` : undefined].filter((part) => !!part);
26
+ if (bindings.length === 0) {
27
+ return '';
28
+ }
29
+ const typePrefix = clause?.isTypeOnly ? 'type ' : '';
30
+ const attributes = node.attributes ? ` ${node.attributes.getText(sourceFile)}` : '';
31
+ return `import ${typePrefix}${bindings.join(', ')} from ${node.moduleSpecifier.getText(sourceFile)}${attributes};`;
32
+ };
16
33
  export const ensureNamedImports = ({ content, importsNeeded, moduleSpecifier }) => {
17
34
  const sourceFile = createSourceFile(content);
18
35
  let importNode;
@@ -32,13 +49,14 @@ export const ensureNamedImports = ({ content, importsNeeded, moduleSpecifier })
32
49
  const namedBindings = importNode.importClause.namedBindings;
33
50
  namedBindings.elements.forEach((element) => {
34
51
  missingImports.delete(element.name.text);
52
+ missingImports.delete(importSpecifierText(element));
35
53
  });
36
54
  if (missingImports.size === 0) {
37
55
  return content;
38
56
  }
39
57
  const existingImports = namedBindings.elements.map((element) => element.getText(sourceFile));
40
58
  const nextImports = [...existingImports, ...Array.from(missingImports).sort()].sort();
41
- const nextImportStatement = `import { ${nextImports.join(', ')} } from '${moduleSpecifier}';`;
59
+ const nextImportStatement = printImportDeclaration(importNode, sourceFile, nextImports);
42
60
  return content.slice(0, importNode.getStart(sourceFile)) + nextImportStatement + content.slice(importNode.getEnd());
43
61
  }
44
62
  let lastImportEnd = 0;