@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-ng/dev-tools",
3
- "version": "0.5.0-beta.2",
3
+ "version": "0.5.0-beta.4",
4
4
  "description": "Development tools for ng-craft: ESLint configs, ESLint rules, and codemods",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -0,0 +1,497 @@
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 route exceptions delegated to the global error component (via globalError()) are registered in CraftGlobalExceptionRegistry.',
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
+ // Cheap pre-filter: the delegated outcome must appear.
34
+ if (!text.includes('globalError')) {
35
+ return;
36
+ }
37
+
38
+ const sourceFile = getProjectSourceFile(
39
+ getProject(getCwd(context)),
40
+ filePath,
41
+ text,
42
+ );
43
+ const requiredRegistrations = collectRequiredRegistrations(sourceFile);
44
+ if (requiredRegistrations.length === 0) {
45
+ return;
46
+ }
47
+
48
+ const registryState = analyzeRegistryState(
49
+ sourceFile,
50
+ requiredRegistrations,
51
+ );
52
+ if (
53
+ registryState.missing.length === 0 &&
54
+ registryState.outOfDate.length === 0
55
+ ) {
56
+ return;
57
+ }
58
+
59
+ ensureRegistryEntries(sourceFile, requiredRegistrations);
60
+ const fixedText = sourceFile.getFullText();
61
+ if (fixedText === text) {
62
+ return;
63
+ }
64
+
65
+ context.report({
66
+ loc: getNodeLoc(sourceCode, registryState.reportNode),
67
+ message: formatRegistryMessage(registryState),
68
+ fix(fixer) {
69
+ return fixer.replaceTextRange([0, text.length], fixedText);
70
+ },
71
+ });
72
+ },
73
+ };
74
+ },
75
+ };
76
+
77
+ function getProject(cwd) {
78
+ let project = projectCache.get(cwd);
79
+ if (project) {
80
+ return project;
81
+ }
82
+
83
+ const manipulationSettings = {
84
+ indentationText: IndentationText.TwoSpaces,
85
+ quoteKind: QuoteKind.Single,
86
+ };
87
+ const tsConfigFilePath = path.join(cwd, 'tsconfig.json');
88
+ project = fs.existsSync(tsConfigFilePath)
89
+ ? new Project({ tsConfigFilePath, manipulationSettings })
90
+ : new Project({
91
+ compilerOptions: { experimentalDecorators: true, target: 9 },
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
+ return filePath;
122
+ }
123
+
124
+ function getCwd(context) {
125
+ return context.cwd ?? process.cwd();
126
+ }
127
+
128
+ // --- collection: which (path, code) pairs must be registered ---
129
+
130
+ function collectRequiredRegistrations(sourceFile) {
131
+ // Keyed by `${path}::${code}` to dedupe; carries the collection variable.
132
+ const registrations = new Map();
133
+
134
+ for (const call of sourceFile.getDescendantsOfKind(
135
+ SyntaxKind.CallExpression,
136
+ )) {
137
+ if (getCallExpressionName(call) !== 'craftRoutes') {
138
+ continue;
139
+ }
140
+
141
+ const collectionVar = resolveCollectionVar(call);
142
+ if (!collectionVar) {
143
+ continue;
144
+ }
145
+
146
+ const routesArray = call.getArguments()[1];
147
+ if (!routesArray || !Node.isArrayLiteralExpression(routesArray)) {
148
+ continue;
149
+ }
150
+
151
+ for (const element of routesArray.getElements()) {
152
+ const routeDef = extractRouteDefinition(element);
153
+ if (!routeDef) {
154
+ continue;
155
+ }
156
+
157
+ for (const code of collectGlobalErrorCodes(routeDef.handleExceptions)) {
158
+ const key = `${routeDef.path}::${code}`;
159
+ if (!registrations.has(key)) {
160
+ registrations.set(key, {
161
+ path: routeDef.path,
162
+ code,
163
+ collectionVar,
164
+ reportNode: routeDef.reportNode,
165
+ });
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ return [...registrations.values()];
172
+ }
173
+
174
+ function getCallExpressionName(callExpression) {
175
+ const expression = callExpression.getExpression();
176
+ if (Node.isIdentifier(expression)) {
177
+ return expression.getText();
178
+ }
179
+ // `route(...).withProviders` etc. — the leftmost call's callee.
180
+ if (Node.isPropertyAccessExpression(expression)) {
181
+ return expression.getName();
182
+ }
183
+ return undefined;
184
+ }
185
+
186
+ // The variable the craftRoutes result is destructured into ends in `Routes`
187
+ // (e.g. `const { demoRoutes, … } = craftRoutes('demo', [...])`).
188
+ function resolveCollectionVar(craftRoutesCall) {
189
+ const declaration = craftRoutesCall.getFirstAncestorByKind(
190
+ SyntaxKind.VariableDeclaration,
191
+ );
192
+ if (!declaration) {
193
+ return undefined;
194
+ }
195
+
196
+ const nameNode = declaration.getNameNode();
197
+ if (Node.isIdentifier(nameNode)) {
198
+ return nameNode.getText();
199
+ }
200
+
201
+ if (Node.isObjectBindingPattern(nameNode)) {
202
+ const element = nameNode
203
+ .getElements()
204
+ .find((el) => el.getNameNode().getText().endsWith('Routes'));
205
+ return element?.getNameNode().getText();
206
+ }
207
+
208
+ return undefined;
209
+ }
210
+
211
+ // A route array element is either `route('path', { … }, { … })` (optionally
212
+ // followed by `.withProviders(…)`) or a plain `{ path: '…', handleExceptions: { … } }`.
213
+ function extractRouteDefinition(element) {
214
+ if (Node.isObjectLiteralExpression(element)) {
215
+ const pathValue = readStringProperty(element, 'path');
216
+ const handleExceptions = readObjectProperty(element, 'handleExceptions');
217
+ if (!pathValue || !handleExceptions) {
218
+ return undefined;
219
+ }
220
+ return { path: pathValue, handleExceptions, reportNode: element };
221
+ }
222
+
223
+ if (Node.isCallExpression(element)) {
224
+ const routeCall = findRouteCall(element);
225
+ if (!routeCall) {
226
+ return undefined;
227
+ }
228
+ const [pathArg, , handlersArg] = routeCall.getArguments();
229
+ if (
230
+ !pathArg ||
231
+ !handlersArg ||
232
+ !Node.isObjectLiteralExpression(handlersArg) ||
233
+ (!Node.isStringLiteral(pathArg) &&
234
+ !Node.isNoSubstitutionTemplateLiteral(pathArg))
235
+ ) {
236
+ return undefined;
237
+ }
238
+ return {
239
+ path: pathArg.getLiteralText(),
240
+ handleExceptions: handlersArg,
241
+ reportNode: routeCall,
242
+ };
243
+ }
244
+
245
+ return undefined;
246
+ }
247
+
248
+ // Walks `route(...).withProviders(...)` (any depth) down to the `route(` call.
249
+ function findRouteCall(node) {
250
+ let current = node;
251
+ while (Node.isCallExpression(current)) {
252
+ if (getCallExpressionName(current) === 'route') {
253
+ return current;
254
+ }
255
+ const expression = current.getExpression();
256
+ if (Node.isPropertyAccessExpression(expression)) {
257
+ current = expression.getExpression();
258
+ continue;
259
+ }
260
+ break;
261
+ }
262
+ return undefined;
263
+ }
264
+
265
+ function readStringProperty(objectLiteral, propertyName) {
266
+ const property = objectLiteral
267
+ .getProperties()
268
+ .find(
269
+ (prop) =>
270
+ Node.isPropertyAssignment(prop) && prop.getName() === propertyName,
271
+ );
272
+ if (!property || !Node.isPropertyAssignment(property)) {
273
+ return undefined;
274
+ }
275
+ const initializer = property.getInitializer();
276
+ if (
277
+ !initializer ||
278
+ (!Node.isStringLiteral(initializer) &&
279
+ !Node.isNoSubstitutionTemplateLiteral(initializer))
280
+ ) {
281
+ return undefined;
282
+ }
283
+ return initializer.getLiteralText();
284
+ }
285
+
286
+ function readObjectProperty(objectLiteral, propertyName) {
287
+ const property = objectLiteral
288
+ .getProperties()
289
+ .find(
290
+ (prop) =>
291
+ Node.isPropertyAssignment(prop) && prop.getName() === propertyName,
292
+ );
293
+ if (!property || !Node.isPropertyAssignment(property)) {
294
+ return undefined;
295
+ }
296
+ const initializer = property.getInitializer();
297
+ return initializer && Node.isObjectLiteralExpression(initializer)
298
+ ? initializer
299
+ : undefined;
300
+ }
301
+
302
+ // The codes whose handler calls `globalError()` (function or generator handler).
303
+ function collectGlobalErrorCodes(handleExceptions) {
304
+ const codes = [];
305
+ for (const property of handleExceptions.getProperties()) {
306
+ if (!Node.isPropertyAssignment(property)) {
307
+ continue;
308
+ }
309
+ const handler = property.getInitializer();
310
+ if (!handler || !handlerCallsGlobalError(handler)) {
311
+ continue;
312
+ }
313
+ codes.push(readPropertyAssignmentName(property));
314
+ }
315
+ return codes;
316
+ }
317
+
318
+ function handlerCallsGlobalError(handlerNode) {
319
+ return handlerNode
320
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
321
+ .some((call) => {
322
+ const expression = call.getExpression();
323
+ return Node.isIdentifier(expression) && expression.getText() === 'globalError';
324
+ });
325
+ }
326
+
327
+ function readPropertyAssignmentName(property) {
328
+ const nameNode = property.getNameNode();
329
+ if (Node.isStringLiteral(nameNode) || Node.isNumericLiteral(nameNode)) {
330
+ return nameNode.getLiteralText();
331
+ }
332
+ return property.getName();
333
+ }
334
+
335
+ // --- registry analysis + maintenance ---
336
+
337
+ function expectedTypeFor(registration) {
338
+ return `CraftRouteExceptionType<typeof ${registration.collectionVar}, '${registration.path}', '${registration.code}'>`;
339
+ }
340
+
341
+ function groupByPath(registrations) {
342
+ const byPath = new Map();
343
+ for (const registration of registrations) {
344
+ const entry = byPath.get(registration.path) ?? [];
345
+ entry.push(registration);
346
+ byPath.set(registration.path, entry);
347
+ }
348
+ return byPath;
349
+ }
350
+
351
+ function expectedPathType(entries) {
352
+ const members = entries
353
+ .map(
354
+ (registration) =>
355
+ `${formatPropertyName(registration.code)}: ${expectedTypeFor(registration)}`,
356
+ )
357
+ .join('; ');
358
+ return `{ ${members} }`;
359
+ }
360
+
361
+ function analyzeRegistryState(sourceFile, requiredRegistrations) {
362
+ const registryInterface = getGlobalRegistryInterface(sourceFile);
363
+ const propertiesByName = new Map(
364
+ (registryInterface?.getProperties() ?? []).map((property) => [
365
+ readInterfacePropertyName(property),
366
+ property,
367
+ ]),
368
+ );
369
+
370
+ const missing = [];
371
+ const outOfDate = [];
372
+
373
+ for (const [routePath, entries] of groupByPath(requiredRegistrations)) {
374
+ const property = propertiesByName.get(routePath);
375
+ if (!property) {
376
+ missing.push(routePath);
377
+ continue;
378
+ }
379
+ const currentType = normalizeText(property.getTypeNode()?.getText() ?? '');
380
+ const expectedType = normalizeText(expectedPathType(entries));
381
+ if (currentType !== expectedType) {
382
+ outOfDate.push(routePath);
383
+ }
384
+ }
385
+
386
+ return {
387
+ missing,
388
+ outOfDate,
389
+ reportNode: requiredRegistrations[0].reportNode,
390
+ };
391
+ }
392
+
393
+ // A file may have several `declare module '@craft-ng/core'` blocks (e.g. one for
394
+ // CraftRouterRoutesRegistry and one for CraftGlobalExceptionRegistry), so search
395
+ // all of them rather than the first.
396
+ function getCraftCoreModuleDeclarations(sourceFile) {
397
+ return sourceFile.getModules().filter((moduleDeclaration) => {
398
+ const nameNode = moduleDeclaration.getNameNode();
399
+ return (
400
+ Node.isStringLiteral(nameNode) &&
401
+ nameNode.getLiteralText() === '@craft-ng/core'
402
+ );
403
+ });
404
+ }
405
+
406
+ function getGlobalRegistryInterface(sourceFile) {
407
+ for (const moduleDeclaration of getCraftCoreModuleDeclarations(sourceFile)) {
408
+ const registryInterface = moduleDeclaration.getInterface(
409
+ 'CraftGlobalExceptionRegistry',
410
+ );
411
+ if (registryInterface) {
412
+ return registryInterface;
413
+ }
414
+ }
415
+ return undefined;
416
+ }
417
+
418
+ function readInterfacePropertyName(property) {
419
+ const nameNode = property.getNameNode();
420
+ if (Node.isStringLiteral(nameNode) || Node.isNumericLiteral(nameNode)) {
421
+ return nameNode.getLiteralText();
422
+ }
423
+ return property.getName();
424
+ }
425
+
426
+ function ensureRegistryEntries(sourceFile, requiredRegistrations) {
427
+ let registryInterface = getGlobalRegistryInterface(sourceFile);
428
+ if (!registryInterface) {
429
+ const moduleDeclaration =
430
+ getCraftCoreModuleDeclarations(sourceFile)[0] ??
431
+ sourceFile.addModule({
432
+ name: "'@craft-ng/core'",
433
+ hasDeclareKeyword: true,
434
+ });
435
+ registryInterface = moduleDeclaration.addInterface({
436
+ name: 'CraftGlobalExceptionRegistry',
437
+ });
438
+ }
439
+
440
+ const propertiesByName = new Map(
441
+ registryInterface
442
+ .getProperties()
443
+ .map((property) => [readInterfacePropertyName(property), property]),
444
+ );
445
+
446
+ for (const [routePath, entries] of groupByPath(requiredRegistrations)) {
447
+ const expectedType = expectedPathType(entries);
448
+ const property = propertiesByName.get(routePath);
449
+ if (!property) {
450
+ registryInterface.addProperty({
451
+ name: formatPropertyName(routePath),
452
+ type: expectedType,
453
+ });
454
+ continue;
455
+ }
456
+ property.setType(expectedType);
457
+ }
458
+ }
459
+
460
+ function formatPropertyName(name) {
461
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : `'${name}'`;
462
+ }
463
+
464
+ function getNodeLoc(sourceCode, node) {
465
+ return {
466
+ start: sourceCode.getLocFromIndex(node.getStart()),
467
+ end: sourceCode.getLocFromIndex(node.getEnd()),
468
+ };
469
+ }
470
+
471
+ function formatRegistryMessage({ missing, outOfDate }) {
472
+ const segments = [];
473
+ if (missing.length > 0) {
474
+ segments.push(`missing ${formatNameList(missing)}`);
475
+ }
476
+ if (outOfDate.length > 0) {
477
+ segments.push(`out of date for ${formatNameList(outOfDate)}`);
478
+ }
479
+ return `CraftGlobalExceptionRegistry is ${segments.join(' and ')}. Run ESLint --fix on this file to register globalError() route exceptions.`;
480
+ }
481
+
482
+ function formatNameList(names) {
483
+ if (names.length === 1) {
484
+ return `'${names[0]}'`;
485
+ }
486
+ if (names.length === 2) {
487
+ return `'${names[0]}' and '${names[1]}'`;
488
+ }
489
+ return `${names
490
+ .slice(0, -1)
491
+ .map((name) => `'${name}'`)
492
+ .join(', ')}, and '${names[names.length - 1]}'`;
493
+ }
494
+
495
+ function normalizeText(text) {
496
+ return text.replace(/\s+/g, ' ').trim();
497
+ }
@@ -1,10 +1,13 @@
1
1
  const brandAngularGenDepsRequired = require('./brand-angular-gen-deps-required.cjs');
2
2
  const brandAngularDepsMatch = require('./brand-angular-deps-match.cjs');
3
3
  const appStartRegistryMatch = require('./app-start-registry-match.cjs');
4
+ const globalExceptionRegistryMatch = require('./global-exception-registry-match.cjs');
4
5
  const componentTestGenDepsMatch = require('./component-test-gen-deps-match.cjs');
5
6
  const craftMethodNameMatch = require('./craft-method-name-match.cjs');
6
7
  const craftComputedNameMatch = require('./craft-computed-name-match.cjs');
7
8
  const preferCraftComputed = require('./prefer-craft-computed.cjs');
9
+ const preferCraftState = require('./prefer-craft-state.cjs');
10
+ const preferCraftEffect = require('./prefer-craft-effect.cjs');
8
11
  const noAngularInject = require('./no-angular-inject.cjs');
9
12
  const noAngularSignalForms = require('./no-angular-signal-forms.cjs');
10
13
  const provideHostNameMatchComponent = require('./provide-host-name-match-component.cjs');
@@ -12,16 +15,24 @@ const preferCraftHttpClient = require('./prefer-craft-http-client.cjs');
12
15
  const preferCraftService = require('./prefer-craft-service.cjs');
13
16
  const preferBrowserBoundaries = require('./prefer-browser-boundaries.cjs');
14
17
  const requireComponentMonitoring = require('./require-component-monitoring.cjs');
18
+ const requireTrackOnDependentPrimitives = require('./require-track-on-dependent-primitives.cjs');
19
+ const requireAssertExhaustiveRouteExceptions = require('./require-assert-exhaustive-route-exceptions.cjs');
20
+ const preferCraftRouterOutlet = require('./prefer-craft-router-outlet.cjs');
21
+ const requirePendingComponentDiCheck = require('./require-pending-component-di-check.cjs');
22
+ const requireChildRouteMountCheck = require('./require-child-route-mount-check.cjs');
15
23
 
16
24
  module.exports = {
17
25
  rules: {
18
26
  'app-start-registry-match': appStartRegistryMatch,
27
+ 'global-exception-registry-match': globalExceptionRegistryMatch,
19
28
  'brand-angular-gen-deps-required': brandAngularGenDepsRequired,
20
29
  'brand-angular-deps-match': brandAngularDepsMatch,
21
30
  'component-test-gen-deps-match': componentTestGenDepsMatch,
22
31
  'craft-method-name-match': craftMethodNameMatch,
23
32
  'craft-computed-name-match': craftComputedNameMatch,
24
33
  'prefer-craft-computed': preferCraftComputed,
34
+ 'prefer-craft-state': preferCraftState,
35
+ 'prefer-craft-effect': preferCraftEffect,
25
36
  'no-angular-inject': noAngularInject,
26
37
  'no-angular-signal-forms': noAngularSignalForms,
27
38
  'provide-host-name-match-component': provideHostNameMatchComponent,
@@ -29,5 +40,11 @@ module.exports = {
29
40
  'prefer-craft-service': preferCraftService,
30
41
  'prefer-browser-boundaries': preferBrowserBoundaries,
31
42
  'require-component-monitoring': requireComponentMonitoring,
43
+ 'require-track-on-dependent-primitives': requireTrackOnDependentPrimitives,
44
+ 'require-assert-exhaustive-route-exceptions':
45
+ requireAssertExhaustiveRouteExceptions,
46
+ 'prefer-craft-router-outlet': preferCraftRouterOutlet,
47
+ 'require-pending-component-di-check': requirePendingComponentDiCheck,
48
+ 'require-child-route-mount-check': requireChildRouteMountCheck,
32
49
  },
33
50
  };