@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 +1 -1
- package/src/eslint-rules/global-exception-registry-match.cjs +497 -0
- package/src/eslint-rules/index.cjs +17 -0
- package/src/eslint-rules/no-angular-inject.cjs +141 -17
- package/src/eslint-rules/prefer-craft-effect.cjs +12 -0
- package/src/eslint-rules/prefer-craft-router-outlet.cjs +190 -0
- package/src/eslint-rules/prefer-craft-signal-utils.cjs +288 -0
- package/src/eslint-rules/prefer-craft-state.cjs +12 -0
- package/src/eslint-rules/require-assert-exhaustive-route-exceptions.cjs +259 -0
- package/src/eslint-rules/require-child-route-mount-check.cjs +224 -0
- package/src/eslint-rules/require-pending-component-di-check.cjs +490 -0
- package/src/eslint-rules/require-track-on-dependent-primitives.cjs +219 -0
- package/src/scripts/angular-brand-codemod.js +26 -0
- package/src/scripts/angular-brand-codemod.js.map +1 -1
- package/src/scripts/angular-brand-codemod.ts +26 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { IndentationText, Node, Project, QuoteKind, SyntaxKind } = require('ts-morph');
|
|
4
|
+
|
|
5
|
+
const projectCache = new Map();
|
|
6
|
+
|
|
7
|
+
const ROUTES_FACTORY = 'craftRoutes';
|
|
8
|
+
const ROUTE_FN = 'route';
|
|
9
|
+
const PENDING_PROP = 'pendingComponent';
|
|
10
|
+
const VT_PROP = 'withLoaderViewTransitionImage';
|
|
11
|
+
const CHECK_TYPE = 'RouteCheckedDI';
|
|
12
|
+
const CASCADE_TYPE = 'ValidateCascadeRoutesFile';
|
|
13
|
+
const CAN_RUN = 'CanRun';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Ensures every route with a lazy `pendingComponent: () => import('./x')` is
|
|
17
|
+
* verified with the per-component, O(1) `RouteCheckedDI` check (setup.md
|
|
18
|
+
* "Escape hatch"). The aggregated cascade never sees the pending component, so
|
|
19
|
+
* its DI must be checked directly — and `RouteCheckedDI` is used (not a second
|
|
20
|
+
* aggregated pass) to stay off the instantiation-count budget.
|
|
21
|
+
*
|
|
22
|
+
* Autofix generates the whole block:
|
|
23
|
+
*
|
|
24
|
+
* type _Check<Feature>PendingDI = RouteCheckedDI<
|
|
25
|
+
* import('./x').GenDeps_<DefaultExportClass>,
|
|
26
|
+
* '<CollectionParam>Params' | '<Collection><Route>ViewTransition', // route auto-provides these
|
|
27
|
+
* <ParentValues>, // borrowed from the cascade check
|
|
28
|
+
* 'pending component: <path>'
|
|
29
|
+
* >;
|
|
30
|
+
* type _CanRun<Feature>Pending = CanRun<_Check<Feature>PendingDI>;
|
|
31
|
+
*
|
|
32
|
+
* The `GenDeps_*` alias is read from the skeleton module, the available service
|
|
33
|
+
* names are derived from the route's path params + (when present) its
|
|
34
|
+
* `withLoaderViewTransitionImage` payload, and the parent context is taken from
|
|
35
|
+
* the collection's own `ValidateCascadeRoutesFile<…, typeof xRoutes>`. If that
|
|
36
|
+
* cascade check is absent (so the context can't be inferred), the rule reports
|
|
37
|
+
* without a fix.
|
|
38
|
+
*/
|
|
39
|
+
module.exports = {
|
|
40
|
+
meta: {
|
|
41
|
+
type: 'problem',
|
|
42
|
+
docs: {
|
|
43
|
+
description:
|
|
44
|
+
'Ensure every route with a lazy pendingComponent is verified with RouteCheckedDI(...).',
|
|
45
|
+
},
|
|
46
|
+
fixable: 'code',
|
|
47
|
+
schema: [],
|
|
48
|
+
},
|
|
49
|
+
create(context) {
|
|
50
|
+
return {
|
|
51
|
+
'Program:exit'() {
|
|
52
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
53
|
+
const filePath = getFilePath(context);
|
|
54
|
+
if (!filePath || !filePath.endsWith('.ts')) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const text = sourceCode.getText();
|
|
59
|
+
if (!text.includes(PENDING_PROP)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sourceFile = getProjectSourceFile(
|
|
64
|
+
getProject(getCwd(context)),
|
|
65
|
+
filePath,
|
|
66
|
+
text,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const collections = collectCollections(sourceFile, filePath);
|
|
70
|
+
const pendingRoutes = collections.flatMap((c) => c.pendingRoutes);
|
|
71
|
+
if (pendingRoutes.length === 0) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const checkedFirstArgs = collectCheckedGenDeps(sourceFile);
|
|
76
|
+
const cascadeContexts = collectCascadeContexts(sourceFile);
|
|
77
|
+
|
|
78
|
+
const issues = pendingRoutes.filter(
|
|
79
|
+
(route) => !checkedFirstArgs.has(normalize(route.genDepsRef)),
|
|
80
|
+
);
|
|
81
|
+
if (issues.length === 0) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const reportLoc = getNodeLoc(sourceCode, issues[0].reportNode);
|
|
86
|
+
|
|
87
|
+
let fixable = true;
|
|
88
|
+
for (const issue of issues) {
|
|
89
|
+
const ctx = cascadeContexts.get(`typeof ${issue.routesName}`);
|
|
90
|
+
if (!ctx) {
|
|
91
|
+
fixable = false;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
appendCheck(sourceFile, issue, ctx);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (fixable) {
|
|
98
|
+
ensureImports(sourceFile, [CHECK_TYPE, CAN_RUN]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const fixedText = sourceFile.getFullText();
|
|
102
|
+
const message = `route(s) with a pendingComponent must be verified with ${CHECK_TYPE}(): ${issues
|
|
103
|
+
.map((i) => i.path)
|
|
104
|
+
.join(', ')}`;
|
|
105
|
+
|
|
106
|
+
if (!fixable || fixedText === text) {
|
|
107
|
+
context.report({ loc: reportLoc, message });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
context.report({
|
|
112
|
+
loc: reportLoc,
|
|
113
|
+
message,
|
|
114
|
+
fix(fixer) {
|
|
115
|
+
return fixer.replaceTextRange([0, text.length], fixedText);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
function collectCollections(sourceFile, filePath) {
|
|
124
|
+
const collections = [];
|
|
125
|
+
|
|
126
|
+
for (const call of sourceFile.getDescendantsOfKind(
|
|
127
|
+
SyntaxKind.CallExpression,
|
|
128
|
+
)) {
|
|
129
|
+
if (call.getExpression().getText() !== ROUTES_FACTORY) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const collectionName = getStringArg(call, 0);
|
|
134
|
+
const routesArray = call.getArguments()[1];
|
|
135
|
+
if (!collectionName || !routesArray) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const routesName = getRoutesBindingName(call);
|
|
140
|
+
if (!routesName) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const pendingRoutes = [];
|
|
145
|
+
for (const element of getArrayElements(routesArray)) {
|
|
146
|
+
const route = readRoute(element);
|
|
147
|
+
if (!route) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const importSpecifier = getLazyImportSpecifier(route.object, PENDING_PROP);
|
|
152
|
+
if (!importSpecifier) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const genDepsName = resolveGenDepsName(filePath, importSpecifier);
|
|
157
|
+
if (!genDepsName) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
pendingRoutes.push({
|
|
162
|
+
routesName,
|
|
163
|
+
path: route.path,
|
|
164
|
+
genDepsRef: `import('${importSpecifier}').${genDepsName}`,
|
|
165
|
+
availableNames: deriveAvailableNames(
|
|
166
|
+
collectionName,
|
|
167
|
+
route.path,
|
|
168
|
+
hasProperty(route.object, VT_PROP),
|
|
169
|
+
),
|
|
170
|
+
suffix: toPascalCase(routesName.replace(/Routes$/, '')),
|
|
171
|
+
reportNode: getPropertyAssignment(route.object, PENDING_PROP),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
collections.push({ collectionName, routesName, pendingRoutes });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return collections;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// A route element is either `route('<path>', { … })` or a `{ path: '<path>', … }`
|
|
182
|
+
// object literal. Returns the path string and the route's object literal.
|
|
183
|
+
function readRoute(element) {
|
|
184
|
+
if (Node.isCallExpression(element) && element.getExpression().getText() === ROUTE_FN) {
|
|
185
|
+
const pathArg = getStringArg(element, 0);
|
|
186
|
+
const object = element.getArguments()[1];
|
|
187
|
+
if (pathArg !== undefined && object && Node.isObjectLiteralExpression(object)) {
|
|
188
|
+
return { path: pathArg, object };
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (Node.isObjectLiteralExpression(element)) {
|
|
194
|
+
const pathProp = getPropertyAssignment(element, 'path');
|
|
195
|
+
const initializer = pathProp?.getInitializer();
|
|
196
|
+
if (initializer && Node.isStringLiteral(initializer)) {
|
|
197
|
+
return { path: initializer.getLiteralText(), object: element };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// `${Collection}${Param}Params` for each `:param`, plus
|
|
205
|
+
// `${Collection}${RouteBase}ViewTransition` when the route declares a payload.
|
|
206
|
+
function deriveAvailableNames(collectionName, routePath, hasViewTransition) {
|
|
207
|
+
const collection = toPascalCase(collectionName);
|
|
208
|
+
const names = [];
|
|
209
|
+
|
|
210
|
+
for (const segment of routePath.split('/')) {
|
|
211
|
+
if (segment.startsWith(':')) {
|
|
212
|
+
const param = segment.slice(1).replace(/\?$/, '');
|
|
213
|
+
names.push(`${collection}${toPascalCase(param)}Params`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (hasViewTransition) {
|
|
218
|
+
names.push(`${collection}${routeBaseServiceName(routePath)}ViewTransition`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return names;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function routeBaseServiceName(routePath) {
|
|
225
|
+
const name = routePath
|
|
226
|
+
.split('/')
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
.map((segment) =>
|
|
229
|
+
segment === '**'
|
|
230
|
+
? 'Wildcard'
|
|
231
|
+
: toPascalCase(segment.replace(/^:/, '').replace(/\?$/, '')),
|
|
232
|
+
)
|
|
233
|
+
.join('');
|
|
234
|
+
return name || 'Root';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function getArrayElements(node) {
|
|
238
|
+
return Node.isArrayLiteralExpression(node) ? node.getElements() : [];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function getStringArg(call, index) {
|
|
242
|
+
const arg = call.getArguments()[index];
|
|
243
|
+
return arg && Node.isStringLiteral(arg) ? arg.getLiteralText() : undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function getPropertyAssignment(objectLiteral, name) {
|
|
247
|
+
if (!Node.isObjectLiteralExpression(objectLiteral)) {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
const property = objectLiteral
|
|
251
|
+
.getProperties()
|
|
252
|
+
.find(
|
|
253
|
+
(candidate) =>
|
|
254
|
+
Node.isPropertyAssignment(candidate) && candidate.getName() === name,
|
|
255
|
+
);
|
|
256
|
+
return Node.isPropertyAssignment(property) ? property : undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function hasProperty(objectLiteral, name) {
|
|
260
|
+
return getPropertyAssignment(objectLiteral, name) !== undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getLazyImportSpecifier(objectLiteral, propertyName) {
|
|
264
|
+
const property = getPropertyAssignment(objectLiteral, propertyName);
|
|
265
|
+
const initializer = property?.getInitializer();
|
|
266
|
+
if (!initializer || !Node.isArrowFunction(initializer)) {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const body = initializer.getBody();
|
|
271
|
+
const callExpression = Node.isCallExpression(body) ? body : undefined;
|
|
272
|
+
if (
|
|
273
|
+
!callExpression ||
|
|
274
|
+
callExpression.getExpression().getKind() !== SyntaxKind.ImportKeyword
|
|
275
|
+
) {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const [argument] = callExpression.getArguments();
|
|
280
|
+
return argument && Node.isStringLiteral(argument)
|
|
281
|
+
? argument.getLiteralText()
|
|
282
|
+
: undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getRoutesBindingName(call) {
|
|
286
|
+
const declaration = call.getFirstAncestorByKind(
|
|
287
|
+
SyntaxKind.VariableDeclaration,
|
|
288
|
+
);
|
|
289
|
+
const nameNode = declaration?.getNameNode();
|
|
290
|
+
if (!nameNode || nameNode.getKind() !== SyntaxKind.ObjectBindingPattern) {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const elements = nameNode.getElements();
|
|
295
|
+
const expected =
|
|
296
|
+
getStringArg(call, 0) !== undefined ? `${getStringArg(call, 0)}Routes` : undefined;
|
|
297
|
+
if (expected) {
|
|
298
|
+
const match = elements.find(
|
|
299
|
+
(element) =>
|
|
300
|
+
(element.getPropertyNameNode()?.getText() ?? element.getName()) ===
|
|
301
|
+
expected,
|
|
302
|
+
);
|
|
303
|
+
if (match) {
|
|
304
|
+
return match.getName();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const routesElements = elements.filter((element) =>
|
|
309
|
+
(element.getPropertyNameNode()?.getText() ?? element.getName()).endsWith(
|
|
310
|
+
'Routes',
|
|
311
|
+
),
|
|
312
|
+
);
|
|
313
|
+
return routesElements.length === 1 ? routesElements[0].getName() : undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// First type-argument text of every `RouteCheckedDI<First, …>` already present.
|
|
317
|
+
function collectCheckedGenDeps(sourceFile) {
|
|
318
|
+
const checked = new Set();
|
|
319
|
+
for (const reference of sourceFile.getDescendantsOfKind(
|
|
320
|
+
SyntaxKind.TypeReference,
|
|
321
|
+
)) {
|
|
322
|
+
if (reference.getTypeName().getText() !== CHECK_TYPE) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const first = reference.getTypeArguments()[0];
|
|
326
|
+
if (first) {
|
|
327
|
+
checked.add(normalize(first.getText()));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return checked;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// `typeof xRoutes` -> "ParentNames, ParentValues" from the cascade check.
|
|
334
|
+
function collectCascadeContexts(sourceFile) {
|
|
335
|
+
const contexts = new Map();
|
|
336
|
+
for (const reference of sourceFile.getDescendantsOfKind(
|
|
337
|
+
SyntaxKind.TypeReference,
|
|
338
|
+
)) {
|
|
339
|
+
if (reference.getTypeName().getText() !== CASCADE_TYPE) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const args = reference.getTypeArguments();
|
|
343
|
+
if (args.length >= 3) {
|
|
344
|
+
contexts.set(args[2].getText(), {
|
|
345
|
+
names: args[0].getText(),
|
|
346
|
+
values: args[1].getText(),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return contexts;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function appendCheck(sourceFile, issue, parentContext) {
|
|
354
|
+
const quoted = issue.availableNames.map((name) => `'${name}'`);
|
|
355
|
+
const available =
|
|
356
|
+
parentContext.names === 'never'
|
|
357
|
+
? quoted.join(' | ') || 'never'
|
|
358
|
+
: [parentContext.names, ...quoted].join(' | ');
|
|
359
|
+
|
|
360
|
+
sourceFile.addStatements((writer) => {
|
|
361
|
+
writer.blankLine();
|
|
362
|
+
writer.writeLine(`type _Check${issue.suffix}PendingDI = ${CHECK_TYPE}<`);
|
|
363
|
+
writer.writeLine(` ${issue.genDepsRef},`);
|
|
364
|
+
writer.writeLine(` ${available},`);
|
|
365
|
+
writer.writeLine(` ${parentContext.values},`);
|
|
366
|
+
writer.writeLine(` 'pending component: ${issue.path}'`);
|
|
367
|
+
writer.writeLine('>;');
|
|
368
|
+
writer.writeLine(
|
|
369
|
+
`type _CanRun${issue.suffix}Pending = ${CAN_RUN}<_Check${issue.suffix}PendingDI>;`,
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function resolveGenDepsName(fromFilePath, importSpecifier) {
|
|
375
|
+
if (!importSpecifier.startsWith('.')) {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
const resolvedBase = path.resolve(path.dirname(fromFilePath), importSpecifier);
|
|
379
|
+
const candidates = [
|
|
380
|
+
`${resolvedBase}.ts`,
|
|
381
|
+
`${resolvedBase}.tsx`,
|
|
382
|
+
path.join(resolvedBase, 'index.ts'),
|
|
383
|
+
];
|
|
384
|
+
const targetPath = candidates.find((candidate) => fs.existsSync(candidate));
|
|
385
|
+
if (!targetPath) {
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const targetText = fs.readFileSync(targetPath, 'utf8');
|
|
390
|
+
const names = [...targetText.matchAll(/export\s+type\s+(GenDeps_\w+)/g)].map(
|
|
391
|
+
(match) => match[1],
|
|
392
|
+
);
|
|
393
|
+
if (names.length === 0) {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const defaultClass = targetText.match(/export\s+default\s+class\s+(\w+)/);
|
|
398
|
+
if (defaultClass) {
|
|
399
|
+
const preferred = `GenDeps_${defaultClass[1]}`;
|
|
400
|
+
if (names.includes(preferred)) {
|
|
401
|
+
return preferred;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return names[0];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function ensureImports(sourceFile, names) {
|
|
408
|
+
const craftImport = sourceFile
|
|
409
|
+
.getImportDeclarations()
|
|
410
|
+
.find((imp) => imp.getModuleSpecifierValue() === '@craft-ng/core');
|
|
411
|
+
if (!craftImport) {
|
|
412
|
+
sourceFile.addImportDeclaration({
|
|
413
|
+
moduleSpecifier: '@craft-ng/core',
|
|
414
|
+
namedImports: names.map((name) => ({ name, isTypeOnly: true })),
|
|
415
|
+
});
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const existing = new Set(
|
|
419
|
+
craftImport.getNamedImports().map((ni) => ni.getName()),
|
|
420
|
+
);
|
|
421
|
+
for (const name of names) {
|
|
422
|
+
if (!existing.has(name)) {
|
|
423
|
+
craftImport.addNamedImport({ name, isTypeOnly: true });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function getProject(cwd) {
|
|
429
|
+
let project = projectCache.get(cwd);
|
|
430
|
+
if (project) {
|
|
431
|
+
return project;
|
|
432
|
+
}
|
|
433
|
+
const manipulationSettings = {
|
|
434
|
+
indentationText: IndentationText.TwoSpaces,
|
|
435
|
+
quoteKind: QuoteKind.Single,
|
|
436
|
+
};
|
|
437
|
+
const tsConfigFilePath = path.join(cwd, 'tsconfig.json');
|
|
438
|
+
project = fs.existsSync(tsConfigFilePath)
|
|
439
|
+
? new Project({ tsConfigFilePath, manipulationSettings })
|
|
440
|
+
: new Project({
|
|
441
|
+
compilerOptions: { experimentalDecorators: true, target: 9 },
|
|
442
|
+
manipulationSettings,
|
|
443
|
+
});
|
|
444
|
+
projectCache.set(cwd, project);
|
|
445
|
+
return project;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function getProjectSourceFile(project, filePath, text) {
|
|
449
|
+
const normalizedPath = path.resolve(filePath);
|
|
450
|
+
const existing = project.getSourceFile(normalizedPath);
|
|
451
|
+
if (existing) {
|
|
452
|
+
existing.replaceWithText(text);
|
|
453
|
+
return existing;
|
|
454
|
+
}
|
|
455
|
+
const added = project.addSourceFileAtPathIfExists(normalizedPath);
|
|
456
|
+
if (added) {
|
|
457
|
+
added.replaceWithText(text);
|
|
458
|
+
return added;
|
|
459
|
+
}
|
|
460
|
+
return project.createSourceFile(normalizedPath, text, { overwrite: true });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function getFilePath(context) {
|
|
464
|
+
const filePath = context.filename ?? context.getFilename();
|
|
465
|
+
return !filePath || filePath === '<input>' ? undefined : filePath;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function getCwd(context) {
|
|
469
|
+
return context.cwd ?? process.cwd();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function getNodeLoc(sourceCode, node) {
|
|
473
|
+
return {
|
|
474
|
+
start: sourceCode.getLocFromIndex(node.getStart()),
|
|
475
|
+
end: sourceCode.getLocFromIndex(node.getEnd()),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function toPascalCase(value) {
|
|
480
|
+
return value
|
|
481
|
+
.replace(/^:/, '')
|
|
482
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
483
|
+
.filter(Boolean)
|
|
484
|
+
.map((segment) => segment[0].toUpperCase() + segment.slice(1))
|
|
485
|
+
.join('');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function normalize(text) {
|
|
489
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
490
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
const DEFAULT_PRIMITIVES = [
|
|
2
|
+
'mutation',
|
|
3
|
+
'query',
|
|
4
|
+
'asyncProcess',
|
|
5
|
+
'state',
|
|
6
|
+
'craftMethod',
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
meta: {
|
|
11
|
+
type: 'problem',
|
|
12
|
+
docs: {
|
|
13
|
+
description:
|
|
14
|
+
'Require primitives that carry dependencies to be yielded with `yield* track(...)` inside a craftService so their dependencies are detected.',
|
|
15
|
+
},
|
|
16
|
+
fixable: 'code',
|
|
17
|
+
schema: [
|
|
18
|
+
{
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
primitives: {
|
|
22
|
+
type: 'array',
|
|
23
|
+
items: { type: 'string' },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
additionalProperties: false,
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
messages: {
|
|
30
|
+
requireTrack:
|
|
31
|
+
"'{{name}}(...)' uses dependencies (it yields) and must be yielded with `yield* track({{name}}(...))` so the enclosing craftService tracks them.",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
create(context) {
|
|
35
|
+
const options = context.options[0] ?? {};
|
|
36
|
+
const primitives = new Set(options.primitives ?? DEFAULT_PRIMITIVES);
|
|
37
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
CallExpression(node) {
|
|
41
|
+
if (
|
|
42
|
+
node.callee.type !== 'Identifier' ||
|
|
43
|
+
!primitives.has(node.callee.name)
|
|
44
|
+
) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!isInsideCraftService(node)) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `yield* track(...)` is only legal when the directly enclosing factory
|
|
53
|
+
// is a generator. Non-generator factories (e.g. `(inputs) => {...}`)
|
|
54
|
+
// cannot yield, so the rule does not apply there.
|
|
55
|
+
if (!isInsideGeneratorFactory(node)) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// "Has dependencies" heuristic: the primitive config yields something.
|
|
60
|
+
if (!argumentsContainYield(node)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (isAlreadyTracked(node)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const primitiveName = node.callee.name;
|
|
69
|
+
|
|
70
|
+
context.report({
|
|
71
|
+
node,
|
|
72
|
+
messageId: 'requireTrack',
|
|
73
|
+
data: { name: primitiveName },
|
|
74
|
+
fix(fixer) {
|
|
75
|
+
const fixes = [
|
|
76
|
+
fixer.replaceText(
|
|
77
|
+
node,
|
|
78
|
+
`yield* track(${sourceCode.getText(node)})`,
|
|
79
|
+
),
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const importFix = createTrackImportFix(fixer, sourceCode);
|
|
83
|
+
if (importFix) {
|
|
84
|
+
fixes.push(importFix);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return fixes;
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
function isInsideCraftService(node) {
|
|
96
|
+
let current = node.parent;
|
|
97
|
+
while (current) {
|
|
98
|
+
if (
|
|
99
|
+
current.type === 'CallExpression' &&
|
|
100
|
+
current.callee.type === 'Identifier' &&
|
|
101
|
+
current.callee.name === 'craftService'
|
|
102
|
+
) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
current = current.parent;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isInsideGeneratorFactory(node) {
|
|
111
|
+
let current = node.parent;
|
|
112
|
+
while (current) {
|
|
113
|
+
if (
|
|
114
|
+
current.type === 'FunctionDeclaration' ||
|
|
115
|
+
current.type === 'FunctionExpression' ||
|
|
116
|
+
current.type === 'ArrowFunctionExpression'
|
|
117
|
+
) {
|
|
118
|
+
return current.generator === true;
|
|
119
|
+
}
|
|
120
|
+
current = current.parent;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isAlreadyTracked(node) {
|
|
126
|
+
// Being the argument of a `track(...)` call is enough to consider the
|
|
127
|
+
// primitive handled — this keeps the autofix idempotent regardless of how the
|
|
128
|
+
// surrounding `yield*` is shaped.
|
|
129
|
+
const parent = node.parent;
|
|
130
|
+
return (
|
|
131
|
+
parent &&
|
|
132
|
+
parent.type === 'CallExpression' &&
|
|
133
|
+
parent.callee.type === 'Identifier' &&
|
|
134
|
+
parent.callee.name === 'track' &&
|
|
135
|
+
parent.arguments[0] === node
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function argumentsContainYield(callNode) {
|
|
140
|
+
for (const argument of callNode.arguments) {
|
|
141
|
+
if (containsYield(argument)) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function containsYield(node) {
|
|
149
|
+
if (!node || typeof node.type !== 'string') {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (node.type === 'YieldExpression') {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const key of Object.keys(node)) {
|
|
158
|
+
if (key === 'parent') {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const value = node[key];
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
for (const item of value) {
|
|
165
|
+
if (item && typeof item.type === 'string' && containsYield(item)) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} else if (value && typeof value.type === 'string') {
|
|
170
|
+
if (containsYield(value)) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function createTrackImportFix(fixer, sourceCode) {
|
|
180
|
+
const program = sourceCode.ast;
|
|
181
|
+
let craftImport;
|
|
182
|
+
|
|
183
|
+
for (const statement of program.body) {
|
|
184
|
+
if (
|
|
185
|
+
statement.type === 'ImportDeclaration' &&
|
|
186
|
+
statement.source.value === '@craft-ng/core'
|
|
187
|
+
) {
|
|
188
|
+
const alreadyImported = statement.specifiers.some(
|
|
189
|
+
(specifier) =>
|
|
190
|
+
specifier.type === 'ImportSpecifier' &&
|
|
191
|
+
specifier.imported.type === 'Identifier' &&
|
|
192
|
+
specifier.imported.name === 'track',
|
|
193
|
+
);
|
|
194
|
+
if (alreadyImported) {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
craftImport = statement;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!craftImport) {
|
|
203
|
+
return fixer.insertTextBefore(
|
|
204
|
+
program.body[0] ?? program,
|
|
205
|
+
"import { track } from '@craft-ng/core';\n",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const namedSpecifiers = craftImport.specifiers.filter(
|
|
210
|
+
(specifier) => specifier.type === 'ImportSpecifier',
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
if (namedSpecifiers.length === 0) {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const lastSpecifier = namedSpecifiers[namedSpecifiers.length - 1];
|
|
218
|
+
return fixer.insertTextAfter(lastSpecifier, ', track');
|
|
219
|
+
}
|