@craft-ng/dev-tools 0.5.1-beta.0 → 0.6.0-beta.2

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 (51) hide show
  1. package/README.md +66 -0
  2. package/generators.json +26 -0
  3. package/package.json +18 -1
  4. package/src/bin/craft.d.ts +2 -0
  5. package/src/bin/craft.js +166 -0
  6. package/src/bin/craft.js.map +1 -0
  7. package/src/eslint-rules/craft-computed-name-match.cjs +8 -125
  8. package/src/eslint-rules/craft-method-name-match.cjs +8 -166
  9. package/src/eslint-rules/craft-name-match-utils.cjs +179 -0
  10. package/src/eslint-rules/craft-signal-source-name-match.cjs +8 -0
  11. package/src/eslint-rules/craft-source-name-match.cjs +8 -0
  12. package/src/eslint-rules/global-exception-registry-match.cjs +78 -13
  13. package/src/eslint-rules/index.cjs +8 -2
  14. package/src/eslint-rules/require-cascade-route-di-check.cjs +223 -0
  15. package/src/eslint-rules/require-primitive-generator-unwrap.cjs +267 -0
  16. package/src/generators/route/compat.d.ts +3 -0
  17. package/src/generators/route/compat.js +6 -0
  18. package/src/generators/route/compat.js.map +1 -0
  19. package/src/generators/route/generator.d.ts +31 -0
  20. package/src/generators/route/generator.js +434 -0
  21. package/src/generators/route/generator.js.map +1 -0
  22. package/src/generators/route/schema.json +50 -0
  23. package/src/generators/route/split-schema.json +19 -0
  24. package/src/index.d.ts +1 -0
  25. package/src/index.js +1 -0
  26. package/src/index.js.map +1 -1
  27. package/src/scripts/primitives/migrate-primitive-generators.d.ts +27 -0
  28. package/src/scripts/primitives/migrate-primitive-generators.js +315 -0
  29. package/src/scripts/primitives/migrate-primitive-generators.js.map +1 -0
  30. package/src/scripts/primitives/migrate-primitive-generators.ts +402 -0
  31. package/src/scripts/primitives/migrate-primitives.js +36 -4
  32. package/src/scripts/primitives/migrate-primitives.js.map +1 -1
  33. package/src/scripts/primitives/migrate-primitives.spec.ts +43 -0
  34. package/src/scripts/primitives/migrate-primitives.ts +62 -4
  35. package/src/scripts/primitives/migration-diagnostic.d.ts +1 -1
  36. package/src/scripts/primitives/migration-diagnostic.ts +1 -0
  37. package/src/scripts/routes/migrate-routes.js +4 -4
  38. package/src/scripts/routes/migrate-routes.js.map +1 -1
  39. package/src/scripts/routes/migrate-routes.ts +4 -6
  40. package/src/scripts/routes/route-command.d.ts +75 -0
  41. package/src/scripts/routes/route-command.js +1187 -0
  42. package/src/scripts/routes/route-command.js.map +1 -0
  43. package/src/scripts/routes/route-command.spec.ts +553 -0
  44. package/src/scripts/routes/route-command.ts +1790 -0
  45. package/src/scripts/services/migrate-services.js +45 -11
  46. package/src/scripts/services/migrate-services.js.map +1 -1
  47. package/src/scripts/services/migrate-services.spec.ts +46 -4
  48. package/src/scripts/services/migrate-services.ts +64 -13
  49. package/src/scripts/services/migration-diagnostic.d.ts +1 -1
  50. package/src/scripts/services/migration-diagnostic.ts +1 -0
  51. package/src/eslint-rules/require-track-on-dependent-primitives.cjs +0 -219
@@ -0,0 +1,1790 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { mkdir, readdir, writeFile } from 'node:fs/promises';
4
+ import {
5
+ basename,
6
+ dirname,
7
+ extname,
8
+ isAbsolute,
9
+ join,
10
+ relative,
11
+ resolve,
12
+ sep,
13
+ } from 'node:path';
14
+ import { promisify } from 'node:util';
15
+ import { names } from '@nx/devkit';
16
+ import {
17
+ ArrayLiteralExpression,
18
+ CallExpression,
19
+ Node,
20
+ Project,
21
+ QuoteKind,
22
+ SourceFile,
23
+ SyntaxKind,
24
+ ts,
25
+ } from 'ts-morph';
26
+ import {
27
+ findAngularDecoratedClass,
28
+ transformSourceFile as generateAngularDependencies,
29
+ } from '../angular-brand-codemod.js';
30
+
31
+ const execFileAsync = promisify(execFile);
32
+
33
+ const nodeRouteFileSystem: RouteFileSystem = {
34
+ exists: existsSync,
35
+ listFiles: listFilesOnDisk,
36
+ read: (filePath) =>
37
+ existsSync(filePath) ? readFileSync(filePath, 'utf8') : undefined,
38
+ write: async (filePath, content) => {
39
+ await mkdir(dirname(filePath), { recursive: true });
40
+ await writeFile(filePath, content, 'utf8');
41
+ },
42
+ };
43
+
44
+ export type RouteCommandDiagnostic = {
45
+ code:
46
+ | 'AMBIGUOUS_PARENT'
47
+ | 'COMPONENT_GENERATION_FAILED'
48
+ | 'COMPONENT_NOT_FOUND'
49
+ | 'DUPLICATE_ROUTE'
50
+ | 'INVALID_ARGUMENT'
51
+ | 'LOCAL_DEPENDENCY'
52
+ | 'NO_ANGULAR_CLI'
53
+ | 'NO_MATCHING_ROUTES'
54
+ | 'PARENT_NOT_FOUND'
55
+ | 'TARGET_EXISTS'
56
+ | 'VALIDATION_FAILED';
57
+ message: string;
58
+ filePath?: string;
59
+ routePath?: string;
60
+ };
61
+
62
+ export type RouteCommandPlan = {
63
+ action: 'add' | 'split';
64
+ summary: string;
65
+ files: string[];
66
+ };
67
+
68
+ export type RouteCommandResult = {
69
+ changedFiles: string[];
70
+ diagnostics: RouteCommandDiagnostic[];
71
+ exitCode: number;
72
+ plan?: RouteCommandPlan;
73
+ };
74
+
75
+ export type RouteFileSystem = {
76
+ exists(filePath: string): boolean;
77
+ listFiles(rootDir: string): string[];
78
+ read(filePath: string): string | undefined;
79
+ write(filePath: string, content: string): Promise<void> | void;
80
+ };
81
+
82
+ export type RouteAddOptions = {
83
+ rootDir?: string;
84
+ project?: string;
85
+ path: string;
86
+ parent?: string;
87
+ component?: string;
88
+ createComponent?: string;
89
+ featureFile?: string;
90
+ redirectTo?: string;
91
+ dryRun?: boolean;
92
+ yes?: boolean;
93
+ json?: boolean;
94
+ validate?: boolean;
95
+ fileSystem?: RouteFileSystem;
96
+ log?: (message: string) => void;
97
+ confirm?: (plan: RouteCommandPlan) => Promise<boolean>;
98
+ };
99
+
100
+ export type RouteSplitOptions = {
101
+ rootDir?: string;
102
+ project?: string;
103
+ parent: string;
104
+ prefix: string;
105
+ target: string;
106
+ dryRun?: boolean;
107
+ yes?: boolean;
108
+ json?: boolean;
109
+ validate?: boolean;
110
+ fileSystem?: RouteFileSystem;
111
+ log?: (message: string) => void;
112
+ confirm?: (plan: RouteCommandPlan) => Promise<boolean>;
113
+ };
114
+
115
+ export type RouteCollection = {
116
+ collectionName: string;
117
+ routesName: string;
118
+ sourceFile: SourceFile;
119
+ call: CallExpression;
120
+ routes: ArrayLiteralExpression;
121
+ };
122
+
123
+ type ComponentTarget = {
124
+ filePath: string;
125
+ className: string;
126
+ };
127
+
128
+ export async function runRouteAdd(
129
+ options: RouteAddOptions,
130
+ ): Promise<RouteCommandResult> {
131
+ const diagnostics: RouteCommandDiagnostic[] = [];
132
+ const rootDir = resolve(options.rootDir ?? process.cwd());
133
+ const fileSystem = options.fileSystem ?? nodeRouteFileSystem;
134
+ const routePath = normalizeRoutePath(options.path);
135
+ if (!routePath || routePath === '/') {
136
+ return failed(
137
+ 'INVALID_ARGUMENT',
138
+ 'Route path must contain at least one segment.',
139
+ );
140
+ }
141
+ const actionCount = [
142
+ options.component,
143
+ options.createComponent,
144
+ options.redirectTo,
145
+ ].filter(Boolean).length;
146
+ if (actionCount !== 1) {
147
+ return failed(
148
+ 'INVALID_ARGUMENT',
149
+ 'Choose exactly one of --component, --create-component, or --redirect-to.',
150
+ );
151
+ }
152
+
153
+ const { project, tsConfigFilePath, sourceRoot } = createRouteProject(
154
+ rootDir,
155
+ options.project,
156
+ fileSystem,
157
+ );
158
+ const initialTexts = snapshotProject(project);
159
+ const collections = discoverRouteCollections(project, sourceRoot);
160
+ const parent = resolveParentCollection(
161
+ collections,
162
+ options.parent,
163
+ routePath,
164
+ rootDir,
165
+ );
166
+ if ('diagnostic' in parent) return resultFromDiagnostics([parent.diagnostic]);
167
+
168
+ const segments = routePath.slice(1).split('/');
169
+ const featureDecision = decideFeatureTarget(
170
+ project,
171
+ collections,
172
+ parent.collection,
173
+ options,
174
+ segments,
175
+ rootDir,
176
+ );
177
+ const targetFilePath = featureDecision.targetFilePath;
178
+ const touched = new Set<string>([targetFilePath]);
179
+ if (featureDecision.parentMount)
180
+ touched.add(parent.collection.sourceFile.getFilePath());
181
+
182
+ let componentTarget: ComponentTarget | undefined;
183
+ if (options.component) {
184
+ componentTarget = parseComponentTarget(options.component, rootDir);
185
+ if (!componentTarget || !fileSystem.exists(componentTarget.filePath)) {
186
+ return failed(
187
+ 'COMPONENT_NOT_FOUND',
188
+ `Component target does not exist: ${options.component}`,
189
+ );
190
+ }
191
+ touched.add(componentTarget.filePath);
192
+ }
193
+
194
+ const plan: RouteCommandPlan = {
195
+ action: 'add',
196
+ summary: options.redirectTo
197
+ ? `Add redirect ${routePath} -> ${options.redirectTo}`
198
+ : `Add component route ${routePath}`,
199
+ files: [...touched],
200
+ };
201
+ printPlan(plan, options.log ?? console.log, options.json === true);
202
+ if (!(await shouldContinue(options, plan))) {
203
+ return { changedFiles: [], diagnostics, exitCode: 0, plan };
204
+ }
205
+ if (options.dryRun) {
206
+ return { changedFiles: [], diagnostics, exitCode: 0, plan };
207
+ }
208
+
209
+ const eslintBaseline =
210
+ options.validate === false
211
+ ? new Set<string>()
212
+ : await captureRouteEslintBaseline(rootDir, sourceRoot, plan.files);
213
+
214
+ if (options.createComponent) {
215
+ const created = await createAngularComponent(
216
+ rootDir,
217
+ options,
218
+ resolveAngularProjectName(rootDir, options.project, sourceRoot),
219
+ );
220
+ if ('diagnostic' in created)
221
+ return resultFromDiagnostics([created.diagnostic], plan);
222
+ componentTarget = created.component;
223
+ touched.add(componentTarget.filePath);
224
+ addSourceFileFromWorkspace(project, componentTarget.filePath, fileSystem);
225
+ }
226
+
227
+ const targetCollection = ensureFeatureCollection(
228
+ project,
229
+ featureDecision,
230
+ parent.collection,
231
+ );
232
+ if (hasRoutePath(targetCollection.routes, featureDecision.localRoutePath)) {
233
+ if (
234
+ routeMatchesRequest(
235
+ targetCollection.routes,
236
+ featureDecision.localRoutePath,
237
+ options,
238
+ componentTarget,
239
+ targetCollection.sourceFile.getFilePath(),
240
+ )
241
+ ) {
242
+ return { changedFiles: [], diagnostics: [], exitCode: 0, plan };
243
+ }
244
+ return resultFromDiagnostics(
245
+ [
246
+ {
247
+ code: 'DUPLICATE_ROUTE',
248
+ message: `Route ${featureDecision.localRoutePath || '<root>'} already exists in ${targetCollection.routesName}.`,
249
+ filePath: targetCollection.sourceFile.getFilePath(),
250
+ routePath,
251
+ },
252
+ ],
253
+ plan,
254
+ );
255
+ }
256
+
257
+ if (options.redirectTo) {
258
+ targetCollection.routes.addElement(
259
+ `{ path: ${quote(featureDecision.localRoutePath)}, redirectTo: ${quote(
260
+ options.redirectTo,
261
+ )}, pathMatch: 'full' }`,
262
+ );
263
+ } else if (componentTarget) {
264
+ const componentSource =
265
+ project.getSourceFile(componentTarget.filePath) ??
266
+ addSourceFileFromWorkspace(project, componentTarget.filePath, fileSystem);
267
+ if (
268
+ !componentSource ||
269
+ !ensureGenDeps(componentSource, componentTarget.className)
270
+ ) {
271
+ return failed(
272
+ 'COMPONENT_NOT_FOUND',
273
+ `Could not generate GenDeps_${componentTarget.className} in ${componentTarget.filePath}.`,
274
+ plan,
275
+ );
276
+ }
277
+ const moduleSpecifier = relativeModuleSpecifier(
278
+ targetCollection.sourceFile.getFilePath(),
279
+ componentTarget.filePath,
280
+ );
281
+ targetCollection.routes.addElement(
282
+ `craftRoute(${quote(featureDecision.localRoutePath)}, {
283
+ componentDeps: {} as import(${quote(moduleSpecifier)}).GenDeps_${componentTarget.className},
284
+ loadComponent: ({ withRetry }: CraftRouteLazyLoadHelpers) => withRetry(import(${quote(
285
+ moduleSpecifier,
286
+ )})).then((m) => m.${componentTarget.className}),
287
+ })`,
288
+ );
289
+ ensureImport(targetCollection.sourceFile, 'craftRoute');
290
+ ensureTypeImport(targetCollection.sourceFile, 'CraftRouteLazyLoadHelpers');
291
+ }
292
+
293
+ ensureCollectionBookkeeping(targetCollection);
294
+ if (featureDecision.parentMount) {
295
+ ensureParentMount(
296
+ parent.collection,
297
+ featureDecision.parentMount,
298
+ targetCollection,
299
+ );
300
+ ensureCollectionBookkeeping(parent.collection);
301
+ }
302
+ for (const file of new Set([
303
+ targetCollection.sourceFile,
304
+ parent.collection.sourceFile,
305
+ ])) {
306
+ file.formatText();
307
+ }
308
+
309
+ const changedFiles = [...touched].filter((filePath) =>
310
+ sourceChanged(project, initialTexts, filePath),
311
+ );
312
+ await saveFiles(project, changedFiles, fileSystem);
313
+ if (options.validate !== false) {
314
+ diagnostics.push(
315
+ ...(await validateRouteChangedFiles(
316
+ rootDir,
317
+ sourceRoot,
318
+ tsConfigFilePath,
319
+ changedFiles,
320
+ eslintBaseline,
321
+ )),
322
+ );
323
+ }
324
+ return {
325
+ changedFiles,
326
+ diagnostics,
327
+ exitCode: diagnostics.length > 0 ? 1 : 0,
328
+ plan,
329
+ };
330
+ }
331
+
332
+ export async function runRouteSplit(
333
+ options: RouteSplitOptions,
334
+ ): Promise<RouteCommandResult> {
335
+ const rootDir = resolve(options.rootDir ?? process.cwd());
336
+ const fileSystem = options.fileSystem ?? nodeRouteFileSystem;
337
+ const { project, tsConfigFilePath, sourceRoot } = createRouteProject(
338
+ rootDir,
339
+ options.project,
340
+ fileSystem,
341
+ );
342
+ const initialTexts = snapshotProject(project);
343
+ const collections = discoverRouteCollections(project, sourceRoot);
344
+ const parent = resolveParentCollection(
345
+ collections,
346
+ options.parent,
347
+ `/${options.prefix}`,
348
+ rootDir,
349
+ );
350
+ if ('diagnostic' in parent) return resultFromDiagnostics([parent.diagnostic]);
351
+
352
+ const prefix = normalizeRoutePath(options.prefix).slice(1);
353
+ const targetFilePath = resolveFromRoot(options.target, rootDir);
354
+ if (fileSystem.exists(targetFilePath)) {
355
+ return failed(
356
+ 'TARGET_EXISTS',
357
+ `Split target already exists: ${targetFilePath}`,
358
+ );
359
+ }
360
+ const matches = parent.collection.routes.getElements().filter((element) => {
361
+ const path = readRoutePath(element);
362
+ return path === prefix || path?.startsWith(`${prefix}/`);
363
+ });
364
+ if (matches.length === 0) {
365
+ return failed(
366
+ 'NO_MATCHING_ROUTES',
367
+ `No statically analyzable route starts with ${prefix} in ${parent.collection.routesName}.`,
368
+ );
369
+ }
370
+ const localDependencies = findLocalDependencies(
371
+ parent.collection.sourceFile,
372
+ matches,
373
+ );
374
+ if (localDependencies.length > 0) {
375
+ return resultFromDiagnostics(
376
+ localDependencies.map((name) => ({
377
+ code: 'LOCAL_DEPENDENCY',
378
+ message: `Cannot move routes using local declaration ${name}; extract or import it first.`,
379
+ filePath: parent.collection.sourceFile.getFilePath(),
380
+ })),
381
+ );
382
+ }
383
+
384
+ const collectionName = collectionNameFromFile(targetFilePath);
385
+ const helperRenames = deriveMovedHelperRenames(
386
+ parent.collection,
387
+ matches,
388
+ prefix,
389
+ collectionName,
390
+ );
391
+ const helperConsumers = findHelperConsumers(
392
+ project,
393
+ parent.collection.sourceFile,
394
+ helperRenames,
395
+ );
396
+
397
+ const plan: RouteCommandPlan = {
398
+ action: 'split',
399
+ summary: `Move ${matches.length} route(s) under ${prefix} to ${targetFilePath}`,
400
+ files: [
401
+ parent.collection.sourceFile.getFilePath(),
402
+ targetFilePath,
403
+ ...helperConsumers.map((sourceFile) => sourceFile.getFilePath()),
404
+ ],
405
+ };
406
+ printPlan(plan, options.log ?? console.log, options.json === true);
407
+ if (!(await shouldContinue(options, plan))) {
408
+ return { changedFiles: [], diagnostics: [], exitCode: 0, plan };
409
+ }
410
+ if (options.dryRun) {
411
+ return { changedFiles: [], diagnostics: [], exitCode: 0, plan };
412
+ }
413
+
414
+ const eslintBaseline =
415
+ options.validate === false
416
+ ? new Set<string>()
417
+ : await captureRouteEslintBaseline(rootDir, sourceRoot, plan.files);
418
+
419
+ const routesName = `${uncapitalize(toPascalCase(collectionName))}Routes`;
420
+ const movedText = matches.map((element) =>
421
+ rewriteMovedRouteText(element.getText(), prefix),
422
+ );
423
+ const imports = parent.collection.sourceFile
424
+ .getImportDeclarations()
425
+ .map((declaration) => declaration.getText())
426
+ .join('\n');
427
+ const context = readCascadeContext(parent.collection);
428
+ const target = project.createSourceFile(
429
+ targetFilePath,
430
+ `${imports}\n\nexport const { ${routesName} } = craftRoutes(${quote(collectionName)}, [\n${movedText
431
+ .map((text) => ` ${text}`)
432
+ .join(',\n')}\n]).withParent<ParentRoutes<${quote(prefix)}>>();\n\n` +
433
+ `assertExhaustiveRouteExceptions(${routesName});\n\n` +
434
+ `type _Check${toPascalCase(collectionName)}DI = ValidateCascadeRoutesFile<${
435
+ context.names
436
+ }, ${context.values}, typeof ${routesName}>;\n` +
437
+ `type _CanRun${toPascalCase(collectionName)} = CanRun<_Check${toPascalCase(
438
+ collectionName,
439
+ )}DI>;\n`,
440
+ { overwrite: false },
441
+ );
442
+ rewriteRelativeSpecifiers(target, parent.collection.sourceFile.getFilePath());
443
+ ensureImport(target, 'craftRoutes');
444
+ ensureImport(target, 'assertExhaustiveRouteExceptions');
445
+ ensureTypeImport(target, 'ParentRoutes');
446
+ ensureTypeImport(target, 'ValidateCascadeRoutesFile');
447
+ ensureTypeImport(target, 'CanRun');
448
+ ensureTypeImport(target, 'Router', '@angular/router');
449
+ rewireMovedHelpers(project, parent.collection, target, helperRenames);
450
+
451
+ const insertionIndex = Math.min(
452
+ ...matches.map((element) =>
453
+ parent.collection.routes.getElements().indexOf(element),
454
+ ),
455
+ );
456
+ const matchedIndexes = matches
457
+ .map((element) => parent.collection.routes.getElements().indexOf(element))
458
+ .sort((a, b) => b - a);
459
+ for (const index of matchedIndexes)
460
+ parent.collection.routes.removeElement(index);
461
+ const moduleSpecifier = relativeModuleSpecifier(
462
+ parent.collection.sourceFile.getFilePath(),
463
+ targetFilePath,
464
+ );
465
+ parent.collection.routes.insertElement(
466
+ insertionIndex,
467
+ `{ path: ${quote(prefix)}, loadChildren: ({ withRetry }) => withRetry(import(${quote(
468
+ moduleSpecifier,
469
+ )})).then((m) => m.${routesName}) }`,
470
+ );
471
+ ensureCollectionBookkeeping(parent.collection);
472
+ target.organizeImports();
473
+ target.formatText();
474
+ parent.collection.sourceFile.formatText();
475
+
476
+ const changedFiles = [...new Set(plan.files)].filter((filePath) =>
477
+ sourceChanged(project, initialTexts, filePath),
478
+ );
479
+ await saveFiles(project, changedFiles, fileSystem);
480
+ const diagnostics =
481
+ options.validate === false
482
+ ? []
483
+ : await validateRouteChangedFiles(
484
+ rootDir,
485
+ sourceRoot,
486
+ tsConfigFilePath,
487
+ changedFiles,
488
+ eslintBaseline,
489
+ );
490
+ return {
491
+ changedFiles,
492
+ diagnostics,
493
+ exitCode: diagnostics.length > 0 ? 1 : 0,
494
+ plan,
495
+ };
496
+ }
497
+
498
+ export function discoverRouteCollections(
499
+ project: Project,
500
+ rootDir = process.cwd(),
501
+ ): RouteCollection[] {
502
+ const result: RouteCollection[] = [];
503
+ for (const sourceFile of project.getSourceFiles()) {
504
+ if (
505
+ !isInside(sourceFile.getFilePath(), rootDir) ||
506
+ sourceFile.isDeclarationFile() ||
507
+ /\.(?:spec|test)\.ts$/.test(sourceFile.getFilePath())
508
+ )
509
+ continue;
510
+ for (const call of sourceFile.getDescendantsOfKind(
511
+ SyntaxKind.CallExpression,
512
+ )) {
513
+ if (call.getExpression().getText() !== 'craftRoutes') continue;
514
+ const routes = call.getArguments()[1];
515
+ const nameArg = call.getArguments()[0];
516
+ if (
517
+ !Node.isArrayLiteralExpression(routes) ||
518
+ !Node.isStringLiteral(nameArg)
519
+ )
520
+ continue;
521
+ const declaration = call.getFirstAncestorByKind(
522
+ SyntaxKind.VariableDeclaration,
523
+ );
524
+ if (declaration?.getVariableStatement()?.getParent() !== sourceFile)
525
+ continue;
526
+ const binding = declaration?.getNameNode();
527
+ if (!binding || !Node.isObjectBindingPattern(binding)) continue;
528
+ const expected = `${uncapitalize(toPascalCase(nameArg.getLiteralValue()))}Routes`;
529
+ const routeBinding =
530
+ binding
531
+ .getElements()
532
+ .find(
533
+ (element) =>
534
+ (element.getPropertyNameNode()?.getText() ??
535
+ element.getName()) === expected,
536
+ ) ??
537
+ binding
538
+ .getElements()
539
+ .filter((element) =>
540
+ (
541
+ element.getPropertyNameNode()?.getText() ?? element.getName()
542
+ ).endsWith('Routes'),
543
+ )[0];
544
+ if (!routeBinding) continue;
545
+ result.push({
546
+ collectionName: nameArg.getLiteralValue(),
547
+ routesName: routeBinding.getName(),
548
+ sourceFile,
549
+ call,
550
+ routes,
551
+ });
552
+ }
553
+ }
554
+ return result;
555
+ }
556
+
557
+ export function listRouteCollections(
558
+ rootDir = process.cwd(),
559
+ projectOption?: string,
560
+ fileSystem: RouteFileSystem = nodeRouteFileSystem,
561
+ ): Array<{ filePath: string; collectionName: string; routesName: string }> {
562
+ const absoluteRoot = resolve(rootDir);
563
+ const { project, sourceRoot } = createRouteProject(
564
+ absoluteRoot,
565
+ projectOption,
566
+ fileSystem,
567
+ );
568
+ return discoverRouteCollections(project, sourceRoot).map((collection) => ({
569
+ filePath: collection.sourceFile.getFilePath(),
570
+ collectionName: collection.collectionName,
571
+ routesName: collection.routesName,
572
+ }));
573
+ }
574
+
575
+ export function listAngularProjects(rootDir = process.cwd()): string[] {
576
+ const absoluteRoot = resolve(rootDir);
577
+ return findTsConfigApps(absoluteRoot, nodeRouteFileSystem).map((filePath) =>
578
+ relative(absoluteRoot, dirname(filePath)).replace(/\\/g, '/'),
579
+ );
580
+ }
581
+
582
+ function createRouteProject(
583
+ rootDir: string,
584
+ projectOption: string | undefined,
585
+ fileSystem: RouteFileSystem,
586
+ ) {
587
+ const tsConfigFilePath = resolveTsConfig(rootDir, projectOption, fileSystem);
588
+ const sourceRoot =
589
+ basename(tsConfigFilePath) === 'tsconfig.app.json'
590
+ ? dirname(tsConfigFilePath)
591
+ : rootDir;
592
+ const project =
593
+ fileSystem === nodeRouteFileSystem && fileSystem.exists(tsConfigFilePath)
594
+ ? new Project({ tsConfigFilePath })
595
+ : new Project({
596
+ compilerOptions: {
597
+ module: ts.ModuleKind.Preserve,
598
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
599
+ target: ts.ScriptTarget.ES2022,
600
+ },
601
+ });
602
+ if (fileSystem === nodeRouteFileSystem) {
603
+ project.addSourceFilesAtPaths([
604
+ join(sourceRoot, '**/*.ts'),
605
+ `!${join(sourceRoot, '**/node_modules/**')}`,
606
+ `!${join(sourceRoot, '**/dist/**')}`,
607
+ `!${join(sourceRoot, '**/.angular/**')}`,
608
+ ]);
609
+ } else {
610
+ for (const filePath of fileSystem
611
+ .listFiles(sourceRoot)
612
+ .filter((filePath) => filePath.endsWith('.ts'))) {
613
+ const text = fileSystem.read(filePath);
614
+ if (text !== undefined) {
615
+ project.createSourceFile(filePath, text, { overwrite: true });
616
+ }
617
+ }
618
+ }
619
+ project.manipulationSettings.set({ quoteKind: QuoteKind.Single });
620
+ return { project, tsConfigFilePath, sourceRoot };
621
+ }
622
+
623
+ function resolveTsConfig(
624
+ rootDir: string,
625
+ projectOption: string | undefined,
626
+ fileSystem: RouteFileSystem,
627
+ ): string {
628
+ if (projectOption) {
629
+ const direct = resolve(rootDir, projectOption);
630
+ if (fileSystem.exists(direct) && direct.endsWith('.json')) return direct;
631
+ for (const candidate of [
632
+ join(direct, 'tsconfig.app.json'),
633
+ join(rootDir, 'apps', projectOption, 'tsconfig.app.json'),
634
+ join(rootDir, 'projects', projectOption, 'tsconfig.app.json'),
635
+ ]) {
636
+ if (fileSystem.exists(candidate)) return candidate;
637
+ }
638
+ }
639
+ const detected = findTsConfigApps(rootDir, fileSystem);
640
+ if (detected.length === 1) return detected[0];
641
+ for (const candidate of [
642
+ join(rootDir, 'tsconfig.app.json'),
643
+ join(rootDir, 'tsconfig.json'),
644
+ ]) {
645
+ if (fileSystem.exists(candidate)) return candidate;
646
+ }
647
+ return join(rootDir, 'tsconfig.json');
648
+ }
649
+
650
+ function findTsConfigApps(
651
+ rootDir: string,
652
+ fileSystem: RouteFileSystem,
653
+ ): string[] {
654
+ return fileSystem
655
+ .listFiles(rootDir)
656
+ .filter((filePath) => basename(filePath) === 'tsconfig.app.json')
657
+ .sort();
658
+ }
659
+
660
+ function resolveParentCollection(
661
+ collections: RouteCollection[],
662
+ parentOption: string | undefined,
663
+ routePath: string,
664
+ rootDir: string,
665
+ ): { collection: RouteCollection } | { diagnostic: RouteCommandDiagnostic } {
666
+ if (parentOption) {
667
+ const [filePart, collectionPart] = parentOption.split('#');
668
+ const filePath = resolveFromRoot(filePart, rootDir);
669
+ const matches = collections.filter(
670
+ (collection) =>
671
+ resolve(collection.sourceFile.getFilePath()) === resolve(filePath) &&
672
+ (!collectionPart ||
673
+ collection.routesName === collectionPart ||
674
+ collection.collectionName === collectionPart),
675
+ );
676
+ if (matches.length === 1) return { collection: matches[0] };
677
+ return {
678
+ diagnostic: {
679
+ code: 'PARENT_NOT_FOUND',
680
+ message: `Cannot resolve parent collection ${parentOption}.`,
681
+ filePath,
682
+ },
683
+ };
684
+ }
685
+
686
+ const first = routePath.slice(1).split('/')[0];
687
+ const featureMatches = collections.filter((collection) =>
688
+ [
689
+ collection.collectionName,
690
+ basename(collection.sourceFile.getFilePath(), '.routes.ts'),
691
+ ]
692
+ .map((value) => value.toLowerCase())
693
+ .includes(first.toLowerCase()),
694
+ );
695
+ if (featureMatches.length === 1) return { collection: featureMatches[0] };
696
+ const roots = collections.filter((collection) =>
697
+ /(?:^|[/\\])app\.routes\.ts$/.test(collection.sourceFile.getFilePath()),
698
+ );
699
+ if (roots.length === 1) return { collection: roots[0] };
700
+ if (collections.length === 1) return { collection: collections[0] };
701
+ return {
702
+ diagnostic: {
703
+ code: 'AMBIGUOUS_PARENT',
704
+ message: `Found ${collections.length} craftRoutes collections; pass --parent <file#collection>.`,
705
+ },
706
+ };
707
+ }
708
+
709
+ function decideFeatureTarget(
710
+ project: Project,
711
+ collections: RouteCollection[],
712
+ parent: RouteCollection,
713
+ options: RouteAddOptions,
714
+ segments: string[],
715
+ rootDir: string,
716
+ ) {
717
+ if (options.featureFile) {
718
+ return {
719
+ targetFilePath: resolveFromRoot(options.featureFile, rootDir),
720
+ localRoutePath: segments.slice(1).join('/'),
721
+ parentMount: segments[0],
722
+ collectionName: collectionNameFromFile(options.featureFile),
723
+ };
724
+ }
725
+ if (options.redirectTo) {
726
+ const parentIsFeature =
727
+ parent.collectionName.toLowerCase() === segments[0].toLowerCase();
728
+ return {
729
+ targetFilePath: parent.sourceFile.getFilePath(),
730
+ localRoutePath: parentIsFeature
731
+ ? segments.slice(1).join('/')
732
+ : segments.join('/'),
733
+ collectionName: parent.collectionName,
734
+ };
735
+ }
736
+ if (options.parent) {
737
+ return {
738
+ targetFilePath: parent.sourceFile.getFilePath(),
739
+ localRoutePath: segments.join('/'),
740
+ collectionName: parent.collectionName,
741
+ };
742
+ }
743
+ if (parent.collectionName.toLowerCase() === segments[0].toLowerCase()) {
744
+ return {
745
+ targetFilePath: parent.sourceFile.getFilePath(),
746
+ localRoutePath: segments.slice(1).join('/'),
747
+ collectionName: parent.collectionName,
748
+ };
749
+ }
750
+ const existingFeature = collections.find(
751
+ (collection) =>
752
+ collection !== parent &&
753
+ collection.collectionName.toLowerCase() === segments[0].toLowerCase(),
754
+ );
755
+ if (existingFeature) {
756
+ return {
757
+ targetFilePath: existingFeature.sourceFile.getFilePath(),
758
+ localRoutePath: segments.slice(1).join('/'),
759
+ collectionName: existingFeature.collectionName,
760
+ };
761
+ }
762
+ const targetFilePath = join(
763
+ dirname(parent.sourceFile.getFilePath()),
764
+ segments[0],
765
+ `${segments[0]}.routes.ts`,
766
+ );
767
+ return {
768
+ targetFilePath,
769
+ localRoutePath: segments.slice(1).join('/'),
770
+ parentMount: segments[0],
771
+ collectionName: segments[0],
772
+ };
773
+ }
774
+
775
+ function ensureFeatureCollection(
776
+ project: Project,
777
+ decision: ReturnType<typeof decideFeatureTarget>,
778
+ parent: RouteCollection,
779
+ ): RouteCollection {
780
+ const existing = discoverRouteCollections(
781
+ project,
782
+ dirname(decision.targetFilePath),
783
+ ).find(
784
+ (collection) =>
785
+ resolve(collection.sourceFile.getFilePath()) ===
786
+ resolve(decision.targetFilePath),
787
+ );
788
+ if (existing) return existing;
789
+ const routesName = `${uncapitalize(toPascalCase(decision.collectionName))}Routes`;
790
+ const context = readCascadeContext(parent);
791
+ const parentSuffix = decision.parentMount
792
+ ? `.withParent<ParentRoutes<${quote(decision.parentMount)}>>()`
793
+ : '';
794
+ const sourceFile = project.createSourceFile(
795
+ decision.targetFilePath,
796
+ `import { assertExhaustiveRouteExceptions, craftRoutes, type CanRun, type ParentRoutes, type ValidateCascadeRoutesFile } from '@craft-ng/core';\n` +
797
+ `import type { Router } from '@angular/router';\n\n` +
798
+ `export const { ${routesName} } = craftRoutes(${quote(
799
+ decision.collectionName,
800
+ )}, [])${parentSuffix};\n\n` +
801
+ `assertExhaustiveRouteExceptions(${routesName});\n\n` +
802
+ `type _Check${toPascalCase(
803
+ decision.collectionName,
804
+ )}DI = ValidateCascadeRoutesFile<${context.names}, ${context.values}, typeof ${routesName}>;\n` +
805
+ `type _CanRun${toPascalCase(decision.collectionName)} = CanRun<_Check${toPascalCase(
806
+ decision.collectionName,
807
+ )}DI>;\n`,
808
+ { overwrite: false },
809
+ );
810
+ const created = discoverRouteCollections(
811
+ project,
812
+ dirname(decision.targetFilePath),
813
+ ).find((collection) => collection.sourceFile === sourceFile);
814
+ if (!created)
815
+ throw new Error(`Could not create route collection ${routesName}.`);
816
+ return created;
817
+ }
818
+
819
+ function ensureParentMount(
820
+ parent: RouteCollection,
821
+ mountPath: string,
822
+ child: RouteCollection,
823
+ ): void {
824
+ const alreadyMounted = parent.routes
825
+ .getElements()
826
+ .some(
827
+ (element) =>
828
+ readRoutePath(element) === mountPath &&
829
+ element.getText().includes('loadChildren'),
830
+ );
831
+ if (!alreadyMounted) {
832
+ const moduleSpecifier = relativeModuleSpecifier(
833
+ parent.sourceFile.getFilePath(),
834
+ child.sourceFile.getFilePath(),
835
+ );
836
+ parent.routes.addElement(
837
+ `{ path: ${quote(mountPath)}, loadChildren: ({ withRetry }) => withRetry(import(${quote(
838
+ moduleSpecifier,
839
+ )})).then((m) => m.${child.routesName}) }`,
840
+ );
841
+ }
842
+ ensureImport(parent.sourceFile, 'assertChildRouteMounts');
843
+ if (
844
+ !hasCall(parent.sourceFile, 'assertChildRouteMounts', parent.routesName)
845
+ ) {
846
+ parent.sourceFile.addStatements(
847
+ `assertChildRouteMounts(${parent.routesName});`,
848
+ );
849
+ }
850
+ }
851
+
852
+ function ensureCollectionBookkeeping(collection: RouteCollection): void {
853
+ ensureImport(collection.sourceFile, 'assertExhaustiveRouteExceptions');
854
+ if (
855
+ !hasCall(
856
+ collection.sourceFile,
857
+ 'assertExhaustiveRouteExceptions',
858
+ collection.routesName,
859
+ )
860
+ ) {
861
+ collection.sourceFile.addStatements(
862
+ `assertExhaustiveRouteExceptions(${collection.routesName});`,
863
+ );
864
+ }
865
+ if (!hasCascadeCheck(collection)) {
866
+ ensureTypeImport(collection.sourceFile, 'ValidateCascadeRoutesFile');
867
+ ensureTypeImport(collection.sourceFile, 'CanRun');
868
+ ensureTypeImport(collection.sourceFile, 'Router', '@angular/router');
869
+ const suffix = toPascalCase(collection.collectionName);
870
+ collection.sourceFile.addStatements(
871
+ `type _Check${suffix}DI = ValidateCascadeRoutesFile<never, Router, typeof ${collection.routesName}>;\ntype _CanRun${suffix} = CanRun<_Check${suffix}DI>;`,
872
+ );
873
+ }
874
+ }
875
+
876
+ function hasCascadeCheck(collection: RouteCollection): boolean {
877
+ return collection.sourceFile.getTypeAliases().some((alias) => {
878
+ const type = alias.getTypeNode();
879
+ return (
880
+ Node.isTypeReference(type) &&
881
+ type.getTypeName().getText() === 'ValidateCascadeRoutesFile' &&
882
+ type.getTypeArguments()[2]?.getText() ===
883
+ `typeof ${collection.routesName}`
884
+ );
885
+ });
886
+ }
887
+
888
+ function readCascadeContext(collection: RouteCollection): {
889
+ names: string;
890
+ values: string;
891
+ } {
892
+ for (const alias of collection.sourceFile.getTypeAliases()) {
893
+ const type = alias.getTypeNode();
894
+ if (!Node.isTypeReference(type)) continue;
895
+ if (type.getTypeName().getText() !== 'ValidateCascadeRoutesFile') continue;
896
+ const args = type.getTypeArguments();
897
+ if (args[2]?.getText() !== `typeof ${collection.routesName}`) continue;
898
+ return {
899
+ names: args[0]?.getText() ?? 'never',
900
+ values: args[1]?.getText() ?? 'Router',
901
+ };
902
+ }
903
+ return { names: 'never', values: 'Router' };
904
+ }
905
+
906
+ function parseComponentTarget(
907
+ value: string,
908
+ rootDir: string,
909
+ ): ComponentTarget | undefined {
910
+ const separator = value.lastIndexOf('#');
911
+ if (separator <= 0 || separator === value.length - 1) return undefined;
912
+ return {
913
+ filePath: resolveFromRoot(value.slice(0, separator), rootDir),
914
+ className: value.slice(separator + 1),
915
+ };
916
+ }
917
+
918
+ async function createAngularComponent(
919
+ rootDir: string,
920
+ options: RouteAddOptions,
921
+ projectName: string | undefined,
922
+ ): Promise<
923
+ { component: ComponentTarget } | { diagnostic: RouteCommandDiagnostic }
924
+ > {
925
+ const angularBinary = findLocalBinary(rootDir, 'ng');
926
+ const nxBinary = findLocalBinary(rootDir, 'nx');
927
+ const angularWorkspace = existsSync(join(rootDir, 'angular.json'));
928
+ const nxWorkspace = existsSync(join(rootDir, 'nx.json'));
929
+ const runner =
930
+ angularWorkspace && angularBinary
931
+ ? {
932
+ binary: angularBinary,
933
+ args: (name: string) => [
934
+ 'generate',
935
+ 'component',
936
+ name,
937
+ '--inline-template',
938
+ '--inline-style',
939
+ '--skip-tests',
940
+ ...(projectName ? ['--project', projectName] : []),
941
+ ],
942
+ }
943
+ : nxWorkspace && nxBinary
944
+ ? {
945
+ binary: nxBinary,
946
+ args: (name: string) => [
947
+ 'generate',
948
+ '@schematics/angular:component',
949
+ name,
950
+ '--inline-template',
951
+ '--inline-style',
952
+ ...(projectName ? ['--project', projectName] : []),
953
+ '--skip-tests',
954
+ ],
955
+ }
956
+ : undefined;
957
+ if (!runner) {
958
+ return {
959
+ diagnostic: {
960
+ code: 'NO_ANGULAR_CLI',
961
+ message:
962
+ 'No compatible local Angular or Nx CLI workspace was found. Expected angular.json + node_modules/.bin/ng, or nx.json + node_modules/.bin/nx.',
963
+ },
964
+ };
965
+ }
966
+ const name = options.createComponent;
967
+ if (!name)
968
+ throw new Error('createAngularComponent requires createComponent.');
969
+ const expectedFileName = `${names(basename(name)).fileName}.ts`;
970
+ const filesBefore = new Set(await findFiles(rootDir, expectedFileName));
971
+ try {
972
+ await execFileAsync(runner.binary, runner.args(name), { cwd: rootDir });
973
+ } catch (error) {
974
+ return {
975
+ diagnostic: {
976
+ code: 'COMPONENT_GENERATION_FAILED',
977
+ message: `Component generation failed through ${basename(runner.binary)}: ${commandError(error)}`,
978
+ },
979
+ };
980
+ }
981
+ const candidates = (await findFiles(rootDir, expectedFileName)).sort(
982
+ (left, right) =>
983
+ Number(filesBefore.has(left)) - Number(filesBefore.has(right)),
984
+ );
985
+ const filePath = candidates
986
+ .filter((candidate) => !candidate.endsWith('.spec.ts'))
987
+ .sort((a, b) => b.length - a.length)[0];
988
+ if (!filePath) {
989
+ return {
990
+ diagnostic: {
991
+ code: 'COMPONENT_NOT_FOUND',
992
+ message: `Angular CLI completed but ${basename(name)}.ts could not be located.`,
993
+ },
994
+ };
995
+ }
996
+ const source = new Project().addSourceFileAtPath(filePath);
997
+ const decorated = findAngularDecoratedClass(source);
998
+ if (decorated.skipped || !decorated.className) {
999
+ return {
1000
+ diagnostic: {
1001
+ code: 'COMPONENT_NOT_FOUND',
1002
+ message: `Generated component class could not be inferred in ${filePath}.`,
1003
+ },
1004
+ };
1005
+ }
1006
+ return { component: { filePath, className: decorated.className } };
1007
+ }
1008
+
1009
+ function resolveAngularProjectName(
1010
+ rootDir: string,
1011
+ projectOption: string | undefined,
1012
+ sourceRoot: string,
1013
+ ): string | undefined {
1014
+ const projectFile = join(sourceRoot, 'project.json');
1015
+ if (existsSync(projectFile)) {
1016
+ try {
1017
+ const parsed = JSON.parse(readFileSync(projectFile, 'utf8')) as {
1018
+ name?: unknown;
1019
+ };
1020
+ if (typeof parsed.name === 'string' && parsed.name) return parsed.name;
1021
+ } catch {
1022
+ // Fall through to the explicit option/basename heuristic.
1023
+ }
1024
+ }
1025
+ if (
1026
+ projectOption &&
1027
+ !projectOption.endsWith('.json') &&
1028
+ !projectOption.includes('/') &&
1029
+ !projectOption.includes('\\')
1030
+ ) {
1031
+ return projectOption;
1032
+ }
1033
+ const relativeSourceRoot = relative(rootDir, sourceRoot);
1034
+ return relativeSourceRoot && !relativeSourceRoot.startsWith('..')
1035
+ ? basename(sourceRoot)
1036
+ : undefined;
1037
+ }
1038
+
1039
+ function ensureGenDeps(sourceFile: SourceFile, className: string): boolean {
1040
+ generateAngularDependencies(sourceFile);
1041
+ return Boolean(sourceFile.getTypeAlias(`GenDeps_${className}`));
1042
+ }
1043
+
1044
+ function hasRoutePath(routes: ArrayLiteralExpression, path: string): boolean {
1045
+ return routes
1046
+ .getElements()
1047
+ .some((element) => readRoutePath(element) === path);
1048
+ }
1049
+
1050
+ function routeMatchesRequest(
1051
+ routes: ArrayLiteralExpression,
1052
+ path: string,
1053
+ options: RouteAddOptions,
1054
+ component: ComponentTarget | undefined,
1055
+ routesFilePath: string,
1056
+ ): boolean {
1057
+ const element = routes
1058
+ .getElements()
1059
+ .find((candidate) => readRoutePath(candidate) === path);
1060
+ if (!element) return false;
1061
+ const text = element.getText();
1062
+ if (options.redirectTo) {
1063
+ return (
1064
+ text.includes(`redirectTo: ${quote(options.redirectTo)}`) ||
1065
+ text.includes(`redirectTo: "${options.redirectTo}"`)
1066
+ );
1067
+ }
1068
+ if (!component) return false;
1069
+ const moduleSpecifier = relativeModuleSpecifier(
1070
+ routesFilePath,
1071
+ component.filePath,
1072
+ );
1073
+ return (
1074
+ text.includes(`GenDeps_${component.className}`) &&
1075
+ text.includes(moduleSpecifier)
1076
+ );
1077
+ }
1078
+
1079
+ function readRoutePath(node: Node): string | undefined {
1080
+ if (
1081
+ Node.isCallExpression(node) &&
1082
+ node.getExpression().getText() === 'craftRoute'
1083
+ ) {
1084
+ const arg = node.getArguments()[0];
1085
+ return Node.isStringLiteral(arg) ? arg.getLiteralValue() : undefined;
1086
+ }
1087
+ if (!Node.isObjectLiteralExpression(node)) return undefined;
1088
+ const property = node.getProperty('path');
1089
+ if (!Node.isPropertyAssignment(property)) return undefined;
1090
+ const initializer = property.getInitializer();
1091
+ return Node.isStringLiteral(initializer)
1092
+ ? initializer.getLiteralValue()
1093
+ : undefined;
1094
+ }
1095
+
1096
+ function rewriteMovedRouteText(text: string, prefix: string): string {
1097
+ const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1098
+ return text
1099
+ .replace(new RegExp(`(craftRoute\\(\\s*['"])${escaped}(?:/)?`), '$1')
1100
+ .replace(new RegExp(`(path\\s*:\\s*['"])${escaped}(?:/)?`), '$1');
1101
+ }
1102
+
1103
+ function deriveMovedHelperRenames(
1104
+ parent: RouteCollection,
1105
+ nodes: Node[],
1106
+ prefix: string,
1107
+ childCollectionName: string,
1108
+ ): Map<string, string> {
1109
+ const parentName = toPascalCase(parent.collectionName);
1110
+ const childName = toPascalCase(childCollectionName);
1111
+ const candidates = new Map<string, string>();
1112
+ for (const node of nodes) {
1113
+ const originalPath = readRoutePath(node);
1114
+ if (originalPath === undefined) continue;
1115
+ const childPath =
1116
+ originalPath === prefix ? '' : originalPath.slice(prefix.length + 1);
1117
+ for (const segment of originalPath.split('/')) {
1118
+ if (!segment.startsWith(':')) continue;
1119
+ const param = segment.slice(1).replace(/\?$/, '');
1120
+ candidates.set(
1121
+ `inject${parentName}${toPascalCase(param)}Params`,
1122
+ `inject${childName}${toPascalCase(param)}Params`,
1123
+ );
1124
+ }
1125
+ const definition = getRouteDefinition(node);
1126
+ if (!definition) continue;
1127
+ const parentBase = routeBaseServiceName(originalPath);
1128
+ const childBase = routeBaseServiceName(childPath);
1129
+ for (const [property, suffix] of [
1130
+ ['data', 'Data'],
1131
+ ['queryParams', 'QueryParams'],
1132
+ ['resolve', 'ResolvedData'],
1133
+ ['withLoaderViewTransitionImage', 'ViewTransition'],
1134
+ ] as const) {
1135
+ if (!definition.getProperty(property)) continue;
1136
+ candidates.set(
1137
+ `inject${parentName}${parentBase}${suffix}`,
1138
+ `inject${childName}${childBase}${suffix}`,
1139
+ );
1140
+ }
1141
+ if (
1142
+ definition.getProperty('canActivate') ||
1143
+ definition.getProperty('canMatch')
1144
+ ) {
1145
+ candidates.set(
1146
+ `inject${parentName}${parentBase}GuardedData`,
1147
+ `inject${childName}${childBase}GuardedData`,
1148
+ );
1149
+ }
1150
+ }
1151
+
1152
+ const declaration = parent.call.getFirstAncestorByKind(
1153
+ SyntaxKind.VariableDeclaration,
1154
+ );
1155
+ const binding = declaration?.getNameNode();
1156
+ if (!binding || !Node.isObjectBindingPattern(binding)) return new Map();
1157
+ const exportedBindings = new Set(
1158
+ binding
1159
+ .getElements()
1160
+ .map(
1161
+ (element) =>
1162
+ element.getPropertyNameNode()?.getText() ?? element.getName(),
1163
+ ),
1164
+ );
1165
+ return new Map(
1166
+ [...candidates].filter(([oldName]) => exportedBindings.has(oldName)),
1167
+ );
1168
+ }
1169
+
1170
+ function getRouteDefinition(node: Node) {
1171
+ if (Node.isObjectLiteralExpression(node)) return node;
1172
+ if (
1173
+ Node.isCallExpression(node) &&
1174
+ node.getExpression().getText() === 'craftRoute'
1175
+ ) {
1176
+ const definition = node.getArguments()[1];
1177
+ return Node.isObjectLiteralExpression(definition) ? definition : undefined;
1178
+ }
1179
+ return undefined;
1180
+ }
1181
+
1182
+ function routeBaseServiceName(path: string): string {
1183
+ if (!path) return 'Root';
1184
+ return path
1185
+ .split('/')
1186
+ .map((segment) => {
1187
+ if (segment === '**') return 'Wildcard';
1188
+ return toPascalCase(segment.replace(/^:/, '').replace(/\?$/, ''));
1189
+ })
1190
+ .join('');
1191
+ }
1192
+
1193
+ function findHelperConsumers(
1194
+ project: Project,
1195
+ parentFile: SourceFile,
1196
+ renames: Map<string, string>,
1197
+ ): SourceFile[] {
1198
+ if (renames.size === 0) return [];
1199
+ return project.getSourceFiles().filter((sourceFile) =>
1200
+ sourceFile.getImportDeclarations().some((declaration) => {
1201
+ if (declaration.getModuleSpecifierSourceFile() !== parentFile)
1202
+ return false;
1203
+ return declaration
1204
+ .getNamedImports()
1205
+ .some((namedImport) => renames.has(namedImport.getName()));
1206
+ }),
1207
+ );
1208
+ }
1209
+
1210
+ function rewireMovedHelpers(
1211
+ project: Project,
1212
+ parent: RouteCollection,
1213
+ target: SourceFile,
1214
+ renames: Map<string, string>,
1215
+ ): void {
1216
+ if (renames.size === 0) return;
1217
+ const parentDeclaration = parent.call.getFirstAncestorByKind(
1218
+ SyntaxKind.VariableDeclaration,
1219
+ );
1220
+ const parentBinding = parentDeclaration?.getNameNode();
1221
+ const targetDeclaration = target
1222
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
1223
+ .find((call) => call.getExpression().getText() === 'craftRoutes')
1224
+ ?.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
1225
+ const targetBinding = targetDeclaration?.getNameNode();
1226
+ if (
1227
+ !parentBinding ||
1228
+ !Node.isObjectBindingPattern(parentBinding) ||
1229
+ !targetBinding ||
1230
+ !Node.isObjectBindingPattern(targetBinding)
1231
+ ) {
1232
+ return;
1233
+ }
1234
+
1235
+ const oldNames = new Set(renames.keys());
1236
+ const parentElements = parentBinding
1237
+ .getElements()
1238
+ .filter(
1239
+ (element) =>
1240
+ !oldNames.has(
1241
+ element.getPropertyNameNode()?.getText() ?? element.getName(),
1242
+ ),
1243
+ )
1244
+ .map((element) => element.getText());
1245
+ parentBinding.replaceWithText(`{ ${parentElements.join(', ')} }`);
1246
+
1247
+ const targetElements = targetBinding
1248
+ .getElements()
1249
+ .map((element) => element.getText());
1250
+ for (const newName of renames.values()) {
1251
+ if (!targetElements.includes(newName)) targetElements.push(newName);
1252
+ }
1253
+ targetBinding.replaceWithText(`{ ${targetElements.join(', ')} }`);
1254
+
1255
+ for (const sourceFile of project.getSourceFiles()) {
1256
+ for (const declaration of [...sourceFile.getImportDeclarations()]) {
1257
+ if (declaration.getModuleSpecifierSourceFile() !== parent.sourceFile)
1258
+ continue;
1259
+ for (const namedImport of [...declaration.getNamedImports()]) {
1260
+ const newName = renames.get(namedImport.getName());
1261
+ if (!newName) continue;
1262
+ const localName =
1263
+ namedImport.getAliasNode()?.getText() ?? namedImport.getName();
1264
+ let targetImport = sourceFile
1265
+ .getImportDeclarations()
1266
+ .find(
1267
+ (candidate) => candidate.getModuleSpecifierSourceFile() === target,
1268
+ );
1269
+ if (!targetImport) {
1270
+ targetImport = sourceFile.addImportDeclaration({
1271
+ moduleSpecifier: relativeModuleSpecifier(
1272
+ sourceFile.getFilePath(),
1273
+ target.getFilePath(),
1274
+ ),
1275
+ });
1276
+ }
1277
+ targetImport.addNamedImport({
1278
+ name: newName,
1279
+ alias: localName === newName ? undefined : localName,
1280
+ });
1281
+ namedImport.remove();
1282
+ }
1283
+ if (
1284
+ declaration.getNamedImports().length === 0 &&
1285
+ !declaration.getDefaultImport() &&
1286
+ !declaration.getNamespaceImport()
1287
+ ) {
1288
+ declaration.remove();
1289
+ }
1290
+ }
1291
+ }
1292
+ }
1293
+
1294
+ function findLocalDependencies(
1295
+ sourceFile: SourceFile,
1296
+ nodes: Node[],
1297
+ ): string[] {
1298
+ const imported = new Set(
1299
+ sourceFile.getImportDeclarations().flatMap((declaration) => {
1300
+ const defaultImport = declaration.getDefaultImport();
1301
+ const namespaceImport = declaration.getNamespaceImport();
1302
+ return [
1303
+ ...declaration
1304
+ .getNamedImports()
1305
+ .map((item) => item.getAliasNode()?.getText() ?? item.getName()),
1306
+ ...(defaultImport ? [defaultImport.getText()] : []),
1307
+ ...(namespaceImport ? [namespaceImport.getText()] : []),
1308
+ ];
1309
+ }),
1310
+ );
1311
+ const locals = new Set([
1312
+ ...sourceFile
1313
+ .getVariableStatements()
1314
+ .flatMap((statement) => statement.getDeclarations())
1315
+ .map((declaration) => declaration.getName()),
1316
+ ...sourceFile
1317
+ .getFunctions()
1318
+ .map((declaration) => declaration.getName())
1319
+ .filter(Boolean),
1320
+ ...sourceFile.getEnums().map((declaration) => declaration.getName()),
1321
+ ...sourceFile.getTypeAliases().map((declaration) => declaration.getName()),
1322
+ ...sourceFile.getInterfaces().map((declaration) => declaration.getName()),
1323
+ ...sourceFile
1324
+ .getClasses()
1325
+ .map((declaration) => declaration.getName())
1326
+ .filter(Boolean),
1327
+ ] as string[]);
1328
+ const ignored = new Set([
1329
+ 'craftRoute',
1330
+ 'import',
1331
+ 'm',
1332
+ 'withRetry',
1333
+ 'true',
1334
+ 'false',
1335
+ 'undefined',
1336
+ ]);
1337
+ const used = new Set(
1338
+ nodes.flatMap((node) =>
1339
+ node
1340
+ .getDescendantsOfKind(SyntaxKind.Identifier)
1341
+ .map((identifier) => identifier.getText()),
1342
+ ),
1343
+ );
1344
+ return [...used].filter(
1345
+ (name) => locals.has(name) && !imported.has(name) && !ignored.has(name),
1346
+ );
1347
+ }
1348
+
1349
+ function rewriteRelativeSpecifiers(
1350
+ target: SourceFile,
1351
+ previousFilePath: string,
1352
+ ): void {
1353
+ for (const declaration of target.getImportDeclarations()) {
1354
+ const specifier = declaration.getModuleSpecifierValue();
1355
+ if (!specifier.startsWith('.')) continue;
1356
+ const absolute = resolve(dirname(previousFilePath), specifier);
1357
+ declaration.setModuleSpecifier(
1358
+ relativeModuleSpecifier(target.getFilePath(), absolute),
1359
+ );
1360
+ }
1361
+ for (const literal of target.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
1362
+ const parent = literal.getParent();
1363
+ if (!literal.getLiteralValue().startsWith('.')) continue;
1364
+ if (
1365
+ Node.isCallExpression(parent) &&
1366
+ (parent.getExpression().getText() === 'import' ||
1367
+ parent.getExpression().getText() === 'require')
1368
+ ) {
1369
+ const absolute = resolve(
1370
+ dirname(previousFilePath),
1371
+ literal.getLiteralValue(),
1372
+ );
1373
+ literal.setLiteralValue(
1374
+ relativeModuleSpecifier(target.getFilePath(), absolute),
1375
+ );
1376
+ } else if (literal.getFirstAncestorByKind(SyntaxKind.ImportType)) {
1377
+ const absolute = resolve(
1378
+ dirname(previousFilePath),
1379
+ literal.getLiteralValue(),
1380
+ );
1381
+ literal.setLiteralValue(
1382
+ relativeModuleSpecifier(target.getFilePath(), absolute),
1383
+ );
1384
+ }
1385
+ }
1386
+ }
1387
+
1388
+ function ensureImport(
1389
+ sourceFile: SourceFile,
1390
+ name: string,
1391
+ moduleSpecifier = '@craft-ng/core',
1392
+ ): void {
1393
+ const declaration = sourceFile
1394
+ .getImportDeclarations()
1395
+ .find(
1396
+ (item) =>
1397
+ item.getModuleSpecifierValue() === moduleSpecifier &&
1398
+ !item.isTypeOnly(),
1399
+ );
1400
+ if (!declaration) {
1401
+ sourceFile.addImportDeclaration({ moduleSpecifier, namedImports: [name] });
1402
+ } else if (
1403
+ !declaration.getNamedImports().some((item) => item.getName() === name)
1404
+ ) {
1405
+ declaration.addNamedImport(name);
1406
+ }
1407
+ }
1408
+
1409
+ function ensureTypeImport(
1410
+ sourceFile: SourceFile,
1411
+ name: string,
1412
+ moduleSpecifier = '@craft-ng/core',
1413
+ ): void {
1414
+ if (
1415
+ sourceFile
1416
+ .getImportDeclarations()
1417
+ .some(
1418
+ (item) =>
1419
+ item.getModuleSpecifierValue() === moduleSpecifier &&
1420
+ item.getNamedImports().some((named) => named.getName() === name),
1421
+ )
1422
+ ) {
1423
+ return;
1424
+ }
1425
+ const valueImport = sourceFile
1426
+ .getImportDeclarations()
1427
+ .find(
1428
+ (item) =>
1429
+ item.getModuleSpecifierValue() === moduleSpecifier &&
1430
+ !item.isTypeOnly(),
1431
+ );
1432
+ if (valueImport) valueImport.addNamedImport({ name, isTypeOnly: true });
1433
+ else
1434
+ sourceFile.addImportDeclaration({
1435
+ moduleSpecifier,
1436
+ isTypeOnly: true,
1437
+ namedImports: [name],
1438
+ });
1439
+ }
1440
+
1441
+ function hasCall(
1442
+ sourceFile: SourceFile,
1443
+ functionName: string,
1444
+ argument: string,
1445
+ ): boolean {
1446
+ return sourceFile
1447
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
1448
+ .some(
1449
+ (call) =>
1450
+ call.getExpression().getText() === functionName &&
1451
+ call.getArguments()[0]?.getText() === argument,
1452
+ );
1453
+ }
1454
+
1455
+ async function saveFiles(
1456
+ project: Project,
1457
+ filePaths: string[],
1458
+ fileSystem: RouteFileSystem,
1459
+ ): Promise<void> {
1460
+ await Promise.all(
1461
+ [...new Set(filePaths)].map(async (filePath) => {
1462
+ const sourceFile = project.getSourceFile(filePath);
1463
+ if (sourceFile) {
1464
+ await fileSystem.write(filePath, sourceFile.getFullText());
1465
+ }
1466
+ }),
1467
+ );
1468
+ }
1469
+
1470
+ function addSourceFileFromWorkspace(
1471
+ project: Project,
1472
+ filePath: string,
1473
+ fileSystem: RouteFileSystem,
1474
+ ): SourceFile | undefined {
1475
+ const existing = project.getSourceFile(filePath);
1476
+ if (existing) return existing;
1477
+ const text = fileSystem.read(filePath);
1478
+ return text === undefined
1479
+ ? undefined
1480
+ : project.createSourceFile(filePath, text, { overwrite: true });
1481
+ }
1482
+
1483
+ function snapshotProject(project: Project): Map<string, string> {
1484
+ return new Map(
1485
+ project
1486
+ .getSourceFiles()
1487
+ .map((sourceFile) => [
1488
+ sourceFile.getFilePath(),
1489
+ sourceFile.getFullText(),
1490
+ ]),
1491
+ );
1492
+ }
1493
+
1494
+ function sourceChanged(
1495
+ project: Project,
1496
+ initialTexts: Map<string, string>,
1497
+ filePath: string,
1498
+ ): boolean {
1499
+ const sourceFile = project.getSourceFile(filePath);
1500
+ return Boolean(
1501
+ sourceFile && initialTexts.get(filePath) !== sourceFile.getFullText(),
1502
+ );
1503
+ }
1504
+
1505
+ export async function validateRouteChangedFiles(
1506
+ rootDir: string,
1507
+ sourceRoot: string,
1508
+ tsConfigFilePath: string,
1509
+ files: string[],
1510
+ eslintBaseline: Set<string>,
1511
+ options: { skipTypeCheck?: boolean } = {},
1512
+ ): Promise<RouteCommandDiagnostic[]> {
1513
+ const diagnostics: RouteCommandDiagnostic[] = [];
1514
+ const eslint = findLocalBinary(rootDir, 'eslint');
1515
+ if (eslint && files.length > 0) {
1516
+ const lint = await runEslintJson(eslint, ['--fix', ...files], sourceRoot);
1517
+ if (lint.failure) {
1518
+ diagnostics.push({
1519
+ code: 'VALIDATION_FAILED',
1520
+ message: `ESLint failed after writing changes: ${lint.failure}`,
1521
+ });
1522
+ } else if (lint.failed) {
1523
+ const newMessages = lint.results.flatMap((result) =>
1524
+ result.messages
1525
+ .filter(
1526
+ (message) =>
1527
+ !eslintBaseline.has(eslintFingerprint(result.filePath, message)),
1528
+ )
1529
+ .map(
1530
+ (message) =>
1531
+ `${result.filePath}:${message.line ?? 0}:${message.column ?? 0} ${message.message}${message.ruleId ? ` (${message.ruleId})` : ''}`,
1532
+ ),
1533
+ );
1534
+ if (newMessages.length > 0) {
1535
+ diagnostics.push({
1536
+ code: 'VALIDATION_FAILED',
1537
+ message: `ESLint failed after writing changes: ${newMessages.join('\n')}`,
1538
+ });
1539
+ }
1540
+ }
1541
+ }
1542
+ if (options.skipTypeCheck) return diagnostics;
1543
+ const ngc = findLocalBinary(rootDir, 'ngc');
1544
+ const tsc = findLocalBinary(rootDir, 'tsc');
1545
+ const typeChecker =
1546
+ basename(tsConfigFilePath) === 'tsconfig.app.json' && ngc ? ngc : tsc;
1547
+ if (typeChecker && existsSync(tsConfigFilePath)) {
1548
+ try {
1549
+ await execFileAsync(
1550
+ typeChecker,
1551
+ ['--project', tsConfigFilePath, '--noEmit'],
1552
+ {
1553
+ cwd: sourceRoot,
1554
+ maxBuffer: 20 * 1024 * 1024,
1555
+ },
1556
+ );
1557
+ } catch (error) {
1558
+ const detail = commandError(error);
1559
+ diagnostics.push({
1560
+ code: 'VALIDATION_FAILED',
1561
+ message: detail.includes('TS2589')
1562
+ ? `TypeScript hit TS2589 after writing changes. Keep the DI check and split the collection with loadChildren; rerun with --feature-file or a more specific --parent. ${detail}`
1563
+ : `TypeScript diagnostics failed after writing changes: ${detail}`,
1564
+ });
1565
+ }
1566
+ }
1567
+ return diagnostics;
1568
+ }
1569
+
1570
+ type EslintMessage = {
1571
+ column?: number;
1572
+ line?: number;
1573
+ message: string;
1574
+ ruleId?: string | null;
1575
+ };
1576
+
1577
+ type EslintResult = {
1578
+ filePath: string;
1579
+ messages: EslintMessage[];
1580
+ };
1581
+
1582
+ export async function captureRouteEslintBaseline(
1583
+ rootDir: string,
1584
+ sourceRoot: string,
1585
+ files: string[],
1586
+ ): Promise<Set<string>> {
1587
+ const eslint = findLocalBinary(rootDir, 'eslint');
1588
+ const existingFiles = files.filter((file) => existsSync(file));
1589
+ if (!eslint || existingFiles.length === 0) return new Set();
1590
+ const lint = await runEslintJson(eslint, existingFiles, sourceRoot);
1591
+ if (lint.failure) return new Set();
1592
+ return new Set(
1593
+ lint.results.flatMap((result) =>
1594
+ result.messages.map((message) =>
1595
+ eslintFingerprint(result.filePath, message),
1596
+ ),
1597
+ ),
1598
+ );
1599
+ }
1600
+
1601
+ async function runEslintJson(
1602
+ eslint: string,
1603
+ args: string[],
1604
+ cwd: string,
1605
+ ): Promise<{
1606
+ failed: boolean;
1607
+ failure?: string;
1608
+ results: EslintResult[];
1609
+ }> {
1610
+ try {
1611
+ const { stdout } = await execFileAsync(
1612
+ eslint,
1613
+ ['--format', 'json', ...args],
1614
+ { cwd, maxBuffer: 20 * 1024 * 1024 },
1615
+ );
1616
+ return { failed: false, results: parseEslintResults(stdout) ?? [] };
1617
+ } catch (error) {
1618
+ const stdout = commandStdout(error);
1619
+ const results = parseEslintResults(stdout);
1620
+ return results
1621
+ ? { failed: true, results }
1622
+ : { failed: true, failure: commandError(error), results: [] };
1623
+ }
1624
+ }
1625
+
1626
+ function parseEslintResults(output: string): EslintResult[] | undefined {
1627
+ if (!output.trim()) return undefined;
1628
+ try {
1629
+ const value: unknown = JSON.parse(output);
1630
+ return Array.isArray(value) ? (value as EslintResult[]) : undefined;
1631
+ } catch {
1632
+ return undefined;
1633
+ }
1634
+ }
1635
+
1636
+ function eslintFingerprint(filePath: string, message: EslintMessage): string {
1637
+ return `${resolve(filePath)}\0${message.ruleId ?? ''}\0${message.message}`;
1638
+ }
1639
+
1640
+ function findLocalBinary(rootDir: string, name: string): string | undefined {
1641
+ let current = rootDir;
1642
+ while (true) {
1643
+ const candidate = join(current, 'node_modules', '.bin', name);
1644
+ if (existsSync(candidate)) return candidate;
1645
+ const parent = dirname(current);
1646
+ if (parent === current) return undefined;
1647
+ current = parent;
1648
+ }
1649
+ }
1650
+
1651
+ function listFilesOnDisk(rootDir: string): string[] {
1652
+ if (!existsSync(rootDir)) return [];
1653
+ const result: string[] = [];
1654
+ const visit = (directory: string): void => {
1655
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
1656
+ if (['node_modules', 'dist', '.angular', '.git'].includes(entry.name)) {
1657
+ continue;
1658
+ }
1659
+ const filePath = join(directory, entry.name);
1660
+ if (entry.isDirectory()) visit(filePath);
1661
+ else if (entry.isFile()) result.push(filePath);
1662
+ }
1663
+ };
1664
+ visit(rootDir);
1665
+ return result;
1666
+ }
1667
+
1668
+ async function findFiles(rootDir: string, fileName: string): Promise<string[]> {
1669
+ const entries = await readdir(rootDir, {
1670
+ recursive: true,
1671
+ withFileTypes: true,
1672
+ });
1673
+ return entries
1674
+ .filter((entry) => entry.isFile() && entry.name === fileName)
1675
+ .map((entry) => join(entry.parentPath, entry.name))
1676
+ .filter((path) => !path.includes(`${sep}node_modules${sep}`));
1677
+ }
1678
+
1679
+ function printPlan(
1680
+ plan: RouteCommandPlan,
1681
+ log: (message: string) => void,
1682
+ json: boolean,
1683
+ ) {
1684
+ if (json) return;
1685
+ log(
1686
+ `${plan.summary}\nFiles:\n${plan.files.map((file) => ` ${file}`).join('\n')}`,
1687
+ );
1688
+ }
1689
+
1690
+ async function shouldContinue(
1691
+ options: {
1692
+ dryRun?: boolean;
1693
+ yes?: boolean;
1694
+ confirm?: (plan: RouteCommandPlan) => Promise<boolean>;
1695
+ },
1696
+ plan: RouteCommandPlan,
1697
+ ): Promise<boolean> {
1698
+ if (options.dryRun || options.yes) return true;
1699
+ return options.confirm ? options.confirm(plan) : false;
1700
+ }
1701
+
1702
+ function failed(
1703
+ code: RouteCommandDiagnostic['code'],
1704
+ message: string,
1705
+ plan?: RouteCommandPlan,
1706
+ ): RouteCommandResult {
1707
+ return resultFromDiagnostics([{ code, message }], plan);
1708
+ }
1709
+
1710
+ function resultFromDiagnostics(
1711
+ diagnostics: RouteCommandDiagnostic[],
1712
+ plan?: RouteCommandPlan,
1713
+ ): RouteCommandResult {
1714
+ return { changedFiles: [], diagnostics, exitCode: 1, plan };
1715
+ }
1716
+
1717
+ function resolveFromRoot(filePath: string, rootDir: string): string {
1718
+ return isAbsolute(filePath) ? resolve(filePath) : resolve(rootDir, filePath);
1719
+ }
1720
+
1721
+ function normalizeRoutePath(path: string): string {
1722
+ const clean = path
1723
+ .trim()
1724
+ .replace(/^\/+|\/+$/g, '')
1725
+ .replace(/\/{2,}/g, '/');
1726
+ return clean ? `/${clean}` : '/';
1727
+ }
1728
+
1729
+ function relativeModuleSpecifier(fromFile: string, toFile: string): string {
1730
+ let value = relative(dirname(fromFile), toFile).replace(/\\/g, '/');
1731
+ const extension = extname(value);
1732
+ if (['.ts', '.tsx', '.js', '.jsx'].includes(extension)) {
1733
+ value = value.slice(0, -extension.length);
1734
+ }
1735
+ if (!value.startsWith('.')) value = `./${value}`;
1736
+ return value;
1737
+ }
1738
+
1739
+ function collectionNameFromFile(filePath: string): string {
1740
+ return basename(filePath)
1741
+ .replace(/\.routes\.ts$/, '')
1742
+ .replace(/\.ts$/, '');
1743
+ }
1744
+
1745
+ function isInside(filePath: string, rootDir: string): boolean {
1746
+ const path = relative(resolve(rootDir), resolve(filePath));
1747
+ return path === '' || (!path.startsWith('..') && !isAbsolute(path));
1748
+ }
1749
+
1750
+ function quote(value: string): string {
1751
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
1752
+ }
1753
+
1754
+ function toPascalCase(value: string): string {
1755
+ return (
1756
+ value
1757
+ .split(/[^A-Za-z0-9]+/)
1758
+ .filter(Boolean)
1759
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1760
+ .join('') || 'Routes'
1761
+ );
1762
+ }
1763
+
1764
+ function uncapitalize(value: string): string {
1765
+ return value.charAt(0).toLowerCase() + value.slice(1);
1766
+ }
1767
+
1768
+ function commandError(error: unknown): string {
1769
+ if (typeof error === 'object' && error) {
1770
+ const value = error as {
1771
+ stdout?: unknown;
1772
+ stderr?: unknown;
1773
+ message?: unknown;
1774
+ };
1775
+ const output = [value.stdout, value.stderr]
1776
+ .filter((item) => typeof item === 'string' && item.trim())
1777
+ .join('\n')
1778
+ .trim();
1779
+ if (output) return output;
1780
+ if (value.message) return String(value.message).trim();
1781
+ }
1782
+ return error instanceof Error ? error.message : String(error);
1783
+ }
1784
+
1785
+ function commandStdout(error: unknown): string {
1786
+ if (typeof error === 'object' && error && 'stdout' in error) {
1787
+ return String((error as { stdout?: unknown }).stdout ?? '');
1788
+ }
1789
+ return '';
1790
+ }