@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,179 @@
1
+ function createNameMatchRule({
2
+ calleeName,
3
+ description,
4
+ supportsObjectConfigForm = false,
5
+ }) {
6
+ return {
7
+ meta: {
8
+ type: 'problem',
9
+ docs: { description },
10
+ fixable: 'code',
11
+ schema: [],
12
+ messages: {
13
+ missingName: `${calleeName} must be called with a string literal name matching '{{declaredName}}' as the first argument.`,
14
+ mismatchedName: `${calleeName} first argument '{{actual}}' must match the declared name '{{declaredName}}'.`,
15
+ },
16
+ },
17
+ create(context) {
18
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
19
+
20
+ return {
21
+ CallExpression(node) {
22
+ if (
23
+ node.callee.type !== 'Identifier' ||
24
+ node.callee.name !== calleeName
25
+ ) {
26
+ return;
27
+ }
28
+
29
+ const declaredName = getDeclaredName(node);
30
+ if (!declaredName) {
31
+ return;
32
+ }
33
+
34
+ const firstArg = node.arguments[0];
35
+
36
+ if (!firstArg) {
37
+ context.report({
38
+ node,
39
+ messageId: 'missingName',
40
+ data: { declaredName },
41
+ fix(fixer) {
42
+ const openParen = sourceCode.getTokenAfter(
43
+ node.callee,
44
+ (token) => token.type === 'Punctuator' && token.value === '(',
45
+ );
46
+ if (!openParen) return null;
47
+ return fixer.insertTextAfter(openParen, `'${declaredName}'`);
48
+ },
49
+ });
50
+ return;
51
+ }
52
+
53
+ if (supportsObjectConfigForm && firstArg.type === 'ObjectExpression') {
54
+ const nameProp = firstArg.properties.find(
55
+ (p) =>
56
+ p.type === 'Property' &&
57
+ !p.computed &&
58
+ p.key.type === 'Identifier' &&
59
+ p.key.name === 'name',
60
+ );
61
+ if (!nameProp) {
62
+ context.report({
63
+ node: firstArg,
64
+ messageId: 'missingName',
65
+ data: { declaredName },
66
+ });
67
+ return;
68
+ }
69
+ const nameValue = nameProp.value;
70
+ if (isStringLiteral(nameValue)) {
71
+ const actual = getStringLiteralValue(nameValue);
72
+ if (actual === declaredName) {
73
+ return;
74
+ }
75
+ context.report({
76
+ node: nameValue,
77
+ messageId: 'mismatchedName',
78
+ data: { declaredName, actual },
79
+ fix(fixer) {
80
+ return fixer.replaceText(nameValue, `'${declaredName}'`);
81
+ },
82
+ });
83
+ } else {
84
+ context.report({
85
+ node: nameValue,
86
+ messageId: 'mismatchedName',
87
+ data: { declaredName, actual: sourceCode.getText(nameValue) },
88
+ });
89
+ }
90
+ return;
91
+ }
92
+
93
+ if (isStringLiteral(firstArg)) {
94
+ const actual = getStringLiteralValue(firstArg);
95
+ if (actual === declaredName) {
96
+ return;
97
+ }
98
+ context.report({
99
+ node: firstArg,
100
+ messageId: 'mismatchedName',
101
+ data: { declaredName, actual },
102
+ fix(fixer) {
103
+ return fixer.replaceText(firstArg, `'${declaredName}'`);
104
+ },
105
+ });
106
+ return;
107
+ }
108
+
109
+ context.report({
110
+ node: firstArg,
111
+ messageId: 'missingName',
112
+ data: { declaredName },
113
+ fix(fixer) {
114
+ return fixer.insertTextBefore(firstArg, `'${declaredName}', `);
115
+ },
116
+ });
117
+ },
118
+ };
119
+ },
120
+ };
121
+ }
122
+
123
+ function getDeclaredName(callNode) {
124
+ const parent = callNode.parent;
125
+ if (!parent) return undefined;
126
+
127
+ if (
128
+ parent.type === 'VariableDeclarator' &&
129
+ parent.init === callNode &&
130
+ parent.id.type === 'Identifier'
131
+ ) {
132
+ return parent.id.name;
133
+ }
134
+
135
+ if (
136
+ parent.type === 'PropertyDefinition' &&
137
+ parent.value === callNode &&
138
+ !parent.computed &&
139
+ parent.key.type === 'Identifier'
140
+ ) {
141
+ return parent.key.name;
142
+ }
143
+
144
+ if (
145
+ parent.type === 'Property' &&
146
+ parent.value === callNode &&
147
+ !parent.computed &&
148
+ parent.key.type === 'Identifier' &&
149
+ parent.parent &&
150
+ parent.parent.type === 'ObjectExpression'
151
+ ) {
152
+ return parent.key.name;
153
+ }
154
+
155
+ return undefined;
156
+ }
157
+
158
+ function isStringLiteral(node) {
159
+ if (node.type === 'Literal' && typeof node.value === 'string') {
160
+ return true;
161
+ }
162
+ if (
163
+ node.type === 'TemplateLiteral' &&
164
+ node.expressions.length === 0 &&
165
+ node.quasis.length === 1
166
+ ) {
167
+ return true;
168
+ }
169
+ return false;
170
+ }
171
+
172
+ function getStringLiteralValue(node) {
173
+ if (node.type === 'Literal') {
174
+ return node.value;
175
+ }
176
+ return node.quasis[0].value.cooked;
177
+ }
178
+
179
+ module.exports = { createNameMatchRule };
@@ -0,0 +1,8 @@
1
+ const { createNameMatchRule } = require('./craft-name-match-utils.cjs');
2
+
3
+ module.exports = createNameMatchRule({
4
+ calleeName: 'signalSource',
5
+ description:
6
+ "Ensure signalSource(name, ...) is called with a string literal first argument that matches the declared variable, class property, or object property name.",
7
+ supportsObjectConfigForm: false,
8
+ });
@@ -0,0 +1,8 @@
1
+ const { createNameMatchRule } = require('./craft-name-match-utils.cjs');
2
+
3
+ module.exports = createNameMatchRule({
4
+ calleeName: 'source$',
5
+ description:
6
+ "Ensure source$(name) is called with a string literal first argument that matches the declared variable, class property, or object property name.",
7
+ supportsObjectConfigForm: false,
8
+ });
@@ -30,8 +30,12 @@ module.exports = {
30
30
  }
31
31
 
32
32
  const text = sourceCode.getText();
33
- // Cheap pre-filter: the delegated outcome must appear.
34
- if (!text.includes('globalError')) {
33
+ // Cheap pre-filter: either a delegated outcome (`globalError()`) that must
34
+ // be registered, or an existing registry that may now hold orphaned entries.
35
+ if (
36
+ !text.includes('globalError') &&
37
+ !text.includes('CraftGlobalExceptionRegistry')
38
+ ) {
35
39
  return;
36
40
  }
37
41
 
@@ -41,7 +45,11 @@ module.exports = {
41
45
  text,
42
46
  );
43
47
  const requiredRegistrations = collectRequiredRegistrations(sourceFile);
44
- if (requiredRegistrations.length === 0) {
48
+ // Nothing to register AND no registry to prune → nothing to do.
49
+ if (
50
+ requiredRegistrations.length === 0 &&
51
+ !getGlobalRegistryInterface(sourceFile)
52
+ ) {
45
53
  return;
46
54
  }
47
55
 
@@ -51,19 +59,25 @@ module.exports = {
51
59
  );
52
60
  if (
53
61
  registryState.missing.length === 0 &&
54
- registryState.outOfDate.length === 0
62
+ registryState.outOfDate.length === 0 &&
63
+ registryState.orphaned.length === 0
55
64
  ) {
56
65
  return;
57
66
  }
58
67
 
68
+ // Capture the report location BEFORE mutating: purging an orphaned entry
69
+ // removes its node, so `reportNode.getStart()` would throw afterwards.
70
+ const reportLoc = getNodeLoc(sourceCode, registryState.reportNode);
71
+
59
72
  ensureRegistryEntries(sourceFile, requiredRegistrations);
73
+ removeOrphanedRegistryEntries(sourceFile, requiredRegistrations);
60
74
  const fixedText = sourceFile.getFullText();
61
75
  if (fixedText === text) {
62
76
  return;
63
77
  }
64
78
 
65
79
  context.report({
66
- loc: getNodeLoc(sourceCode, registryState.reportNode),
80
+ loc: reportLoc,
67
81
  message: formatRegistryMessage(registryState),
68
82
  fix(fixer) {
69
83
  return fixer.replaceTextRange([0, text.length], fixedText);
@@ -369,8 +383,9 @@ function analyzeRegistryState(sourceFile, requiredRegistrations) {
369
383
 
370
384
  const missing = [];
371
385
  const outOfDate = [];
386
+ const requiredByPath = groupByPath(requiredRegistrations);
372
387
 
373
- for (const [routePath, entries] of groupByPath(requiredRegistrations)) {
388
+ for (const [routePath, entries] of requiredByPath) {
374
389
  const property = propertiesByName.get(routePath);
375
390
  if (!property) {
376
391
  missing.push(routePath);
@@ -383,11 +398,47 @@ function analyzeRegistryState(sourceFile, requiredRegistrations) {
383
398
  }
384
399
  }
385
400
 
386
- return {
387
- missing,
388
- outOfDate,
389
- reportNode: requiredRegistrations[0].reportNode,
390
- };
401
+ // Orphaned: a registry entry whose route path no longer delegates any code to
402
+ // `globalError()` (the guard/handler was refactored, or the route removed).
403
+ // Such an entry resolves `CraftRouteExceptionType<…>` to `never`, silently
404
+ // collapsing `CraftGlobalHandledException` for every consumer.
405
+ const orphaned = [];
406
+ for (const routePath of propertiesByName.keys()) {
407
+ if (!requiredByPath.has(routePath)) {
408
+ orphaned.push(routePath);
409
+ }
410
+ }
411
+
412
+ // With no globalError() route left, the report anchors on the first orphaned
413
+ // registry property (falling back to the interface itself).
414
+ const reportNode =
415
+ requiredRegistrations[0]?.reportNode ??
416
+ (orphaned.length > 0
417
+ ? propertiesByName.get(orphaned[0])
418
+ : registryInterface);
419
+
420
+ return { missing, outOfDate, orphaned, reportNode };
421
+ }
422
+
423
+ function removeOrphanedRegistryEntries(sourceFile, requiredRegistrations) {
424
+ const registryInterface = getGlobalRegistryInterface(sourceFile);
425
+ if (!registryInterface) {
426
+ return;
427
+ }
428
+
429
+ const requiredPaths = new Set(
430
+ requiredRegistrations.map((registration) => registration.path),
431
+ );
432
+ for (const property of registryInterface.getProperties()) {
433
+ if (!requiredPaths.has(readInterfacePropertyName(property))) {
434
+ property.remove();
435
+ }
436
+ }
437
+
438
+ // Deliberately keep an emptied `interface CraftGlobalExceptionRegistry {}`
439
+ // rather than deleting the interface / `declare module` block: the block may
440
+ // carry sibling augmentations (e.g. CraftRouterRoutesRegistry) or leading
441
+ // documentation, and an empty registry is valid + stable (no re-report).
391
442
  }
392
443
 
393
444
  // A file may have several `declare module '@craft-ng/core'` blocks (e.g. one for
@@ -468,7 +519,7 @@ function getNodeLoc(sourceCode, node) {
468
519
  };
469
520
  }
470
521
 
471
- function formatRegistryMessage({ missing, outOfDate }) {
522
+ function formatRegistryMessage({ missing, outOfDate, orphaned }) {
472
523
  const segments = [];
473
524
  if (missing.length > 0) {
474
525
  segments.push(`missing ${formatNameList(missing)}`);
@@ -476,6 +527,9 @@ function formatRegistryMessage({ missing, outOfDate }) {
476
527
  if (outOfDate.length > 0) {
477
528
  segments.push(`out of date for ${formatNameList(outOfDate)}`);
478
529
  }
530
+ if (orphaned.length > 0) {
531
+ segments.push(`orphaned for ${formatNameList(orphaned)}`);
532
+ }
479
533
  return `CraftGlobalExceptionRegistry is ${segments.join(' and ')}. Run ESLint --fix on this file to register globalError() route exceptions.`;
480
534
  }
481
535
 
@@ -492,6 +546,17 @@ function formatNameList(names) {
492
546
  .join(', ')}, and '${names[names.length - 1]}'`;
493
547
  }
494
548
 
549
+ // Compares two type strings for equality up to Prettier's formatting freedom.
550
+ // The rule emits the type on one line, but Prettier re-wraps long entries across
551
+ // lines — which introduces spaces around `<`/`>`/`,` and a trailing `;` before the
552
+ // closing brace. Collapsing whitespace alone would leave those, so the comparison
553
+ // would flip to "out of date" and fight Prettier forever. We additionally strip
554
+ // spaces adjacent to punctuation (keeping token boundaries like `typeof demoRoutes`
555
+ // intact) and drop a member-terminating `;` right before a `}`.
495
556
  function normalizeText(text) {
496
- return text.replace(/\s+/g, ' ').trim();
557
+ return text
558
+ .replace(/\s+/g, ' ')
559
+ .replace(/\s*([<>(),;])\s*/g, '$1')
560
+ .replace(/;(?=}|$)/g, '')
561
+ .trim();
497
562
  }
@@ -5,6 +5,8 @@ const globalExceptionRegistryMatch = require('./global-exception-registry-match.
5
5
  const componentTestGenDepsMatch = require('./component-test-gen-deps-match.cjs');
6
6
  const craftMethodNameMatch = require('./craft-method-name-match.cjs');
7
7
  const craftComputedNameMatch = require('./craft-computed-name-match.cjs');
8
+ const craftSourceNameMatch = require('./craft-source-name-match.cjs');
9
+ const craftSignalSourceNameMatch = require('./craft-signal-source-name-match.cjs');
8
10
  const preferCraftComputed = require('./prefer-craft-computed.cjs');
9
11
  const preferCraftState = require('./prefer-craft-state.cjs');
10
12
  const preferCraftEffect = require('./prefer-craft-effect.cjs');
@@ -15,7 +17,7 @@ const preferCraftHttpClient = require('./prefer-craft-http-client.cjs');
15
17
  const preferCraftService = require('./prefer-craft-service.cjs');
16
18
  const preferBrowserBoundaries = require('./prefer-browser-boundaries.cjs');
17
19
  const requireComponentMonitoring = require('./require-component-monitoring.cjs');
18
- const requireTrackOnDependentPrimitives = require('./require-track-on-dependent-primitives.cjs');
20
+ const requirePrimitiveGeneratorUnwrap = require('./require-primitive-generator-unwrap.cjs');
19
21
  const requireAssertExhaustiveRouteExceptions = require('./require-assert-exhaustive-route-exceptions.cjs');
20
22
  const preferCraftRouterOutlet = require('./prefer-craft-router-outlet.cjs');
21
23
  const requirePendingComponentDiCheck = require('./require-pending-component-di-check.cjs');
@@ -23,6 +25,7 @@ const requireCraftExceptionHandler = require('./require-craft-exception-handler.
23
25
  const requireExceptionComponentDiCheck = require('./require-exception-component-di-check.cjs');
24
26
  const requireChildRouteMountCheck = require('./require-child-route-mount-check.cjs');
25
27
  const requireLazyLoadWithRetry = require('./require-lazy-load-with-retry.cjs');
28
+ const requireCascadeRouteDiCheck = require('./require-cascade-route-di-check.cjs');
26
29
 
27
30
  module.exports = {
28
31
  rules: {
@@ -33,6 +36,8 @@ module.exports = {
33
36
  'component-test-gen-deps-match': componentTestGenDepsMatch,
34
37
  'craft-method-name-match': craftMethodNameMatch,
35
38
  'craft-computed-name-match': craftComputedNameMatch,
39
+ 'craft-source-name-match': craftSourceNameMatch,
40
+ 'craft-signal-source-name-match': craftSignalSourceNameMatch,
36
41
  'prefer-craft-computed': preferCraftComputed,
37
42
  'prefer-craft-state': preferCraftState,
38
43
  'prefer-craft-effect': preferCraftEffect,
@@ -43,7 +48,7 @@ module.exports = {
43
48
  'prefer-craft-service': preferCraftService,
44
49
  'prefer-browser-boundaries': preferBrowserBoundaries,
45
50
  'require-component-monitoring': requireComponentMonitoring,
46
- 'require-track-on-dependent-primitives': requireTrackOnDependentPrimitives,
51
+ 'require-primitive-generator-unwrap': requirePrimitiveGeneratorUnwrap,
47
52
  'require-assert-exhaustive-route-exceptions':
48
53
  requireAssertExhaustiveRouteExceptions,
49
54
  'prefer-craft-router-outlet': preferCraftRouterOutlet,
@@ -52,5 +57,6 @@ module.exports = {
52
57
  'require-exception-component-di-check': requireExceptionComponentDiCheck,
53
58
  'require-child-route-mount-check': requireChildRouteMountCheck,
54
59
  'require-lazy-load-with-retry': requireLazyLoadWithRetry,
60
+ 'require-cascade-route-di-check': requireCascadeRouteDiCheck,
55
61
  },
56
62
  };
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ IndentationText,
5
+ Node,
6
+ Project,
7
+ QuoteKind,
8
+ SyntaxKind,
9
+ } = require('ts-morph');
10
+
11
+ const CRAFT_MODULE = '@craft-ng/core';
12
+
13
+ module.exports = {
14
+ meta: {
15
+ type: 'problem',
16
+ docs: {
17
+ description:
18
+ 'Require every craftRoutes collection to have a ValidateCascadeRoutesFile + CanRun DI check in the same file.',
19
+ },
20
+ fixable: 'code',
21
+ schema: [],
22
+ },
23
+ create(context) {
24
+ return {
25
+ 'Program:exit'() {
26
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
27
+ const text = sourceCode.getText();
28
+ if (!text.includes('craftRoutes(')) return;
29
+
30
+ const project = new Project({
31
+ useInMemoryFileSystem: true,
32
+ manipulationSettings: {
33
+ indentationText: IndentationText.TwoSpaces,
34
+ quoteKind: QuoteKind.Single,
35
+ },
36
+ });
37
+ const sourceFile = project.createSourceFile('/route-file.ts', text);
38
+ const collections = collectCollections(sourceFile);
39
+ if (collections.length === 0) return;
40
+
41
+ const cascadeChecks = collectCascadeChecks(sourceFile);
42
+ const canRunChecks = collectCanRunChecks(sourceFile);
43
+ const missing = collections.filter(({ routesName }) => {
44
+ const aliases = cascadeChecks.get(routesName) ?? [];
45
+ return (
46
+ aliases.length === 0 ||
47
+ !aliases.some((name) => canRunChecks.has(name))
48
+ );
49
+ });
50
+ if (missing.length === 0) return;
51
+ const reportLoc = nodeLocation(
52
+ sourceCode,
53
+ missing[0].call.getStart(),
54
+ missing[0].call.getWidth(),
55
+ );
56
+
57
+ for (const collection of missing) {
58
+ const suffix = toPascalCase(
59
+ collection.collectionName ||
60
+ collection.routesName.replace(/Routes$/, ''),
61
+ );
62
+ const existingChecks = cascadeChecks.get(collection.routesName) ?? [];
63
+ const checkName =
64
+ existingChecks[0] ??
65
+ uniqueTypeName(sourceFile, `_Check${suffix}DI`);
66
+ const canRunName = uniqueTypeName(sourceFile, `_CanRun${suffix}`);
67
+ if (existingChecks.length === 0) {
68
+ sourceFile.addTypeAlias({
69
+ name: checkName,
70
+ type: `ValidateCascadeRoutesFile<never, Router, typeof ${collection.routesName}>`,
71
+ });
72
+ }
73
+ sourceFile.addTypeAlias({
74
+ name: canRunName,
75
+ type: `CanRun<${checkName}>`,
76
+ });
77
+ }
78
+ if (
79
+ missing.some(
80
+ (collection) => !cascadeChecks.get(collection.routesName)?.length,
81
+ )
82
+ ) {
83
+ ensureNamedImport(
84
+ sourceFile,
85
+ 'ValidateCascadeRoutesFile',
86
+ CRAFT_MODULE,
87
+ true,
88
+ );
89
+ ensureNamedImport(sourceFile, 'Router', '@angular/router', true);
90
+ }
91
+ ensureNamedImport(sourceFile, 'CanRun', CRAFT_MODULE, true);
92
+ sourceFile.formatText();
93
+
94
+ const fixedText = sourceFile.getFullText();
95
+ context.report({
96
+ loc: reportLoc,
97
+ message: `craftRoutes collection(s) missing a same-file ValidateCascadeRoutesFile + CanRun check: ${missing
98
+ .map((item) => item.routesName)
99
+ .join(', ')}`,
100
+ fix(fixer) {
101
+ return fixer.replaceTextRange([0, text.length], fixedText);
102
+ },
103
+ });
104
+ },
105
+ };
106
+ },
107
+ };
108
+
109
+ function collectCollections(sourceFile) {
110
+ const collections = [];
111
+ for (const call of sourceFile.getDescendantsOfKind(
112
+ SyntaxKind.CallExpression,
113
+ )) {
114
+ if (call.getExpression().getText() !== 'craftRoutes') continue;
115
+ const declaration = call.getFirstAncestorByKind(
116
+ SyntaxKind.VariableDeclaration,
117
+ );
118
+ const binding = declaration?.getNameNode();
119
+ if (!binding || !Node.isObjectBindingPattern(binding)) continue;
120
+ const firstArg = call.getArguments()[0];
121
+ const collectionName = Node.isStringLiteral(firstArg)
122
+ ? firstArg.getLiteralValue()
123
+ : '';
124
+ const expected = collectionName
125
+ ? `${uncapitalize(toPascalCase(collectionName))}Routes`
126
+ : '';
127
+ const routeElements = binding
128
+ .getElements()
129
+ .filter((element) =>
130
+ (
131
+ element.getPropertyNameNode()?.getText() ?? element.getName()
132
+ ).endsWith('Routes'),
133
+ );
134
+ const element =
135
+ routeElements.find(
136
+ (candidate) =>
137
+ (candidate.getPropertyNameNode()?.getText() ??
138
+ candidate.getName()) === expected,
139
+ ) ?? (routeElements.length === 1 ? routeElements[0] : undefined);
140
+ if (!element) continue;
141
+ collections.push({ collectionName, routesName: element.getName(), call });
142
+ }
143
+ return collections;
144
+ }
145
+
146
+ function collectCascadeChecks(sourceFile) {
147
+ const result = new Map();
148
+ for (const alias of sourceFile.getTypeAliases()) {
149
+ const typeNode = alias.getTypeNode();
150
+ if (!typeNode || !Node.isTypeReference(typeNode)) continue;
151
+ if (typeNode.getTypeName().getText() !== 'ValidateCascadeRoutesFile')
152
+ continue;
153
+ const routeType = typeNode.getTypeArguments()[2]?.getText() ?? '';
154
+ const match = /^typeof\s+([A-Za-z_$][\w$]*)$/.exec(routeType.trim());
155
+ if (!match) continue;
156
+ const aliases = result.get(match[1]) ?? [];
157
+ aliases.push(alias.getName());
158
+ result.set(match[1], aliases);
159
+ }
160
+ return result;
161
+ }
162
+
163
+ function collectCanRunChecks(sourceFile) {
164
+ const result = new Set();
165
+ for (const alias of sourceFile.getTypeAliases()) {
166
+ const typeNode = alias.getTypeNode();
167
+ if (!typeNode || !Node.isTypeReference(typeNode)) continue;
168
+ if (typeNode.getTypeName().getText() !== 'CanRun') continue;
169
+ const checked = typeNode.getTypeArguments()[0]?.getText();
170
+ if (checked) result.add(checked);
171
+ }
172
+ return result;
173
+ }
174
+
175
+ function ensureNamedImport(sourceFile, name, moduleSpecifier, typeOnly) {
176
+ const existing = sourceFile
177
+ .getImportDeclarations()
178
+ .find(
179
+ (declaration) =>
180
+ declaration.getModuleSpecifierValue() === moduleSpecifier,
181
+ );
182
+ if (existing) {
183
+ if (!existing.getNamedImports().some((item) => item.getName() === name)) {
184
+ existing.addNamedImport({
185
+ name,
186
+ isTypeOnly: typeOnly && !existing.isTypeOnly(),
187
+ });
188
+ }
189
+ return;
190
+ }
191
+ sourceFile.addImportDeclaration({
192
+ moduleSpecifier,
193
+ isTypeOnly: typeOnly,
194
+ namedImports: [name],
195
+ });
196
+ }
197
+
198
+ function uniqueTypeName(sourceFile, base) {
199
+ let name = base;
200
+ let index = 2;
201
+ while (sourceFile.getTypeAlias(name)) name = `${base}${index++}`;
202
+ return name;
203
+ }
204
+
205
+ function nodeLocation(sourceCode, start, width) {
206
+ const from = sourceCode.getLocFromIndex(start);
207
+ const to = sourceCode.getLocFromIndex(start + width);
208
+ return { start: from, end: to };
209
+ }
210
+
211
+ function toPascalCase(value) {
212
+ return (
213
+ value
214
+ .split(/[^A-Za-z0-9]+/)
215
+ .filter(Boolean)
216
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
217
+ .join('') || 'Routes'
218
+ );
219
+ }
220
+
221
+ function uncapitalize(value) {
222
+ return value.charAt(0).toLowerCase() + value.slice(1);
223
+ }