@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,455 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const {
4
+ IndentationText,
5
+ Node,
6
+ Project,
7
+ QuoteKind,
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 provide provideHostName('ClassName') in providers.",
19
+ },
20
+ fixable: 'code',
21
+ schema: [],
22
+ },
23
+ create(context) {
24
+ return {
25
+ 'Program:exit'() {
26
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
27
+ const filePath = getFilePath(context);
28
+ if (!filePath || !filePath.endsWith('.ts')) {
29
+ return;
30
+ }
31
+
32
+ const text = sourceCode.getText();
33
+ if (!text.includes('@Component') && !text.includes('@Directive')) {
34
+ return;
35
+ }
36
+
37
+ const sourceFile = getProjectSourceFile(
38
+ getProject(getCwd(context)),
39
+ filePath,
40
+ text,
41
+ );
42
+ const issues = collectHostNameIssues(sourceFile);
43
+ if (issues.length === 0) {
44
+ return;
45
+ }
46
+
47
+ const reportLoc = getNodeLoc(sourceCode, issues[0].reportNode);
48
+
49
+ for (const issue of issues) {
50
+ applyIssueFix(issue);
51
+ }
52
+ ensureProvideHostNameImport(sourceFile);
53
+
54
+ const fixedText = sourceFile.getFullText();
55
+ if (fixedText === text) {
56
+ return;
57
+ }
58
+
59
+ context.report({
60
+ loc: reportLoc,
61
+ message: formatIssueMessage(issues),
62
+ fix(fixer) {
63
+ return fixer.replaceTextRange([0, text.length], fixedText);
64
+ },
65
+ });
66
+ },
67
+ };
68
+ },
69
+ };
70
+
71
+ function getProject(cwd) {
72
+ let project = projectCache.get(cwd);
73
+ if (project) {
74
+ return project;
75
+ }
76
+
77
+ const manipulationSettings = {
78
+ indentationText: IndentationText.TwoSpaces,
79
+ quoteKind: QuoteKind.Single,
80
+ };
81
+ const tsConfigFilePath = path.join(cwd, 'tsconfig.json');
82
+ project = fs.existsSync(tsConfigFilePath)
83
+ ? new Project({
84
+ tsConfigFilePath,
85
+ manipulationSettings,
86
+ })
87
+ : new Project({
88
+ compilerOptions: {
89
+ experimentalDecorators: true,
90
+ target: 9,
91
+ },
92
+ manipulationSettings,
93
+ });
94
+
95
+ projectCache.set(cwd, project);
96
+ return project;
97
+ }
98
+
99
+ function getProjectSourceFile(project, filePath, text) {
100
+ const normalizedPath = path.resolve(filePath);
101
+ const existingSourceFile = project.getSourceFile(normalizedPath);
102
+ if (existingSourceFile) {
103
+ existingSourceFile.replaceWithText(text);
104
+ return existingSourceFile;
105
+ }
106
+
107
+ const sourceFile = project.addSourceFileAtPathIfExists(normalizedPath);
108
+ if (sourceFile) {
109
+ sourceFile.replaceWithText(text);
110
+ return sourceFile;
111
+ }
112
+
113
+ return project.createSourceFile(normalizedPath, text, { overwrite: true });
114
+ }
115
+
116
+ function getFilePath(context) {
117
+ const filePath = context.filename ?? context.getFilename();
118
+ if (!filePath || filePath === '<input>') {
119
+ return undefined;
120
+ }
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 collectHostNameIssues(sourceFile) {
137
+ const angularDecorators = collectAngularDecoratorNames(sourceFile);
138
+ if (
139
+ angularDecorators.componentNames.size === 0 &&
140
+ angularDecorators.directiveNames.size === 0 &&
141
+ angularDecorators.namespaceNames.size === 0
142
+ ) {
143
+ return [];
144
+ }
145
+
146
+ const issues = [];
147
+
148
+ for (const classDeclaration of sourceFile.getClasses()) {
149
+ const className = classDeclaration.getName();
150
+ if (!className) {
151
+ continue;
152
+ }
153
+
154
+ const decoratorMatch = findAngularDecorator(
155
+ classDeclaration,
156
+ angularDecorators,
157
+ );
158
+ if (!decoratorMatch) {
159
+ continue;
160
+ }
161
+
162
+ const metadata = getDecoratorMetadata(decoratorMatch.decorator);
163
+ if (!metadata) {
164
+ continue;
165
+ }
166
+
167
+ const providersProperty = getObjectProperty(metadata, 'providers');
168
+ if (!providersProperty) {
169
+ issues.push({
170
+ kind: 'missing-providers',
171
+ decoratorKind: decoratorMatch.kind,
172
+ className,
173
+ metadata,
174
+ reportNode: classDeclaration,
175
+ });
176
+ continue;
177
+ }
178
+
179
+ const providersArray = getArrayInitializer(providersProperty);
180
+ if (!providersArray) {
181
+ continue;
182
+ }
183
+
184
+ const hostNameCalls = findProvideHostNameCalls(providersArray);
185
+ if (hostNameCalls.length === 0) {
186
+ issues.push({
187
+ kind: 'missing-call',
188
+ decoratorKind: decoratorMatch.kind,
189
+ className,
190
+ providersArray,
191
+ reportNode: classDeclaration,
192
+ });
193
+ continue;
194
+ }
195
+
196
+ const expectedHostName = `${getHostNamePrefix(decoratorMatch.kind)}${className}`;
197
+ const matchingCall = hostNameCalls.find((callExpression) =>
198
+ isMatchingHostNameCall(callExpression, expectedHostName),
199
+ );
200
+ if (matchingCall) {
201
+ continue;
202
+ }
203
+
204
+ issues.push({
205
+ kind: 'mismatched-call',
206
+ decoratorKind: decoratorMatch.kind,
207
+ className,
208
+ callExpression: hostNameCalls[0],
209
+ reportNode: classDeclaration,
210
+ });
211
+ }
212
+
213
+ return issues;
214
+ }
215
+
216
+ function collectAngularDecoratorNames(sourceFile) {
217
+ const componentNames = new Set();
218
+ const directiveNames = new Set();
219
+ const namespaceNames = new Set();
220
+
221
+ for (const importDeclaration of sourceFile.getImportDeclarations()) {
222
+ if (importDeclaration.getModuleSpecifierValue() !== '@angular/core') {
223
+ continue;
224
+ }
225
+
226
+ for (const namedImport of importDeclaration.getNamedImports()) {
227
+ const importedName = namedImport.getName();
228
+ const localName = namedImport.getAliasNode()?.getText() ?? importedName;
229
+
230
+ if (importedName === 'Component') {
231
+ componentNames.add(localName);
232
+ }
233
+ if (importedName === 'Directive') {
234
+ directiveNames.add(localName);
235
+ }
236
+ }
237
+
238
+ const namespaceImport = importDeclaration.getNamespaceImport();
239
+ if (namespaceImport) {
240
+ namespaceNames.add(namespaceImport.getText());
241
+ }
242
+ }
243
+
244
+ return {
245
+ componentNames,
246
+ directiveNames,
247
+ namespaceNames,
248
+ };
249
+ }
250
+
251
+ function findAngularDecorator(classDeclaration, angularDecorators) {
252
+ for (const decorator of classDeclaration.getDecorators()) {
253
+ const expression = decorator.getExpression();
254
+ if (!Node.isCallExpression(expression)) {
255
+ continue;
256
+ }
257
+
258
+ const callee = expression.getExpression();
259
+ if (Node.isIdentifier(callee)) {
260
+ const decoratorName = callee.getText();
261
+ if (angularDecorators.componentNames.has(decoratorName)) {
262
+ return { decorator, kind: 'component' };
263
+ }
264
+ if (angularDecorators.directiveNames.has(decoratorName)) {
265
+ return { decorator, kind: 'directive' };
266
+ }
267
+ continue;
268
+ }
269
+
270
+ if (!Node.isPropertyAccessExpression(callee)) {
271
+ continue;
272
+ }
273
+
274
+ const objectExpression = callee.getExpression();
275
+ const propertyName = callee.getName();
276
+ if (
277
+ Node.isIdentifier(objectExpression) &&
278
+ angularDecorators.namespaceNames.has(objectExpression.getText())
279
+ ) {
280
+ if (propertyName === 'Component') {
281
+ return { decorator, kind: 'component' };
282
+ }
283
+ if (propertyName === 'Directive') {
284
+ return { decorator, kind: 'directive' };
285
+ }
286
+ }
287
+ }
288
+
289
+ return undefined;
290
+ }
291
+
292
+ function getHostNamePrefix(kind) {
293
+ return kind === 'directive' ? 'directive:' : 'component:';
294
+ }
295
+
296
+ function getDecoratorMetadata(decorator) {
297
+ const expression = decorator.getExpression();
298
+ if (!Node.isCallExpression(expression)) {
299
+ return undefined;
300
+ }
301
+
302
+ const [metadataArgument] = expression.getArguments();
303
+ if (!metadataArgument || !Node.isObjectLiteralExpression(metadataArgument)) {
304
+ return undefined;
305
+ }
306
+
307
+ return metadataArgument;
308
+ }
309
+
310
+ function getObjectProperty(objectLiteralExpression, name) {
311
+ const property = objectLiteralExpression
312
+ .getProperties()
313
+ .find(
314
+ (candidate) =>
315
+ Node.isPropertyAssignment(candidate) && candidate.getName() === name,
316
+ );
317
+
318
+ return Node.isPropertyAssignment(property) ? property : undefined;
319
+ }
320
+
321
+ function getArrayInitializer(propertyAssignment) {
322
+ const initializer = propertyAssignment.getInitializer();
323
+ if (!initializer || !Node.isArrayLiteralExpression(initializer)) {
324
+ return undefined;
325
+ }
326
+
327
+ return initializer;
328
+ }
329
+
330
+ function findProvideHostNameCalls(arrayLiteralExpression) {
331
+ return arrayLiteralExpression
332
+ .getElements()
333
+ .filter((element) => Node.isCallExpression(element))
334
+ .filter((callExpression) => isProvideHostNameCall(callExpression));
335
+ }
336
+
337
+ function isProvideHostNameCall(callExpression) {
338
+ const expression = callExpression.getExpression();
339
+ if (Node.isIdentifier(expression)) {
340
+ return expression.getText() === 'provideHostName';
341
+ }
342
+
343
+ if (Node.isPropertyAccessExpression(expression)) {
344
+ return expression.getName() === 'provideHostName';
345
+ }
346
+
347
+ return false;
348
+ }
349
+
350
+ function isMatchingHostNameCall(callExpression, expectedHostName) {
351
+ const [nameArgument] = callExpression.getArguments();
352
+ if (
353
+ !nameArgument ||
354
+ (!Node.isStringLiteral(nameArgument) &&
355
+ !Node.isNoSubstitutionTemplateLiteral(nameArgument))
356
+ ) {
357
+ return false;
358
+ }
359
+
360
+ return nameArgument.getLiteralText() === expectedHostName;
361
+ }
362
+
363
+ function applyIssueFix(issue) {
364
+ const expectedHostName = `${getHostNamePrefix(issue.decoratorKind)}${issue.className}`;
365
+ const expectedCall = `provideHostName('${expectedHostName}')`;
366
+
367
+ if (issue.kind === 'missing-providers') {
368
+ issue.metadata.addPropertyAssignment({
369
+ name: 'providers',
370
+ initializer: `[${expectedCall}]`,
371
+ });
372
+ return;
373
+ }
374
+
375
+ if (issue.kind === 'missing-call') {
376
+ issue.providersArray.addElement(expectedCall);
377
+ return;
378
+ }
379
+
380
+ if (issue.kind === 'mismatched-call') {
381
+ issue.callExpression.replaceWithText(expectedCall);
382
+ }
383
+ }
384
+
385
+ function ensureProvideHostNameImport(sourceFile) {
386
+ const coreImports = sourceFile
387
+ .getImportDeclarations()
388
+ .filter(
389
+ (importDeclaration) =>
390
+ importDeclaration.getModuleSpecifierValue() === '@craft-ng/core',
391
+ );
392
+
393
+ for (const importDeclaration of coreImports) {
394
+ const namedImports = importDeclaration.getNamedImports();
395
+ const directImport = namedImports.find(
396
+ (namedImport) =>
397
+ namedImport.getName() === 'provideHostName' &&
398
+ !namedImport.getAliasNode(),
399
+ );
400
+ if (directImport) {
401
+ return;
402
+ }
403
+ }
404
+
405
+ for (const importDeclaration of coreImports) {
406
+ for (const namedImport of importDeclaration.getNamedImports()) {
407
+ if (
408
+ namedImport.getName() === 'provideHostName' &&
409
+ namedImport.getAliasNode()
410
+ ) {
411
+ namedImport.remove();
412
+ }
413
+ }
414
+ }
415
+
416
+ const targetImport = coreImports.find(
417
+ (importDeclaration) => !importDeclaration.getNamespaceImport(),
418
+ );
419
+ if (targetImport) {
420
+ const hasNamedImport = targetImport
421
+ .getNamedImports()
422
+ .some((namedImport) => namedImport.getName() === 'provideHostName');
423
+ if (!hasNamedImport) {
424
+ targetImport.addNamedImport('provideHostName');
425
+ }
426
+ return;
427
+ }
428
+
429
+ const insertIndex = sourceFile
430
+ .getStatements()
431
+ .findIndex(
432
+ (statement) => statement.getKind() !== SyntaxKind.ImportDeclaration,
433
+ );
434
+
435
+ sourceFile.insertImportDeclaration(
436
+ insertIndex < 0 ? sourceFile.getImportDeclarations().length : insertIndex,
437
+ {
438
+ moduleSpecifier: '@craft-ng/core',
439
+ namedImports: ['provideHostName'],
440
+ },
441
+ );
442
+ }
443
+
444
+ function formatIssueMessage(issues) {
445
+ if (issues.length === 1) {
446
+ const issue = issues[0];
447
+ const expectedHostName = `${getHostNamePrefix(issue.decoratorKind)}${issue.className}`;
448
+ return `${issue.className} must include provideHostName('${expectedHostName}') in providers.`;
449
+ }
450
+
451
+ const classNames = [...new Set(issues.map((issue) => issue.className))].join(
452
+ ', ',
453
+ );
454
+ return `Classes must include provideHostName('<kind>:<ClassName>') in providers: ${classNames}.`;
455
+ }
@@ -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
+ }