@craft-ng/dev-tools 0.1.9 → 0.4.0-beta.1

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.
@@ -3,7 +3,11 @@ import { tmpdir } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { afterEach, describe, expect, it } from 'vitest';
5
5
  import { Project } from 'ts-morph';
6
- import { transformSourceFile } from './angular-brand-codemod';
6
+ import {
7
+ discoverAngularBrandConfigFilePath,
8
+ loadAngularBrandConfigFromFile,
9
+ transformSourceFile,
10
+ } from './angular-brand-codemod';
7
11
 
8
12
  const tempDirectories: string[] = [];
9
13
 
@@ -85,7 +89,10 @@ describe('angular-brand-codemod', () => {
85
89
  'publicProperties: GetPublicComponentProperties<ParentComponent>;',
86
90
  );
87
91
  expect(output).toContain('GenDeps_ChildComponent: GenDeps_ChildComponent;');
92
+ expect(output).toContain('propertiesDeps: {');
93
+ expect(output).toContain('api: {');
88
94
  expect(output).toContain('ApiService: ApiService;');
95
+ expect(output).toContain('http: {');
89
96
  expect(output).toContain('HttpClient: HttpClient;');
90
97
  expect(output).toMatch(/provided: \{\s+UserStore: UserStore;\s+\};/m);
91
98
  expect(output).toMatch(
@@ -132,6 +139,7 @@ describe('angular-brand-codemod', () => {
132
139
  expect(output).not.toContain('deps({');
133
140
  expect(output).toContain('export default class DemoComponent {}');
134
141
  expect(output).toContain('export type GenDeps_DemoComponent = GetDeps<{');
142
+ expect(output).toContain('propertiesDeps: {};');
135
143
  expect(output).toContain(
136
144
  'publicProperties: GetPublicComponentProperties<DemoComponent>;',
137
145
  );
@@ -172,6 +180,8 @@ describe('angular-brand-codemod', () => {
172
180
  transformSourceFile(sourceFile);
173
181
  const output = sourceFile.getFullText();
174
182
 
183
+ expect(output).toContain('propertiesDeps: {');
184
+ expect(output).toContain('store: {');
175
185
  expect(output).toContain('UserStore: ReturnType<typeof injectUserStore>;');
176
186
  expect(output).toContain('UserStore: ReturnType<typeof provideUserStore>;');
177
187
  expect(output).not.toContain('missingProvider: {};');
@@ -195,6 +205,7 @@ describe('angular-brand-codemod', () => {
195
205
  export declare function craftService(...args: any[]): any;
196
206
  export declare function toCraftService(...args: any[]): any;
197
207
  export type DerivedService<T, U> = T & U;
208
+ export type ExtractDeps<T> = T;
198
209
  export type GetDeps<T> = T;
199
210
  export type GetInjectedServiceDependencies<T> = T;
200
211
  export type GetPublicComponentProperties<T> = T;
@@ -241,20 +252,644 @@ describe('angular-brand-codemod', () => {
241
252
  transformSourceFile(sourceFile);
242
253
  const output = sourceFile.getFullText();
243
254
 
255
+ expect(output).toContain('propertiesDeps: {');
256
+ expect(output).toContain('userId: ExtractDeps<DemoComponent["userId"]>;');
257
+ expect(output).toContain('apiService: {');
244
258
  expect(output).toContain(
245
- 'ApiService: GetInjectedServiceDependencies<typeof injectApiService>;',
259
+ 'ApiService: ExtractDeps<typeof injectApiService>["ApiService"];',
246
260
  );
247
261
  expect(output).toContain(
248
262
  'publicProperties: GetPublicComponentProperties<DemoComponent>;',
249
263
  );
250
264
  expect(output).toContain(
251
- 'Router: DerivedService<GetInjectedServiceDependencies<typeof injectRouter>, {',
265
+ 'Router: DerivedService<ExtractDeps<typeof injectRouter>["Router"], {',
252
266
  );
253
267
  expect(output).toContain(
254
268
  `navigate: GetServiceOutput<typeof injectRouter>["navigate"];`,
255
269
  );
256
270
  expect(output).not.toContain('missingProvider: {};');
257
271
  });
272
+
273
+ it('adds Router to generated deps when RouterModule is used in component imports', async () => {
274
+ const project = await createRouterMetadataProjectFixture({
275
+ 'src/app/demo.ts': `
276
+ import { Component } from '@angular/core';
277
+ import { RouterModule } from '@angular/router';
278
+
279
+ @Component({
280
+ standalone: true,
281
+ imports: [RouterModule],
282
+ template: '',
283
+ })
284
+ export class DemoComponent {}
285
+ `,
286
+ });
287
+
288
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
289
+ transformSourceFile(sourceFile);
290
+ const output = sourceFile.getFullText();
291
+
292
+ expect(output).toMatch(
293
+ /import \{[^}]*type Router[^}]*\} from ['"]@angular\/router['"];/,
294
+ );
295
+ expect(output).toContain('RouterModule: RouterModule;');
296
+ expect(output).toContain('Router: Router;');
297
+ expect(output).toMatch(/missingProvider: \{[\s\S]*Router: Router;/);
298
+ });
299
+
300
+ it('adds Router to generated deps when standalone router declarables are used in metadata imports', async () => {
301
+ const project = await createRouterMetadataProjectFixture({
302
+ 'src/app/demo.ts': `
303
+ import { Component } from '@angular/core';
304
+ import { RouterOutlet } from '@angular/router';
305
+
306
+ @Component({
307
+ standalone: true,
308
+ imports: [RouterOutlet],
309
+ template: '',
310
+ })
311
+ export class DemoComponent {}
312
+ `,
313
+ });
314
+
315
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
316
+ transformSourceFile(sourceFile);
317
+ const output = sourceFile.getFullText();
318
+
319
+ expect(output).toContain('RouterOutlet: RouterOutlet;');
320
+ expect(output).toContain('Router: Router;');
321
+ expect(output).toMatch(/missingProvider: \{[\s\S]*Router: Router;/);
322
+ });
323
+
324
+ it('emits Router only once when multiple router metadata imports are present', async () => {
325
+ const project = await createRouterMetadataProjectFixture({
326
+ 'src/app/demo.ts': `
327
+ import { Component } from '@angular/core';
328
+ import { RouterModule, RouterOutlet } from '@angular/router';
329
+
330
+ @Component({
331
+ standalone: true,
332
+ imports: [RouterModule, RouterOutlet],
333
+ template: '',
334
+ })
335
+ export class DemoComponent {}
336
+ `,
337
+ });
338
+
339
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
340
+ transformSourceFile(sourceFile);
341
+ const output = sourceFile.getFullText();
342
+
343
+ expect(output).toContain('RouterModule: RouterModule;');
344
+ expect(output).toContain('RouterOutlet: RouterOutlet;');
345
+ expect(output.match(/Router: Router;/g) ?? []).toHaveLength(2);
346
+ });
347
+
348
+ it('does not add Router for non-router metadata imports', async () => {
349
+ const project = await createRouterMetadataProjectFixture({
350
+ 'src/app/child.ts': `
351
+ import { Component } from '@angular/core';
352
+
353
+ @Component({
354
+ standalone: true,
355
+ template: '',
356
+ })
357
+ export class ChildComponent {}
358
+ `,
359
+ 'src/app/demo.ts': `
360
+ import { Component } from '@angular/core';
361
+ import { ChildComponent } from './child';
362
+
363
+ @Component({
364
+ standalone: true,
365
+ imports: [ChildComponent],
366
+ template: '',
367
+ })
368
+ export class DemoComponent {}
369
+ `,
370
+ });
371
+
372
+ transformSourceFile(getFixtureSourceFile(project, 'src/app/child.ts'));
373
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
374
+ transformSourceFile(sourceFile);
375
+ const output = sourceFile.getFullText();
376
+
377
+ expect(output).toContain('GenDeps_ChildComponent: GenDeps_ChildComponent;');
378
+ expect(output).not.toContain('Router: Router;');
379
+ expect(output).not.toContain('type Router');
380
+ });
381
+
382
+ it('suppresses missingProvider.Router when Router is already provided locally', async () => {
383
+ const project = await createRouterMetadataProjectFixture({
384
+ 'src/app/demo.ts': `
385
+ import { Component } from '@angular/core';
386
+ import { Router, RouterModule } from '@angular/router';
387
+
388
+ @Component({
389
+ standalone: true,
390
+ imports: [RouterModule],
391
+ providers: [Router],
392
+ template: '',
393
+ })
394
+ export class DemoComponent {}
395
+ `,
396
+ });
397
+
398
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
399
+ transformSourceFile(sourceFile);
400
+ const output = sourceFile.getFullText();
401
+
402
+ expect(output).toMatch(
403
+ /import \{[^}]*Router[^}]*\} from ['"]@angular\/router['"];/,
404
+ );
405
+ expect(output).not.toContain('type Router');
406
+ expect(output).toContain('Router: Router;');
407
+ expect(output).toMatch(/provided: \{[\s\S]*Router: Router;/);
408
+ expect(output).not.toMatch(/missingProvider: \{[\s\S]*Router: Router;/);
409
+ });
410
+
411
+ it('emits FormField<any> when FormField is imported in metadata imports', async () => {
412
+ const project = await createSignalFormsMetadataProjectFixture({
413
+ 'src/app/status.component.ts': `
414
+ import { Component } from '@angular/core';
415
+
416
+ @Component({
417
+ standalone: true,
418
+ template: '',
419
+ })
420
+ export class StatusComponent {}
421
+ `,
422
+ 'src/app/demo.ts': `
423
+ import { CommonModule } from '@angular/common';
424
+ import { Component } from '@angular/core';
425
+ import { FormField } from '@angular/forms/signals';
426
+ import { StatusComponent } from './status.component';
427
+
428
+ @Component({
429
+ standalone: true,
430
+ imports: [CommonModule, StatusComponent, FormField],
431
+ template: '',
432
+ })
433
+ export class DemoComponent {}
434
+ `,
435
+ });
436
+
437
+ transformSourceFile(
438
+ getFixtureSourceFile(project, 'src/app/status.component.ts'),
439
+ );
440
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
441
+ transformSourceFile(sourceFile);
442
+ const output = sourceFile.getFullText();
443
+ const depsSection = extractGeneratedSection(output, 'deps');
444
+
445
+ expect(depsSection).toContain('CommonModule: CommonModule;');
446
+ expect(depsSection).toContain(
447
+ 'GenDeps_StatusComponent: GenDeps_StatusComponent;',
448
+ );
449
+ expect(depsSection).toContain('FormField: FormField<any>;');
450
+ expect(depsSection).toMatch(
451
+ /CommonModule: CommonModule;[\s\S]*GenDeps_StatusComponent: GenDeps_StatusComponent;[\s\S]*FormField: FormField<any>;/,
452
+ );
453
+ expect(output).not.toContain('type FormField');
454
+ });
455
+
456
+ it('applies inline project config rules for metadata imports', async () => {
457
+ const project = await createTranslateProjectFixture({
458
+ 'src/app/demo.ts': `
459
+ import { Component } from '@angular/core';
460
+ import { TranslatePipe } from '@ngx-translate/core';
461
+
462
+ @Component({
463
+ standalone: true,
464
+ imports: [TranslatePipe],
465
+ template: '',
466
+ })
467
+ export class DemoComponent {}
468
+ `,
469
+ });
470
+
471
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
472
+ transformSourceFile(sourceFile, {
473
+ config: {
474
+ importAugmentations: [
475
+ {
476
+ match: {
477
+ module: '@ngx-translate/core',
478
+ symbols: ['TranslatePipe'],
479
+ metadata: ['imports'],
480
+ },
481
+ deps: [{ key: 'TranslateService', symbol: 'TranslateService' }],
482
+ missingProvider: [
483
+ { key: 'TranslateService', symbol: 'TranslateService' },
484
+ ],
485
+ },
486
+ ],
487
+ },
488
+ });
489
+ const output = sourceFile.getFullText();
490
+
491
+ expect(output).toMatch(
492
+ /import \{[^}]*TranslatePipe[^}]*type TranslateService[^}]*\} from ['"]@ngx-translate\/core['"];/,
493
+ );
494
+ expect(extractGeneratedSection(output, 'deps')).toContain(
495
+ 'TranslateService: TranslateService;',
496
+ );
497
+ expect(extractGeneratedSection(output, 'missingProvider')).toContain(
498
+ 'TranslateService: TranslateService;',
499
+ );
500
+ });
501
+
502
+ it('auto-discovers craft-brand.config.ts from the source file directory', async () => {
503
+ const project = await createTranslateProjectFixture({
504
+ 'craft-brand.config.ts': `
505
+ import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
506
+
507
+ export default defineAngularBrandConfig({
508
+ importAugmentations: [
509
+ {
510
+ match: {
511
+ module: '@ngx-translate/core',
512
+ symbols: ['TranslatePipe'],
513
+ metadata: ['imports'],
514
+ },
515
+ deps: [{ key: 'TranslateService', symbol: 'TranslateService' }],
516
+ missingProvider: [
517
+ { key: 'TranslateService', symbol: 'TranslateService' },
518
+ ],
519
+ },
520
+ ],
521
+ });
522
+ `,
523
+ 'src/app/demo.ts': `
524
+ import { Component } from '@angular/core';
525
+ import { TranslatePipe } from '@ngx-translate/core';
526
+
527
+ @Component({
528
+ standalone: true,
529
+ imports: [TranslatePipe],
530
+ template: '',
531
+ })
532
+ export class DemoComponent {}
533
+ `,
534
+ });
535
+
536
+ const rootDirectory = tempDirectories[tempDirectories.length - 1]!;
537
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
538
+ const discoveredPath = discoverAngularBrandConfigFilePath(
539
+ join(rootDirectory, 'src/app'),
540
+ );
541
+
542
+ expect(discoveredPath).toBe(join(rootDirectory, 'craft-brand.config.ts'));
543
+
544
+ transformSourceFile(sourceFile);
545
+ const output = sourceFile.getFullText();
546
+
547
+ expect(extractGeneratedSection(output, 'deps')).toContain(
548
+ 'TranslateService: TranslateService;',
549
+ );
550
+ expect(extractGeneratedSection(output, 'missingProvider')).toContain(
551
+ 'TranslateService: TranslateService;',
552
+ );
553
+ });
554
+
555
+ it('does not trigger project config rules for plain TS imports outside metadata', async () => {
556
+ const project = await createTranslateProjectFixture({
557
+ 'craft-brand.config.ts': `
558
+ import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
559
+
560
+ export default defineAngularBrandConfig({
561
+ importAugmentations: [
562
+ {
563
+ match: {
564
+ module: '@ngx-translate/core',
565
+ symbols: ['TranslatePipe'],
566
+ metadata: ['imports'],
567
+ },
568
+ deps: [{ key: 'TranslateService', symbol: 'TranslateService' }],
569
+ missingProvider: [
570
+ { key: 'TranslateService', symbol: 'TranslateService' },
571
+ ],
572
+ },
573
+ ],
574
+ });
575
+ `,
576
+ 'src/app/demo.ts': `
577
+ import { Component } from '@angular/core';
578
+ import { TranslatePipe } from '@ngx-translate/core';
579
+
580
+ @Component({
581
+ standalone: true,
582
+ imports: [],
583
+ template: '',
584
+ })
585
+ export class DemoComponent {
586
+ protected readonly unused = TranslatePipe;
587
+ }
588
+ `,
589
+ });
590
+
591
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
592
+ transformSourceFile(sourceFile);
593
+ const output = sourceFile.getFullText();
594
+
595
+ expect(extractGeneratedSection(output, 'deps')).not.toContain(
596
+ 'TranslateService: TranslateService;',
597
+ );
598
+ expect(output).not.toContain('type TranslateService');
599
+ expect(extractGeneratedSection(output, 'missingProvider')).toBe('');
600
+ });
601
+
602
+ it('supports project config rules for hostDirectives metadata', async () => {
603
+ const project = await createProjectFixture({
604
+ 'src/angular-core.d.ts': `
605
+ declare module '@angular/core' {
606
+ export declare function Component(metadata: unknown): ClassDecorator;
607
+ export declare function Directive(metadata?: unknown): ClassDecorator;
608
+ }
609
+ `,
610
+ 'src/acme-host.d.ts': `
611
+ declare module '@acme/host' {
612
+ export declare class HostFeatureDirective {}
613
+ export declare class HostFeatureService {}
614
+ }
615
+ `,
616
+ 'craft-brand.config.ts': `
617
+ import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
618
+
619
+ export default defineAngularBrandConfig({
620
+ importAugmentations: [
621
+ {
622
+ match: {
623
+ module: '@acme/host',
624
+ symbols: ['HostFeatureDirective'],
625
+ metadata: ['hostDirectives'],
626
+ },
627
+ deps: [{ key: 'HostFeatureService', symbol: 'HostFeatureService' }],
628
+ missingProvider: [
629
+ { key: 'HostFeatureService', symbol: 'HostFeatureService' },
630
+ ],
631
+ },
632
+ ],
633
+ });
634
+ `,
635
+ 'src/app/demo.ts': `
636
+ import { Component } from '@angular/core';
637
+ import { HostFeatureDirective } from '@acme/host';
638
+
639
+ @Component({
640
+ standalone: true,
641
+ hostDirectives: [HostFeatureDirective],
642
+ template: '',
643
+ })
644
+ export class DemoComponent {}
645
+ `,
646
+ });
647
+
648
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
649
+ transformSourceFile(sourceFile);
650
+ const output = sourceFile.getFullText();
651
+
652
+ expect(extractGeneratedSection(output, 'deps')).toContain(
653
+ 'HostFeatureService: HostFeatureService;',
654
+ );
655
+ expect(extractGeneratedSection(output, 'missingProvider')).toContain(
656
+ 'HostFeatureService: HostFeatureService;',
657
+ );
658
+ });
659
+
660
+ it('deduplicates project config entries when several metadata symbols match the same rule', async () => {
661
+ const project = await createTranslateProjectFixture({
662
+ 'craft-brand.config.ts': `
663
+ import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
664
+
665
+ export default defineAngularBrandConfig({
666
+ importAugmentations: [
667
+ {
668
+ match: {
669
+ module: '@ngx-translate/core',
670
+ metadata: ['imports', 'hostDirectives'],
671
+ },
672
+ deps: [{ key: 'TranslateService', symbol: 'TranslateService' }],
673
+ missingProvider: [
674
+ { key: 'TranslateService', symbol: 'TranslateService' },
675
+ ],
676
+ },
677
+ ],
678
+ });
679
+ `,
680
+ 'src/app/demo.ts': `
681
+ import { Component } from '@angular/core';
682
+ import {
683
+ TranslateDirective,
684
+ TranslatePipe,
685
+ } from '@ngx-translate/core';
686
+
687
+ @Component({
688
+ standalone: true,
689
+ imports: [TranslatePipe],
690
+ hostDirectives: [TranslateDirective],
691
+ template: '',
692
+ })
693
+ export class DemoComponent {}
694
+ `,
695
+ });
696
+
697
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
698
+ transformSourceFile(sourceFile);
699
+ const output = sourceFile.getFullText();
700
+
701
+ expect(
702
+ output.match(/TranslateService: TranslateService;/g) ?? [],
703
+ ).toHaveLength(2);
704
+ expect(output.match(/type TranslateService/g) ?? []).toHaveLength(1);
705
+ });
706
+
707
+ it('removes configured missing providers when the key is already provided locally', async () => {
708
+ const project = await createTranslateProjectFixture({
709
+ 'craft-brand.config.ts': `
710
+ import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
711
+
712
+ export default defineAngularBrandConfig({
713
+ importAugmentations: [
714
+ {
715
+ match: {
716
+ module: '@ngx-translate/core',
717
+ symbols: ['TranslatePipe'],
718
+ metadata: ['imports'],
719
+ },
720
+ deps: [{ key: 'TranslateService', symbol: 'TranslateService' }],
721
+ missingProvider: [
722
+ { key: 'TranslateService', symbol: 'TranslateService' },
723
+ ],
724
+ },
725
+ ],
726
+ });
727
+ `,
728
+ 'src/app/demo.ts': `
729
+ import { Component } from '@angular/core';
730
+ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
731
+
732
+ @Component({
733
+ standalone: true,
734
+ imports: [TranslatePipe],
735
+ providers: [TranslateService],
736
+ template: '',
737
+ })
738
+ export class DemoComponent {}
739
+ `,
740
+ });
741
+
742
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
743
+ transformSourceFile(sourceFile);
744
+ const output = sourceFile.getFullText();
745
+
746
+ expect(extractGeneratedSection(output, 'deps')).toContain(
747
+ 'TranslateService: TranslateService;',
748
+ );
749
+ expect(extractGeneratedSection(output, 'provided')).toContain(
750
+ 'TranslateService: TranslateService;',
751
+ );
752
+ expect(extractGeneratedSection(output, 'missingProvider')).not.toContain(
753
+ 'TranslateService: TranslateService;',
754
+ );
755
+ });
756
+
757
+ it('adds type imports for configured missingProvider-only entries', async () => {
758
+ const project = await createTranslateProjectFixture({
759
+ 'src/app/demo.ts': `
760
+ import { Component } from '@angular/core';
761
+ import { TranslatePipe } from '@ngx-translate/core';
762
+
763
+ @Component({
764
+ standalone: true,
765
+ imports: [TranslatePipe],
766
+ template: '',
767
+ })
768
+ export class DemoComponent {}
769
+ `,
770
+ });
771
+
772
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
773
+ transformSourceFile(sourceFile, {
774
+ config: {
775
+ importAugmentations: [
776
+ {
777
+ match: {
778
+ module: '@ngx-translate/core',
779
+ symbols: ['TranslatePipe'],
780
+ metadata: ['imports'],
781
+ },
782
+ missingProvider: [
783
+ { key: 'TranslateService', symbol: 'TranslateService' },
784
+ ],
785
+ },
786
+ ],
787
+ },
788
+ });
789
+ const output = sourceFile.getFullText();
790
+
791
+ expect(output).toMatch(
792
+ /import \{[^}]*TranslatePipe[^}]*type TranslateService[^}]*\} from ['"]@ngx-translate\/core['"];/,
793
+ );
794
+ expect(extractGeneratedSection(output, 'deps')).not.toContain(
795
+ 'TranslateService: TranslateService;',
796
+ );
797
+ expect(extractGeneratedSection(output, 'missingProvider')).toContain(
798
+ 'TranslateService: TranslateService;',
799
+ );
800
+ });
801
+
802
+ it('throws a clear error for invalid project config files', async () => {
803
+ const project = await createTranslateProjectFixture({
804
+ 'craft-brand.config.ts': `
805
+ export default 42;
806
+ `,
807
+ 'src/app/demo.ts': `
808
+ import { Component } from '@angular/core';
809
+ import { TranslatePipe } from '@ngx-translate/core';
810
+
811
+ @Component({
812
+ standalone: true,
813
+ imports: [TranslatePipe],
814
+ template: '',
815
+ })
816
+ export class DemoComponent {}
817
+ `,
818
+ });
819
+
820
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
821
+
822
+ expect(() => transformSourceFile(sourceFile)).toThrow(
823
+ /Invalid Angular brand config at ".*craft-brand\.config\.ts": Expected the default export to be an object\./,
824
+ );
825
+ });
826
+
827
+ it('prefers an explicit configFilePath over auto-discovery', async () => {
828
+ const project = await createTranslateProjectFixture({
829
+ 'craft-brand.config.ts': `
830
+ import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
831
+
832
+ export default defineAngularBrandConfig({
833
+ importAugmentations: [
834
+ {
835
+ match: {
836
+ module: '@ngx-translate/core',
837
+ symbols: ['TranslatePipe'],
838
+ metadata: ['imports'],
839
+ },
840
+ deps: [{ key: 'WrongService', symbol: 'WrongService' }],
841
+ },
842
+ ],
843
+ });
844
+ `,
845
+ 'custom-brand.config.ts': `
846
+ import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
847
+
848
+ export default defineAngularBrandConfig({
849
+ importAugmentations: [
850
+ {
851
+ match: {
852
+ module: '@ngx-translate/core',
853
+ symbols: ['TranslatePipe'],
854
+ metadata: ['imports'],
855
+ },
856
+ deps: [{ key: 'TranslateService', symbol: 'TranslateService' }],
857
+ },
858
+ ],
859
+ });
860
+ `,
861
+ 'src/app/demo.ts': `
862
+ import { Component } from '@angular/core';
863
+ import { TranslatePipe } from '@ngx-translate/core';
864
+
865
+ @Component({
866
+ standalone: true,
867
+ imports: [TranslatePipe],
868
+ template: '',
869
+ })
870
+ export class DemoComponent {}
871
+ `,
872
+ });
873
+
874
+ const rootDirectory = tempDirectories[tempDirectories.length - 1]!;
875
+ const explicitConfig = loadAngularBrandConfigFromFile(
876
+ join(rootDirectory, 'custom-brand.config.ts'),
877
+ );
878
+ const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
879
+
880
+ transformSourceFile(sourceFile, {
881
+ configFilePath: join(rootDirectory, 'custom-brand.config.ts'),
882
+ });
883
+ const output = sourceFile.getFullText();
884
+
885
+ expect(explicitConfig.importAugmentations).toHaveLength(1);
886
+ expect(extractGeneratedSection(output, 'deps')).toContain(
887
+ 'TranslateService: TranslateService;',
888
+ );
889
+ expect(extractGeneratedSection(output, 'deps')).not.toContain(
890
+ 'WrongService: WrongService;',
891
+ );
892
+ });
258
893
  });
259
894
 
260
895
  async function createProjectFixture(
@@ -287,6 +922,73 @@ async function createProjectFixture(
287
922
  });
288
923
  }
289
924
 
925
+ async function createRouterMetadataProjectFixture(
926
+ files: Record<string, string>,
927
+ ): Promise<Project> {
928
+ return createProjectFixture({
929
+ 'src/angular-core.d.ts': `
930
+ declare module '@angular/core' {
931
+ export declare function Component(metadata: unknown): ClassDecorator;
932
+ }
933
+ `,
934
+ 'src/angular-router.d.ts': `
935
+ declare module '@angular/router' {
936
+ export declare class Router {
937
+ navigate(commands: unknown[]): void;
938
+ }
939
+ export declare class RouterModule {}
940
+ export declare class RouterOutlet {}
941
+ }
942
+ `,
943
+ ...files,
944
+ });
945
+ }
946
+
947
+ async function createTranslateProjectFixture(
948
+ files: Record<string, string>,
949
+ ): Promise<Project> {
950
+ return createProjectFixture({
951
+ 'src/angular-core.d.ts': `
952
+ declare module '@angular/core' {
953
+ export declare function Component(metadata: unknown): ClassDecorator;
954
+ export declare function Directive(metadata?: unknown): ClassDecorator;
955
+ }
956
+ `,
957
+ 'src/ngx-translate.d.ts': `
958
+ declare module '@ngx-translate/core' {
959
+ export declare class TranslatePipe {}
960
+ export declare class TranslateDirective {}
961
+ export declare class TranslateService {}
962
+ export declare class WrongService {}
963
+ }
964
+ `,
965
+ ...files,
966
+ });
967
+ }
968
+
969
+ async function createSignalFormsMetadataProjectFixture(
970
+ files: Record<string, string>,
971
+ ): Promise<Project> {
972
+ return createProjectFixture({
973
+ 'src/angular-common.d.ts': `
974
+ declare module '@angular/common' {
975
+ export declare class CommonModule {}
976
+ }
977
+ `,
978
+ 'src/angular-core.d.ts': `
979
+ declare module '@angular/core' {
980
+ export declare function Component(metadata: unknown): ClassDecorator;
981
+ }
982
+ `,
983
+ 'src/angular-forms.d.ts': `
984
+ declare module '@angular/forms/signals' {
985
+ export declare class FormField<T> {}
986
+ }
987
+ `,
988
+ ...files,
989
+ });
990
+ }
991
+
290
992
  async function writeFixtureFiles(
291
993
  rootDirectory: string,
292
994
  files: Record<string, string>,
@@ -303,3 +1005,14 @@ function getFixtureSourceFile(project: Project, relativePath: string) {
303
1005
  .getSourceFiles()
304
1006
  .find((sourceFile) => sourceFile.getFilePath().endsWith(relativePath))!;
305
1007
  }
1008
+
1009
+ function extractGeneratedSection(
1010
+ output: string,
1011
+ sectionName: 'deps' | 'provided' | 'missingProvider',
1012
+ ) {
1013
+ const match = output.match(
1014
+ new RegExp(`${sectionName}: \\{([\\s\\S]*?)\\n\\s+\\};`),
1015
+ );
1016
+
1017
+ return match?.[1] ?? '';
1018
+ }