@craft-ng/dev-tools 0.1.9 → 0.4.0-beta.1

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/README.md CHANGED
@@ -10,6 +10,66 @@ Development tools for ng-craft: ESLint configs, ESLint rules, and codemods.
10
10
  npm install -D @craft-ng/dev-tools
11
11
  ```
12
12
 
13
+ ## Angular Brand Codemod Config
14
+
15
+ `craft-brand` can load a typed project config from `craft-brand.config.ts`.
16
+
17
+ ```ts
18
+ import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
19
+
20
+ export default defineAngularBrandConfig({
21
+ importAugmentations: [
22
+ {
23
+ match: {
24
+ module: '@ngx-translate/core',
25
+ symbols: ['TranslatePipe'],
26
+ metadata: ['imports'],
27
+ },
28
+ deps: [{ key: 'TranslateService', symbol: 'TranslateService' }],
29
+ missingProvider: [
30
+ { key: 'TranslateService', symbol: 'TranslateService' },
31
+ ],
32
+ },
33
+ ],
34
+ });
35
+ ```
36
+
37
+ Rule semantics:
38
+
39
+ - `match.module`: module specifier to match
40
+ - `match.symbols`: optional exported symbol names that trigger the rule
41
+ - `match.metadata`: `imports` and/or `hostDirectives`
42
+ - `deps`: synthetic entries added to generated `GenDeps`
43
+ - `missingProvider`: synthetic entries added to generated `missingProvider`
44
+
45
+ Entry semantics:
46
+
47
+ - `key`: property name generated in `GenDeps`
48
+ - `symbol`: imported type name used in the generated type
49
+ - `module`: optional import source override, defaults to `match.module`
50
+
51
+ Discovery behavior:
52
+
53
+ - `craft-brand` auto-discovers `craft-brand.config.ts` by walking upward from `--root`
54
+ - the ESLint rules `brand-angular-gen-deps-required` and `brand-angular-deps-match` use the same upward discovery from the analyzed file, bounded by `context.cwd`
55
+ - `--config <path>` overrides auto-discovery for the CLI
56
+ - `brand-angular-gen-deps-required` can generate a missing `GenDeps_*` alias in the current file
57
+ - `brand-angular-deps-match` can autofix an existing `GenDeps_*` alias in the current file
58
+
59
+ Current scope:
60
+
61
+ - `TypeScript` config file only
62
+ - declarative rules only
63
+ - matching only from Angular `imports` and `hostDirectives`
64
+ - no arbitrary `typeText` generation and no helper-local `typeof injectX` expressions
65
+
66
+ Example CLI usage:
67
+
68
+ ```bash
69
+ craft-brand --root apps/demo/src
70
+ craft-brand --root apps/demo/src --config ./craft-brand.config.ts
71
+ ```
72
+
13
73
  ```bash
14
74
  import craftRules from '@craft-ng/dev-tools/eslint-rules';
15
75
 
@@ -19,9 +79,13 @@ export default [
19
79
  'craft-ng': craftRules
20
80
  },
21
81
  rules: {
82
+ // Adds a Quick Fix in VS Code through the ESLint extension
83
+ 'craft-ng/brand-angular-gen-deps-required': 'error',
22
84
  'craft-ng/brand-angular-deps-match': 'error',
23
85
  'craft-ng/no-angular-inject': 'error',
24
- 'craft-ng/no-direct-angular-class-export': 'error',
86
+ 'craft-ng/prefer-craft-service': 'error',
87
+ 'craft-ng/prefer-craft-http-client': 'error',
88
+ 'craft-ng/prefer-browser-boundaries': 'error',
25
89
  }
26
90
  }
27
91
  ];
@@ -61,10 +125,14 @@ module.exports = defineConfig([
61
125
  },
62
126
  ],
63
127
  '@typescript-eslint/consistent-type-definitions': 'off',
64
- // Ajoutez vos règles craft-ng ici
128
+ // `brand-angular-gen-deps-required` generates missing GenDeps aliases with ESLint autofix
129
+ 'craft-ng/brand-angular-gen-deps-required': 'error',
130
+ // `brand-angular-deps-match` refreshes existing GenDeps aliases with ESLint autofix
65
131
  'craft-ng/brand-angular-deps-match': 'error',
66
132
  'craft-ng/no-angular-inject': 'error',
67
- 'craft-ng/no-direct-angular-class-export': 'error',
133
+ 'craft-ng/prefer-craft-service': 'error',
134
+ 'craft-ng/prefer-craft-http-client': 'error',
135
+ 'craft-ng/prefer-browser-boundaries': 'error',
68
136
  },
69
137
  },
70
138
  {
@@ -74,3 +142,28 @@ module.exports = defineConfig([
74
142
  },
75
143
  ]);
76
144
  ```
145
+
146
+ ## Editor / AI Refresh
147
+
148
+ `GenDeps_* = GetDeps<...>` remains a source artifact generated in your `.ts` files.
149
+
150
+ Use two refresh flows:
151
+
152
+ - Current file without `GenDeps_*`: trigger the VS Code ESLint Quick Fix on `craft-ng/brand-angular-gen-deps-required`, or run `eslint --fix path/to/file.ts`
153
+ - Current file with `GenDeps_*`: trigger the VS Code ESLint Quick Fix on `craft-ng/brand-angular-deps-match`, or run `eslint --fix path/to/file.ts`
154
+ - Bulk refresh: run `craft-brand --root <source-root>`
155
+
156
+ Recommended workflow:
157
+
158
+ - after changing `inject(...)`, constructor injection, component `imports`, `providers`, or `viewProviders`, run the Quick Fix for the current file
159
+ - when doing a larger refactor or upgrading a whole app/lib, run `craft-brand --root <source-root>`
160
+ - when a browser API already exists in `@craft-ng/core/browser-boundaries`, enable `craft-ng/prefer-browser-boundaries` to prevent direct access to `window`, `document`, `localStorage`, `console`, and similar globals
161
+
162
+ Notes:
163
+
164
+ - the ESLint Quick Fix can generate a missing alias or refresh an existing one, but only for the current file
165
+ - the same flow works well for AI agents: file-local updates via `eslint --fix`, bulk updates via `craft-brand --root`
166
+ - `craft-ng/no-angular-inject` now targets raw Angular `inject()` only
167
+ - `craft-ng/prefer-craft-service` forbids authored Angular `@Injectable()` / `@Service()` classes in favor of `craftService(...)`
168
+ - `craft-ng/prefer-craft-http-client` forbids Angular `HttpClient` in favor of `CraftHttpClient`
169
+ - `craft-ng/prefer-craft-service` and `craft-ng/prefer-craft-http-client` also expose a VS Code Quick Fix suggestion that inserts a temporary local disable comment annotated with the intended migration target
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-ng/dev-tools",
3
- "version": "0.1.9",
3
+ "version": "0.4.0-beta.1",
4
4
  "description": "Development tools for ng-craft: ESLint configs, ESLint rules, and codemods",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -0,0 +1,357 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const {
4
+ IndentationText,
5
+ Node,
6
+ Project,
7
+ QuoteKind,
8
+ SyntaxKind,
9
+ } = require('ts-morph');
10
+
11
+ const projectCache = new Map();
12
+
13
+ module.exports = {
14
+ meta: {
15
+ type: 'problem',
16
+ docs: {
17
+ description:
18
+ 'Ensure craftService definitions using onAppStart are registered in CraftAppStartRegistry.',
19
+ },
20
+ fixable: 'code',
21
+ schema: [],
22
+ },
23
+ create(context) {
24
+ return {
25
+ 'Program:exit'() {
26
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
27
+ const filePath = getFilePath(context);
28
+ if (!filePath || !filePath.endsWith('.ts')) {
29
+ return;
30
+ }
31
+
32
+ const text = sourceCode.getText();
33
+ if (!text.includes('onAppStart')) {
34
+ return;
35
+ }
36
+
37
+ const sourceFile = getProjectSourceFile(
38
+ getProject(getCwd(context)),
39
+ filePath,
40
+ text,
41
+ );
42
+ const requiredRegistrations = collectRequiredRegistrations(sourceFile);
43
+ if (requiredRegistrations.length === 0) {
44
+ return;
45
+ }
46
+
47
+ const registryState = analyzeRegistryState(
48
+ sourceFile,
49
+ requiredRegistrations,
50
+ );
51
+ if (
52
+ registryState.missing.length === 0 &&
53
+ registryState.outOfDate.length === 0
54
+ ) {
55
+ return;
56
+ }
57
+
58
+ ensureRegistryEntries(sourceFile, requiredRegistrations);
59
+ const fixedText = sourceFile.getFullText();
60
+ if (fixedText === text) {
61
+ return;
62
+ }
63
+
64
+ context.report({
65
+ loc: getNodeLoc(sourceCode, registryState.reportNode),
66
+ message: formatRegistryMessage(registryState),
67
+ fix(fixer) {
68
+ return fixer.replaceTextRange([0, text.length], fixedText);
69
+ },
70
+ });
71
+ },
72
+ };
73
+ },
74
+ };
75
+
76
+ function getProject(cwd) {
77
+ let project = projectCache.get(cwd);
78
+ if (project) {
79
+ return project;
80
+ }
81
+
82
+ const manipulationSettings = {
83
+ indentationText: IndentationText.TwoSpaces,
84
+ quoteKind: QuoteKind.Single,
85
+ };
86
+ const tsConfigFilePath = path.join(cwd, 'tsconfig.json');
87
+ project = fs.existsSync(tsConfigFilePath)
88
+ ? new Project({
89
+ tsConfigFilePath,
90
+ manipulationSettings,
91
+ })
92
+ : new Project({
93
+ compilerOptions: {
94
+ experimentalDecorators: true,
95
+ target: 9,
96
+ },
97
+ manipulationSettings,
98
+ });
99
+
100
+ projectCache.set(cwd, project);
101
+ return project;
102
+ }
103
+
104
+ function getProjectSourceFile(project, filePath, text) {
105
+ const normalizedPath = path.resolve(filePath);
106
+ const existingSourceFile = project.getSourceFile(normalizedPath);
107
+ if (existingSourceFile) {
108
+ existingSourceFile.replaceWithText(text);
109
+ return existingSourceFile;
110
+ }
111
+
112
+ const sourceFile = project.addSourceFileAtPathIfExists(normalizedPath);
113
+ if (sourceFile) {
114
+ sourceFile.replaceWithText(text);
115
+ return sourceFile;
116
+ }
117
+
118
+ return project.createSourceFile(normalizedPath, text, { overwrite: true });
119
+ }
120
+
121
+ function getFilePath(context) {
122
+ const filePath = context.filename ?? context.getFilename();
123
+ if (!filePath || filePath === '<input>') {
124
+ return undefined;
125
+ }
126
+
127
+ return filePath;
128
+ }
129
+
130
+ function getCwd(context) {
131
+ return context.cwd ?? process.cwd();
132
+ }
133
+
134
+ function collectRequiredRegistrations(sourceFile) {
135
+ const registrations = new Map();
136
+
137
+ for (const declaration of sourceFile.getVariableDeclarations()) {
138
+ const initializer = declaration.getInitializerIfKind(
139
+ SyntaxKind.CallExpression,
140
+ );
141
+ if (!initializer || getCallExpressionName(initializer) !== 'craftService') {
142
+ continue;
143
+ }
144
+
145
+ if (!containsOnAppStartCall(initializer)) {
146
+ continue;
147
+ }
148
+
149
+ const tag = readCraftServiceName(initializer);
150
+ const injectReference = readInjectReferenceName(declaration);
151
+ if (!tag || !injectReference || registrations.has(tag)) {
152
+ continue;
153
+ }
154
+
155
+ registrations.set(tag, {
156
+ tag,
157
+ injectReference,
158
+ reportNode: initializer,
159
+ });
160
+ }
161
+
162
+ return [...registrations.values()];
163
+ }
164
+
165
+ function getCallExpressionName(callExpression) {
166
+ const expression = callExpression.getExpression();
167
+ return Node.isIdentifier(expression) ? expression.getText() : undefined;
168
+ }
169
+
170
+ function containsOnAppStartCall(callExpression) {
171
+ return callExpression
172
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
173
+ .some((innerCall) => getCallExpressionName(innerCall) === 'onAppStart');
174
+ }
175
+
176
+ function readCraftServiceName(callExpression) {
177
+ const [definitionArgument] = callExpression.getArguments();
178
+ if (
179
+ !definitionArgument ||
180
+ !Node.isObjectLiteralExpression(definitionArgument)
181
+ ) {
182
+ return undefined;
183
+ }
184
+
185
+ const nameProperty = definitionArgument
186
+ .getProperties()
187
+ .find(
188
+ (property) =>
189
+ Node.isPropertyAssignment(property) && property.getName() === 'name',
190
+ );
191
+ if (!nameProperty || !Node.isPropertyAssignment(nameProperty)) {
192
+ return undefined;
193
+ }
194
+
195
+ const initializer = nameProperty.getInitializer();
196
+ if (
197
+ !initializer ||
198
+ (!Node.isStringLiteral(initializer) &&
199
+ !Node.isNoSubstitutionTemplateLiteral(initializer))
200
+ ) {
201
+ return undefined;
202
+ }
203
+
204
+ return initializer.getLiteralText();
205
+ }
206
+
207
+ function readInjectReferenceName(declaration) {
208
+ const nameNode = declaration.getNameNode();
209
+ if (!Node.isObjectBindingPattern(nameNode)) {
210
+ return undefined;
211
+ }
212
+
213
+ const bindingElement =
214
+ nameNode
215
+ .getElements()
216
+ .find((element) =>
217
+ element.getNameNode().getText().startsWith('inject'),
218
+ ) ??
219
+ (nameNode.getElements().length === 1
220
+ ? nameNode.getElements()[0]
221
+ : undefined);
222
+
223
+ return bindingElement?.getNameNode().getText();
224
+ }
225
+
226
+ function analyzeRegistryState(sourceFile, requiredRegistrations) {
227
+ const moduleDeclaration = getCraftCoreModuleDeclaration(sourceFile);
228
+ const registryInterface = moduleDeclaration?.getInterface(
229
+ 'CraftAppStartRegistry',
230
+ );
231
+ const propertiesByName = new Map(
232
+ (registryInterface?.getProperties() ?? []).map((property) => [
233
+ readPropertyName(property),
234
+ property,
235
+ ]),
236
+ );
237
+ const missing = [];
238
+ const outOfDate = [];
239
+
240
+ for (const registration of requiredRegistrations) {
241
+ const property = propertiesByName.get(registration.tag);
242
+ if (!property) {
243
+ missing.push(registration.tag);
244
+ continue;
245
+ }
246
+
247
+ const currentType = normalizeText(property.getTypeNode()?.getText() ?? '');
248
+ const expectedType = normalizeText(
249
+ `typeof ${registration.injectReference}`,
250
+ );
251
+ if (currentType !== expectedType) {
252
+ outOfDate.push(registration.tag);
253
+ }
254
+ }
255
+
256
+ return {
257
+ missing,
258
+ outOfDate,
259
+ reportNode: requiredRegistrations[0].reportNode,
260
+ };
261
+ }
262
+
263
+ function getCraftCoreModuleDeclaration(sourceFile) {
264
+ return sourceFile.getModules().find((moduleDeclaration) => {
265
+ const nameNode = moduleDeclaration.getNameNode();
266
+ return (
267
+ Node.isStringLiteral(nameNode) &&
268
+ nameNode.getLiteralText() === '@craft-ng/core'
269
+ );
270
+ });
271
+ }
272
+
273
+ function readPropertyName(property) {
274
+ const nameNode = property.getNameNode();
275
+ if (Node.isIdentifier(nameNode) || Node.isPrivateIdentifier(nameNode)) {
276
+ return nameNode.getText();
277
+ }
278
+
279
+ if (Node.isStringLiteral(nameNode) || Node.isNumericLiteral(nameNode)) {
280
+ return nameNode.getLiteralText();
281
+ }
282
+
283
+ return property.getName();
284
+ }
285
+
286
+ function ensureRegistryEntries(sourceFile, requiredRegistrations) {
287
+ const moduleDeclaration =
288
+ getCraftCoreModuleDeclaration(sourceFile) ??
289
+ sourceFile.addModule({
290
+ name: "'@craft-ng/core'",
291
+ hasDeclareKeyword: true,
292
+ });
293
+ const registryInterface =
294
+ moduleDeclaration.getInterface('CraftAppStartRegistry') ??
295
+ moduleDeclaration.addInterface({
296
+ name: 'CraftAppStartRegistry',
297
+ });
298
+ const propertiesByName = new Map(
299
+ registryInterface
300
+ .getProperties()
301
+ .map((property) => [readPropertyName(property), property]),
302
+ );
303
+
304
+ for (const registration of requiredRegistrations) {
305
+ const expectedType = `typeof ${registration.injectReference}`;
306
+ const property = propertiesByName.get(registration.tag);
307
+ if (!property) {
308
+ registryInterface.addProperty({
309
+ name: formatPropertyName(registration.tag),
310
+ type: expectedType,
311
+ });
312
+ continue;
313
+ }
314
+
315
+ property.setType(expectedType);
316
+ }
317
+ }
318
+
319
+ function formatPropertyName(name) {
320
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : `'${name}'`;
321
+ }
322
+
323
+ function getNodeLoc(sourceCode, node) {
324
+ return {
325
+ start: sourceCode.getLocFromIndex(node.getStart()),
326
+ end: sourceCode.getLocFromIndex(node.getEnd()),
327
+ };
328
+ }
329
+
330
+ function formatRegistryMessage({ missing, outOfDate }) {
331
+ const segments = [];
332
+ if (missing.length > 0) {
333
+ segments.push(`missing ${formatNameList(missing)}`);
334
+ }
335
+
336
+ if (outOfDate.length > 0) {
337
+ segments.push(`out of date for ${formatNameList(outOfDate)}`);
338
+ }
339
+
340
+ return `CraftAppStartRegistry is ${segments.join(' and ')}. Run ESLint --fix on this file to register app-start services.`;
341
+ }
342
+
343
+ function formatNameList(names) {
344
+ if (names.length === 1) {
345
+ return names[0];
346
+ }
347
+
348
+ if (names.length === 2) {
349
+ return `${names[0]} and ${names[1]}`;
350
+ }
351
+
352
+ return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`;
353
+ }
354
+
355
+ function normalizeText(text) {
356
+ return text.replace(/\s+/g, ' ').trim();
357
+ }