@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
|
@@ -5,10 +5,14 @@ module.exports = {
|
|
|
5
5
|
description:
|
|
6
6
|
'Disallow Angular inject() usage so dependencies go through craftService or toCraftService.',
|
|
7
7
|
},
|
|
8
|
+
hasSuggestions: true,
|
|
8
9
|
schema: [],
|
|
9
10
|
},
|
|
10
11
|
create(context) {
|
|
11
12
|
const angularNamespaceImports = new Set();
|
|
13
|
+
const angularInjectImports = new Map();
|
|
14
|
+
const reportedInjectImports = new Set();
|
|
15
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
12
16
|
|
|
13
17
|
return {
|
|
14
18
|
ImportDeclaration(node) {
|
|
@@ -22,11 +26,7 @@ module.exports = {
|
|
|
22
26
|
specifier.imported.type === 'Identifier' &&
|
|
23
27
|
specifier.imported.name === 'inject'
|
|
24
28
|
) {
|
|
25
|
-
|
|
26
|
-
node: specifier,
|
|
27
|
-
message:
|
|
28
|
-
'Angular inject() is forbidden. Expose a craftService/toCraftService injector instead.',
|
|
29
|
-
});
|
|
29
|
+
angularInjectImports.set(specifier.local.name, specifier);
|
|
30
30
|
continue;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -36,24 +36,50 @@ module.exports = {
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
CallExpression(node) {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
callee.property.type !== 'Identifier' ||
|
|
46
|
-
callee.property.name !== 'inject'
|
|
47
|
-
) {
|
|
39
|
+
const angularInjectNode = getAngularInjectCallee(
|
|
40
|
+
node.callee,
|
|
41
|
+
angularInjectImports,
|
|
42
|
+
angularNamespaceImports,
|
|
43
|
+
);
|
|
44
|
+
if (!angularInjectNode) {
|
|
48
45
|
return;
|
|
49
46
|
}
|
|
50
47
|
|
|
48
|
+
if (node.callee.type === 'Identifier') {
|
|
49
|
+
reportedInjectImports.add(node.callee.name);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tokenText = getInjectTokenText(node.arguments[0], sourceCode);
|
|
53
|
+
const helperName = getRecommendedInjectorName(node.arguments[0]);
|
|
54
|
+
|
|
51
55
|
context.report({
|
|
52
|
-
node:
|
|
53
|
-
message:
|
|
54
|
-
|
|
56
|
+
node: angularInjectNode,
|
|
57
|
+
message: createInjectMessage(tokenText, helperName),
|
|
58
|
+
suggest: [
|
|
59
|
+
createTemporaryDisableSuggestion(
|
|
60
|
+
sourceCode,
|
|
61
|
+
angularInjectNode,
|
|
62
|
+
'no-angular-inject',
|
|
63
|
+
helperName
|
|
64
|
+
? `replace this Angular inject(${tokenText}) call with ${helperName} from a craftService/toCraftService adapter`
|
|
65
|
+
: 'replace this Angular inject() call with a craftService/toCraftService injector',
|
|
66
|
+
),
|
|
67
|
+
],
|
|
55
68
|
});
|
|
56
69
|
},
|
|
70
|
+
'Program:exit'() {
|
|
71
|
+
for (const [localName, specifier] of angularInjectImports) {
|
|
72
|
+
if (reportedInjectImports.has(localName)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
context.report({
|
|
77
|
+
node: specifier,
|
|
78
|
+
message:
|
|
79
|
+
'Angular inject() is forbidden. Import and use the injectX helper exposed by a craftService/toCraftService adapter instead.',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
},
|
|
57
83
|
};
|
|
58
84
|
},
|
|
59
85
|
};
|
|
@@ -61,3 +87,101 @@ module.exports = {
|
|
|
61
87
|
function isAngularModule(sourceValue) {
|
|
62
88
|
return typeof sourceValue === 'string' && sourceValue.startsWith('@angular/');
|
|
63
89
|
}
|
|
90
|
+
|
|
91
|
+
function getAngularInjectCallee(
|
|
92
|
+
callee,
|
|
93
|
+
angularInjectImports,
|
|
94
|
+
angularNamespaceImports,
|
|
95
|
+
) {
|
|
96
|
+
if (callee.type === 'Identifier' && angularInjectImports.has(callee.name)) {
|
|
97
|
+
return callee;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
callee.type === 'MemberExpression' &&
|
|
102
|
+
!callee.computed &&
|
|
103
|
+
callee.object.type === 'Identifier' &&
|
|
104
|
+
angularNamespaceImports.has(callee.object.name) &&
|
|
105
|
+
callee.property.type === 'Identifier' &&
|
|
106
|
+
callee.property.name === 'inject'
|
|
107
|
+
) {
|
|
108
|
+
return callee.property;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function createInjectMessage(tokenText, helperName) {
|
|
115
|
+
if (!helperName) {
|
|
116
|
+
return 'Angular inject() is forbidden. Use the injectX helper exposed by a craftService/toCraftService adapter instead.';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return `Angular inject(${tokenText}) is forbidden. Use ${helperName} from a craftService/toCraftService adapter instead.`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getInjectTokenText(argument, sourceCode) {
|
|
123
|
+
if (!argument) {
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return sourceCode.getText(argument);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getRecommendedInjectorName(argument) {
|
|
131
|
+
const tokenName = getTokenName(argument);
|
|
132
|
+
if (!tokenName) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return `inject${toPascalCase(tokenName)}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getTokenName(argument) {
|
|
140
|
+
if (!argument) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (argument.type === 'Identifier') {
|
|
145
|
+
return argument.name;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (
|
|
149
|
+
argument.type === 'MemberExpression' &&
|
|
150
|
+
!argument.computed &&
|
|
151
|
+
argument.property.type === 'Identifier'
|
|
152
|
+
) {
|
|
153
|
+
return argument.property.name;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toPascalCase(value) {
|
|
160
|
+
return value
|
|
161
|
+
.replace(/^[^A-Za-z$]+/, '')
|
|
162
|
+
.split(/[^A-Za-z0-9$]+|(?<=[a-z0-9])(?=[A-Z])/)
|
|
163
|
+
.filter(Boolean)
|
|
164
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
|
165
|
+
.join('');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function createTemporaryDisableSuggestion(
|
|
169
|
+
sourceCode,
|
|
170
|
+
node,
|
|
171
|
+
ruleName,
|
|
172
|
+
migrationNote,
|
|
173
|
+
) {
|
|
174
|
+
return {
|
|
175
|
+
desc: 'Insert a temporary eslint-disable-next-line comment with a migration note.',
|
|
176
|
+
fix(fixer) {
|
|
177
|
+
const lineStart = sourceCode.getIndexFromLoc({
|
|
178
|
+
line: node.loc.start.line,
|
|
179
|
+
column: 0,
|
|
180
|
+
});
|
|
181
|
+
return fixer.insertTextBeforeRange(
|
|
182
|
+
[lineStart, lineStart],
|
|
183
|
+
`// eslint-disable-next-line craft-ng/${ruleName} -- ${migrationNote}\n`,
|
|
184
|
+
);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const { createPreferCraftSignalRule } = require('./prefer-craft-signal-utils.cjs');
|
|
2
|
+
|
|
3
|
+
module.exports = createPreferCraftSignalRule({
|
|
4
|
+
angularName: 'effect',
|
|
5
|
+
craftName: 'craftEffect',
|
|
6
|
+
needsName: true,
|
|
7
|
+
messageId: 'preferCraftEffect',
|
|
8
|
+
message:
|
|
9
|
+
"Angular effect() is forbidden. Use craftEffect('name', ...) from @craft-ng/core instead for observability and host name tracking.",
|
|
10
|
+
description:
|
|
11
|
+
'Disallow Angular effect() in favor of craftEffect() from @craft-ng/core.',
|
|
12
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
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 ANGULAR_ROUTER_MODULE = '@angular/router';
|
|
8
|
+
const CRAFT_CORE_MODULE = '@craft-ng/core';
|
|
9
|
+
const ANGULAR_SYMBOL = 'RouterOutlet';
|
|
10
|
+
const CRAFT_SYMBOL = 'CraftRouterOutlet';
|
|
11
|
+
const ANGULAR_TAG = 'router-outlet';
|
|
12
|
+
const CRAFT_TAG = 'craft-router-outlet';
|
|
13
|
+
|
|
14
|
+
// Match `<router-outlet`, `</router-outlet`, but NOT `<craft-router-outlet`
|
|
15
|
+
// (the `(?<![\w-])` guard stops the `-router-outlet` suffix from matching).
|
|
16
|
+
const TAG_PATTERN = /<(\/?)(?<![\w-])router-outlet\b/g;
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
meta: {
|
|
20
|
+
type: 'problem',
|
|
21
|
+
docs: {
|
|
22
|
+
description:
|
|
23
|
+
'Disallow Angular RouterOutlet / <router-outlet>. Use CraftRouterOutlet / <craft-router-outlet> (the non-blocking outlet) instead.',
|
|
24
|
+
},
|
|
25
|
+
fixable: 'code',
|
|
26
|
+
schema: [],
|
|
27
|
+
messages: {
|
|
28
|
+
forbidden:
|
|
29
|
+
'Use CraftRouterOutlet / <craft-router-outlet> instead of Angular RouterOutlet / <router-outlet>.',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
create(context) {
|
|
33
|
+
return {
|
|
34
|
+
'Program:exit'() {
|
|
35
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
36
|
+
const filePath = getFilePath(context);
|
|
37
|
+
if (!filePath || !filePath.endsWith('.ts')) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const text = sourceCode.getText();
|
|
42
|
+
const hasImport = text.includes(ANGULAR_SYMBOL);
|
|
43
|
+
const hasTag = TAG_PATTERN.test(text);
|
|
44
|
+
TAG_PATTERN.lastIndex = 0;
|
|
45
|
+
if (!hasImport && !hasTag) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sourceFile = getProjectSourceFile(
|
|
50
|
+
getProject(getCwd(context)),
|
|
51
|
+
filePath,
|
|
52
|
+
text,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const importChanged = rewriteAngularRouterOutletImport(sourceFile);
|
|
56
|
+
|
|
57
|
+
// Rewrite the inline template tag(s) on the post-import-edit text so the
|
|
58
|
+
// single whole-file fix carries both changes.
|
|
59
|
+
let fixedText = sourceFile.getFullText();
|
|
60
|
+
const tagChanged = TAG_PATTERN.test(fixedText);
|
|
61
|
+
TAG_PATTERN.lastIndex = 0;
|
|
62
|
+
if (tagChanged) {
|
|
63
|
+
fixedText = fixedText.replace(TAG_PATTERN, `<$1${CRAFT_TAG}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if ((!importChanged && !tagChanged) || fixedText === text) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
context.report({
|
|
71
|
+
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
72
|
+
messageId: 'forbidden',
|
|
73
|
+
fix(fixer) {
|
|
74
|
+
return fixer.replaceTextRange([0, text.length], fixedText);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Removes `RouterOutlet` from the `@angular/router` import, renames every
|
|
83
|
+
// `RouterOutlet` reference to `CraftRouterOutlet`, and ensures the craft import.
|
|
84
|
+
// Returns whether anything changed.
|
|
85
|
+
function rewriteAngularRouterOutletImport(sourceFile) {
|
|
86
|
+
const routerImport = sourceFile
|
|
87
|
+
.getImportDeclarations()
|
|
88
|
+
.find((imp) => imp.getModuleSpecifierValue() === ANGULAR_ROUTER_MODULE);
|
|
89
|
+
|
|
90
|
+
const routerOutletSpecifier = routerImport
|
|
91
|
+
?.getNamedImports()
|
|
92
|
+
.find((named) => named.getName() === ANGULAR_SYMBOL);
|
|
93
|
+
|
|
94
|
+
if (!routerOutletSpecifier) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Drop the named import (and the whole declaration if it becomes empty).
|
|
99
|
+
routerOutletSpecifier.remove();
|
|
100
|
+
if (
|
|
101
|
+
routerImport.getNamedImports().length === 0 &&
|
|
102
|
+
!routerImport.getDefaultImport() &&
|
|
103
|
+
!routerImport.getNamespaceImport()
|
|
104
|
+
) {
|
|
105
|
+
routerImport.remove();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Rename remaining `RouterOutlet` references (imports array, GenDeps, …).
|
|
109
|
+
for (const identifier of sourceFile.getDescendantsOfKind(
|
|
110
|
+
SyntaxKind.Identifier,
|
|
111
|
+
)) {
|
|
112
|
+
if (!identifier.wasForgotten() && identifier.getText() === ANGULAR_SYMBOL) {
|
|
113
|
+
identifier.replaceWithText(CRAFT_SYMBOL);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
ensureCraftImport(sourceFile);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function ensureCraftImport(sourceFile) {
|
|
122
|
+
const craftImport = sourceFile
|
|
123
|
+
.getImportDeclarations()
|
|
124
|
+
.find((imp) => imp.getModuleSpecifierValue() === CRAFT_CORE_MODULE);
|
|
125
|
+
|
|
126
|
+
if (craftImport) {
|
|
127
|
+
const alreadyImported = craftImport
|
|
128
|
+
.getNamedImports()
|
|
129
|
+
.some((named) => named.getName() === CRAFT_SYMBOL);
|
|
130
|
+
if (!alreadyImported) {
|
|
131
|
+
craftImport.addNamedImport(CRAFT_SYMBOL);
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
sourceFile.addImportDeclaration({
|
|
135
|
+
moduleSpecifier: CRAFT_CORE_MODULE,
|
|
136
|
+
namedImports: [CRAFT_SYMBOL],
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getProject(cwd) {
|
|
142
|
+
let project = projectCache.get(cwd);
|
|
143
|
+
if (project) {
|
|
144
|
+
return project;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const manipulationSettings = {
|
|
148
|
+
indentationText: IndentationText.TwoSpaces,
|
|
149
|
+
quoteKind: QuoteKind.Single,
|
|
150
|
+
};
|
|
151
|
+
const tsConfigFilePath = path.join(cwd, 'tsconfig.json');
|
|
152
|
+
project = fs.existsSync(tsConfigFilePath)
|
|
153
|
+
? new Project({ tsConfigFilePath, manipulationSettings })
|
|
154
|
+
: new Project({
|
|
155
|
+
compilerOptions: { experimentalDecorators: true, target: 9 },
|
|
156
|
+
manipulationSettings,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
projectCache.set(cwd, project);
|
|
160
|
+
return project;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getProjectSourceFile(project, filePath, text) {
|
|
164
|
+
const normalizedPath = path.resolve(filePath);
|
|
165
|
+
const existingSourceFile = project.getSourceFile(normalizedPath);
|
|
166
|
+
if (existingSourceFile) {
|
|
167
|
+
existingSourceFile.replaceWithText(text);
|
|
168
|
+
return existingSourceFile;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const sourceFile = project.addSourceFileAtPathIfExists(normalizedPath);
|
|
172
|
+
if (sourceFile) {
|
|
173
|
+
sourceFile.replaceWithText(text);
|
|
174
|
+
return sourceFile;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return project.createSourceFile(normalizedPath, text, { overwrite: true });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getFilePath(context) {
|
|
181
|
+
const filePath = context.filename ?? context.getFilename();
|
|
182
|
+
if (!filePath || filePath === '<input>') {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
return filePath;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function getCwd(context) {
|
|
189
|
+
return context.cwd ?? process.cwd();
|
|
190
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
const ANGULAR_CORE = '@angular/core';
|
|
2
|
+
const CRAFT_CORE = '@craft-ng/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Factory building a rule that forbids a primitive imported from `@angular/core`
|
|
6
|
+
* (`signal`, `computed`, `effect`) in favor of its craft counterpart
|
|
7
|
+
* (`state`, `craftComputed`, `craftEffect`).
|
|
8
|
+
*
|
|
9
|
+
* The autofix:
|
|
10
|
+
* - renames the call expression callee,
|
|
11
|
+
* - inserts the derived name as the first argument when the craft replacement
|
|
12
|
+
* requires one (`needsName`) and a name can be derived from the declaration,
|
|
13
|
+
* - rewrites the import: drops the forbidden specifier from `@angular/core` and
|
|
14
|
+
* adds the replacement import from `@craft-ng/core` (merging into an existing
|
|
15
|
+
* `@craft-ng/core` import when present).
|
|
16
|
+
*
|
|
17
|
+
* Namespace usages (`import * as core from '@angular/core'; core.signal()`) are
|
|
18
|
+
* reported without an autofix since they cannot be migrated by a local rename.
|
|
19
|
+
*/
|
|
20
|
+
function createPreferCraftSignalRule({
|
|
21
|
+
angularName,
|
|
22
|
+
craftName,
|
|
23
|
+
needsName,
|
|
24
|
+
messageId,
|
|
25
|
+
message,
|
|
26
|
+
description,
|
|
27
|
+
}) {
|
|
28
|
+
return {
|
|
29
|
+
meta: {
|
|
30
|
+
type: 'problem',
|
|
31
|
+
docs: { description },
|
|
32
|
+
fixable: 'code',
|
|
33
|
+
schema: [],
|
|
34
|
+
messages: { [messageId]: message },
|
|
35
|
+
},
|
|
36
|
+
create(context) {
|
|
37
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
38
|
+
const namespaceImports = new Set();
|
|
39
|
+
const usages = [];
|
|
40
|
+
let importNode = null;
|
|
41
|
+
let importSpecifier = null;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
ImportDeclaration(node) {
|
|
45
|
+
if (node.source.value !== ANGULAR_CORE) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const specifier of node.specifiers) {
|
|
50
|
+
if (
|
|
51
|
+
specifier.type === 'ImportSpecifier' &&
|
|
52
|
+
specifier.imported.type === 'Identifier' &&
|
|
53
|
+
specifier.imported.name === angularName
|
|
54
|
+
) {
|
|
55
|
+
importNode = node;
|
|
56
|
+
importSpecifier = specifier;
|
|
57
|
+
} else if (specifier.type === 'ImportNamespaceSpecifier') {
|
|
58
|
+
namespaceImports.add(specifier.local.name);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
CallExpression(node) {
|
|
63
|
+
const callee = node.callee;
|
|
64
|
+
|
|
65
|
+
// Namespace usage (`core.signal()`) cannot be migrated by a local
|
|
66
|
+
// rename, so report it as-is without a fix.
|
|
67
|
+
if (
|
|
68
|
+
callee.type === 'MemberExpression' &&
|
|
69
|
+
!callee.computed &&
|
|
70
|
+
callee.object.type === 'Identifier' &&
|
|
71
|
+
namespaceImports.has(callee.object.name) &&
|
|
72
|
+
callee.property.type === 'Identifier' &&
|
|
73
|
+
callee.property.name === angularName
|
|
74
|
+
) {
|
|
75
|
+
context.report({ node: callee.property, messageId });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
callee.type !== 'Identifier' ||
|
|
81
|
+
callee.name !== angularName ||
|
|
82
|
+
!isAngularImport(angularName, sourceCode, node)
|
|
83
|
+
) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const declaredName = needsName ? getDeclaredName(node) : undefined;
|
|
88
|
+
usages.push({
|
|
89
|
+
callNode: node,
|
|
90
|
+
callee,
|
|
91
|
+
declaredName,
|
|
92
|
+
fixable: !needsName || Boolean(declaredName),
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
'Program:exit'() {
|
|
96
|
+
if (!importSpecifier) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Autofix is all-or-nothing per file: only rewrite the import and
|
|
101
|
+
// rename usages when every usage can be safely renamed. Otherwise a
|
|
102
|
+
// partial fix would leave the renamed import referencing a callee
|
|
103
|
+
// that was never updated (or vice versa).
|
|
104
|
+
const canAutofix = usages.every((usage) => usage.fixable);
|
|
105
|
+
|
|
106
|
+
context.report({
|
|
107
|
+
node: importSpecifier,
|
|
108
|
+
messageId,
|
|
109
|
+
fix: canAutofix
|
|
110
|
+
? (fixer) =>
|
|
111
|
+
fixImport(
|
|
112
|
+
fixer,
|
|
113
|
+
sourceCode,
|
|
114
|
+
importNode,
|
|
115
|
+
importSpecifier,
|
|
116
|
+
craftName,
|
|
117
|
+
)
|
|
118
|
+
: undefined,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
for (const usage of usages) {
|
|
122
|
+
context.report({
|
|
123
|
+
node: usage.callee,
|
|
124
|
+
messageId,
|
|
125
|
+
fix: canAutofix
|
|
126
|
+
? (fixer) => fixUsage(fixer, sourceCode, usage, craftName, needsName)
|
|
127
|
+
: undefined,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function fixUsage(fixer, sourceCode, usage, craftName, needsName) {
|
|
137
|
+
const fixes = [fixer.replaceText(usage.callee, craftName)];
|
|
138
|
+
|
|
139
|
+
if (needsName) {
|
|
140
|
+
const openParen = sourceCode.getTokenAfter(
|
|
141
|
+
usage.callee,
|
|
142
|
+
(token) => token.type === 'Punctuator' && token.value === '(',
|
|
143
|
+
);
|
|
144
|
+
if (!openParen) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const separator = usage.callNode.arguments.length > 0 ? ', ' : '';
|
|
148
|
+
fixes.push(
|
|
149
|
+
fixer.insertTextAfter(openParen, `'${usage.declaredName}'${separator}`),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return fixes;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function fixImport(fixer, sourceCode, importNode, specifier, craftName) {
|
|
157
|
+
const namedSpecifiers = importNode.specifiers.filter(
|
|
158
|
+
(s) => s.type === 'ImportSpecifier',
|
|
159
|
+
);
|
|
160
|
+
const hasOtherKind = importNode.specifiers.some(
|
|
161
|
+
(s) => s.type !== 'ImportSpecifier',
|
|
162
|
+
);
|
|
163
|
+
const removesWholeImport = namedSpecifiers.length === 1 && !hasOtherKind;
|
|
164
|
+
|
|
165
|
+
const craftImport = findCraftImport(sourceCode);
|
|
166
|
+
const alreadyImported =
|
|
167
|
+
craftImport &&
|
|
168
|
+
craftImport.specifiers.some(
|
|
169
|
+
(s) =>
|
|
170
|
+
s.type === 'ImportSpecifier' &&
|
|
171
|
+
s.imported.type === 'Identifier' &&
|
|
172
|
+
s.imported.name === craftName,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// When the forbidden specifier is the only thing imported from @angular/core
|
|
176
|
+
// and there is no existing @craft-ng/core import to merge into, swap the
|
|
177
|
+
// whole declaration in place to avoid an insert that overlaps the removal.
|
|
178
|
+
if (removesWholeImport && !craftImport) {
|
|
179
|
+
return fixer.replaceText(
|
|
180
|
+
importNode,
|
|
181
|
+
`import { ${craftName} } from '${CRAFT_CORE}';`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const fixes = [removeSpecifier(fixer, sourceCode, importNode, specifier, removesWholeImport)];
|
|
186
|
+
|
|
187
|
+
if (craftImport && !alreadyImported) {
|
|
188
|
+
const lastSpecifier =
|
|
189
|
+
craftImport.specifiers[craftImport.specifiers.length - 1];
|
|
190
|
+
fixes.push(fixer.insertTextAfter(lastSpecifier, `, ${craftName}`));
|
|
191
|
+
} else if (!craftImport) {
|
|
192
|
+
fixes.push(
|
|
193
|
+
fixer.insertTextAfter(
|
|
194
|
+
importNode,
|
|
195
|
+
`\nimport { ${craftName} } from '${CRAFT_CORE}';`,
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return fixes;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function removeSpecifier(fixer, sourceCode, importNode, specifier, removesWholeImport) {
|
|
204
|
+
if (removesWholeImport) {
|
|
205
|
+
const text = sourceCode.getText();
|
|
206
|
+
let end = importNode.range[1];
|
|
207
|
+
if (text[end] === '\n') {
|
|
208
|
+
end += 1;
|
|
209
|
+
}
|
|
210
|
+
return fixer.removeRange([importNode.range[0], end]);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const tokenAfter = sourceCode.getTokenAfter(specifier);
|
|
214
|
+
if (tokenAfter && tokenAfter.value === ',') {
|
|
215
|
+
return fixer.removeRange([specifier.range[0], tokenAfter.range[1]]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const tokenBefore = sourceCode.getTokenBefore(specifier);
|
|
219
|
+
if (tokenBefore && tokenBefore.value === ',') {
|
|
220
|
+
return fixer.removeRange([tokenBefore.range[0], specifier.range[1]]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return fixer.remove(specifier);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function findCraftImport(sourceCode) {
|
|
227
|
+
return sourceCode.ast.body.find(
|
|
228
|
+
(statement) =>
|
|
229
|
+
statement.type === 'ImportDeclaration' &&
|
|
230
|
+
statement.source.value === CRAFT_CORE &&
|
|
231
|
+
statement.importKind !== 'type' &&
|
|
232
|
+
statement.specifiers.some((s) => s.type === 'ImportSpecifier'),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function getDeclaredName(callNode) {
|
|
237
|
+
const parent = callNode.parent;
|
|
238
|
+
if (!parent) return undefined;
|
|
239
|
+
|
|
240
|
+
if (
|
|
241
|
+
parent.type === 'VariableDeclarator' &&
|
|
242
|
+
parent.init === callNode &&
|
|
243
|
+
parent.id.type === 'Identifier'
|
|
244
|
+
) {
|
|
245
|
+
return parent.id.name;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
parent.type === 'PropertyDefinition' &&
|
|
250
|
+
parent.value === callNode &&
|
|
251
|
+
!parent.computed &&
|
|
252
|
+
parent.key.type === 'Identifier'
|
|
253
|
+
) {
|
|
254
|
+
return parent.key.name;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isAngularImport(angularName, sourceCode, node) {
|
|
261
|
+
const getScope = sourceCode.getScope
|
|
262
|
+
? (n) => sourceCode.getScope(n)
|
|
263
|
+
: (n) => sourceCode.scopeManager.acquire(n) ?? sourceCode.scopeManager.globalScope;
|
|
264
|
+
|
|
265
|
+
let currentScope = getScope(node);
|
|
266
|
+
while (currentScope) {
|
|
267
|
+
for (const variable of currentScope.variables) {
|
|
268
|
+
if (variable.name !== angularName) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
for (const def of variable.defs) {
|
|
272
|
+
if (
|
|
273
|
+
def.type === 'ImportBinding' &&
|
|
274
|
+
def.parent &&
|
|
275
|
+
def.parent.source &&
|
|
276
|
+
def.parent.source.value === ANGULAR_CORE
|
|
277
|
+
) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
currentScope = currentScope.upper;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = { createPreferCraftSignalRule };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const { createPreferCraftSignalRule } = require('./prefer-craft-signal-utils.cjs');
|
|
2
|
+
|
|
3
|
+
module.exports = createPreferCraftSignalRule({
|
|
4
|
+
angularName: 'signal',
|
|
5
|
+
craftName: 'state',
|
|
6
|
+
needsName: false,
|
|
7
|
+
messageId: 'preferCraftState',
|
|
8
|
+
message:
|
|
9
|
+
'Angular signal() is forbidden. Use state() from @craft-ng/core instead for observability and host name tracking.',
|
|
10
|
+
description:
|
|
11
|
+
'Disallow Angular signal() in favor of state() from @craft-ng/core.',
|
|
12
|
+
});
|