@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.
@@ -0,0 +1,79 @@
1
+ const path = require('node:path');
2
+
3
+ module.exports = {
4
+ meta: {
5
+ type: 'problem',
6
+ docs: {
7
+ description:
8
+ 'Disallow Angular provideAppInitializer() usage outside the internal @craft-ng bridge in favor of onAppStart().',
9
+ },
10
+ schema: [],
11
+ },
12
+ create(context) {
13
+ const filePath = getFilePath(context);
14
+ if (filePath && path.basename(filePath) === 'craft-app-config.ts') {
15
+ return {};
16
+ }
17
+
18
+ const angularNamespaceImports = new Set();
19
+
20
+ return {
21
+ ImportDeclaration(node) {
22
+ if (!isAngularModule(node.source.value)) {
23
+ return;
24
+ }
25
+
26
+ for (const specifier of node.specifiers) {
27
+ if (
28
+ specifier.type === 'ImportSpecifier' &&
29
+ specifier.imported.type === 'Identifier' &&
30
+ specifier.imported.name === 'provideAppInitializer'
31
+ ) {
32
+ context.report({
33
+ node: specifier,
34
+ message:
35
+ 'Angular provideAppInitializer() is forbidden. Model startup work with onAppStart(...) through craftService({ appStart: true }, ...).',
36
+ });
37
+ continue;
38
+ }
39
+
40
+ if (specifier.type === 'ImportNamespaceSpecifier') {
41
+ angularNamespaceImports.add(specifier.local.name);
42
+ }
43
+ }
44
+ },
45
+ CallExpression(node) {
46
+ const callee = node.callee;
47
+ if (
48
+ callee.type !== 'MemberExpression' ||
49
+ callee.computed ||
50
+ callee.object.type !== 'Identifier' ||
51
+ !angularNamespaceImports.has(callee.object.name) ||
52
+ callee.property.type !== 'Identifier' ||
53
+ callee.property.name !== 'provideAppInitializer'
54
+ ) {
55
+ return;
56
+ }
57
+
58
+ context.report({
59
+ node: callee.property,
60
+ message:
61
+ 'Angular provideAppInitializer() is forbidden. Model startup work with onAppStart(...) through craftService({ appStart: true }, ...).',
62
+ });
63
+ },
64
+ };
65
+ },
66
+ };
67
+
68
+ function getFilePath(context) {
69
+ const filePath = context.filename ?? context.getFilename();
70
+ if (!filePath || filePath === '<input>') {
71
+ return undefined;
72
+ }
73
+
74
+ return filePath;
75
+ }
76
+
77
+ function isAngularModule(sourceValue) {
78
+ return typeof sourceValue === 'string' && sourceValue.startsWith('@angular/');
79
+ }
@@ -0,0 +1,92 @@
1
+ const BLOCKED_GLOBALS = new Map([
2
+ ['window', 'BrowserWindow'],
3
+ ['document', 'BrowserDocument'],
4
+ ['localStorage', 'LocalStorage'],
5
+ ['sessionStorage', 'SessionStorage'],
6
+ ['location', 'BrowserLocation'],
7
+ ['history', 'BrowserHistory'],
8
+ ['navigator', 'BrowserNavigator'],
9
+ ['performance', 'BrowserPerformance'],
10
+ ['crypto', 'BrowserCrypto'],
11
+ ['console', 'Console'],
12
+ ['alert', 'BrowserWindow.alert'],
13
+ ['confirm', 'BrowserWindow.confirm'],
14
+ ['scrollTo', 'BrowserWindow.scrollTo'],
15
+ ]);
16
+
17
+ module.exports = {
18
+ meta: {
19
+ type: 'problem',
20
+ docs: {
21
+ description:
22
+ 'Disallow direct browser globals when an @craft-ng browser boundary exists.',
23
+ },
24
+ schema: [],
25
+ },
26
+ create(context) {
27
+ const sourceCode = context.sourceCode;
28
+
29
+ function report(node, actualName) {
30
+ const preferredBoundary = BLOCKED_GLOBALS.get(actualName);
31
+
32
+ if (!preferredBoundary) {
33
+ return;
34
+ }
35
+
36
+ context.report({
37
+ node,
38
+ message: `Use the @craft-ng browser boundary ${preferredBoundary} instead of direct ${actualName} access.`,
39
+ });
40
+ }
41
+
42
+ function findReference(node) {
43
+ let scope = sourceCode.getScope(node);
44
+
45
+ while (scope) {
46
+ const reference = scope.references.find(
47
+ (candidate) => candidate.identifier === node,
48
+ );
49
+
50
+ if (reference) {
51
+ return reference;
52
+ }
53
+
54
+ scope = scope.upper;
55
+ }
56
+
57
+ return undefined;
58
+ }
59
+
60
+ function isGlobalReference(node) {
61
+ const reference = findReference(node);
62
+ return Boolean(
63
+ reference &&
64
+ (reference.resolved == null || reference.resolved.defs.length === 0),
65
+ );
66
+ }
67
+
68
+ return {
69
+ Identifier(node) {
70
+ if (!BLOCKED_GLOBALS.has(node.name) || !isGlobalReference(node)) {
71
+ return;
72
+ }
73
+
74
+ report(node, node.name);
75
+ },
76
+ MemberExpression(node) {
77
+ if (
78
+ node.object.type !== 'Identifier' ||
79
+ node.object.name !== 'globalThis' ||
80
+ !isGlobalReference(node.object) ||
81
+ node.property.type !== 'Identifier' ||
82
+ node.computed ||
83
+ !BLOCKED_GLOBALS.has(node.property.name)
84
+ ) {
85
+ return;
86
+ }
87
+
88
+ report(node, node.property.name);
89
+ },
90
+ };
91
+ },
92
+ };
@@ -0,0 +1,120 @@
1
+ module.exports = {
2
+ meta: {
3
+ type: 'problem',
4
+ docs: {
5
+ description:
6
+ 'Disallow Angular HttpClient usage in favor of CraftHttpClient.',
7
+ },
8
+ hasSuggestions: true,
9
+ schema: [],
10
+ },
11
+ create(context) {
12
+ const angularHttpNamespaceImports = new Set();
13
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
14
+
15
+ return {
16
+ ImportDeclaration(node) {
17
+ if (node.source.value !== '@angular/common/http') {
18
+ return;
19
+ }
20
+
21
+ for (const specifier of node.specifiers) {
22
+ if (
23
+ specifier.type === 'ImportSpecifier' &&
24
+ specifier.imported.type === 'Identifier' &&
25
+ specifier.imported.name === 'HttpClient'
26
+ ) {
27
+ context.report({
28
+ node: specifier,
29
+ message:
30
+ 'Angular HttpClient is forbidden. Use CraftHttpClient from @craft-ng/core instead.',
31
+ suggest: [
32
+ createTemporaryDisableSuggestion(
33
+ sourceCode,
34
+ specifier,
35
+ 'prefer-craft-http-client',
36
+ 'migrate this usage to CraftHttpClient',
37
+ ),
38
+ ],
39
+ });
40
+ continue;
41
+ }
42
+
43
+ if (specifier.type === 'ImportNamespaceSpecifier') {
44
+ angularHttpNamespaceImports.add(specifier.local.name);
45
+ }
46
+ }
47
+ },
48
+ MemberExpression(node) {
49
+ if (
50
+ node.computed ||
51
+ node.object.type !== 'Identifier' ||
52
+ !angularHttpNamespaceImports.has(node.object.name) ||
53
+ node.property.type !== 'Identifier' ||
54
+ node.property.name !== 'HttpClient'
55
+ ) {
56
+ return;
57
+ }
58
+
59
+ context.report({
60
+ node: node.property,
61
+ message:
62
+ 'Angular HttpClient is forbidden. Use CraftHttpClient from @craft-ng/core instead.',
63
+ suggest: [
64
+ createTemporaryDisableSuggestion(
65
+ sourceCode,
66
+ node,
67
+ 'prefer-craft-http-client',
68
+ 'migrate this usage to CraftHttpClient',
69
+ ),
70
+ ],
71
+ });
72
+ },
73
+ TSQualifiedName(node) {
74
+ if (
75
+ node.left.type !== 'Identifier' ||
76
+ !angularHttpNamespaceImports.has(node.left.name) ||
77
+ node.right.type !== 'Identifier' ||
78
+ node.right.name !== 'HttpClient'
79
+ ) {
80
+ return;
81
+ }
82
+
83
+ context.report({
84
+ node: node.right,
85
+ message:
86
+ 'Angular HttpClient is forbidden. Use CraftHttpClient from @craft-ng/core instead.',
87
+ suggest: [
88
+ createTemporaryDisableSuggestion(
89
+ sourceCode,
90
+ node,
91
+ 'prefer-craft-http-client',
92
+ 'migrate this usage to CraftHttpClient',
93
+ ),
94
+ ],
95
+ });
96
+ },
97
+ };
98
+ },
99
+ };
100
+
101
+ function createTemporaryDisableSuggestion(
102
+ sourceCode,
103
+ node,
104
+ ruleName,
105
+ migrationNote,
106
+ ) {
107
+ return {
108
+ desc: 'Insert a temporary eslint-disable-next-line comment with a migration note.',
109
+ fix(fixer) {
110
+ const lineStart = sourceCode.getIndexFromLoc({
111
+ line: node.loc.start.line,
112
+ column: 0,
113
+ });
114
+ return fixer.insertTextBeforeRange(
115
+ [lineStart, lineStart],
116
+ `// eslint-disable-next-line craft-ng/${ruleName} -- ${migrationNote}\n`,
117
+ );
118
+ },
119
+ };
120
+ }
@@ -0,0 +1,140 @@
1
+ module.exports = {
2
+ meta: {
3
+ type: 'problem',
4
+ docs: {
5
+ description:
6
+ 'Disallow Angular @Injectable and @Service decorators in favor of craftService or toCraftService.',
7
+ },
8
+ hasSuggestions: true,
9
+ schema: [],
10
+ },
11
+ create(context) {
12
+ const angularNamespaceImports = new Set();
13
+ const angularInjectableImports = new Set();
14
+ const angularServiceImports = new Set();
15
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
16
+
17
+ return {
18
+ ImportDeclaration(node) {
19
+ if (!isAngularModule(node.source.value)) {
20
+ return;
21
+ }
22
+
23
+ for (const specifier of node.specifiers) {
24
+ if (
25
+ specifier.type === 'ImportSpecifier' &&
26
+ specifier.imported.type === 'Identifier' &&
27
+ specifier.imported.name === 'Injectable'
28
+ ) {
29
+ angularInjectableImports.add(specifier.local.name);
30
+ continue;
31
+ }
32
+
33
+ if (
34
+ specifier.type === 'ImportSpecifier' &&
35
+ specifier.imported.type === 'Identifier' &&
36
+ specifier.imported.name === 'Service'
37
+ ) {
38
+ angularServiceImports.add(specifier.local.name);
39
+ continue;
40
+ }
41
+
42
+ if (specifier.type === 'ImportNamespaceSpecifier') {
43
+ angularNamespaceImports.add(specifier.local.name);
44
+ }
45
+ }
46
+ },
47
+ Decorator(node) {
48
+ const decoratorName = getAngularBlockedDecoratorName(
49
+ node.expression,
50
+ angularInjectableImports,
51
+ angularServiceImports,
52
+ angularNamespaceImports,
53
+ );
54
+ if (!decoratorName) {
55
+ return;
56
+ }
57
+
58
+ context.report({
59
+ node,
60
+ message: `Angular @${decoratorName} is forbidden. Author services with craftService(...) and adapt Angular dependencies with toCraftService(...) instead.`,
61
+ suggest: [
62
+ createTemporaryDisableSuggestion(
63
+ sourceCode,
64
+ node,
65
+ 'prefer-craft-service',
66
+ 'migrate this Angular service to craftService(...) or toCraftService(...)',
67
+ ),
68
+ ],
69
+ });
70
+ },
71
+ };
72
+ },
73
+ };
74
+
75
+ function isAngularModule(sourceValue) {
76
+ return typeof sourceValue === 'string' && sourceValue.startsWith('@angular/');
77
+ }
78
+
79
+ function getAngularBlockedDecoratorName(
80
+ expression,
81
+ angularInjectableImports,
82
+ angularServiceImports,
83
+ angularNamespaceImports,
84
+ ) {
85
+ if (expression.type === 'Identifier') {
86
+ if (angularInjectableImports.has(expression.name)) {
87
+ return 'Injectable';
88
+ }
89
+
90
+ if (angularServiceImports.has(expression.name)) {
91
+ return 'Service';
92
+ }
93
+
94
+ return undefined;
95
+ }
96
+
97
+ if (expression.type === 'CallExpression') {
98
+ return getAngularBlockedDecoratorName(
99
+ expression.callee,
100
+ angularInjectableImports,
101
+ angularServiceImports,
102
+ angularNamespaceImports,
103
+ );
104
+ }
105
+
106
+ if (
107
+ expression.type === 'MemberExpression' &&
108
+ !expression.computed &&
109
+ expression.object.type === 'Identifier' &&
110
+ angularNamespaceImports.has(expression.object.name) &&
111
+ expression.property.type === 'Identifier' &&
112
+ (expression.property.name === 'Injectable' ||
113
+ expression.property.name === 'Service')
114
+ ) {
115
+ return expression.property.name;
116
+ }
117
+
118
+ return undefined;
119
+ }
120
+
121
+ function createTemporaryDisableSuggestion(
122
+ sourceCode,
123
+ node,
124
+ ruleName,
125
+ migrationNote,
126
+ ) {
127
+ return {
128
+ desc: 'Insert a temporary eslint-disable-next-line comment with a migration note.',
129
+ fix(fixer) {
130
+ const lineStart = sourceCode.getIndexFromLoc({
131
+ line: node.loc.start.line,
132
+ column: 0,
133
+ });
134
+ return fixer.insertTextBeforeRange(
135
+ [lineStart, lineStart],
136
+ `// eslint-disable-next-line craft-ng/${ruleName} -- ${migrationNote}\n`,
137
+ );
138
+ },
139
+ };
140
+ }
@@ -1,5 +1,24 @@
1
1
  import { ClassDeclaration, Node, SourceFile } from 'ts-morph';
2
2
  export type AngularKind = 'component' | 'directive' | 'pipe' | 'injectable';
3
+ export type AngularBrandMetadataContext = 'imports' | 'hostDirectives';
4
+ export type AngularBrandConfigEntry = {
5
+ key: string;
6
+ symbol: string;
7
+ typeText?: string;
8
+ module?: string;
9
+ };
10
+ export type AngularBrandImportAugmentationRule = {
11
+ match: {
12
+ module: string;
13
+ symbols?: readonly string[];
14
+ metadata?: readonly AngularBrandMetadataContext[];
15
+ };
16
+ deps?: readonly AngularBrandConfigEntry[];
17
+ missingProvider?: readonly AngularBrandConfigEntry[];
18
+ };
19
+ export type AngularBrandConfig = {
20
+ importAugmentations?: readonly AngularBrandImportAugmentationRule[];
21
+ };
3
22
  export type TransformResult = {
4
23
  changed: boolean;
5
24
  skipped: boolean;
@@ -16,6 +35,8 @@ export type AngularBrandCodemodOptions = {
16
35
  transformOnlyStandaloneDeclarables?: boolean;
17
36
  includeProviders?: boolean;
18
37
  includeViewProviders?: boolean;
38
+ config?: AngularBrandConfig;
39
+ configFilePath?: string;
19
40
  };
20
41
  export type AngularClassSearchResult = {
21
42
  classDeclaration?: ClassDeclaration;
@@ -43,6 +64,7 @@ export type GeneratedDependencyEntry = {
43
64
  };
44
65
  export type GeneratedDependencyGroups = {
45
66
  deps: GeneratedDependencyEntry[];
67
+ propertiesDeps: GeneratedDependencyEntry[];
46
68
  provided: GeneratedDependencyEntry[];
47
69
  missingProvider: GeneratedDependencyEntry[];
48
70
  };
@@ -74,6 +96,9 @@ export type RunSummary = {
74
96
  countByAngularKind: Record<AngularKind, number>;
75
97
  files: RunFileReport[];
76
98
  };
99
+ export declare function defineAngularBrandConfig<Config extends AngularBrandConfig>(config: Config): Config;
100
+ export declare function discoverAngularBrandConfigFilePath(searchFromDir: string, stopDir?: string): string | undefined;
101
+ export declare function loadAngularBrandConfigFromFile(configFilePath: string): AngularBrandConfig;
77
102
  export declare function transformSourceFile(sourceFile: SourceFile, options?: AngularBrandCodemodOptions): TransformResult;
78
103
  export declare function analyzeSourceFileDependencies(sourceFile: SourceFile, options?: AngularBrandCodemodOptions): DependencyAnalysisResult;
79
104
  export declare function findAngularDecoratedClass(sourceFile: SourceFile): AngularClassSearchResult;