@craft-ng/dev-tools 0.5.0-beta.4 → 0.5.1-beta.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.
Files changed (67) hide show
  1. package/README.md +132 -23
  2. package/package.json +6 -2
  3. package/src/bin/craft-migrate-primitives.d.ts +2 -0
  4. package/src/bin/craft-migrate-primitives.js +71 -0
  5. package/src/bin/craft-migrate-primitives.js.map +1 -0
  6. package/src/bin/craft-migrate-routes.d.ts +2 -0
  7. package/src/bin/craft-migrate-routes.js +77 -0
  8. package/src/bin/craft-migrate-routes.js.map +1 -0
  9. package/src/bin/craft-migrate-services.d.ts +2 -0
  10. package/src/bin/craft-migrate-services.js +75 -0
  11. package/src/bin/craft-migrate-services.js.map +1 -0
  12. package/src/bin/craft-migrate.d.ts +2 -0
  13. package/src/bin/craft-migrate.js +90 -0
  14. package/src/bin/craft-migrate.js.map +1 -0
  15. package/src/eslint-rules/global-exception-registry-match.cjs +4 -4
  16. package/src/eslint-rules/index.cjs +6 -0
  17. package/src/eslint-rules/require-craft-exception-handler.cjs +142 -0
  18. package/src/eslint-rules/require-exception-component-di-check.cjs +314 -0
  19. package/src/eslint-rules/require-lazy-load-with-retry.cjs +148 -0
  20. package/src/eslint-rules/require-pending-component-di-check.cjs +3 -3
  21. package/src/index.d.ts +8 -0
  22. package/src/index.js +8 -0
  23. package/src/index.js.map +1 -1
  24. package/src/scripts/angular-brand-codemod.d.ts +17 -0
  25. package/src/scripts/angular-brand-codemod.js +44 -8
  26. package/src/scripts/angular-brand-codemod.js.map +1 -1
  27. package/src/scripts/angular-brand-codemod.spec.ts +3 -3
  28. package/src/scripts/angular-brand-codemod.ts +78 -7
  29. package/src/scripts/migrate.d.ts +32 -0
  30. package/src/scripts/migrate.js +67 -0
  31. package/src/scripts/migrate.js.map +1 -0
  32. package/src/scripts/migrate.ts +128 -0
  33. package/src/scripts/migration-workspace.d.ts +2 -0
  34. package/src/scripts/migration-workspace.js +72 -0
  35. package/src/scripts/migration-workspace.js.map +1 -0
  36. package/src/scripts/migration-workspace.ts +93 -0
  37. package/src/scripts/primitives/migrate-primitives.d.ts +27 -0
  38. package/src/scripts/primitives/migrate-primitives.js +354 -0
  39. package/src/scripts/primitives/migrate-primitives.js.map +1 -0
  40. package/src/scripts/primitives/migrate-primitives.spec.ts +279 -0
  41. package/src/scripts/primitives/migrate-primitives.ts +543 -0
  42. package/src/scripts/primitives/migration-diagnostic.d.ts +8 -0
  43. package/src/scripts/primitives/migration-diagnostic.js +2 -0
  44. package/src/scripts/primitives/migration-diagnostic.js.map +1 -0
  45. package/src/scripts/primitives/migration-diagnostic.ts +13 -0
  46. package/src/scripts/routes/migrate-routes.d.ts +34 -0
  47. package/src/scripts/routes/migrate-routes.js +669 -0
  48. package/src/scripts/routes/migrate-routes.js.map +1 -0
  49. package/src/scripts/routes/migrate-routes.spec.ts +264 -0
  50. package/src/scripts/routes/migrate-routes.ts +897 -0
  51. package/src/scripts/routes/migration-diagnostic.d.ts +16 -0
  52. package/src/scripts/routes/migration-diagnostic.js +2 -0
  53. package/src/scripts/routes/migration-diagnostic.js.map +1 -0
  54. package/src/scripts/routes/migration-diagnostic.ts +25 -0
  55. package/src/scripts/services/config.d.ts +2 -0
  56. package/src/scripts/services/config.js +39 -0
  57. package/src/scripts/services/config.js.map +1 -0
  58. package/src/scripts/services/config.ts +60 -0
  59. package/src/scripts/services/migrate-services.d.ts +29 -0
  60. package/src/scripts/services/migrate-services.js +895 -0
  61. package/src/scripts/services/migrate-services.js.map +1 -0
  62. package/src/scripts/services/migrate-services.spec.ts +719 -0
  63. package/src/scripts/services/migrate-services.ts +1282 -0
  64. package/src/scripts/services/migration-diagnostic.d.ts +8 -0
  65. package/src/scripts/services/migration-diagnostic.js +2 -0
  66. package/src/scripts/services/migration-diagnostic.js.map +1 -0
  67. package/src/scripts/services/migration-diagnostic.ts +27 -0
@@ -0,0 +1,1282 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
+ import {
5
+ basename,
6
+ dirname,
7
+ extname,
8
+ isAbsolute,
9
+ join,
10
+ relative,
11
+ resolve,
12
+ } from 'node:path';
13
+ import { promisify } from 'node:util';
14
+ import {
15
+ CallExpression,
16
+ ClassDeclaration,
17
+ ConstructorDeclaration,
18
+ MethodDeclaration,
19
+ Node,
20
+ Project,
21
+ PropertyAssignment,
22
+ QuoteKind,
23
+ SourceFile,
24
+ SyntaxKind,
25
+ } from 'ts-morph';
26
+ import type {
27
+ ServiceMigrationOverride,
28
+ ServiceMigrationScope,
29
+ ServiceMigrationStrategy,
30
+ } from '../angular-brand-codemod.js';
31
+ import { loadCraftDevToolsConfig } from './config.js';
32
+ import type {
33
+ ServiceMigrationDiagnostic,
34
+ ServiceMigrationDiagnosticCode,
35
+ } from './migration-diagnostic.js';
36
+ import { migrateEslintConfig } from '../migration-workspace.js';
37
+
38
+ const execFileAsync = promisify(execFile);
39
+ const MANUAL_SCOPE = 'CRAFT_IMPLEMENTATION_REQUIRED';
40
+
41
+ export type MigrateServicesOptions = {
42
+ rootDir?: string;
43
+ tsConfigFilePath?: string;
44
+ configFilePath?: string;
45
+ files?: readonly string[];
46
+ write?: boolean;
47
+ check?: boolean;
48
+ json?: boolean;
49
+ jsonFilePath?: string;
50
+ failOnManual?: boolean;
51
+ eslint?: boolean;
52
+ log?: (message: string) => void;
53
+ };
54
+
55
+ export type MigratedServiceFile = {
56
+ filePath: string;
57
+ changed: boolean;
58
+ symbols: string[];
59
+ companions: string[];
60
+ };
61
+
62
+ export type MigrateServicesResult = {
63
+ changedFiles: string[];
64
+ files: MigratedServiceFile[];
65
+ diagnostics: ServiceMigrationDiagnostic[];
66
+ remainingLegacyServices: number;
67
+ eslintRan: boolean;
68
+ exitCode: number;
69
+ };
70
+
71
+ type ServiceDescriptor = {
72
+ classDeclaration: ClassDeclaration;
73
+ sourceFile: SourceFile;
74
+ symbol: string;
75
+ name: string;
76
+ scope: ServiceMigrationScope;
77
+ strategy: ServiceMigrationStrategy;
78
+ override?: ServiceMigrationOverride;
79
+ helperSourceFile?: SourceFile;
80
+ httpOperations?: HttpOperationDescriptor[];
81
+ };
82
+
83
+ type HttpOperationDescriptor = {
84
+ methodName: string;
85
+ httpMethod: 'post' | 'put' | 'patch' | 'delete';
86
+ responseType: string;
87
+ urlText: string;
88
+ payloadText?: string;
89
+ parameterCount: number;
90
+ typeParametersText: string;
91
+ parametersText: string;
92
+ };
93
+
94
+ export async function runServicesMigration(
95
+ options: MigrateServicesOptions = {},
96
+ ): Promise<MigrateServicesResult> {
97
+ const rootDir = resolve(options.rootDir ?? process.cwd());
98
+ const tsConfigFilePath = options.tsConfigFilePath
99
+ ? resolve(options.tsConfigFilePath)
100
+ : defaultTsConfig(rootDir);
101
+ const project = new Project({
102
+ ...(existsSync(tsConfigFilePath) ? { tsConfigFilePath } : {}),
103
+ manipulationSettings: { quoteKind: QuoteKind.Single },
104
+ skipAddingFilesFromTsConfig: false,
105
+ });
106
+ project.addSourceFilesAtPaths([
107
+ join(rootDir, '**/*.ts'),
108
+ `!${join(rootDir, '**/node_modules/**')}`,
109
+ `!${join(rootDir, '**/dist/**')}`,
110
+ `!${join(rootDir, '**/.angular/**')}`,
111
+ `!${join(rootDir, '**/*.d.ts')}`,
112
+ ]);
113
+
114
+ const config = loadCraftDevToolsConfig(rootDir, options.configFilePath);
115
+ const overrides = config.serviceMigration?.overrides ?? [];
116
+ const selected = options.files?.length
117
+ ? new Set(options.files.map((file) => resolve(rootDir, file)))
118
+ : undefined;
119
+ const projectSourceFiles = project.getSourceFiles().filter((file) => {
120
+ const path = resolve(file.getFilePath());
121
+ return isInside(path, rootDir);
122
+ });
123
+ const sourceFiles = projectSourceFiles.filter(
124
+ (file) => !selected || selected.has(resolve(file.getFilePath())),
125
+ );
126
+ const diagnostics: ServiceMigrationDiagnostic[] = [];
127
+ const descriptors = sourceFiles.flatMap((sourceFile) =>
128
+ sourceFile
129
+ .getClasses()
130
+ .filter(isLegacyServiceClass)
131
+ .map((classDeclaration) =>
132
+ describeService(classDeclaration, overrides, rootDir, diagnostics),
133
+ ),
134
+ );
135
+ inferFunctionScopes(descriptors, projectSourceFiles);
136
+ diagnoseCycles(descriptors, diagnostics);
137
+
138
+ const files = new Map<string, MigratedServiceFile>();
139
+ const touched = new Set<SourceFile>();
140
+ for (const descriptor of descriptors) {
141
+ const file = descriptor.sourceFile;
142
+ const report = getFileReport(files, file.getFilePath());
143
+ report.symbols.push(descriptor.symbol);
144
+ if (descriptor.strategy === 'ignore') continue;
145
+ const unsafe = getUnsafeReason(descriptor);
146
+ if (unsafe) {
147
+ diagnose(diagnostics, unsafe.code, descriptor, unsafe.message);
148
+ const companion = createCompanion(project, descriptor);
149
+ descriptor.helperSourceFile = companion.file;
150
+ report.companions.push(companion.file.getFilePath());
151
+ if (companion.changed) touched.add(companion.file);
152
+ continue;
153
+ }
154
+ migrateSimpleClass(descriptor, descriptors, diagnostics);
155
+ touched.add(file);
156
+ }
157
+
158
+ rewriteConsumers(sourceFiles, descriptors, diagnostics, touched);
159
+ rewriteHttpOperationSubscribers(sourceFiles, descriptors, diagnostics, touched);
160
+ rewriteProviders(sourceFiles, descriptors, diagnostics, touched);
161
+ const eslintConfig = migrateEslintConfig(project, dirname(tsConfigFilePath));
162
+ if (eslintConfig) touched.add(eslintConfig);
163
+ for (const file of touched) {
164
+ if (file === eslintConfig) file.formatText();
165
+ else file.organizeImports();
166
+ getFileReport(files, file.getFilePath()).changed = true;
167
+ }
168
+
169
+ if (options.write) {
170
+ await Promise.all([...touched].map((file) => file.save()));
171
+ }
172
+ let eslintRan = false;
173
+ if (options.write && options.eslint !== false && touched.size > 0) {
174
+ await runEslint(
175
+ [...touched].map((file) => file.getFilePath()),
176
+ rootDir,
177
+ );
178
+ eslintRan = true;
179
+ }
180
+
181
+ const remainingLegacyServices = options.write
182
+ ? project
183
+ .getSourceFiles()
184
+ .reduce(
185
+ (count, file) =>
186
+ count + file.getClasses().filter(isLegacyServiceClass).length,
187
+ 0,
188
+ )
189
+ : descriptors.length;
190
+ const result: MigrateServicesResult = {
191
+ changedFiles: [...touched].map((file) => file.getFilePath()),
192
+ files: [...files.values()],
193
+ diagnostics,
194
+ remainingLegacyServices,
195
+ eslintRan,
196
+ exitCode:
197
+ (options.check && remainingLegacyServices > 0) ||
198
+ (options.failOnManual && diagnostics.length > 0)
199
+ ? 1
200
+ : 0,
201
+ };
202
+ if (options.jsonFilePath) {
203
+ const path = resolve(options.jsonFilePath);
204
+ await mkdir(dirname(path), { recursive: true });
205
+ await writeFile(path, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
206
+ }
207
+ const log = options.log ?? console.log;
208
+ if (options.json) log(JSON.stringify(result, null, 2));
209
+ else logSummary(result, log, options.write === true);
210
+ return result;
211
+ }
212
+
213
+ function describeService(
214
+ classDeclaration: ClassDeclaration,
215
+ overrides: readonly ServiceMigrationOverride[],
216
+ rootDir: string,
217
+ diagnostics: ServiceMigrationDiagnostic[],
218
+ ): ServiceDescriptor {
219
+ const sourceFile = classDeclaration.getSourceFile();
220
+ const symbol = classDeclaration.getNameOrThrow();
221
+ const module = relative(rootDir, sourceFile.getFilePath()).replace(
222
+ /\\/g,
223
+ '/',
224
+ );
225
+ const override = [...overrides]
226
+ .reverse()
227
+ .find(
228
+ (candidate) =>
229
+ (!candidate.file || matches(candidate.file, module)) &&
230
+ (!candidate.module || matches(candidate.module, module)) &&
231
+ (!candidate.symbol || candidate.symbol === symbol),
232
+ );
233
+ const decorator = classDeclaration
234
+ .getDecorators()
235
+ .find((item) => ['Injectable', 'Service'].includes(item.getName()))!;
236
+ const argument = decorator.getArguments()[0];
237
+ const text = argument?.getText() ?? '';
238
+ let scope: ServiceMigrationScope;
239
+ if (classDeclaration.isAbstract()) scope = 'abstract';
240
+ else if (
241
+ decorator.getName() === 'Service' &&
242
+ !/autoProvided\s*:\s*false/.test(text)
243
+ )
244
+ scope = 'global';
245
+ else if (/providedIn\s*:\s*['"]root['"]/.test(text)) scope = 'global';
246
+ else if (/providedIn\s*:/.test(text)) {
247
+ scope = 'toProvide';
248
+ diagnose(
249
+ diagnostics,
250
+ 'UNSUPPORTED_PROVIDED_IN',
251
+ { sourceFile, symbol } as ServiceDescriptor,
252
+ `providedIn de ${symbol} n'est pas transposable automatiquement.`,
253
+ );
254
+ } else scope = 'toProvide';
255
+ return {
256
+ classDeclaration,
257
+ sourceFile,
258
+ symbol,
259
+ name: override?.name ?? (symbol.replace(/Service$/, '') || symbol),
260
+ scope: override?.scope ?? scope,
261
+ strategy: override?.strategy ?? 'craftService',
262
+ override,
263
+ };
264
+ }
265
+
266
+ function inferFunctionScopes(
267
+ descriptors: readonly ServiceDescriptor[],
268
+ sourceFiles: readonly SourceFile[],
269
+ ): void {
270
+ const runtimeFiles = sourceFiles.filter((file) => !isTestFile(file));
271
+ for (const descriptor of descriptors) {
272
+ if (
273
+ descriptor.scope !== 'toProvide' ||
274
+ descriptor.override?.scope ||
275
+ descriptor.strategy !== 'craftService' ||
276
+ getUnsafeReason(descriptor)
277
+ )
278
+ continue;
279
+ const injectionSites = runtimeFiles.flatMap((file) =>
280
+ collectInjectionSites(file, descriptor.symbol),
281
+ );
282
+ if (injectionSites.length === 1 && injectionSites[0]?.componentOwned)
283
+ descriptor.scope = 'function';
284
+ }
285
+ }
286
+
287
+ function collectInjectionSites(
288
+ file: SourceFile,
289
+ symbol: string,
290
+ ): { node: Node; componentOwned: boolean }[] {
291
+ const sites: { node: Node; componentOwned: boolean }[] = [];
292
+ for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
293
+ if (
294
+ call.getExpression().getText() !== 'inject' ||
295
+ call.getArguments()[0]?.getText() !== symbol
296
+ )
297
+ continue;
298
+ const owner = call.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
299
+ sites.push({
300
+ node: call,
301
+ componentOwned: Boolean(owner && isComponentOwnedClass(owner)),
302
+ });
303
+ }
304
+ for (const parameter of file.getDescendantsOfKind(
305
+ SyntaxKind.Parameter,
306
+ )) {
307
+ if (parameter.getTypeNode()?.getText() !== symbol) continue;
308
+ const constructorDeclaration = parameter.getFirstAncestorByKind(
309
+ SyntaxKind.Constructor,
310
+ );
311
+ const owner = parameter.getFirstAncestorByKind(
312
+ SyntaxKind.ClassDeclaration,
313
+ );
314
+ if (constructorDeclaration)
315
+ sites.push({
316
+ node: parameter,
317
+ componentOwned: Boolean(owner && isComponentOwnedClass(owner)),
318
+ });
319
+ }
320
+ return sites;
321
+ }
322
+
323
+ function isComponentOwnedClass(value: ClassDeclaration): boolean {
324
+ return value
325
+ .getDecorators()
326
+ .some((decorator) =>
327
+ ['Component', 'Directive'].includes(decorator.getName()),
328
+ );
329
+ }
330
+
331
+ function isTestFile(file: SourceFile): boolean {
332
+ return /\.(?:spec|test)\.ts$/.test(file.getFilePath());
333
+ }
334
+
335
+ function getUnsafeReason(
336
+ descriptor: ServiceDescriptor,
337
+ ): { code: ServiceMigrationDiagnosticCode; message: string } | undefined {
338
+ const value = descriptor.classDeclaration;
339
+ if (descriptor.strategy === 'companion')
340
+ return {
341
+ code: 'NON_CONVERTIBLE_CLASS',
342
+ message: 'Stratégie companion imposée par la configuration.',
343
+ };
344
+ if (value.getExtends())
345
+ return {
346
+ code: 'NON_CONVERTIBLE_CLASS',
347
+ message: `${descriptor.symbol} utilise l'héritage.`,
348
+ };
349
+ if (value.getImplements().length)
350
+ return {
351
+ code: 'FRAMEWORK_CONTRACT',
352
+ message: `${descriptor.symbol} implémente un contrat de framework.`,
353
+ };
354
+ if (value.getGetAccessors().length || value.getSetAccessors().length)
355
+ return {
356
+ code: 'NON_CONVERTIBLE_CLASS',
357
+ message: `${descriptor.symbol} contient des accesseurs.`,
358
+ };
359
+ if (value.getConstructors().some(hasUnsafeConstructorLogic))
360
+ return {
361
+ code: 'NON_CONVERTIBLE_CLASS',
362
+ message: `${descriptor.symbol} contient de la logique constructeur.`,
363
+ };
364
+ if (value.getMethods().some((method) => /^ng[A-Z]/.test(method.getName())))
365
+ return {
366
+ code: 'LIFECYCLE_HOOK',
367
+ message: `${descriptor.symbol} contient un lifecycle Angular.`,
368
+ };
369
+ if (
370
+ value
371
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
372
+ .some((call) => call.getExpression().getText() === 'injectAsync')
373
+ )
374
+ return {
375
+ code: 'INJECT_ASYNC',
376
+ message: `${descriptor.symbol} utilise injectAsync().`,
377
+ };
378
+ if (
379
+ value
380
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
381
+ .some(hasInjectionFlags)
382
+ )
383
+ return {
384
+ code: 'INJECTION_FLAGS',
385
+ message: `${descriptor.symbol} utilise des flags d'injection.`,
386
+ };
387
+ return undefined;
388
+ }
389
+
390
+ function hasUnsafeConstructorLogic(
391
+ constructorDeclaration: ConstructorDeclaration,
392
+ ): boolean {
393
+ return constructorDeclaration
394
+ .getStatements()
395
+ .some((statement) => !isMigratableConstructorStatement(statement));
396
+ }
397
+
398
+ function isMigratableConstructorStatement(statement: Node): boolean {
399
+ if (!Node.isExpressionStatement(statement)) return false;
400
+ const expression = statement.getExpression();
401
+ if (!Node.isCallExpression(expression)) return false;
402
+ return ['effect'].includes(expression.getExpression().getText());
403
+ }
404
+
405
+ function migrateSimpleClass(
406
+ descriptor: ServiceDescriptor,
407
+ all: readonly ServiceDescriptor[],
408
+ diagnostics: ServiceMigrationDiagnostic[],
409
+ ): void {
410
+ const value = descriptor.classDeclaration;
411
+ const file = descriptor.sourceFile;
412
+ const yields: string[] = [];
413
+ const declarations: string[] = [];
414
+ const exposed: string[] = [];
415
+ let hasTrackedPrimitive = false;
416
+ migrateHttpResources(descriptor, diagnostics);
417
+ const httpOperations = collectHttpOperations(descriptor);
418
+ descriptor.httpOperations = httpOperations;
419
+ const propertyNames = new Set(value.getProperties().map((property) => property.getName()));
420
+ for (const parameter of value
421
+ .getConstructors()
422
+ .flatMap((ctor) => ctor.getParameters())) {
423
+ const token = parameter.getTypeNode()?.getText();
424
+ if (!token) continue;
425
+ if (
426
+ token === 'HttpClient' &&
427
+ !hasHttpClientUsageOutsideMigratedMethods(
428
+ value,
429
+ parameter.getName(),
430
+ httpOperations,
431
+ )
432
+ )
433
+ continue;
434
+ yields.push(
435
+ makeYield(toInternalName(parameter.getName()), token, descriptor, all, diagnostics),
436
+ );
437
+ }
438
+ for (const property of value.getProperties()) {
439
+ const name = property.getName();
440
+ const internalName = toInternalName(name);
441
+ const initializer = property.getInitializer();
442
+ if (!initializer) {
443
+ diagnose(
444
+ diagnostics,
445
+ 'NON_CONVERTIBLE_CLASS',
446
+ descriptor,
447
+ `La propriété ${name} n'a pas d'initialiseur.`,
448
+ );
449
+ continue;
450
+ }
451
+ if (
452
+ Node.isCallExpression(initializer) &&
453
+ initializer.getExpression().getText() === 'inject'
454
+ ) {
455
+ const token = initializer.getArguments()[0]?.getText();
456
+ if (
457
+ token === 'HttpClient' &&
458
+ !hasHttpClientUsageOutsideMigratedMethods(value, name, httpOperations)
459
+ )
460
+ continue;
461
+ if (token)
462
+ yields.push(makeYield(internalName, token, descriptor, all, diagnostics));
463
+ } else {
464
+ const shouldTrack = isDependentPrimitive(initializer);
465
+ if (shouldTrack) {
466
+ hasTrackedPrimitive = true;
467
+ ensureCoreImports(file, ['track']);
468
+ }
469
+ const imperativeStateComment = property
470
+ .getFullText()
471
+ .includes('CRAFT_IMPERATIVE_CODE_DETECTED')
472
+ ? '// CRAFT_IMPERATIVE_CODE_DETECTED: imperative code detected, prefer a declarative approach.\n'
473
+ : '';
474
+ declarations.push(
475
+ `${imperativeStateComment}const ${internalName} = ${shouldTrack ? 'yield* track(' : ''}${rewriteThis(initializer.getText(), propertyNames)}${shouldTrack ? ')' : ''};`,
476
+ );
477
+ }
478
+ if (
479
+ property.getScope() !== 'private' &&
480
+ property.getScope() !== 'protected'
481
+ )
482
+ exposed.push(`${name}: ${internalName}`);
483
+ }
484
+ for (const constructorStatement of value
485
+ .getConstructors()
486
+ .flatMap((ctor) => ctor.getStatements())) {
487
+ declarations.push(`${rewriteThis(constructorStatement.getText(), propertyNames)}`);
488
+ }
489
+ for (const method of value.getMethods()) {
490
+ const name = method.getName();
491
+ const httpOperation = httpOperations.find(
492
+ (operation) => operation.methodName === name,
493
+ );
494
+ if (httpOperation) {
495
+ declarations.push(printHttpOperationGenerator(httpOperation));
496
+ if (method.getScope() !== 'private' && method.getScope() !== 'protected')
497
+ exposed.push(name);
498
+ ensureCoreImports(file, ['CraftHttpClient']);
499
+ continue;
500
+ }
501
+ const asyncKeyword = method.isAsync() ? 'async ' : '';
502
+ const star = method.isGenerator() ? '*' : '';
503
+ const typeParameters = method.getTypeParameters().map((parameter) => parameter.getText()).join(', ');
504
+ const params = method
505
+ .getParameters()
506
+ .map((parameter) => parameter.getText())
507
+ .join(', ');
508
+ const containsMigratedQuery = method.getDescendantsOfKind(SyntaxKind.CallExpression)
509
+ .some((call) => call.getExpression().getText() === 'query');
510
+ const returnType = containsMigratedQuery ? undefined : method.getReturnTypeNode()?.getText();
511
+ const body = method.getBodyText() ?? '';
512
+ const imperativeWorkflowComment = getImperativeWorkflowComment(method);
513
+ if (imperativeWorkflowComment) {
514
+ diagnose(
515
+ diagnostics,
516
+ 'IMPERATIVE_WORKFLOW_REQUIRES_REVIEW',
517
+ descriptor,
518
+ `${descriptor.name}.${name} orchestre impérativement un submit, plusieurs mises à jour d'état et/ou une navigation. Préférer insertFormSubmit avec une réaction au statut du formulaire, ou source$ avec on$/effect.`,
519
+ );
520
+ }
521
+ declarations.push(
522
+ `${imperativeWorkflowComment ? `${imperativeWorkflowComment}\n` : ''}${asyncKeyword}function${star} ${name}${typeParameters ? `<${typeParameters}>` : ''}(${params})${returnType ? `: ${returnType}` : ''} {${rewriteThis(body, propertyNames)}}`,
523
+ );
524
+ if (method.getScope() !== 'private' && method.getScope() !== 'protected')
525
+ exposed.push(name);
526
+ }
527
+ const contract = `{ ${value
528
+ .getMethods()
529
+ .filter(
530
+ (method) =>
531
+ method.getScope() !== 'private' && method.getScope() !== 'protected',
532
+ )
533
+ .map(
534
+ (method) =>
535
+ `${method.getName()}(${method
536
+ .getParameters()
537
+ .map((parameter) => parameter.getText())
538
+ .join(
539
+ ', ',
540
+ )}): ${method.getReturnTypeNode()?.getText() ?? 'unknown'};`,
541
+ )
542
+ .join(' ')} }`;
543
+ const factory =
544
+ descriptor.scope === 'abstract'
545
+ ? `abstract<${contract}>()`
546
+ : `${yields.length > 0 || hasTrackedPrimitive ? 'function*' : 'function'} () {\n${[...yields, ...declarations].map((line) => ` ${line}`).join('\n')}\n return { ${exposed.join(', ')} };\n}`;
547
+ const helpers =
548
+ descriptor.scope === 'abstract'
549
+ ? `${descriptor.name}Requirement, inject${descriptor.name}, provide${descriptor.name}`
550
+ : `inject${descriptor.name}, ${descriptor.name}ToYield${isProviderCapableScope(descriptor.scope) ? `, provide${descriptor.name}` : ''}`;
551
+ const typeAlias = `\nexport type ${descriptor.symbol} = ReturnType<typeof inject${descriptor.name}>;`;
552
+ value.replaceWithText(
553
+ `export const { ${helpers} } = craftService({ name: '${descriptor.name}', scope: '${descriptor.scope}' }, ${factory});${typeAlias}`,
554
+ );
555
+ ensureCoreImports(file, [
556
+ 'craftService',
557
+ ...(descriptor.scope === 'abstract' ? ['abstract'] : []),
558
+ ]);
559
+ removeDecoratorImport(file, 'Injectable');
560
+ removeDecoratorImport(file, 'Service');
561
+ }
562
+
563
+ function getImperativeWorkflowComment(
564
+ method: MethodDeclaration,
565
+ ): string | undefined {
566
+ const calls = method.getDescendantsOfKind(SyntaxKind.CallExpression);
567
+ const hasSubmit = calls.some((call) => {
568
+ const expression = call.getExpression();
569
+ return (
570
+ expression.getText() === 'submit' ||
571
+ (Node.isPropertyAccessExpression(expression) &&
572
+ expression.getName() === 'submit')
573
+ );
574
+ });
575
+ if (!hasSubmit) return undefined;
576
+
577
+ const stateWrites = calls.filter((call) => {
578
+ const expression = call.getExpression();
579
+ return (
580
+ Node.isPropertyAccessExpression(expression) &&
581
+ ['set', 'update'].includes(expression.getName())
582
+ );
583
+ }).length;
584
+ const hasNavigation = calls.some((call) => {
585
+ const expression = call.getExpression();
586
+ return (
587
+ Node.isPropertyAccessExpression(expression) &&
588
+ ['navigate', 'navigateByUrl'].includes(expression.getName())
589
+ );
590
+ });
591
+ if (stateWrites < 2 && !(stateWrites >= 1 && hasNavigation))
592
+ return undefined;
593
+
594
+ return '// CRAFT_REACTIVE_WORKFLOW_RECOMMENDED: workflow impératif détecté...';
595
+ }
596
+
597
+ function isDependentPrimitive(initializer: Node): boolean {
598
+ if (
599
+ !Node.isCallExpression(initializer) ||
600
+ !['query', 'mutation', 'asyncProcess', 'state', 'craftMethod'].includes(
601
+ initializer.getExpression().getText(),
602
+ )
603
+ )
604
+ return false;
605
+ return initializer
606
+ .getDescendantsOfKind(SyntaxKind.YieldExpression)
607
+ .some((yieldExpression) => yieldExpression.getAsteriskToken() !== undefined);
608
+ }
609
+
610
+ function migrateHttpResources(
611
+ descriptor: ServiceDescriptor,
612
+ diagnostics: ServiceMigrationDiagnostic[],
613
+ ): void {
614
+ const calls = descriptor.classDeclaration
615
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
616
+ .filter((call) => call.getExpression().getText() === 'httpResource');
617
+ for (const call of calls) {
618
+ const params = call.getArguments()[0];
619
+ if (!params) continue;
620
+ let paramsText = params.getText();
621
+ const chainedNames = [
622
+ ...paramsText.matchAll(/\bchain\(\s*([A-Za-z_$][\w$]*)\s*\)/g),
623
+ ].map((match) => match[1]);
624
+ if (/\bchain\s*\(/.test(paramsText)) {
625
+ const rewritten = paramsText
626
+ .replace(/\(\s*\{\s*chain\s*\}\s*\)\s*=>/, '() =>')
627
+ .replace(/\bchain\(\s*([A-Za-z_$][\w$]*)\s*\)\s*;/g, '$1.value();');
628
+ if (/\bchain\s*\(/.test(rewritten)) {
629
+ diagnose(
630
+ diagnostics,
631
+ 'RESOURCE_CHAIN_REQUIRES_REWRITE',
632
+ descriptor,
633
+ 'httpResource utilise une forme complexe de chain(...); la dépendance entre queries doit être migrée manuellement.',
634
+ );
635
+ continue;
636
+ }
637
+ paramsText = rewritten;
638
+ const methodDeclaration = call.getFirstAncestorByKind(
639
+ SyntaxKind.MethodDeclaration,
640
+ );
641
+ for (const name of chainedNames) {
642
+ methodDeclaration
643
+ ?.getParameter(name)
644
+ ?.setType('{ readonly value: () => unknown }');
645
+ }
646
+ }
647
+ const stateType = call.getTypeArguments()[0]?.getText() ?? 'unknown';
648
+ const optionsNode = call.getArguments()[1];
649
+ const options = Node.isObjectLiteralExpression(optionsNode)
650
+ ? optionsNode
651
+ .getProperties()
652
+ .map((property) => property.getText())
653
+ .join(',\n ')
654
+ : optionsNode
655
+ ? `...${optionsNode.getText()}`
656
+ : '';
657
+ const method =
658
+ /\bmethod\s*:\s*['"]([A-Za-z]+)['"]/.exec(paramsText)?.[1]?.toUpperCase() ??
659
+ 'GET';
660
+ call.replaceWithText(`query({
661
+ params: ${paramsText},
662
+ ${options ? `${options},` : ''}
663
+ loader: function* ({ params: request }) {
664
+ return yield* CraftHttpClient.request(({ response }) => ({
665
+ ...(typeof request === 'string' ? { url: request } : request),
666
+ method: '${method}',
667
+ success: response<${stateType}>(),
668
+ }));
669
+ },
670
+ }, ({ set, update }) => ({ set, update }))`);
671
+ ensureCoreImports(descriptor.sourceFile, ['CraftHttpClient', 'query']);
672
+ }
673
+ }
674
+
675
+ function collectHttpOperations(
676
+ descriptor: ServiceDescriptor,
677
+ ): HttpOperationDescriptor[] {
678
+ return descriptor.classDeclaration
679
+ .getMethods()
680
+ .map((method) => collectHttpOperation(method))
681
+ .filter((operation): operation is HttpOperationDescriptor =>
682
+ Boolean(operation),
683
+ );
684
+ }
685
+
686
+ function collectHttpOperation(
687
+ method: MethodDeclaration,
688
+ ): HttpOperationDescriptor | undefined {
689
+ const body = method.getBody();
690
+ const statements = body && Node.isBlock(body) ? body.getStatements() : [];
691
+ if (statements.length !== 1) return undefined;
692
+ const statement = statements[0];
693
+ if (!Node.isReturnStatement(statement)) return undefined;
694
+ const expression = statement.getExpression();
695
+ if (!Node.isCallExpression(expression)) return undefined;
696
+ const access = expression.getExpression();
697
+ if (!Node.isPropertyAccessExpression(access)) return undefined;
698
+ const httpMethod = access.getName();
699
+ if (!isWritableHttpMethod(httpMethod)) return undefined;
700
+ if (!looksLikeHttpClientReceiver(access.getExpression().getText()))
701
+ return undefined;
702
+ const args = expression.getArguments();
703
+ const urlText = args[0]?.getText();
704
+ if (!urlText) return undefined;
705
+ const payloadText =
706
+ httpMethod === 'delete' ? undefined : (args[1]?.getText() ?? '{}');
707
+ const responseType = expression.getTypeArguments()[0]?.getText() ?? 'unknown';
708
+ const typeParametersText = method
709
+ .getTypeParameters()
710
+ .map((parameter) => parameter.getText())
711
+ .join(', ');
712
+ const parametersText = method
713
+ .getParameters()
714
+ .map((parameter) => parameter.getText())
715
+ .join(', ');
716
+ return {
717
+ methodName: method.getName(),
718
+ httpMethod,
719
+ responseType,
720
+ urlText,
721
+ payloadText,
722
+ parameterCount: method.getParameters().length,
723
+ typeParametersText,
724
+ parametersText,
725
+ };
726
+ }
727
+
728
+ function isWritableHttpMethod(
729
+ value: string,
730
+ ): value is HttpOperationDescriptor['httpMethod'] {
731
+ return ['post', 'put', 'patch', 'delete'].includes(value);
732
+ }
733
+
734
+ function looksLikeHttpClientReceiver(value: string): boolean {
735
+ return /(^|\.|_)http(Client)?$/i.test(value);
736
+ }
737
+
738
+ function printHttpOperationGenerator(operation: HttpOperationDescriptor): string {
739
+ const payload =
740
+ operation.payloadText === undefined
741
+ ? ''
742
+ : `\n payload: ${operation.payloadText},`;
743
+ return `function* ${operation.methodName}${operation.typeParametersText ? `<${operation.typeParametersText}>` : ''}(${operation.parametersText}) {
744
+ return yield* CraftHttpClient.${operation.httpMethod}(({ response }) => ({
745
+ url: ${operation.urlText},${payload}
746
+ success: response<${operation.responseType}>(),
747
+ }));
748
+ }`;
749
+ }
750
+
751
+ function hasHttpClientUsageOutsideMigratedMethods(
752
+ serviceClass: ClassDeclaration,
753
+ localName: string,
754
+ operations: readonly HttpOperationDescriptor[],
755
+ ): boolean {
756
+ const migratedMethods = new Set(
757
+ operations.map((operation) => operation.methodName),
758
+ );
759
+ return serviceClass.getMethods().some((method) => {
760
+ if (migratedMethods.has(method.getName())) return false;
761
+ const text = method.getBodyText() ?? '';
762
+ return (
763
+ new RegExp(`\\bthis\\.${escapeRegex(localName)}\\b`).test(text) ||
764
+ new RegExp(`\\b${escapeRegex(localName)}\\s*\\.`).test(text)
765
+ );
766
+ });
767
+ }
768
+
769
+ function makeYield(
770
+ localName: string,
771
+ token: string,
772
+ owner: ServiceDescriptor,
773
+ all: readonly ServiceDescriptor[],
774
+ diagnostics: ServiceMigrationDiagnostic[],
775
+ ): string {
776
+ const dependency = all.find((item) => item.symbol === token);
777
+ if (token === 'Router') {
778
+ ensureCoreImports(owner.sourceFile, ['CraftRouterToYield']);
779
+ return `const ${localName} = yield* CraftRouterToYield();`;
780
+ }
781
+ if (!dependency) {
782
+ if (token === 'HttpClient') {
783
+ diagnose(
784
+ diagnostics,
785
+ 'HTTP_CLIENT_REWRITE_REQUIRED',
786
+ owner,
787
+ 'Les appels HttpClient impératifs restants doivent être migrés vers mutation/CraftHttpClient.',
788
+ );
789
+ ensureExternalAdapter(owner, token);
790
+ return `const ${localName} = yield* ${token}ToYield(); // HTTP_CLIENT_REWRITE_REQUIRED`;
791
+ }
792
+ diagnose(
793
+ diagnostics,
794
+ 'THIRD_PARTY_SCOPE_REQUIRED',
795
+ owner,
796
+ `${token} requiert un adapter toCraftService avec scope CRAFT_SCOPE_REQUIRED.`,
797
+ );
798
+ ensureExternalAdapter(owner, token);
799
+ return `const ${localName} = yield* ${token}ToYield(); // CRAFT_SCOPE_REQUIRED`;
800
+ }
801
+ ensureRelativeImport(
802
+ owner.sourceFile,
803
+ dependency,
804
+ `${dependency.name}ToYield`,
805
+ );
806
+ return `const ${localName} = yield* ${dependency.name}ToYield();`;
807
+ }
808
+
809
+ function ensureExternalAdapter(owner: ServiceDescriptor, token: string): void {
810
+ const file = owner.sourceFile;
811
+ if (
812
+ file.getFullText().includes(`const { ${token}ToYield } = toCraftService(`)
813
+ )
814
+ return;
815
+ const statementIndex = file.getStatements().indexOf(owner.classDeclaration);
816
+ file.insertStatements(
817
+ Math.max(0, statementIndex),
818
+ `const { ${token}ToYield } = toCraftService({ name: '${token}', scope: 'global', token: ${token} }); // HTTP_CLIENT_REWRITE_REQUIRED`,
819
+ );
820
+ ensureCoreImports(file, ['toCraftService']);
821
+ }
822
+
823
+ function createCompanion(
824
+ project: Project,
825
+ descriptor: ServiceDescriptor,
826
+ ): { file: SourceFile; changed: boolean } {
827
+ const originalPath = descriptor.sourceFile.getFilePath();
828
+ const companionPath =
829
+ originalPath.slice(0, -extname(originalPath).length) + '.craft.ts';
830
+ const module = `./${basename(originalPath, extname(originalPath))}`;
831
+ const companionScope =
832
+ descriptor.scope === 'abstract' ? 'toProvide' : descriptor.scope;
833
+ const helpers = `inject${descriptor.name}, ${descriptor.name}ToYield${isProviderCapableScope(companionScope) ? `, provide${descriptor.name}` : ''}`;
834
+ const provide =
835
+ isProviderCapableScope(companionScope)
836
+ ? ` provide: () => [${descriptor.symbol}],\n`
837
+ : '';
838
+ const text = `// Generated by craft-migrate-services. ${MANUAL_SCOPE}: keep the legacy class until it can be rewritten.\nimport { toCraftService } from '@craft-ng/core';\nimport { ${descriptor.symbol} } from '${module}';\n\nexport const { ${helpers} } = toCraftService({\n name: '${descriptor.name}',\n scope: '${companionScope}',\n token: ${descriptor.symbol},\n${provide}});\n`;
839
+ const existing = project.getSourceFile(companionPath);
840
+ if (existing) {
841
+ if (
842
+ existing.getFullText().includes(MANUAL_SCOPE) &&
843
+ existing.getFullText().includes(`token: ${descriptor.symbol}`)
844
+ ) {
845
+ return { file: existing, changed: false };
846
+ }
847
+ existing.replaceWithText(text);
848
+ return { file: existing, changed: true };
849
+ }
850
+ return {
851
+ file: project.createSourceFile(companionPath, text, { overwrite: false }),
852
+ changed: true,
853
+ };
854
+ }
855
+
856
+ function rewriteConsumers(
857
+ files: readonly SourceFile[],
858
+ descriptors: readonly ServiceDescriptor[],
859
+ diagnostics: ServiceMigrationDiagnostic[],
860
+ touched: Set<SourceFile>,
861
+ ): void {
862
+ for (const file of files) {
863
+ for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
864
+ if (call.wasForgotten() || call.getExpression().getText() !== 'inject')
865
+ continue;
866
+ const token = call.getArguments()[0]?.getText();
867
+ const service = descriptors.find((item) => item.symbol === token);
868
+ if (!service) continue;
869
+ call.replaceWithText(`inject${service.name}()`);
870
+ ensureRelativeImport(file, service, `inject${service.name}`);
871
+ touched.add(file);
872
+ }
873
+ }
874
+ }
875
+
876
+ function rewriteHttpOperationSubscribers(
877
+ files: readonly SourceFile[],
878
+ descriptors: readonly ServiceDescriptor[],
879
+ diagnostics: ServiceMigrationDiagnostic[],
880
+ touched: Set<SourceFile>,
881
+ ): void {
882
+ for (const file of files) {
883
+ for (const classDeclaration of file.getClasses()) {
884
+ const injectedServices = collectInjectedCraftServices(
885
+ classDeclaration,
886
+ descriptors,
887
+ );
888
+ if (injectedServices.size === 0) continue;
889
+ for (const subscribeCall of [
890
+ ...classDeclaration.getDescendantsOfKind(SyntaxKind.CallExpression),
891
+ ]) {
892
+ if (subscribeCall.wasForgotten()) continue;
893
+ const subscribeAccess = subscribeCall.getExpression();
894
+ if (
895
+ !Node.isPropertyAccessExpression(subscribeAccess) ||
896
+ subscribeAccess.getName() !== 'subscribe'
897
+ )
898
+ continue;
899
+ const operationCall = subscribeAccess.getExpression();
900
+ if (!Node.isCallExpression(operationCall)) continue;
901
+ const operationAccess = operationCall.getExpression();
902
+ if (!Node.isPropertyAccessExpression(operationAccess)) continue;
903
+ const receiverName = readReceiverPropertyName(
904
+ operationAccess.getExpression().getText(),
905
+ );
906
+ if (!receiverName) continue;
907
+ const service = injectedServices.get(receiverName);
908
+ if (!service) continue;
909
+ const operation = service.httpOperations?.find(
910
+ (item) => item.methodName === operationAccess.getName(),
911
+ );
912
+ if (!operation) continue;
913
+ if (
914
+ subscribeCall.getArguments().length > 0 ||
915
+ operation.parameterCount === 0
916
+ ) {
917
+ diagnose(
918
+ diagnostics,
919
+ 'MUTATION_SUBSCRIBE_REQUIRES_REWRITE',
920
+ { ...service, sourceFile: file },
921
+ `${service.name}.${operation.methodName} est un appel HTTP mutable avec subscribe complexe; créer une mutation locale et migrer les callbacks manuellement.`,
922
+ );
923
+ continue;
924
+ }
925
+ const mutationName = ensureLocalMutationProperty(
926
+ classDeclaration,
927
+ service,
928
+ operation,
929
+ );
930
+ subscribeCall.replaceWithText(
931
+ printMutationCall(mutationName, operationCall),
932
+ );
933
+ ensureCoreImports(file, ['mutation']);
934
+ ensureRelativeImport(file, service, `${service.name}ToYield`);
935
+ ensureRelativeImport(file, service, `inject${service.name}`);
936
+ touched.add(file);
937
+ }
938
+ }
939
+ }
940
+ }
941
+
942
+ function collectInjectedCraftServices(
943
+ classDeclaration: ClassDeclaration,
944
+ descriptors: readonly ServiceDescriptor[],
945
+ ): Map<string, ServiceDescriptor> {
946
+ const byInjectHelper: Map<string, ServiceDescriptor> = new Map(
947
+ descriptors.map((descriptor) => [
948
+ `inject${descriptor.name}`,
949
+ descriptor,
950
+ ] as const),
951
+ );
952
+ const result = new Map<string, ServiceDescriptor>();
953
+ for (const property of classDeclaration.getProperties()) {
954
+ const initializer = property.getInitializer();
955
+ if (
956
+ !initializer ||
957
+ !Node.isCallExpression(initializer) ||
958
+ !Node.isIdentifier(initializer.getExpression())
959
+ )
960
+ continue;
961
+ const service = byInjectHelper.get(initializer.getExpression().getText());
962
+ if (service) result.set(property.getName(), service);
963
+ }
964
+ return result;
965
+ }
966
+
967
+ function readReceiverPropertyName(text: string): string | undefined {
968
+ const match = /^(?:this\.)?([A-Za-z_$][\w$]*)$/.exec(text.trim());
969
+ return match?.[1];
970
+ }
971
+
972
+ function ensureLocalMutationProperty(
973
+ classDeclaration: ClassDeclaration,
974
+ service: ServiceDescriptor,
975
+ operation: HttpOperationDescriptor,
976
+ ): string {
977
+ const baseName = `${operation.methodName}Mutation`;
978
+ const mutationName = uniqueClassPropertyName(classDeclaration, baseName);
979
+ if (classDeclaration.getProperty(mutationName)) return mutationName;
980
+ const paramsType = `Parameters<ReturnType<typeof inject${service.name}>['${operation.methodName}']>`;
981
+ const method =
982
+ operation.parameterCount === 1
983
+ ? `(params: ${paramsType}[0]) => params`
984
+ : `(params: ${paramsType}) => params`;
985
+ const call =
986
+ operation.parameterCount === 1
987
+ ? `${toLocalServiceName(service.name)}.${operation.methodName}(params)`
988
+ : `${toLocalServiceName(service.name)}.${operation.methodName}(...params)`;
989
+ classDeclaration.insertProperty(0, {
990
+ name: mutationName,
991
+ isReadonly: true,
992
+ initializer: `mutation({
993
+ method: ${method},
994
+ loader: function* ({ params }) {
995
+ const ${toLocalServiceName(service.name)} = yield* ${service.name}ToYield();
996
+ return yield* ${call};
997
+ },
998
+ })`,
999
+ });
1000
+ return mutationName;
1001
+ }
1002
+
1003
+ function toLocalServiceName(name: string): string {
1004
+ return `${name.charAt(0).toLowerCase()}${name.slice(1)}`;
1005
+ }
1006
+
1007
+ function uniqueClassPropertyName(
1008
+ classDeclaration: ClassDeclaration,
1009
+ baseName: string,
1010
+ ): string {
1011
+ if (!classDeclaration.getProperty(baseName)) return baseName;
1012
+ let index = 2;
1013
+ while (classDeclaration.getProperty(`${baseName}${index}`)) index += 1;
1014
+ return `${baseName}${index}`;
1015
+ }
1016
+
1017
+ function printMutationCall(
1018
+ mutationName: string,
1019
+ operationCall: CallExpression,
1020
+ ): string {
1021
+ const args = operationCall.getArguments().map((arg) => arg.getText());
1022
+ const params = args.length === 1 ? args[0] : `[${args.join(', ')}]`;
1023
+ return `this.${mutationName}.mutate(${params})`;
1024
+ }
1025
+
1026
+ function rewriteProviders(
1027
+ files: readonly SourceFile[],
1028
+ descriptors: readonly ServiceDescriptor[],
1029
+ diagnostics: ServiceMigrationDiagnostic[],
1030
+ touched: Set<SourceFile>,
1031
+ ): void {
1032
+ for (const file of files) {
1033
+ const assignments = file
1034
+ .getDescendantsOfKind(SyntaxKind.PropertyAssignment)
1035
+ .filter((property) => property.getName() === 'providers');
1036
+ for (const assignment of assignments) {
1037
+ const array = assignment.getInitializerIfKind(
1038
+ SyntaxKind.ArrayLiteralExpression,
1039
+ );
1040
+ if (!array) {
1041
+ const owner = descriptors[0];
1042
+ if (owner)
1043
+ diagnose(
1044
+ diagnostics,
1045
+ 'COMPLEX_PROVIDER',
1046
+ { ...owner, sourceFile: file },
1047
+ 'Provider non statique.',
1048
+ );
1049
+ continue;
1050
+ }
1051
+ for (const element of [...array.getElements()]) {
1052
+ if (!Node.isIdentifier(element)) continue;
1053
+ const service = descriptors.find(
1054
+ (item) => item.symbol === element.getText(),
1055
+ );
1056
+ if (!service || service.scope === 'global') continue;
1057
+ if (service.scope === 'function') {
1058
+ array.removeElement(element);
1059
+ touched.add(file);
1060
+ continue;
1061
+ }
1062
+ element.replaceWithText(`provide${service.name}()`);
1063
+ ensureRelativeImport(file, service, `provide${service.name}`);
1064
+ touched.add(file);
1065
+ }
1066
+ }
1067
+ }
1068
+ }
1069
+
1070
+ function isProviderCapableScope(
1071
+ scope: ServiceMigrationScope,
1072
+ ): scope is 'toProvide' | 'manuallyProvidedAtRoot' | 'abstract' {
1073
+ return [
1074
+ 'toProvide',
1075
+ 'manuallyProvidedAtRoot',
1076
+ 'abstract',
1077
+ ].includes(scope);
1078
+ }
1079
+
1080
+ function diagnoseCycles(
1081
+ descriptors: readonly ServiceDescriptor[],
1082
+ diagnostics: ServiceMigrationDiagnostic[],
1083
+ ): void {
1084
+ const edges = new Map<string, string[]>();
1085
+ for (const descriptor of descriptors) {
1086
+ const deps = descriptor.classDeclaration
1087
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
1088
+ .filter((call) => call.getExpression().getText() === 'inject')
1089
+ .map((call) => call.getArguments()[0]?.getText())
1090
+ .filter((value): value is string => Boolean(value));
1091
+ edges.set(descriptor.symbol, deps);
1092
+ }
1093
+ for (const descriptor of descriptors) {
1094
+ if (hasPath(edges, descriptor.symbol, descriptor.symbol, new Set(), true)) {
1095
+ diagnose(
1096
+ diagnostics,
1097
+ 'DI_CYCLE',
1098
+ descriptor,
1099
+ `Cycle DI impliquant ${descriptor.symbol}.`,
1100
+ );
1101
+ }
1102
+ }
1103
+ }
1104
+
1105
+ function hasPath(
1106
+ edges: Map<string, string[]>,
1107
+ current: string,
1108
+ target: string,
1109
+ seen: Set<string>,
1110
+ first: boolean,
1111
+ ): boolean {
1112
+ if (!first && current === target) return true;
1113
+ if (seen.has(current)) return false;
1114
+ seen.add(current);
1115
+ return (edges.get(current) ?? []).some((next) =>
1116
+ hasPath(edges, next, target, seen, false),
1117
+ );
1118
+ }
1119
+
1120
+ function isLegacyServiceClass(value: ClassDeclaration): boolean {
1121
+ return value
1122
+ .getDecorators()
1123
+ .some((decorator) =>
1124
+ ['Injectable', 'Service'].includes(decorator.getName()),
1125
+ );
1126
+ }
1127
+
1128
+ function hasInjectionFlags(call: CallExpression): boolean {
1129
+ if (
1130
+ call.getExpression().getText() !== 'inject' ||
1131
+ call.getArguments().length < 2
1132
+ )
1133
+ return false;
1134
+ return /optional|self|host|skipSelf/.test(call.getArguments()[1].getText());
1135
+ }
1136
+
1137
+ function ensureCoreImports(file: SourceFile, names: string[]): void {
1138
+ let declaration = file.getImportDeclaration('@craft-ng/core');
1139
+ if (!declaration)
1140
+ declaration = file.addImportDeclaration({
1141
+ moduleSpecifier: '@craft-ng/core',
1142
+ });
1143
+ const existing = new Set(
1144
+ declaration.getNamedImports().map((item) => item.getName()),
1145
+ );
1146
+ declaration.addNamedImports(names.filter((name) => !existing.has(name)));
1147
+ }
1148
+
1149
+ function ensureRelativeImport(
1150
+ file: SourceFile,
1151
+ service: ServiceDescriptor,
1152
+ name: string,
1153
+ ): void {
1154
+ if (file === service.sourceFile) return;
1155
+ const helperFile = service.helperSourceFile ?? service.sourceFile;
1156
+ let specifier = relative(
1157
+ dirname(file.getFilePath()),
1158
+ helperFile.getFilePath(),
1159
+ )
1160
+ .replace(/\\/g, '/')
1161
+ .replace(/\.ts$/, '');
1162
+ if (!specifier.startsWith('.')) specifier = `./${specifier}`;
1163
+ let declaration = file.getImportDeclaration(specifier);
1164
+ if (!declaration)
1165
+ declaration = file.addImportDeclaration({ moduleSpecifier: specifier });
1166
+ if (!declaration.getNamedImports().some((item) => item.getName() === name))
1167
+ declaration.addNamedImport(name);
1168
+ }
1169
+
1170
+ function removeDecoratorImport(file: SourceFile, name: string): void {
1171
+ for (const declaration of file.getImportDeclarations()) {
1172
+ const imported = declaration
1173
+ .getNamedImports()
1174
+ .find((item) => item.getName() === name);
1175
+ if (!imported) continue;
1176
+ imported.remove();
1177
+ if (
1178
+ !declaration.getDefaultImport() &&
1179
+ !declaration.getNamespaceImport() &&
1180
+ declaration.getNamedImports().length === 0
1181
+ )
1182
+ declaration.remove();
1183
+ }
1184
+ }
1185
+
1186
+ function diagnose(
1187
+ diagnostics: ServiceMigrationDiagnostic[],
1188
+ code: ServiceMigrationDiagnosticCode,
1189
+ descriptor: Pick<ServiceDescriptor, 'sourceFile' | 'symbol'>,
1190
+ message: string,
1191
+ ): void {
1192
+ if (
1193
+ diagnostics.some(
1194
+ (item) =>
1195
+ item.code === code &&
1196
+ item.filePath === descriptor.sourceFile.getFilePath() &&
1197
+ item.symbol === descriptor.symbol,
1198
+ )
1199
+ )
1200
+ return;
1201
+ diagnostics.push({
1202
+ code,
1203
+ filePath: descriptor.sourceFile.getFilePath(),
1204
+ symbol: descriptor.symbol,
1205
+ message,
1206
+ manual: true,
1207
+ });
1208
+ }
1209
+
1210
+ function getFileReport(
1211
+ map: Map<string, MigratedServiceFile>,
1212
+ filePath: string,
1213
+ ): MigratedServiceFile {
1214
+ let report = map.get(filePath);
1215
+ if (!report) {
1216
+ report = { filePath, changed: false, symbols: [], companions: [] };
1217
+ map.set(filePath, report);
1218
+ }
1219
+ return report;
1220
+ }
1221
+
1222
+ function rewriteThis(text: string, propertyNames: ReadonlySet<string>): string {
1223
+ let rewritten = text;
1224
+ for (const name of propertyNames) {
1225
+ rewritten = rewritten.replace(
1226
+ new RegExp(`\\bthis\\.${escapeRegex(name)}\\b`, 'g'),
1227
+ toInternalName(name),
1228
+ );
1229
+ }
1230
+ return rewritten.replace(/\bthis\./g, '');
1231
+ }
1232
+
1233
+ function toInternalName(name: string): string {
1234
+ return `_${name}`;
1235
+ }
1236
+
1237
+ function matches(pattern: string, value: string): boolean {
1238
+ if (!pattern.includes('*'))
1239
+ return pattern === value || value.endsWith(pattern);
1240
+ const regex = new RegExp(
1241
+ `^${pattern.split('*').map(escapeRegex).join('.*')}$`,
1242
+ );
1243
+ return regex.test(value);
1244
+ }
1245
+
1246
+ function escapeRegex(value: string): string {
1247
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1248
+ }
1249
+
1250
+ function isInside(filePath: string, rootDir: string): boolean {
1251
+ const path = relative(rootDir, filePath);
1252
+ return path === '' || (!path.startsWith('..') && !isAbsolute(path));
1253
+ }
1254
+
1255
+ function defaultTsConfig(rootDir: string): string {
1256
+ for (const name of ['tsconfig.app.json', 'tsconfig.json']) {
1257
+ const candidate = join(rootDir, name);
1258
+ if (existsSync(candidate)) return candidate;
1259
+ }
1260
+ return join(rootDir, 'tsconfig.json');
1261
+ }
1262
+
1263
+ async function runEslint(files: string[], cwd: string): Promise<void> {
1264
+ const local = join(cwd, 'node_modules', '.bin', 'eslint');
1265
+ await execFileAsync(
1266
+ existsSync(local) ? local : 'eslint',
1267
+ ['--fix', ...files],
1268
+ { cwd },
1269
+ );
1270
+ }
1271
+
1272
+ function logSummary(
1273
+ result: MigrateServicesResult,
1274
+ log: (message: string) => void,
1275
+ wrote: boolean,
1276
+ ): void {
1277
+ log(
1278
+ `${wrote ? 'Migrated' : 'Would migrate'} ${result.changedFiles.length} file(s); ${result.diagnostics.length} manual diagnostic(s).`,
1279
+ );
1280
+ for (const diagnostic of result.diagnostics)
1281
+ log(`[${diagnostic.code}] ${diagnostic.filePath}: ${diagnostic.message}`);
1282
+ }