@craft-ts/dev-tools 0.7.0-beta.13 → 0.7.0-beta.16

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