@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,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
+ }