@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.
- package/README.md +66 -0
- package/generators.json +26 -0
- package/package.json +18 -1
- package/src/bin/craft.d.ts +2 -0
- package/src/bin/craft.js +166 -0
- package/src/bin/craft.js.map +1 -0
- package/src/eslint-rules/craft-computed-name-match.cjs +8 -125
- package/src/eslint-rules/craft-method-name-match.cjs +8 -166
- package/src/eslint-rules/craft-name-match-utils.cjs +179 -0
- package/src/eslint-rules/craft-signal-source-name-match.cjs +8 -0
- package/src/eslint-rules/craft-source-name-match.cjs +8 -0
- package/src/eslint-rules/global-exception-registry-match.cjs +78 -13
- package/src/eslint-rules/index.cjs +8 -2
- package/src/eslint-rules/require-cascade-route-di-check.cjs +223 -0
- package/src/eslint-rules/require-primitive-generator-unwrap.cjs +267 -0
- package/src/generators/route/compat.d.ts +3 -0
- package/src/generators/route/compat.js +6 -0
- package/src/generators/route/compat.js.map +1 -0
- package/src/generators/route/generator.d.ts +31 -0
- package/src/generators/route/generator.js +434 -0
- package/src/generators/route/generator.js.map +1 -0
- package/src/generators/route/schema.json +50 -0
- package/src/generators/route/split-schema.json +19 -0
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
- package/src/index.js.map +1 -1
- package/src/scripts/primitives/migrate-primitive-generators.d.ts +27 -0
- package/src/scripts/primitives/migrate-primitive-generators.js +315 -0
- package/src/scripts/primitives/migrate-primitive-generators.js.map +1 -0
- package/src/scripts/primitives/migrate-primitive-generators.ts +402 -0
- package/src/scripts/primitives/migrate-primitives.js +36 -4
- package/src/scripts/primitives/migrate-primitives.js.map +1 -1
- package/src/scripts/primitives/migrate-primitives.spec.ts +43 -0
- package/src/scripts/primitives/migrate-primitives.ts +62 -4
- package/src/scripts/primitives/migration-diagnostic.d.ts +1 -1
- package/src/scripts/primitives/migration-diagnostic.ts +1 -0
- package/src/scripts/routes/migrate-routes.js +4 -4
- package/src/scripts/routes/migrate-routes.js.map +1 -1
- package/src/scripts/routes/migrate-routes.ts +4 -6
- package/src/scripts/routes/route-command.d.ts +75 -0
- package/src/scripts/routes/route-command.js +1187 -0
- package/src/scripts/routes/route-command.js.map +1 -0
- package/src/scripts/routes/route-command.spec.ts +553 -0
- package/src/scripts/routes/route-command.ts +1790 -0
- package/src/scripts/services/migrate-services.js +45 -11
- package/src/scripts/services/migrate-services.js.map +1 -1
- package/src/scripts/services/migrate-services.spec.ts +46 -4
- package/src/scripts/services/migrate-services.ts +64 -13
- package/src/scripts/services/migration-diagnostic.d.ts +1 -1
- package/src/scripts/services/migration-diagnostic.ts +1 -0
- 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:
|
|
34
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
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
|
|
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-
|
|
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
|
+
}
|