@craft-ng/dev-tools 0.5.0-beta.2 → 0.5.0-beta.4

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.
@@ -0,0 +1,259 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { IndentationText, Project, QuoteKind, SyntaxKind } = require('ts-morph');
4
+
5
+ const projectCache = new Map();
6
+
7
+ const ROUTES_FACTORY = 'craftRoutes';
8
+ const ASSERT_FN = 'assertExhaustiveRouteExceptions';
9
+
10
+ module.exports = {
11
+ meta: {
12
+ type: 'problem',
13
+ docs: {
14
+ description:
15
+ "Ensure every craftRoutes(...) collection is checked with assertExhaustiveRouteExceptions(...) so a route's handleExceptions stays exhaustive over its reachable codes.",
16
+ },
17
+ fixable: 'code',
18
+ schema: [],
19
+ messages: {
20
+ missingAssert:
21
+ "craftRoutes collection '{{routesName}}' must be checked with assertExhaustiveRouteExceptions({{routesName}}).",
22
+ },
23
+ },
24
+ create(context) {
25
+ return {
26
+ 'Program:exit'() {
27
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
28
+ const filePath = getFilePath(context);
29
+ if (!filePath || !filePath.endsWith('.ts')) {
30
+ return;
31
+ }
32
+
33
+ const text = sourceCode.getText();
34
+ if (!text.includes(ROUTES_FACTORY)) {
35
+ return;
36
+ }
37
+
38
+ const sourceFile = getProjectSourceFile(
39
+ getProject(getCwd(context)),
40
+ filePath,
41
+ text,
42
+ );
43
+
44
+ const collections = collectRouteCollections(sourceFile);
45
+ if (collections.length === 0) {
46
+ return;
47
+ }
48
+
49
+ const asserted = collectAssertedNames(sourceFile);
50
+ const issues = collections.filter(
51
+ (collection) => !asserted.has(collection.routesName),
52
+ );
53
+ if (issues.length === 0) {
54
+ return;
55
+ }
56
+
57
+ const reportLoc = getNodeLoc(sourceCode, issues[0].callExpression);
58
+
59
+ // Insert bottom-up so earlier statement indices stay valid.
60
+ for (const issue of [...issues].sort(
61
+ (a, b) => b.statementIndex - a.statementIndex,
62
+ )) {
63
+ sourceFile.insertStatements(
64
+ issue.statementIndex + 1,
65
+ `${ASSERT_FN}(${issue.routesName});`,
66
+ );
67
+ }
68
+ ensureAssertImport(sourceFile);
69
+
70
+ const fixedText = sourceFile.getFullText();
71
+ if (fixedText === text) {
72
+ return;
73
+ }
74
+
75
+ const routesNames = issues.map((i) => i.routesName).join(', ');
76
+
77
+ context.report({
78
+ loc: reportLoc,
79
+ message: `craftRoutes collection(s) missing ${ASSERT_FN}(): ${routesNames}`,
80
+ fix(fixer) {
81
+ return fixer.replaceTextRange([0, text.length], fixedText);
82
+ },
83
+ });
84
+ },
85
+ };
86
+ },
87
+ };
88
+
89
+ function collectRouteCollections(sourceFile) {
90
+ const collections = [];
91
+
92
+ for (const call of sourceFile.getDescendantsOfKind(
93
+ SyntaxKind.CallExpression,
94
+ )) {
95
+ if (call.getExpression().getText() !== ROUTES_FACTORY) {
96
+ continue;
97
+ }
98
+
99
+ const routesName = getRoutesBindingName(call);
100
+ if (!routesName) {
101
+ continue;
102
+ }
103
+
104
+ const statement = call.getFirstAncestorByKind(
105
+ SyntaxKind.VariableStatement,
106
+ );
107
+ if (!statement || statement.getParent() !== sourceFile) {
108
+ // Only top-level `const { xRoutes } = craftRoutes(...)` declarations can
109
+ // be safely followed by a sibling assert statement.
110
+ continue;
111
+ }
112
+
113
+ collections.push({
114
+ routesName,
115
+ callExpression: call,
116
+ statementIndex: statement.getChildIndex(),
117
+ });
118
+ }
119
+
120
+ return collections;
121
+ }
122
+
123
+ // The routes object is destructured as `{ <name>Routes, inject... }`. Resolve
124
+ // the local name bound to the `<name>Routes` property (honouring renames).
125
+ function getRoutesBindingName(call) {
126
+ const declaration = call.getFirstAncestorByKind(
127
+ SyntaxKind.VariableDeclaration,
128
+ );
129
+ if (!declaration) {
130
+ return undefined;
131
+ }
132
+
133
+ const nameNode = declaration.getNameNode();
134
+ if (!nameNode || nameNode.getKind() !== SyntaxKind.ObjectBindingPattern) {
135
+ return undefined;
136
+ }
137
+
138
+ const elements = nameNode.getElements();
139
+ const firstArg = call.getArguments()[0];
140
+ const expectedProperty =
141
+ firstArg && firstArg.getKind() === SyntaxKind.StringLiteral
142
+ ? `${firstArg.getLiteralValue()}Routes`
143
+ : undefined;
144
+
145
+ if (expectedProperty) {
146
+ const match = elements.find(
147
+ (element) =>
148
+ (element.getPropertyNameNode()?.getText() ?? element.getName()) ===
149
+ expectedProperty,
150
+ );
151
+ if (match) {
152
+ return match.getName();
153
+ }
154
+ }
155
+
156
+ // Fallback: the single binding whose property name ends with `Routes`.
157
+ const routesElements = elements.filter((element) =>
158
+ (element.getPropertyNameNode()?.getText() ?? element.getName()).endsWith(
159
+ 'Routes',
160
+ ),
161
+ );
162
+ return routesElements.length === 1 ? routesElements[0].getName() : undefined;
163
+ }
164
+
165
+ function collectAssertedNames(sourceFile) {
166
+ const asserted = new Set();
167
+
168
+ for (const call of sourceFile.getDescendantsOfKind(
169
+ SyntaxKind.CallExpression,
170
+ )) {
171
+ if (call.getExpression().getText() !== ASSERT_FN) {
172
+ continue;
173
+ }
174
+ const arg = call.getArguments()[0];
175
+ if (arg) {
176
+ asserted.add(arg.getText());
177
+ }
178
+ }
179
+
180
+ return asserted;
181
+ }
182
+
183
+ function ensureAssertImport(sourceFile) {
184
+ const craftNgCoreImport = sourceFile
185
+ .getImportDeclarations()
186
+ .find((imp) => imp.getModuleSpecifierValue() === '@craft-ng/core');
187
+
188
+ if (craftNgCoreImport) {
189
+ const alreadyImported = craftNgCoreImport
190
+ .getNamedImports()
191
+ .some((ni) => ni.getName() === ASSERT_FN);
192
+ if (!alreadyImported) {
193
+ craftNgCoreImport.addNamedImport(ASSERT_FN);
194
+ }
195
+ } else {
196
+ sourceFile.addImportDeclaration({
197
+ moduleSpecifier: '@craft-ng/core',
198
+ namedImports: [ASSERT_FN],
199
+ });
200
+ }
201
+ }
202
+
203
+ function getProject(cwd) {
204
+ let project = projectCache.get(cwd);
205
+ if (project) {
206
+ return project;
207
+ }
208
+
209
+ const manipulationSettings = {
210
+ indentationText: IndentationText.TwoSpaces,
211
+ quoteKind: QuoteKind.Single,
212
+ };
213
+ const tsConfigFilePath = path.join(cwd, 'tsconfig.json');
214
+ project = fs.existsSync(tsConfigFilePath)
215
+ ? new Project({ tsConfigFilePath, manipulationSettings })
216
+ : new Project({
217
+ compilerOptions: { experimentalDecorators: true, target: 9 },
218
+ manipulationSettings,
219
+ });
220
+
221
+ projectCache.set(cwd, project);
222
+ return project;
223
+ }
224
+
225
+ function getProjectSourceFile(project, filePath, text) {
226
+ const normalizedPath = path.resolve(filePath);
227
+ const existingSourceFile = project.getSourceFile(normalizedPath);
228
+ if (existingSourceFile) {
229
+ existingSourceFile.replaceWithText(text);
230
+ return existingSourceFile;
231
+ }
232
+
233
+ const sourceFile = project.addSourceFileAtPathIfExists(normalizedPath);
234
+ if (sourceFile) {
235
+ sourceFile.replaceWithText(text);
236
+ return sourceFile;
237
+ }
238
+
239
+ return project.createSourceFile(normalizedPath, text, { overwrite: true });
240
+ }
241
+
242
+ function getFilePath(context) {
243
+ const filePath = context.filename ?? context.getFilename();
244
+ if (!filePath || filePath === '<input>') {
245
+ return undefined;
246
+ }
247
+ return filePath;
248
+ }
249
+
250
+ function getCwd(context) {
251
+ return context.cwd ?? process.cwd();
252
+ }
253
+
254
+ function getNodeLoc(sourceCode, node) {
255
+ return {
256
+ start: sourceCode.getLocFromIndex(node.getStart()),
257
+ end: sourceCode.getLocFromIndex(node.getEnd()),
258
+ };
259
+ }
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+
3
+ // Parent-side companion to `.withParent<ParentRoutes<'path'>>()`.
4
+ //
5
+ // A `craftRoutes(...)` collection that mounts lazy children via
6
+ // `loadChildren: () => import('./x').then((m) => m.xRoutes)` must be checked once
7
+ // with `assertChildRouteMounts(xRoutes)`, so a `.withParent`-pinned child mounted
8
+ // under the wrong route path becomes a compile error. This rule reports the
9
+ // missing assert and, on `--fix`, inserts the call plus its import.
10
+ //
11
+ // Everything it needs lives in the same file (the collection binding), so the fix
12
+ // is fully derivable and applied with surgical native-AST text insertions.
13
+
14
+ const ROUTES_FACTORY = 'craftRoutes';
15
+ const ASSERT_FN = 'assertChildRouteMounts';
16
+ const CRAFT_MODULE = '@craft-ng/core';
17
+
18
+ module.exports = {
19
+ meta: {
20
+ type: 'problem',
21
+ docs: {
22
+ description:
23
+ 'Ensure every craftRoutes collection mounting lazy loadChildren is checked with assertChildRouteMounts(...).',
24
+ },
25
+ fixable: 'code',
26
+ schema: [],
27
+ },
28
+ create(context) {
29
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
30
+
31
+ const routesCalls = [];
32
+ const assertedArgs = new Set();
33
+ let craftImport = null;
34
+
35
+ return {
36
+ ImportDeclaration(node) {
37
+ if (
38
+ node.source.value === CRAFT_MODULE &&
39
+ node.importKind !== 'type' &&
40
+ !craftImport
41
+ ) {
42
+ craftImport = node;
43
+ }
44
+ },
45
+ CallExpression(node) {
46
+ if (node.callee.type !== 'Identifier') {
47
+ return;
48
+ }
49
+ if (node.callee.name === ROUTES_FACTORY) {
50
+ routesCalls.push(node);
51
+ } else if (node.callee.name === ASSERT_FN) {
52
+ const arg = node.arguments[0];
53
+ if (arg && arg.type === 'Identifier') {
54
+ assertedArgs.add(arg.name);
55
+ }
56
+ }
57
+ },
58
+ 'Program:exit'() {
59
+ const fixes = [];
60
+ const missing = [];
61
+
62
+ for (const call of routesCalls) {
63
+ const collectionName = stringArg(call, 0);
64
+ const routesArray = call.arguments[1];
65
+ if (
66
+ collectionName === undefined ||
67
+ !routesArray ||
68
+ routesArray.type !== 'ArrayExpression'
69
+ ) {
70
+ continue;
71
+ }
72
+
73
+ if (!routesArray.elements.some(hasLazyLoadChildren)) {
74
+ continue;
75
+ }
76
+
77
+ const binding = resolveBindingName(call, collectionName);
78
+ if (!binding || assertedArgs.has(binding)) {
79
+ continue;
80
+ }
81
+
82
+ const statement = enclosingStatement(call);
83
+ if (!statement) {
84
+ continue;
85
+ }
86
+
87
+ fixes.push((fixer) =>
88
+ fixer.insertTextAfter(statement, `\n\n${ASSERT_FN}(${binding});`),
89
+ );
90
+ missing.push({ node: call, binding });
91
+ }
92
+
93
+ if (missing.length === 0) {
94
+ return;
95
+ }
96
+
97
+ if (!isAssertImported(craftImport)) {
98
+ fixes.push((fixer) => addAssertImport(fixer, sourceCode, craftImport));
99
+ }
100
+
101
+ context.report({
102
+ loc: missing[0].node.loc,
103
+ message: `craftRoutes collection(s) mounting lazy loadChildren must be checked with ${ASSERT_FN}(): ${missing
104
+ .map((m) => m.binding)
105
+ .join(', ')}`,
106
+ fix(fixer) {
107
+ return fixes.map((makeFix) => makeFix(fixer)).filter(Boolean);
108
+ },
109
+ });
110
+ },
111
+ };
112
+ },
113
+ };
114
+
115
+ // `{ …, loadChildren: () => import('./x').then((m) => m.xRoutes) }`
116
+ function hasLazyLoadChildren(element) {
117
+ if (!element || element.type !== 'ObjectExpression') {
118
+ return false;
119
+ }
120
+ const loadChildren = findProperty(element, 'loadChildren');
121
+ return Boolean(loadChildren) && loadChildren.value.type === 'ArrowFunctionExpression';
122
+ }
123
+
124
+ function findProperty(objectExpression, name) {
125
+ return objectExpression.properties.find(
126
+ (property) =>
127
+ property.type === 'Property' &&
128
+ !property.computed &&
129
+ ((property.key.type === 'Identifier' && property.key.name === name) ||
130
+ (property.key.type === 'Literal' && property.key.value === name)),
131
+ );
132
+ }
133
+
134
+ function stringArg(call, index) {
135
+ const arg = call.arguments[index];
136
+ return arg && arg.type === 'Literal' && typeof arg.value === 'string'
137
+ ? arg.value
138
+ : undefined;
139
+ }
140
+
141
+ // The collection is destructured as `const { <name>Routes, inject... } = craftRoutes(...)`.
142
+ function resolveBindingName(call, collectionName) {
143
+ const declarator = enclosingDeclarator(call);
144
+ if (!declarator || declarator.id.type !== 'ObjectPattern') {
145
+ return undefined;
146
+ }
147
+ const properties = declarator.id.properties.filter(
148
+ (property) => property.type === 'Property' && property.key.type === 'Identifier',
149
+ );
150
+
151
+ const expected = `${collectionName}Routes`;
152
+ const exact = properties.find((property) => property.key.name === expected);
153
+ if (exact && exact.value.type === 'Identifier') {
154
+ return exact.value.name;
155
+ }
156
+
157
+ const endingInRoutes = properties.filter((property) =>
158
+ property.key.name.endsWith('Routes'),
159
+ );
160
+ return endingInRoutes.length === 1 && endingInRoutes[0].value.type === 'Identifier'
161
+ ? endingInRoutes[0].value.name
162
+ : undefined;
163
+ }
164
+
165
+ function enclosingDeclarator(node) {
166
+ let current = node.parent;
167
+ while (current) {
168
+ if (current.type === 'VariableDeclarator') {
169
+ return current;
170
+ }
171
+ if (current.type === 'Program' || current.type === 'BlockStatement') {
172
+ return undefined;
173
+ }
174
+ current = current.parent;
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ // The top-level statement to append the assert after.
180
+ function enclosingStatement(node) {
181
+ let current = node.parent;
182
+ while (current) {
183
+ if (
184
+ current.type === 'VariableDeclaration' &&
185
+ current.parent &&
186
+ (current.parent.type === 'Program' ||
187
+ current.parent.type === 'ExportNamedDeclaration')
188
+ ) {
189
+ return current.parent.type === 'ExportNamedDeclaration'
190
+ ? current.parent
191
+ : current;
192
+ }
193
+ current = current.parent;
194
+ }
195
+ return undefined;
196
+ }
197
+
198
+ function isAssertImported(craftImport) {
199
+ if (!craftImport) {
200
+ return false;
201
+ }
202
+ return craftImport.specifiers.some(
203
+ (specifier) =>
204
+ specifier.type === 'ImportSpecifier' &&
205
+ specifier.imported.name === ASSERT_FN,
206
+ );
207
+ }
208
+
209
+ function addAssertImport(fixer, sourceCode, craftImport) {
210
+ if (craftImport) {
211
+ const brace = sourceCode.getFirstToken(
212
+ craftImport,
213
+ (token) => token.value === '{',
214
+ );
215
+ if (brace) {
216
+ return fixer.insertTextAfter(brace, `\n ${ASSERT_FN},`);
217
+ }
218
+ }
219
+ // No mergeable value import — add a fresh one at the top of the file.
220
+ return fixer.insertTextBeforeRange(
221
+ [0, 0],
222
+ `import { ${ASSERT_FN} } from '${CRAFT_MODULE}';\n`,
223
+ );
224
+ }