@craft-ng/dev-tools 0.5.0-beta.1 → 0.5.0-beta.3

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,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
+ }
@@ -0,0 +1,212 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const {
4
+ IndentationText,
5
+ Project,
6
+ QuoteKind,
7
+ Scope,
8
+ SyntaxKind,
9
+ } = require('ts-morph');
10
+
11
+ const projectCache = new Map();
12
+
13
+ module.exports = {
14
+ meta: {
15
+ type: 'problem',
16
+ docs: {
17
+ description:
18
+ "Ensure Angular @Component and @Directive classes declare 'private readonly _monitoring = componentMonitoring()'.",
19
+ },
20
+ fixable: 'code',
21
+ schema: [],
22
+ messages: {
23
+ missingMonitoring:
24
+ "Component/Directive '{{className}}' must declare 'private readonly _monitoring = componentMonitoring()'.",
25
+ },
26
+ },
27
+ create(context) {
28
+ return {
29
+ 'Program:exit'() {
30
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
31
+ const filePath = getFilePath(context);
32
+ if (!filePath || !filePath.endsWith('.ts')) {
33
+ return;
34
+ }
35
+
36
+ const text = sourceCode.getText();
37
+ if (!text.includes('@Component') && !text.includes('@Directive')) {
38
+ return;
39
+ }
40
+
41
+ const sourceFile = getProjectSourceFile(
42
+ getProject(getCwd(context)),
43
+ filePath,
44
+ text,
45
+ );
46
+
47
+ const issues = collectMonitoringIssues(sourceFile);
48
+ if (issues.length === 0) {
49
+ return;
50
+ }
51
+
52
+ const reportLoc = getNodeLoc(sourceCode, issues[0].classDeclaration);
53
+
54
+ for (const issue of issues) {
55
+ applyMonitoringFix(issue);
56
+ }
57
+ ensureComponentMonitoringImport(sourceFile);
58
+
59
+ const fixedText = sourceFile.getFullText();
60
+ if (fixedText === text) {
61
+ return;
62
+ }
63
+
64
+ const classNames = issues.map((i) => i.className).join(', ');
65
+
66
+ context.report({
67
+ loc: reportLoc,
68
+ message: `Component(s) missing componentMonitoring(): ${classNames}`,
69
+ fix(fixer) {
70
+ return fixer.replaceTextRange([0, text.length], fixedText);
71
+ },
72
+ });
73
+ },
74
+ };
75
+ },
76
+ };
77
+
78
+ function getProject(cwd) {
79
+ let project = projectCache.get(cwd);
80
+ if (project) {
81
+ return project;
82
+ }
83
+
84
+ const manipulationSettings = {
85
+ indentationText: IndentationText.TwoSpaces,
86
+ quoteKind: QuoteKind.Single,
87
+ };
88
+ const tsConfigFilePath = path.join(cwd, 'tsconfig.json');
89
+ project = fs.existsSync(tsConfigFilePath)
90
+ ? new Project({ tsConfigFilePath, manipulationSettings })
91
+ : new Project({
92
+ compilerOptions: { experimentalDecorators: true, target: 9 },
93
+ manipulationSettings,
94
+ });
95
+
96
+ projectCache.set(cwd, project);
97
+ return project;
98
+ }
99
+
100
+ function getProjectSourceFile(project, filePath, text) {
101
+ const normalizedPath = path.resolve(filePath);
102
+ const existingSourceFile = project.getSourceFile(normalizedPath);
103
+ if (existingSourceFile) {
104
+ existingSourceFile.replaceWithText(text);
105
+ return existingSourceFile;
106
+ }
107
+
108
+ const sourceFile = project.addSourceFileAtPathIfExists(normalizedPath);
109
+ if (sourceFile) {
110
+ sourceFile.replaceWithText(text);
111
+ return sourceFile;
112
+ }
113
+
114
+ return project.createSourceFile(normalizedPath, text, { overwrite: true });
115
+ }
116
+
117
+ function getFilePath(context) {
118
+ const filePath = context.filename ?? context.getFilename();
119
+ if (!filePath || filePath === '<input>') {
120
+ return undefined;
121
+ }
122
+ return filePath;
123
+ }
124
+
125
+ function getCwd(context) {
126
+ return context.cwd ?? process.cwd();
127
+ }
128
+
129
+ function getNodeLoc(sourceCode, node) {
130
+ return {
131
+ start: sourceCode.getLocFromIndex(node.getStart()),
132
+ end: sourceCode.getLocFromIndex(node.getEnd()),
133
+ };
134
+ }
135
+
136
+ function isAngularComponentOrDirective(classDeclaration) {
137
+ for (const decorator of classDeclaration.getDecorators()) {
138
+ const name = decorator.getName();
139
+ if (name === 'Component' || name === 'Directive') {
140
+ return true;
141
+ }
142
+ }
143
+ return false;
144
+ }
145
+
146
+ function hasMonitoringProperty(classDeclaration) {
147
+ for (const prop of classDeclaration.getProperties()) {
148
+ if (
149
+ prop.getName() === '_monitoring' &&
150
+ prop.hasModifier(SyntaxKind.PrivateKeyword) &&
151
+ prop.isReadonly()
152
+ ) {
153
+ const initializer = prop.getInitializer();
154
+ if (
155
+ initializer &&
156
+ initializer.getKindName() === 'CallExpression' &&
157
+ initializer.getText().startsWith('componentMonitoring(')
158
+ ) {
159
+ return true;
160
+ }
161
+ }
162
+ }
163
+ return false;
164
+ }
165
+
166
+ function collectMonitoringIssues(sourceFile) {
167
+ const issues = [];
168
+
169
+ for (const classDeclaration of sourceFile.getClasses()) {
170
+ const className = classDeclaration.getName();
171
+ if (!className) continue;
172
+ if (!isAngularComponentOrDirective(classDeclaration)) continue;
173
+ if (hasMonitoringProperty(classDeclaration)) continue;
174
+
175
+ issues.push({ className, classDeclaration });
176
+ }
177
+
178
+ return issues;
179
+ }
180
+
181
+ function applyMonitoringFix(issue) {
182
+ const { classDeclaration } = issue;
183
+
184
+ // Insert as first property of the class
185
+ classDeclaration.insertProperty(0, {
186
+ name: '_monitoring',
187
+ scope: Scope.Private,
188
+ isReadonly: true,
189
+ initializer: 'componentMonitoring()',
190
+ });
191
+ }
192
+
193
+ function ensureComponentMonitoringImport(sourceFile) {
194
+ const craftNgCoreImport = sourceFile
195
+ .getImportDeclarations()
196
+ .find((imp) => imp.getModuleSpecifierValue() === '@craft-ng/core');
197
+
198
+ if (craftNgCoreImport) {
199
+ const namedImports = craftNgCoreImport.getNamedImports();
200
+ const alreadyImported = namedImports.some(
201
+ (ni) => ni.getName() === 'componentMonitoring',
202
+ );
203
+ if (!alreadyImported) {
204
+ craftNgCoreImport.addNamedImport('componentMonitoring');
205
+ }
206
+ } else {
207
+ sourceFile.addImportDeclaration({
208
+ moduleSpecifier: '@craft-ng/core',
209
+ namedImports: ['componentMonitoring'],
210
+ });
211
+ }
212
+ }