@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.
@@ -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
- context.report({
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 callee = node.callee;
40
- if (
41
- callee.type !== 'MemberExpression' ||
42
- callee.computed ||
43
- callee.object.type !== 'Identifier' ||
44
- !angularNamespaceImports.has(callee.object.name) ||
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: callee.property,
53
- message:
54
- 'Angular inject() is forbidden. Expose a craftService/toCraftService injector instead.',
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
+ });