@craft-ng/dev-tools 0.1.2 → 0.1.3

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.
@@ -0,0 +1,1780 @@
1
+ import {
2
+ ArrayLiteralExpression,
3
+ ClassDeclaration,
4
+ Decorator,
5
+ ExportAssignment,
6
+ ExportDeclaration,
7
+ Identifier,
8
+ Node,
9
+ ObjectLiteralExpression,
10
+ Project,
11
+ PropertyAssignment,
12
+ QuoteKind,
13
+ SourceFile,
14
+ SyntaxKind,
15
+ TypeNode,
16
+ ts,
17
+ } from 'ts-morph';
18
+ import { existsSync, readdirSync, statSync } from 'node:fs';
19
+ import { basename, extname, isAbsolute, join, resolve } from 'node:path';
20
+
21
+ export type AngularKind = 'component' | 'directive' | 'pipe' | 'injectable';
22
+
23
+ export type TransformResult = {
24
+ changed: boolean;
25
+ skipped: boolean;
26
+ warnings: string[];
27
+ angularKind?: string;
28
+ className?: string;
29
+ dependencies: string[];
30
+ dependencyGroups: DependencyGroups;
31
+ };
32
+
33
+ export type AngularBrandCodemodOptions = {
34
+ helperImportPath?: string;
35
+ transformOnlyStandaloneDeclarables?: boolean;
36
+ includeProviders?: boolean;
37
+ includeViewProviders?: boolean;
38
+ };
39
+
40
+ export type AngularClassSearchResult = {
41
+ classDeclaration?: ClassDeclaration;
42
+ angularKind?: AngularKind;
43
+ className?: string;
44
+ skipped: boolean;
45
+ warnings: string[];
46
+ };
47
+
48
+ export type DependencyExtractionResult = {
49
+ dependencies: string[];
50
+ warnings: string[];
51
+ };
52
+
53
+ export type DependencyGroups = {
54
+ injected: string[];
55
+ importDeps: string[];
56
+ providers: string[];
57
+ };
58
+
59
+ export type DependencyAnalysisResult = Omit<TransformResult, 'changed'> & {
60
+ classDeclaration?: ClassDeclaration;
61
+ };
62
+
63
+ export type ExistingDependencyGroupsResult = {
64
+ found: boolean;
65
+ warnings: string[];
66
+ dependencyGroups: DependencyGroups;
67
+ exportAssignmentNode?: Node;
68
+ depsObjectNode?: Node;
69
+ propertyNodes: Partial<Record<keyof DependencyGroups, Node>>;
70
+ };
71
+
72
+ type MetadataDependencyGroups = {
73
+ imports: string[];
74
+ hostDirectives: string[];
75
+ providers: string[];
76
+ viewProviders: string[];
77
+ warnings: string[];
78
+ };
79
+
80
+ type NormalizedOptions = Required<AngularBrandCodemodOptions>;
81
+
82
+ type InjectDecoratorTokenResult =
83
+ | { found: false }
84
+ | { found: true; dependency?: string };
85
+
86
+ type RunFileReport = TransformResult & {
87
+ filePath: string;
88
+ };
89
+
90
+ export type RunSummary = {
91
+ transformedFiles: number;
92
+ skippedFiles: number;
93
+ warnings: number;
94
+ countByAngularKind: Record<AngularKind, number>;
95
+ files: RunFileReport[];
96
+ };
97
+
98
+ const SUPPORTED_DECORATORS: Record<string, AngularKind> = {
99
+ Component: 'component',
100
+ Directive: 'directive',
101
+ Pipe: 'pipe',
102
+ Injectable: 'injectable',
103
+ };
104
+
105
+ const DEFAULT_OPTIONS: NormalizedOptions = {
106
+ helperImportPath: '@craft-ng/core',
107
+ transformOnlyStandaloneDeclarables: false,
108
+ includeProviders: true,
109
+ includeViewProviders: true,
110
+ };
111
+
112
+ const PRIMITIVE_TYPE_TEXTS = new Set([
113
+ 'any',
114
+ 'bigint',
115
+ 'boolean',
116
+ 'false',
117
+ 'never',
118
+ 'null',
119
+ 'number',
120
+ 'object',
121
+ 'string',
122
+ 'symbol',
123
+ 'true',
124
+ 'undefined',
125
+ 'unknown',
126
+ 'void',
127
+ 'Array',
128
+ 'Boolean',
129
+ 'Number',
130
+ 'Object',
131
+ 'ReadonlyArray',
132
+ 'String',
133
+ ]);
134
+
135
+ const GENERATED_FILE_SUFFIXES = [
136
+ '.d.ts',
137
+ '.gen.ts',
138
+ '.generated.ts',
139
+ '.ngfactory.ts',
140
+ '.ngsummary.ts',
141
+ '.ngtypecheck.ts',
142
+ ];
143
+
144
+ const IGNORED_DIRECTORIES = new Set([
145
+ '.angular',
146
+ '.git',
147
+ '.nx',
148
+ '.turbo',
149
+ 'coverage',
150
+ 'dist',
151
+ 'node_modules',
152
+ 'out-tsc',
153
+ 'tmp',
154
+ ]);
155
+
156
+ export function transformSourceFile(
157
+ sourceFile: SourceFile,
158
+ options: AngularBrandCodemodOptions = {},
159
+ ): TransformResult {
160
+ const normalizedOptions = normalizeOptions(options);
161
+ const analysis = analyzeSourceFileDependencies(sourceFile, options);
162
+ const result: TransformResult = {
163
+ ...analysis,
164
+ changed: false,
165
+ };
166
+
167
+ const { angularKind, classDeclaration, className } = analysis;
168
+
169
+ if (analysis.skipped || !classDeclaration || !angularKind || !className) {
170
+ return result;
171
+ }
172
+
173
+ const exportSafety = getExportRewriteSafety(
174
+ sourceFile,
175
+ classDeclaration,
176
+ className,
177
+ );
178
+ if (!exportSafety.safe) {
179
+ return skip(result, exportSafety.warnings);
180
+ }
181
+
182
+ const importSafety = getHelperImportSafety(
183
+ sourceFile,
184
+ normalizedOptions.helperImportPath,
185
+ );
186
+ if (!importSafety.safe) {
187
+ return skip(result, importSafety.warnings);
188
+ }
189
+
190
+ removeConflictingExports(sourceFile, classDeclaration, className);
191
+ ensureHelperImports(sourceFile, normalizedOptions.helperImportPath);
192
+ writeDefaultBrandExport(sourceFile, className, result.dependencyGroups);
193
+
194
+ result.changed = true;
195
+ return result;
196
+ }
197
+
198
+ export function analyzeSourceFileDependencies(
199
+ sourceFile: SourceFile,
200
+ options: AngularBrandCodemodOptions = {},
201
+ ): DependencyAnalysisResult {
202
+ const normalizedOptions = normalizeOptions(options);
203
+ const result: DependencyAnalysisResult = {
204
+ skipped: false,
205
+ warnings: [],
206
+ dependencies: [],
207
+ dependencyGroups: emptyDependencyGroups(),
208
+ };
209
+
210
+ const angularClass = findAngularDecoratedClass(sourceFile);
211
+ result.warnings.push(...angularClass.warnings);
212
+ result.angularKind = angularClass.angularKind;
213
+ result.className = angularClass.className;
214
+ result.classDeclaration = angularClass.classDeclaration;
215
+
216
+ if (angularClass.skipped) {
217
+ result.skipped = true;
218
+ return result;
219
+ }
220
+
221
+ if (!angularClass.classDeclaration) {
222
+ return result;
223
+ }
224
+
225
+ const { angularKind, classDeclaration, className } = angularClass;
226
+
227
+ if (!angularKind || !className) {
228
+ result.skipped = true;
229
+ result.warnings.push('Angular class name or kind could not be resolved.');
230
+ return result;
231
+ }
232
+
233
+ if (
234
+ normalizedOptions.transformOnlyStandaloneDeclarables &&
235
+ (angularKind === 'component' || angularKind === 'directive') &&
236
+ !isStandaloneDeclarable(classDeclaration)
237
+ ) {
238
+ result.skipped = true;
239
+ result.warnings.push(
240
+ `${decoratorLabel(angularKind)} ${className} is not standalone and transformOnlyStandaloneDeclarables is enabled.`,
241
+ );
242
+ return result;
243
+ }
244
+
245
+ const metadataDeps =
246
+ angularKind === 'component' || angularKind === 'directive'
247
+ ? extractDecoratorMetadataDepGroups(
248
+ classDeclaration,
249
+ angularKind,
250
+ normalizedOptions,
251
+ )
252
+ : emptyMetadataDependencyGroups();
253
+ const constructorDeps = extractConstructorDeps(classDeclaration);
254
+ const injectCallDeps = extractInjectCallDeps(classDeclaration);
255
+
256
+ result.warnings.push(
257
+ ...metadataDeps.warnings,
258
+ ...constructorDeps.warnings,
259
+ ...injectCallDeps.warnings,
260
+ );
261
+ result.dependencies = mergeDeps(
262
+ constructorDeps.dependencies,
263
+ injectCallDeps.dependencies,
264
+ metadataDeps.imports,
265
+ metadataDeps.hostDirectives,
266
+ metadataDeps.providers,
267
+ metadataDeps.viewProviders,
268
+ );
269
+ result.dependencyGroups = {
270
+ injected: mergeDeps(
271
+ constructorDeps.dependencies,
272
+ injectCallDeps.dependencies,
273
+ ),
274
+ importDeps: mergeDeps(metadataDeps.imports, metadataDeps.hostDirectives),
275
+ providers: mergeDeps(metadataDeps.providers, metadataDeps.viewProviders),
276
+ };
277
+
278
+ return result;
279
+ }
280
+
281
+ export function findAngularDecoratedClass(
282
+ sourceFile: SourceFile,
283
+ ): AngularClassSearchResult {
284
+ const decoratedClasses = sourceFile
285
+ .getClasses()
286
+ .map((classDeclaration) => {
287
+ const angularKind = getAngularKind(classDeclaration);
288
+ return angularKind ? { classDeclaration, angularKind } : undefined;
289
+ })
290
+ .filter(
291
+ (
292
+ entry,
293
+ ): entry is {
294
+ classDeclaration: ClassDeclaration;
295
+ angularKind: AngularKind;
296
+ } => Boolean(entry),
297
+ );
298
+
299
+ if (decoratedClasses.length === 0) {
300
+ return { skipped: false, warnings: [] };
301
+ }
302
+
303
+ if (decoratedClasses.length > 1) {
304
+ return {
305
+ skipped: true,
306
+ warnings: [
307
+ `Skipped file because it contains ${decoratedClasses.length} supported Angular classes.`,
308
+ ],
309
+ };
310
+ }
311
+
312
+ const [{ classDeclaration, angularKind }] = decoratedClasses;
313
+ const className = classDeclaration.getName();
314
+ if (!className) {
315
+ return {
316
+ classDeclaration,
317
+ angularKind,
318
+ skipped: true,
319
+ warnings: ['Skipped anonymous default-exported Angular class.'],
320
+ };
321
+ }
322
+
323
+ return {
324
+ classDeclaration,
325
+ angularKind,
326
+ className,
327
+ skipped: false,
328
+ warnings: [],
329
+ };
330
+ }
331
+
332
+ export function getAngularKind(
333
+ classDeclaration: ClassDeclaration,
334
+ ): AngularKind | undefined {
335
+ for (const decorator of classDeclaration.getDecorators()) {
336
+ const decoratorName = getDecoratorName(decorator);
337
+ if (decoratorName && decoratorName in SUPPORTED_DECORATORS) {
338
+ return SUPPORTED_DECORATORS[decoratorName];
339
+ }
340
+ }
341
+
342
+ return undefined;
343
+ }
344
+
345
+ export function isStandaloneDeclarable(
346
+ classDeclaration: ClassDeclaration,
347
+ ): boolean {
348
+ const angularKind = getAngularKind(classDeclaration);
349
+ if (angularKind !== 'component' && angularKind !== 'directive') {
350
+ return false;
351
+ }
352
+
353
+ const metadata = getDecoratorMetadataObject(classDeclaration);
354
+ const standaloneProperty = metadata
355
+ ? getObjectPropertyAssignment(metadata, 'standalone')
356
+ : undefined;
357
+ const initializer = standaloneProperty?.getInitializer();
358
+
359
+ return Node.isTrueLiteral(initializer);
360
+ }
361
+
362
+ export function extractDecoratorMetadataDeps(
363
+ classDeclaration: ClassDeclaration,
364
+ angularKind = getAngularKind(classDeclaration),
365
+ options: AngularBrandCodemodOptions = {},
366
+ ): DependencyExtractionResult {
367
+ if (angularKind !== 'component' && angularKind !== 'directive') {
368
+ return { dependencies: [], warnings: [] };
369
+ }
370
+
371
+ const groups = extractDecoratorMetadataDepGroups(
372
+ classDeclaration,
373
+ angularKind,
374
+ normalizeOptions(options),
375
+ );
376
+
377
+ return {
378
+ dependencies: mergeDeps(
379
+ groups.imports,
380
+ groups.hostDirectives,
381
+ groups.providers,
382
+ groups.viewProviders,
383
+ ),
384
+ warnings: groups.warnings,
385
+ };
386
+ }
387
+
388
+ export function extractConstructorDeps(
389
+ classDeclaration: ClassDeclaration,
390
+ ): DependencyExtractionResult {
391
+ const warnings: string[] = [];
392
+ const dependencies: string[] = [];
393
+
394
+ for (const constructorDeclaration of classDeclaration.getConstructors()) {
395
+ for (const parameter of constructorDeclaration.getParameters()) {
396
+ const injectToken = getInjectDecoratorToken(
397
+ parameter.getDecorators(),
398
+ warnings,
399
+ );
400
+ if (injectToken.found) {
401
+ if (injectToken.dependency) {
402
+ dependencies.push(injectToken.dependency);
403
+ }
404
+ continue;
405
+ }
406
+
407
+ const typeNode = parameter.getTypeNode();
408
+ if (!typeNode) {
409
+ warnings.push(
410
+ `Skipped constructor parameter "${parameter.getName()}" because it has no type.`,
411
+ );
412
+ continue;
413
+ }
414
+
415
+ const dependency = getDependencyTextFromTypeNode(typeNode);
416
+ if (!dependency) {
417
+ warnings.push(
418
+ `Skipped constructor parameter "${parameter.getName()}" because type "${typeNode.getText()}" is not a static Angular dependency.`,
419
+ );
420
+ continue;
421
+ }
422
+
423
+ if (!isRuntimeSafeTypeDependency(typeNode, dependency)) {
424
+ warnings.push(
425
+ `Skipped constructor dependency "${dependency}" because it is unresolved, type-only, or not a runtime value.`,
426
+ );
427
+ continue;
428
+ }
429
+
430
+ dependencies.push(dependency);
431
+ }
432
+ }
433
+
434
+ return { dependencies: mergeDeps(dependencies), warnings };
435
+ }
436
+
437
+ export function extractInjectCallDeps(
438
+ classDeclaration: ClassDeclaration,
439
+ ): DependencyExtractionResult {
440
+ const warnings: string[] = [];
441
+ const dependencies: string[] = [];
442
+
443
+ for (const callExpression of classDeclaration.getDescendantsOfKind(
444
+ SyntaxKind.CallExpression,
445
+ )) {
446
+ const expression = callExpression.getExpression();
447
+ const injectMethodName = getInjectMethodName(expression);
448
+ if (!injectMethodName?.startsWith('inject')) {
449
+ continue;
450
+ }
451
+
452
+ const dependency =
453
+ injectMethodName === 'inject'
454
+ ? getAngularInjectCallDependency(callExpression)
455
+ : getInjectionHelperDependency(expression);
456
+
457
+ if (!dependency) {
458
+ warnings.push(
459
+ `Skipped ${injectMethodName}() call in ${classDeclaration.getName() ?? 'anonymous class'} because the injection dependency is not static.`,
460
+ );
461
+ continue;
462
+ }
463
+
464
+ dependencies.push(dependency);
465
+ }
466
+
467
+ return { dependencies: mergeDeps(dependencies), warnings };
468
+ }
469
+
470
+ export function mergeDeps(...dependencyGroups: readonly string[][]): string[] {
471
+ const seen = new Set<string>();
472
+ const dependencies: string[] = [];
473
+
474
+ for (const group of dependencyGroups) {
475
+ for (const dependency of group) {
476
+ if (seen.has(dependency)) {
477
+ continue;
478
+ }
479
+
480
+ seen.add(dependency);
481
+ dependencies.push(dependency);
482
+ }
483
+ }
484
+
485
+ return dependencies;
486
+ }
487
+
488
+ function emptyDependencyGroups(): DependencyGroups {
489
+ return {
490
+ injected: [],
491
+ importDeps: [],
492
+ providers: [],
493
+ };
494
+ }
495
+
496
+ function createDepsExpression(dependencyGroups: DependencyGroups): string {
497
+ return `deps({ injected: [${dependencyGroups.injected.join(', ')}], importDeps: [${dependencyGroups.importDeps.join(', ')}], providers: [${dependencyGroups.providers.join(', ')}] })`;
498
+ }
499
+
500
+ function invalidExistingDependencyGroups(
501
+ node: Node,
502
+ warnings: string[],
503
+ ): ExistingDependencyGroupsResult {
504
+ return {
505
+ found: true,
506
+ warnings,
507
+ dependencyGroups: emptyDependencyGroups(),
508
+ exportAssignmentNode: node,
509
+ depsObjectNode: node,
510
+ propertyNodes: {},
511
+ };
512
+ }
513
+
514
+ function readExistingDependencyGroupProperty(
515
+ objectLiteral: ObjectLiteralExpression,
516
+ propertyName: keyof DependencyGroups,
517
+ warnings: string[],
518
+ propertyNodes: ExistingDependencyGroupsResult['propertyNodes'],
519
+ ): string[] {
520
+ const property = getObjectPropertyAssignment(objectLiteral, propertyName);
521
+ if (!property) {
522
+ warnings.push(`deps(...) is missing the "${propertyName}" array.`);
523
+ return [];
524
+ }
525
+
526
+ propertyNodes[propertyName] = property;
527
+ const initializer = property.getInitializer();
528
+ if (!Node.isArrayLiteralExpression(initializer)) {
529
+ warnings.push(`deps.${propertyName} must be a static array.`);
530
+ return [];
531
+ }
532
+
533
+ const dependencies: string[] = [];
534
+
535
+ for (const element of initializer.getElements()) {
536
+ const dependency = getStaticExpressionText(element);
537
+ if (!dependency) {
538
+ warnings.push(
539
+ `deps.${propertyName} contains a non-static expression: ${element.getText()}`,
540
+ );
541
+ continue;
542
+ }
543
+
544
+ dependencies.push(dependency);
545
+ }
546
+
547
+ return dependencies;
548
+ }
549
+
550
+ function getInjectMethodName(expression: Node): string | undefined {
551
+ if (Node.isIdentifier(expression)) {
552
+ return expression.getText();
553
+ }
554
+
555
+ if (Node.isPropertyAccessExpression(expression)) {
556
+ return expression.getName();
557
+ }
558
+
559
+ return undefined;
560
+ }
561
+
562
+ function getAngularInjectCallDependency(
563
+ callExpression: import('ts-morph').CallExpression,
564
+ ): string | undefined {
565
+ const [token] = callExpression.getArguments();
566
+ return getStaticExpressionText(token);
567
+ }
568
+
569
+ function getInjectionHelperDependency(expression: Node): string | undefined {
570
+ if (Node.isIdentifier(expression)) {
571
+ return expression.getText();
572
+ }
573
+
574
+ if (Node.isPropertyAccessExpression(expression)) {
575
+ const left = expression.getExpression();
576
+ if (Node.isIdentifier(left)) {
577
+ return expression.getText();
578
+ }
579
+ }
580
+
581
+ return undefined;
582
+ }
583
+
584
+ export function ensureHelperImports(
585
+ sourceFile: SourceFile,
586
+ helperImportPath: string,
587
+ ): void {
588
+ const missingImports = ['brandAngularSymbol', 'deps'].filter(
589
+ (importName) =>
590
+ !hasLocalNamedImport(sourceFile, helperImportPath, importName),
591
+ );
592
+
593
+ if (missingImports.length === 0) {
594
+ return;
595
+ }
596
+
597
+ const existingImport = sourceFile
598
+ .getImportDeclarations()
599
+ .find(
600
+ (importDeclaration) =>
601
+ importDeclaration.getModuleSpecifierValue() === helperImportPath,
602
+ );
603
+
604
+ if (existingImport) {
605
+ existingImport.addNamedImports(missingImports);
606
+ return;
607
+ }
608
+
609
+ sourceFile.addImportDeclaration({
610
+ moduleSpecifier: helperImportPath,
611
+ namedImports: missingImports,
612
+ });
613
+ }
614
+
615
+ export function removeConflictingExports(
616
+ sourceFile: SourceFile,
617
+ classDeclaration: ClassDeclaration,
618
+ className: string,
619
+ ): void {
620
+ if (classDeclaration.isDefaultExport()) {
621
+ classDeclaration.setIsDefaultExport(false);
622
+ }
623
+
624
+ if (classDeclaration.isExported()) {
625
+ classDeclaration.setIsExported(false);
626
+ }
627
+
628
+ for (const exportAssignment of sourceFile.getExportAssignments()) {
629
+ if (
630
+ isDefaultIdentifierExport(exportAssignment, className) ||
631
+ isDefaultBrandedExport(exportAssignment, className)
632
+ ) {
633
+ exportAssignment.remove();
634
+ }
635
+ }
636
+
637
+ for (const exportDeclaration of sourceFile.getExportDeclarations()) {
638
+ removeClassFromExportDeclaration(exportDeclaration, className);
639
+ }
640
+ }
641
+
642
+ export function writeDefaultBrandExport(
643
+ sourceFile: SourceFile,
644
+ className: string,
645
+ dependencyGroups: DependencyGroups,
646
+ ): void {
647
+ sourceFile.addExportAssignment({
648
+ expression: `brandAngularSymbol(${className}, ${createDepsExpression(dependencyGroups)})`,
649
+ isExportEquals: false,
650
+ });
651
+ }
652
+
653
+ export function readExistingDependencyGroups(
654
+ sourceFile: SourceFile,
655
+ className?: string,
656
+ ): ExistingDependencyGroupsResult {
657
+ const defaultExport = sourceFile
658
+ .getExportAssignments()
659
+ .find((exportAssignment) => {
660
+ if (exportAssignment.isExportEquals()) {
661
+ return false;
662
+ }
663
+
664
+ const expression = exportAssignment.getExpression();
665
+ if (!Node.isCallExpression(expression)) {
666
+ return false;
667
+ }
668
+
669
+ const callTarget = expression.getExpression();
670
+ if (
671
+ !Node.isIdentifier(callTarget) ||
672
+ callTarget.getText() !== 'brandAngularSymbol'
673
+ ) {
674
+ return false;
675
+ }
676
+
677
+ if (!className) {
678
+ return true;
679
+ }
680
+
681
+ const [targetClass] = expression.getArguments();
682
+ return (
683
+ Node.isIdentifier(targetClass) && targetClass.getText() === className
684
+ );
685
+ });
686
+
687
+ if (!defaultExport) {
688
+ return {
689
+ found: false,
690
+ warnings: [],
691
+ dependencyGroups: emptyDependencyGroups(),
692
+ propertyNodes: {},
693
+ };
694
+ }
695
+
696
+ const expression = defaultExport.getExpression();
697
+ if (!Node.isCallExpression(expression)) {
698
+ return invalidExistingDependencyGroups(defaultExport, [
699
+ 'Default export must call brandAngularSymbol(ClassName, deps({ ... })).',
700
+ ]);
701
+ }
702
+
703
+ const [, depsArgument] = expression.getArguments();
704
+ if (!Node.isCallExpression(depsArgument)) {
705
+ return invalidExistingDependencyGroups(defaultExport, [
706
+ 'brandAngularSymbol must receive deps({ injected, importDeps, providers }).',
707
+ ]);
708
+ }
709
+
710
+ const depsCallTarget = depsArgument.getExpression();
711
+ if (
712
+ !Node.isIdentifier(depsCallTarget) ||
713
+ depsCallTarget.getText() !== 'deps'
714
+ ) {
715
+ return invalidExistingDependencyGroups(depsArgument, [
716
+ 'brandAngularSymbol second argument must be a deps(...) call.',
717
+ ]);
718
+ }
719
+
720
+ const [depsObject] = depsArgument.getArguments();
721
+ if (!Node.isObjectLiteralExpression(depsObject)) {
722
+ return invalidExistingDependencyGroups(depsArgument, [
723
+ 'deps(...) must be called with an object literal.',
724
+ ]);
725
+ }
726
+
727
+ const warnings: string[] = [];
728
+ const propertyNodes: ExistingDependencyGroupsResult['propertyNodes'] = {};
729
+ const dependencyGroups: DependencyGroups = {
730
+ injected: readExistingDependencyGroupProperty(
731
+ depsObject,
732
+ 'injected',
733
+ warnings,
734
+ propertyNodes,
735
+ ),
736
+ importDeps: readExistingDependencyGroupProperty(
737
+ depsObject,
738
+ 'importDeps',
739
+ warnings,
740
+ propertyNodes,
741
+ ),
742
+ providers: readExistingDependencyGroupProperty(
743
+ depsObject,
744
+ 'providers',
745
+ warnings,
746
+ propertyNodes,
747
+ ),
748
+ };
749
+
750
+ return {
751
+ found: true,
752
+ warnings,
753
+ dependencyGroups,
754
+ exportAssignmentNode: defaultExport,
755
+ depsObjectNode: depsObject,
756
+ propertyNodes,
757
+ };
758
+ }
759
+
760
+ export function formatDependencyGroups(
761
+ dependencyGroups: DependencyGroups,
762
+ ): string {
763
+ return [
764
+ `injected=[${dependencyGroups.injected.join(', ')}]`,
765
+ `importDeps=[${dependencyGroups.importDeps.join(', ')}]`,
766
+ `providers=[${dependencyGroups.providers.join(', ')}]`,
767
+ ].join(' ');
768
+ }
769
+
770
+ export async function runAngularBrandCodemod(
771
+ options: AngularBrandCodemodOptions & {
772
+ rootDir?: string;
773
+ tsConfigFilePath?: string;
774
+ dryRun?: boolean;
775
+ log?: (message: string) => void;
776
+ } = {},
777
+ ): Promise<RunSummary> {
778
+ const rootDir = resolve(options.rootDir ?? process.cwd());
779
+ const tsConfigFilePath = options.tsConfigFilePath
780
+ ? resolve(options.tsConfigFilePath)
781
+ : getDefaultTsConfigPath(rootDir);
782
+ const project = createProject(tsConfigFilePath);
783
+ const files = collectTypeScriptFiles(rootDir);
784
+
785
+ project.addSourceFilesAtPaths(files);
786
+ setProjectQuoteKind(project);
787
+
788
+ const summary: RunSummary = {
789
+ transformedFiles: 0,
790
+ skippedFiles: 0,
791
+ warnings: 0,
792
+ countByAngularKind: {
793
+ component: 0,
794
+ directive: 0,
795
+ injectable: 0,
796
+ pipe: 0,
797
+ },
798
+ files: [],
799
+ };
800
+
801
+ for (const sourceFile of project.getSourceFiles()) {
802
+ if (!isWithinRoot(sourceFile.getFilePath(), rootDir)) {
803
+ continue;
804
+ }
805
+
806
+ const result = transformSourceFile(sourceFile, options);
807
+ const report: RunFileReport = {
808
+ ...result,
809
+ filePath: sourceFile.getFilePath(),
810
+ };
811
+
812
+ summary.files.push(report);
813
+ summary.warnings += result.warnings.length;
814
+
815
+ if (result.changed) {
816
+ summary.transformedFiles += 1;
817
+ if (
818
+ result.angularKind &&
819
+ result.angularKind in summary.countByAngularKind
820
+ ) {
821
+ summary.countByAngularKind[result.angularKind as AngularKind] += 1;
822
+ }
823
+
824
+ if (!options.dryRun) {
825
+ await sourceFile.save();
826
+ }
827
+ }
828
+
829
+ if (result.skipped) {
830
+ summary.skippedFiles += 1;
831
+ }
832
+
833
+ logFileResult(report, options.log ?? console.log);
834
+ }
835
+
836
+ logSummary(summary, options.log ?? console.log);
837
+ return summary;
838
+ }
839
+
840
+ function normalizeOptions(
841
+ options: AngularBrandCodemodOptions,
842
+ ): NormalizedOptions {
843
+ return {
844
+ helperImportPath:
845
+ options.helperImportPath ?? DEFAULT_OPTIONS.helperImportPath,
846
+ transformOnlyStandaloneDeclarables:
847
+ options.transformOnlyStandaloneDeclarables ??
848
+ DEFAULT_OPTIONS.transformOnlyStandaloneDeclarables,
849
+ includeProviders:
850
+ options.includeProviders ?? DEFAULT_OPTIONS.includeProviders,
851
+ includeViewProviders:
852
+ options.includeViewProviders ?? DEFAULT_OPTIONS.includeViewProviders,
853
+ };
854
+ }
855
+
856
+ function skip(result: TransformResult, warnings: string[]): TransformResult {
857
+ result.skipped = true;
858
+ result.warnings.push(...warnings);
859
+ return result;
860
+ }
861
+
862
+ function getDecoratorName(decorator: Decorator): string | undefined {
863
+ const expression = decorator.getExpression();
864
+ if (Node.isCallExpression(expression)) {
865
+ const callTarget = expression.getExpression();
866
+ if (Node.isIdentifier(callTarget)) {
867
+ return callTarget.getText();
868
+ }
869
+
870
+ if (Node.isPropertyAccessExpression(callTarget)) {
871
+ return callTarget.getName();
872
+ }
873
+
874
+ return undefined;
875
+ }
876
+
877
+ if (Node.isIdentifier(expression)) {
878
+ return expression.getText();
879
+ }
880
+
881
+ if (Node.isPropertyAccessExpression(expression)) {
882
+ return expression.getName();
883
+ }
884
+
885
+ return undefined;
886
+ }
887
+
888
+ function decoratorLabel(angularKind: AngularKind): string {
889
+ switch (angularKind) {
890
+ case 'component':
891
+ return '@Component';
892
+ case 'directive':
893
+ return '@Directive';
894
+ case 'pipe':
895
+ return '@Pipe';
896
+ case 'injectable':
897
+ return '@Injectable';
898
+ }
899
+ }
900
+
901
+ function getDecoratorMetadataObject(
902
+ classDeclaration: ClassDeclaration,
903
+ ): ObjectLiteralExpression | undefined {
904
+ for (const decorator of classDeclaration.getDecorators()) {
905
+ if (!getAngularKindFromDecorator(decorator)) {
906
+ continue;
907
+ }
908
+
909
+ const callExpression = decorator.getCallExpression();
910
+ const [metadata] = callExpression?.getArguments() ?? [];
911
+ if (Node.isObjectLiteralExpression(metadata)) {
912
+ return metadata;
913
+ }
914
+ }
915
+
916
+ return undefined;
917
+ }
918
+
919
+ function getAngularKindFromDecorator(
920
+ decorator: Decorator,
921
+ ): AngularKind | undefined {
922
+ const decoratorName = getDecoratorName(decorator);
923
+ return decoratorName && decoratorName in SUPPORTED_DECORATORS
924
+ ? SUPPORTED_DECORATORS[decoratorName]
925
+ : undefined;
926
+ }
927
+
928
+ function getObjectPropertyAssignment(
929
+ objectLiteral: ObjectLiteralExpression,
930
+ propertyName: string,
931
+ ): PropertyAssignment | undefined {
932
+ const property = objectLiteral.getProperties().find((objectProperty) => {
933
+ if (!Node.isPropertyAssignment(objectProperty)) {
934
+ return false;
935
+ }
936
+
937
+ return getStaticPropertyName(objectProperty.getNameNode()) === propertyName;
938
+ });
939
+
940
+ return Node.isPropertyAssignment(property) ? property : undefined;
941
+ }
942
+
943
+ function extractDecoratorMetadataDepGroups(
944
+ classDeclaration: ClassDeclaration,
945
+ angularKind: AngularKind,
946
+ options: NormalizedOptions,
947
+ ): MetadataDependencyGroups {
948
+ const metadata = getDecoratorMetadataObject(classDeclaration);
949
+ const groups = emptyMetadataDependencyGroups();
950
+
951
+ if (!metadata) {
952
+ return groups;
953
+ }
954
+
955
+ groups.imports = extractMetadataArrayProperty(
956
+ metadata,
957
+ 'imports',
958
+ 'imports',
959
+ groups.warnings,
960
+ );
961
+ groups.hostDirectives = extractMetadataArrayProperty(
962
+ metadata,
963
+ 'hostDirectives',
964
+ 'hostDirectives',
965
+ groups.warnings,
966
+ );
967
+
968
+ if (options.includeProviders) {
969
+ groups.providers = extractMetadataArrayProperty(
970
+ metadata,
971
+ 'providers',
972
+ 'providers',
973
+ groups.warnings,
974
+ );
975
+ }
976
+
977
+ if (options.includeViewProviders && angularKind === 'component') {
978
+ groups.viewProviders = extractMetadataArrayProperty(
979
+ metadata,
980
+ 'viewProviders',
981
+ 'viewProviders',
982
+ groups.warnings,
983
+ );
984
+ }
985
+
986
+ return groups;
987
+ }
988
+
989
+ function emptyMetadataDependencyGroups(): MetadataDependencyGroups {
990
+ return {
991
+ imports: [],
992
+ hostDirectives: [],
993
+ providers: [],
994
+ viewProviders: [],
995
+ warnings: [],
996
+ };
997
+ }
998
+
999
+ function extractMetadataArrayProperty(
1000
+ metadata: ObjectLiteralExpression,
1001
+ propertyName: string,
1002
+ context: string,
1003
+ warnings: string[],
1004
+ ): string[] {
1005
+ const property = getObjectPropertyAssignment(metadata, propertyName);
1006
+ if (!property) {
1007
+ return [];
1008
+ }
1009
+
1010
+ const initializer = property.getInitializer();
1011
+ if (!Node.isArrayLiteralExpression(initializer)) {
1012
+ warnings.push(
1013
+ `Skipped metadata property "${propertyName}" because it is not a static array.`,
1014
+ );
1015
+ return [];
1016
+ }
1017
+
1018
+ return extractStaticArrayElements(initializer, context, warnings);
1019
+ }
1020
+
1021
+ function extractStaticArrayElements(
1022
+ arrayLiteral: ArrayLiteralExpression,
1023
+ context: string,
1024
+ warnings: string[],
1025
+ ): string[] {
1026
+ const dependencies: string[] = [];
1027
+
1028
+ for (const element of arrayLiteral.getElements()) {
1029
+ if (Node.isArrayLiteralExpression(element)) {
1030
+ dependencies.push(
1031
+ ...extractStaticArrayElements(element, context, warnings),
1032
+ );
1033
+ continue;
1034
+ }
1035
+
1036
+ if (Node.isSpreadElement(element)) {
1037
+ warnings.push(
1038
+ `Skipped spread element in "${context}" metadata dependencies.`,
1039
+ );
1040
+ continue;
1041
+ }
1042
+
1043
+ if (isProviderContext(context) && Node.isCallExpression(element)) {
1044
+ const providerFactory = getStaticExpressionText(element.getExpression());
1045
+ if (providerFactory) {
1046
+ dependencies.push(providerFactory);
1047
+ continue;
1048
+ }
1049
+ }
1050
+
1051
+ const dependency = getStaticExpressionText(element);
1052
+ if (dependency) {
1053
+ dependencies.push(dependency);
1054
+ continue;
1055
+ }
1056
+
1057
+ if (Node.isObjectLiteralExpression(element)) {
1058
+ dependencies.push(
1059
+ ...extractStaticObjectElementDependencies(element, context, warnings),
1060
+ );
1061
+ continue;
1062
+ }
1063
+
1064
+ warnings.push(
1065
+ `Skipped complex expression "${element.getText()}" in "${context}" metadata dependencies.`,
1066
+ );
1067
+ }
1068
+
1069
+ return dependencies;
1070
+ }
1071
+
1072
+ function isProviderContext(context: string): boolean {
1073
+ return context === 'providers' || context === 'viewProviders';
1074
+ }
1075
+
1076
+ function extractStaticObjectElementDependencies(
1077
+ objectLiteral: ObjectLiteralExpression,
1078
+ context: string,
1079
+ warnings: string[],
1080
+ ): string[] {
1081
+ const dependencies: string[] = [];
1082
+ const knownValueProperties =
1083
+ context === 'hostDirectives'
1084
+ ? new Set(['directive'])
1085
+ : new Set(['provide', 'useClass', 'useExisting']);
1086
+
1087
+ for (const property of objectLiteral.getProperties()) {
1088
+ if (!Node.isPropertyAssignment(property)) {
1089
+ warnings.push(
1090
+ `Skipped complex object member in "${context}" metadata dependencies.`,
1091
+ );
1092
+ continue;
1093
+ }
1094
+
1095
+ const propertyName = getStaticPropertyName(property.getNameNode());
1096
+
1097
+ if (!propertyName || !knownValueProperties.has(propertyName)) {
1098
+ continue;
1099
+ }
1100
+
1101
+ const dependency = getStaticExpressionText(property.getInitializer());
1102
+ if (dependency) {
1103
+ dependencies.push(dependency);
1104
+ continue;
1105
+ }
1106
+
1107
+ warnings.push(
1108
+ `Skipped complex "${propertyName}" value in "${context}" metadata dependencies.`,
1109
+ );
1110
+ }
1111
+
1112
+ return dependencies;
1113
+ }
1114
+
1115
+ function getInjectDecoratorToken(
1116
+ decorators: Decorator[],
1117
+ warnings: string[],
1118
+ ): InjectDecoratorTokenResult {
1119
+ for (const decorator of decorators) {
1120
+ if (getDecoratorName(decorator) !== 'Inject') {
1121
+ continue;
1122
+ }
1123
+
1124
+ const [token] = decorator.getCallExpression()?.getArguments() ?? [];
1125
+ const dependency = getStaticExpressionText(token);
1126
+ if (!dependency) {
1127
+ warnings.push(
1128
+ 'Skipped @Inject() dependency because the token is not a static identifier.',
1129
+ );
1130
+ return { found: true };
1131
+ }
1132
+
1133
+ return { found: true, dependency };
1134
+ }
1135
+
1136
+ return { found: false };
1137
+ }
1138
+
1139
+ function getStaticExpressionText(
1140
+ expression: Node | undefined,
1141
+ ): string | undefined {
1142
+ if (!expression) {
1143
+ return undefined;
1144
+ }
1145
+
1146
+ if (Node.isIdentifier(expression)) {
1147
+ return expression.getText();
1148
+ }
1149
+
1150
+ if (Node.isPropertyAccessExpression(expression)) {
1151
+ return expression.getText();
1152
+ }
1153
+
1154
+ return undefined;
1155
+ }
1156
+
1157
+ function getStaticPropertyName(nameNode: Node): string | undefined {
1158
+ if (Node.isIdentifier(nameNode)) {
1159
+ return nameNode.getText();
1160
+ }
1161
+
1162
+ if (Node.isStringLiteral(nameNode) || Node.isNumericLiteral(nameNode)) {
1163
+ return nameNode.getLiteralText();
1164
+ }
1165
+
1166
+ return undefined;
1167
+ }
1168
+
1169
+ function getDependencyTextFromTypeNode(typeNode: TypeNode): string | undefined {
1170
+ if (isPrimitiveTypeNode(typeNode)) {
1171
+ return undefined;
1172
+ }
1173
+
1174
+ if (Node.isTypeReference(typeNode)) {
1175
+ const typeName = typeNode.getTypeName();
1176
+ const dependency = typeName.getText();
1177
+ return isPrimitiveDependencyText(dependency) ? undefined : dependency;
1178
+ }
1179
+
1180
+ if (Node.isExpressionWithTypeArguments(typeNode)) {
1181
+ const expression = typeNode.getExpression();
1182
+ return getStaticExpressionText(expression);
1183
+ }
1184
+
1185
+ return undefined;
1186
+ }
1187
+
1188
+ function isPrimitiveTypeNode(typeNode: TypeNode): boolean {
1189
+ return [
1190
+ SyntaxKind.AnyKeyword,
1191
+ SyntaxKind.BigIntKeyword,
1192
+ SyntaxKind.BooleanKeyword,
1193
+ SyntaxKind.FalseKeyword,
1194
+ SyntaxKind.NeverKeyword,
1195
+ SyntaxKind.NullKeyword,
1196
+ SyntaxKind.NumberKeyword,
1197
+ SyntaxKind.ObjectKeyword,
1198
+ SyntaxKind.StringKeyword,
1199
+ SyntaxKind.SymbolKeyword,
1200
+ SyntaxKind.TrueKeyword,
1201
+ SyntaxKind.UndefinedKeyword,
1202
+ SyntaxKind.UnknownKeyword,
1203
+ SyntaxKind.VoidKeyword,
1204
+ ].includes(typeNode.getKind());
1205
+ }
1206
+
1207
+ function isPrimitiveDependencyText(dependency: string): boolean {
1208
+ return PRIMITIVE_TYPE_TEXTS.has(dependency);
1209
+ }
1210
+
1211
+ function isRuntimeSafeTypeDependency(
1212
+ typeNode: TypeNode,
1213
+ dependency: string,
1214
+ ): boolean {
1215
+ const identifiers = getDependencyIdentifiersFromTypeNode(typeNode);
1216
+ if (
1217
+ identifiers.length === 0 ||
1218
+ identifiers.map((identifier) => identifier.getText()).join('.') !==
1219
+ dependency
1220
+ ) {
1221
+ return false;
1222
+ }
1223
+
1224
+ const [rootIdentifier] = identifiers;
1225
+ const leafIdentifier = identifiers[identifiers.length - 1];
1226
+ const isQualifiedDependency = identifiers.length > 1;
1227
+
1228
+ return (
1229
+ isRuntimeSafeIdentifier(rootIdentifier, {
1230
+ allowNamespaceContainer: isQualifiedDependency,
1231
+ }) &&
1232
+ isRuntimeSafeIdentifier(leafIdentifier, { allowNamespaceContainer: false })
1233
+ );
1234
+ }
1235
+
1236
+ function getDependencyIdentifiersFromTypeNode(
1237
+ typeNode: TypeNode,
1238
+ ): Identifier[] {
1239
+ if (!Node.isTypeReference(typeNode)) {
1240
+ return [];
1241
+ }
1242
+
1243
+ const typeName = typeNode.getTypeName();
1244
+ if (Node.isIdentifier(typeName)) {
1245
+ return [typeName];
1246
+ }
1247
+
1248
+ return typeName.getDescendantsOfKind(SyntaxKind.Identifier);
1249
+ }
1250
+
1251
+ function isRuntimeSafeIdentifier(
1252
+ identifier: Identifier,
1253
+ options: { allowNamespaceContainer: boolean },
1254
+ ): boolean {
1255
+ const symbol = identifier.getSymbol() ?? identifier.getType().getSymbol();
1256
+ if (!symbol) {
1257
+ return false;
1258
+ }
1259
+
1260
+ const declarations = symbol.getDeclarations();
1261
+ if (declarations.length === 0) {
1262
+ return false;
1263
+ }
1264
+
1265
+ if (
1266
+ declarations.some((declaration) => isTypeOnlyImportDeclaration(declaration))
1267
+ ) {
1268
+ return false;
1269
+ }
1270
+
1271
+ if (
1272
+ options.allowNamespaceContainer &&
1273
+ declarations.some((declaration) => Node.isNamespaceImport(declaration))
1274
+ ) {
1275
+ return true;
1276
+ }
1277
+
1278
+ const aliasedSymbol = symbol.getAliasedSymbol();
1279
+ const runtimeDeclarations = aliasedSymbol?.getDeclarations() ?? declarations;
1280
+ if (runtimeDeclarations.length === 0) {
1281
+ return false;
1282
+ }
1283
+
1284
+ return runtimeDeclarations.some((declaration) =>
1285
+ isRuntimeValueDeclaration(declaration, options),
1286
+ );
1287
+ }
1288
+
1289
+ function isTypeOnlyImportDeclaration(declaration: Node): boolean {
1290
+ if (Node.isImportSpecifier(declaration)) {
1291
+ return (
1292
+ declaration.isTypeOnly() ||
1293
+ declaration.getImportDeclaration().isTypeOnly()
1294
+ );
1295
+ }
1296
+
1297
+ if (Node.isImportClause(declaration)) {
1298
+ return declaration.isTypeOnly();
1299
+ }
1300
+
1301
+ if (Node.isNamespaceImport(declaration)) {
1302
+ return (
1303
+ declaration
1304
+ .getFirstAncestorByKind(SyntaxKind.ImportDeclaration)
1305
+ ?.isTypeOnly() ?? false
1306
+ );
1307
+ }
1308
+
1309
+ return false;
1310
+ }
1311
+
1312
+ function isRuntimeValueDeclaration(
1313
+ declaration: Node,
1314
+ options: { allowNamespaceContainer: boolean },
1315
+ ): boolean {
1316
+ if (
1317
+ Node.isClassDeclaration(declaration) ||
1318
+ Node.isEnumDeclaration(declaration) ||
1319
+ Node.isFunctionDeclaration(declaration) ||
1320
+ Node.isVariableDeclaration(declaration)
1321
+ ) {
1322
+ return true;
1323
+ }
1324
+
1325
+ if (
1326
+ options.allowNamespaceContainer &&
1327
+ Node.isModuleDeclaration(declaration)
1328
+ ) {
1329
+ return true;
1330
+ }
1331
+
1332
+ return false;
1333
+ }
1334
+
1335
+ function getExportRewriteSafety(
1336
+ sourceFile: SourceFile,
1337
+ classDeclaration: ClassDeclaration,
1338
+ className: string,
1339
+ ): { safe: true; warnings: [] } | { safe: false; warnings: string[] } {
1340
+ const warnings: string[] = [];
1341
+
1342
+ for (const statement of sourceFile.getStatements()) {
1343
+ if (Node.isFunctionDeclaration(statement) && statement.isDefaultExport()) {
1344
+ warnings.push(
1345
+ 'Skipped file because it has an unrelated default-exported function.',
1346
+ );
1347
+ }
1348
+
1349
+ if (
1350
+ Node.isClassDeclaration(statement) &&
1351
+ statement !== classDeclaration &&
1352
+ statement.isDefaultExport()
1353
+ ) {
1354
+ warnings.push(
1355
+ 'Skipped file because it has an unrelated default-exported class.',
1356
+ );
1357
+ }
1358
+ }
1359
+
1360
+ for (const exportAssignment of sourceFile.getExportAssignments()) {
1361
+ if (exportAssignment.isExportEquals()) {
1362
+ warnings.push(
1363
+ 'Skipped file because export = is not safe to combine with export default.',
1364
+ );
1365
+ continue;
1366
+ }
1367
+
1368
+ if (
1369
+ !isDefaultIdentifierExport(exportAssignment, className) &&
1370
+ !isDefaultBrandedExport(exportAssignment, className)
1371
+ ) {
1372
+ warnings.push(
1373
+ `Skipped file because it has a complex default export: ${exportAssignment.getText()}`,
1374
+ );
1375
+ }
1376
+ }
1377
+
1378
+ for (const exportDeclaration of sourceFile.getExportDeclarations()) {
1379
+ const defaultExportSpecifier = exportDeclaration
1380
+ .getNamedExports()
1381
+ .find((specifier) => {
1382
+ const alias = specifier.getAliasNode()?.getText();
1383
+ return (
1384
+ alias === 'default' || (!alias && specifier.getName() === 'default')
1385
+ );
1386
+ });
1387
+
1388
+ if (
1389
+ defaultExportSpecifier &&
1390
+ defaultExportSpecifier.getName() !== className
1391
+ ) {
1392
+ warnings.push(
1393
+ 'Skipped file because it has an unrelated named default export.',
1394
+ );
1395
+ }
1396
+
1397
+ if (exportDeclaration.getModuleSpecifier()) {
1398
+ const reExportsClass = exportDeclaration
1399
+ .getNamedExports()
1400
+ .some((specifier) => specifier.getName() === className);
1401
+ if (reExportsClass) {
1402
+ warnings.push(
1403
+ `Skipped file because it re-exports "${className}" from another module.`,
1404
+ );
1405
+ }
1406
+ }
1407
+ }
1408
+
1409
+ return warnings.length === 0
1410
+ ? { safe: true, warnings: [] }
1411
+ : { safe: false, warnings };
1412
+ }
1413
+
1414
+ function getHelperImportSafety(
1415
+ sourceFile: SourceFile,
1416
+ helperImportPath: string,
1417
+ ): { safe: true; warnings: [] } | { safe: false; warnings: string[] } {
1418
+ const warnings = ['brandAngularSymbol', 'deps']
1419
+ .filter((helperName) =>
1420
+ hasConflictingTopLevelBinding(sourceFile, helperImportPath, helperName),
1421
+ )
1422
+ .map(
1423
+ (helperName) =>
1424
+ `Skipped file because "${helperName}" is already bound to a different top-level symbol.`,
1425
+ );
1426
+
1427
+ return warnings.length === 0
1428
+ ? { safe: true, warnings: [] }
1429
+ : { safe: false, warnings };
1430
+ }
1431
+
1432
+ function hasConflictingTopLevelBinding(
1433
+ sourceFile: SourceFile,
1434
+ helperImportPath: string,
1435
+ helperName: string,
1436
+ ): boolean {
1437
+ for (const importDeclaration of sourceFile.getImportDeclarations()) {
1438
+ if (importDeclaration.getDefaultImport()?.getText() === helperName) {
1439
+ return true;
1440
+ }
1441
+
1442
+ if (importDeclaration.getNamespaceImport()?.getText() === helperName) {
1443
+ return true;
1444
+ }
1445
+
1446
+ for (const namedImport of importDeclaration.getNamedImports()) {
1447
+ const localName =
1448
+ namedImport.getAliasNode()?.getText() ?? namedImport.getName();
1449
+ if (localName !== helperName) {
1450
+ continue;
1451
+ }
1452
+
1453
+ return importDeclaration.getModuleSpecifierValue() !== helperImportPath;
1454
+ }
1455
+ }
1456
+
1457
+ for (const statement of sourceFile.getStatements()) {
1458
+ if (
1459
+ Node.isClassDeclaration(statement) &&
1460
+ statement.getName() === helperName
1461
+ ) {
1462
+ return true;
1463
+ }
1464
+
1465
+ if (
1466
+ Node.isFunctionDeclaration(statement) &&
1467
+ statement.getName() === helperName
1468
+ ) {
1469
+ return true;
1470
+ }
1471
+
1472
+ if (
1473
+ Node.isEnumDeclaration(statement) &&
1474
+ statement.getName() === helperName
1475
+ ) {
1476
+ return true;
1477
+ }
1478
+
1479
+ if (
1480
+ Node.isInterfaceDeclaration(statement) &&
1481
+ statement.getName() === helperName
1482
+ ) {
1483
+ return true;
1484
+ }
1485
+
1486
+ if (
1487
+ Node.isTypeAliasDeclaration(statement) &&
1488
+ statement.getName() === helperName
1489
+ ) {
1490
+ return true;
1491
+ }
1492
+
1493
+ if (Node.isVariableStatement(statement)) {
1494
+ const hasVariableName = statement
1495
+ .getDeclarationList()
1496
+ .getDeclarations()
1497
+ .some((declaration) => declaration.getName() === helperName);
1498
+ if (hasVariableName) {
1499
+ return true;
1500
+ }
1501
+ }
1502
+ }
1503
+
1504
+ return false;
1505
+ }
1506
+
1507
+ function isDefaultIdentifierExport(
1508
+ exportAssignment: ExportAssignment,
1509
+ className: string,
1510
+ ): boolean {
1511
+ if (exportAssignment.isExportEquals()) {
1512
+ return false;
1513
+ }
1514
+
1515
+ const expression = exportAssignment.getExpression();
1516
+ return Node.isIdentifier(expression) && expression.getText() === className;
1517
+ }
1518
+
1519
+ function isDefaultBrandedExport(
1520
+ exportAssignment: ExportAssignment,
1521
+ className: string,
1522
+ ): boolean {
1523
+ if (exportAssignment.isExportEquals()) {
1524
+ return false;
1525
+ }
1526
+
1527
+ const expression = exportAssignment.getExpression();
1528
+ if (!Node.isCallExpression(expression)) {
1529
+ return false;
1530
+ }
1531
+
1532
+ const callTarget = expression.getExpression();
1533
+ if (
1534
+ !Node.isIdentifier(callTarget) ||
1535
+ callTarget.getText() !== 'brandAngularSymbol'
1536
+ ) {
1537
+ return false;
1538
+ }
1539
+
1540
+ const [targetClass] = expression.getArguments();
1541
+ return Node.isIdentifier(targetClass) && targetClass.getText() === className;
1542
+ }
1543
+
1544
+ function removeClassFromExportDeclaration(
1545
+ exportDeclaration: ExportDeclaration,
1546
+ className: string,
1547
+ ): void {
1548
+ if (exportDeclaration.getModuleSpecifier()) {
1549
+ return;
1550
+ }
1551
+
1552
+ for (const namedExport of exportDeclaration.getNamedExports()) {
1553
+ if (namedExport.getName() === className) {
1554
+ namedExport.remove();
1555
+ }
1556
+ }
1557
+
1558
+ if (exportDeclaration.getNamedExports().length === 0) {
1559
+ exportDeclaration.remove();
1560
+ }
1561
+ }
1562
+
1563
+ function hasLocalNamedImport(
1564
+ sourceFile: SourceFile,
1565
+ helperImportPath: string,
1566
+ importName: string,
1567
+ ): boolean {
1568
+ return sourceFile.getImportDeclarations().some((importDeclaration) => {
1569
+ if (importDeclaration.getModuleSpecifierValue() !== helperImportPath) {
1570
+ return false;
1571
+ }
1572
+
1573
+ return importDeclaration.getNamedImports().some((namedImport) => {
1574
+ const localName =
1575
+ namedImport.getAliasNode()?.getText() ?? namedImport.getName();
1576
+ return localName === importName;
1577
+ });
1578
+ });
1579
+ }
1580
+
1581
+ function createProject(tsConfigFilePath: string | undefined): Project {
1582
+ if (tsConfigFilePath) {
1583
+ return new Project({
1584
+ skipAddingFilesFromTsConfig: true,
1585
+ tsConfigFilePath,
1586
+ });
1587
+ }
1588
+
1589
+ return new Project({
1590
+ compilerOptions: {
1591
+ experimentalDecorators: true,
1592
+ module: ts.ModuleKind.Preserve,
1593
+ moduleResolution: ts.ModuleResolutionKind.Node10,
1594
+ target: ts.ScriptTarget.ES2022,
1595
+ },
1596
+ });
1597
+ }
1598
+
1599
+ function getDefaultTsConfigPath(rootDir: string): string | undefined {
1600
+ const tsConfigPath = join(rootDir, 'tsconfig.json');
1601
+ return existsSync(tsConfigPath) ? tsConfigPath : undefined;
1602
+ }
1603
+
1604
+ function collectTypeScriptFiles(rootDir: string): string[] {
1605
+ const files: string[] = [];
1606
+ collectTypeScriptFilesInto(rootDir, files);
1607
+ return files;
1608
+ }
1609
+
1610
+ function collectTypeScriptFilesInto(directory: string, files: string[]): void {
1611
+ for (const entryName of readdirSync(directory)) {
1612
+ const entryPath = join(directory, entryName);
1613
+ const stats = statSync(entryPath);
1614
+
1615
+ if (stats.isDirectory()) {
1616
+ if (!IGNORED_DIRECTORIES.has(entryName)) {
1617
+ collectTypeScriptFilesInto(entryPath, files);
1618
+ }
1619
+ continue;
1620
+ }
1621
+
1622
+ if (
1623
+ !stats.isFile() ||
1624
+ extname(entryName) !== '.ts' ||
1625
+ isGeneratedFile(entryPath)
1626
+ ) {
1627
+ continue;
1628
+ }
1629
+
1630
+ files.push(entryPath);
1631
+ }
1632
+ }
1633
+
1634
+ function isGeneratedFile(filePath: string): boolean {
1635
+ const fileName = basename(filePath);
1636
+ return (
1637
+ GENERATED_FILE_SUFFIXES.some((suffix) => fileName.endsWith(suffix)) ||
1638
+ filePath.includes('/generated/') ||
1639
+ filePath.includes('\\generated\\')
1640
+ );
1641
+ }
1642
+
1643
+ function setProjectQuoteKind(project: Project): void {
1644
+ const firstModuleSpecifier = project
1645
+ .getSourceFiles()
1646
+ .flatMap((sourceFile) => sourceFile.getImportDeclarations())
1647
+ .map((importDeclaration) =>
1648
+ importDeclaration.getModuleSpecifier().getText(),
1649
+ )
1650
+ .find(Boolean);
1651
+
1652
+ project.manipulationSettings.set({
1653
+ quoteKind: firstModuleSpecifier?.startsWith('"')
1654
+ ? QuoteKind.Double
1655
+ : QuoteKind.Single,
1656
+ });
1657
+ }
1658
+
1659
+ function isWithinRoot(filePath: string, rootDir: string): boolean {
1660
+ const absoluteFilePath = isAbsolute(filePath) ? filePath : resolve(filePath);
1661
+ return (
1662
+ absoluteFilePath === rootDir || absoluteFilePath.startsWith(`${rootDir}/`)
1663
+ );
1664
+ }
1665
+
1666
+ function logFileResult(
1667
+ report: RunFileReport,
1668
+ log: (message: string) => void,
1669
+ ): void {
1670
+ if (!report.changed && !report.skipped && !report.angularKind) {
1671
+ return;
1672
+ }
1673
+
1674
+ const status = report.changed
1675
+ ? 'transformed'
1676
+ : report.skipped
1677
+ ? 'skipped'
1678
+ : 'unchanged';
1679
+ const details = [
1680
+ `file=${report.filePath}`,
1681
+ report.angularKind ? `kind=${report.angularKind}` : undefined,
1682
+ report.className ? `class=${report.className}` : undefined,
1683
+ `injected=[${report.dependencyGroups.injected.join(', ')}]`,
1684
+ `importDeps=[${report.dependencyGroups.importDeps.join(', ')}]`,
1685
+ `providers=[${report.dependencyGroups.providers.join(', ')}]`,
1686
+ `status=${status}`,
1687
+ ].filter(Boolean);
1688
+
1689
+ log(details.join(' '));
1690
+
1691
+ for (const warning of report.warnings) {
1692
+ log(` warning: ${warning}`);
1693
+ }
1694
+ }
1695
+
1696
+ function logSummary(summary: RunSummary, log: (message: string) => void): void {
1697
+ log(
1698
+ [
1699
+ `summary transformed=${summary.transformedFiles}`,
1700
+ `skipped=${summary.skippedFiles}`,
1701
+ `warnings=${summary.warnings}`,
1702
+ `components=${summary.countByAngularKind.component}`,
1703
+ `directives=${summary.countByAngularKind.directive}`,
1704
+ `pipes=${summary.countByAngularKind.pipe}`,
1705
+ `injectables=${summary.countByAngularKind.injectable}`,
1706
+ ].join(' '),
1707
+ );
1708
+ }
1709
+
1710
+ function parseCliArgs(argv: string[]): AngularBrandCodemodOptions & {
1711
+ rootDir?: string;
1712
+ tsConfigFilePath?: string;
1713
+ dryRun?: boolean;
1714
+ } {
1715
+ const options: AngularBrandCodemodOptions & {
1716
+ rootDir?: string;
1717
+ tsConfigFilePath?: string;
1718
+ dryRun?: boolean;
1719
+ } = {};
1720
+
1721
+ for (let index = 0; index < argv.length; index += 1) {
1722
+ const arg = argv[index];
1723
+
1724
+ switch (arg) {
1725
+ case '--root':
1726
+ options.rootDir = argv[++index];
1727
+ break;
1728
+ case '--tsconfig':
1729
+ options.tsConfigFilePath = argv[++index];
1730
+ break;
1731
+ case '--helper-import':
1732
+ options.helperImportPath = argv[++index];
1733
+ break;
1734
+ case '--transform-only-standalone-declarables':
1735
+ options.transformOnlyStandaloneDeclarables = true;
1736
+ break;
1737
+ case '--no-providers':
1738
+ options.includeProviders = false;
1739
+ break;
1740
+ case '--no-view-providers':
1741
+ options.includeViewProviders = false;
1742
+ break;
1743
+ case '--dry-run':
1744
+ options.dryRun = true;
1745
+ break;
1746
+ case '--help':
1747
+ printHelpAndExit();
1748
+ break;
1749
+ default:
1750
+ throw new Error(`Unknown argument: ${arg}`);
1751
+ }
1752
+ }
1753
+
1754
+ return options;
1755
+ }
1756
+
1757
+ function printHelpAndExit(): never {
1758
+ console.log(`Usage: craft-brand [options]
1759
+
1760
+ Options:
1761
+ --root <dir> Project root. Defaults to cwd.
1762
+ --tsconfig <path> tsconfig path. Defaults to <root>/tsconfig.json.
1763
+ --helper-import <path> Import path for brandAngularSymbol and deps.
1764
+ --transform-only-standalone-declarables Only transform standalone components/directives.
1765
+ --no-providers Do not include metadata providers in deps().
1766
+ --no-view-providers Do not include component viewProviders in deps().
1767
+ --dry-run Print results without writing files.
1768
+ --help Show this help.
1769
+ `);
1770
+ process.exit(0);
1771
+ }
1772
+
1773
+ if (require.main === module) {
1774
+ runAngularBrandCodemod(parseCliArgs(process.argv.slice(2))).catch(
1775
+ (error: unknown) => {
1776
+ console.error(error);
1777
+ process.exitCode = 1;
1778
+ },
1779
+ );
1780
+ }