@onecx/angular-linter-rules 8.0.0-rc.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 ADDED
@@ -0,0 +1,17 @@
1
+ # @onecx/angular-linter-rules
2
+
3
+ `@onecx/angular-linter-rules` is an ESLint plugin that provides opinionated Angular rules to ensure code quality and consistency across Angular projects within the OneCX ecosystem.
4
+ More information about the library can be found [here](https://onecx.github.io/docs/documentation/current/onecx-portal-ui-libs/libraries/angular-linter-rules.html)
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ npm install @onecx/angular-linter-rules
10
+ ```
11
+
12
+ ## Additional Commands
13
+ - `npx nx run angular-linter-rules:build` - Builds the library and outputs the result to the `dist` folder.
14
+ - `npx nx run angular-linter-rules:build-migrations` - Builds the migration files for the library.
15
+ - `npx nx run angular-linter-rules:test` - Runs the unit tests for the library.
16
+ - `npx nx run angular-linter-rules:lint` - Lints the library's codebase.
17
+ - `npx nx run angular-linter-rules:release` - Releases a new version of the library to npm, following semantic versioning guidelines.
@@ -0,0 +1,19 @@
1
+ const baseConfig = require('../.eslintrc.json')
2
+
3
+ module.exports = [
4
+ ...baseConfig,
5
+ {
6
+ files: ['**/*.json'],
7
+ rules: {
8
+ '@nx/dependency-checks': [
9
+ 'error',
10
+ {
11
+ ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'],
12
+ },
13
+ ],
14
+ },
15
+ languageOptions: {
16
+ parser: require('jsonc-eslint-parser'),
17
+ },
18
+ },
19
+ ]
package/jest.config.ts ADDED
@@ -0,0 +1,10 @@
1
+ export default {
2
+ displayName: 'angular-linter-rules',
3
+ preset: '../../jest.preset.js',
4
+ testEnvironment: 'node',
5
+ transform: {
6
+ '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
7
+ },
8
+ moduleFileExtensions: ['ts', 'js', 'html'],
9
+ coverageDirectory: '../../coverage/libs/angular-linter-rules',
10
+ }
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@onecx/angular-linter-rules",
3
+ "version": "8.0.0-rc.1",
4
+ "type": "commonjs",
5
+ "main": "./src/index.js",
6
+ "types": "./src/index.d.ts",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "dependencies": {
11
+ "tslib": "^2.3.0"
12
+ }
13
+ }
package/project.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "angular-linter-rules",
3
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
4
+ "sourceRoot": "libs/angular-linter-rules/src",
5
+ "projectType": "library",
6
+ "tags": [],
7
+ "targets": {
8
+ "build": {
9
+ "executor": "@nx/js:tsc",
10
+ "outputs": ["{options.outputPath}"],
11
+ "options": {
12
+ "outputPath": "dist/libs/angular-linter-rules",
13
+ "main": "libs/angular-linter-rules/src/index.ts",
14
+ "tsConfig": "libs/angular-linter-rules/tsconfig.lib.json",
15
+ "assets": ["libs/angular-linter-rules/*.md"]
16
+ }
17
+ },
18
+ "test": {
19
+ "executor": "@nx/jest:jest",
20
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
21
+ "options": {
22
+ "jestConfig": "libs/angular-linter-rules/jest.config.ts",
23
+ "codeCoverage": true,
24
+ "passWithNoTests": true
25
+ }
26
+ }
27
+ }
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { rules } from './lib/rules'
2
+ import { configs } from './lib/configs'
3
+
4
+ export { rules, configs }
5
+
6
+ export const plugin = {
7
+ rules,
8
+ configs,
9
+ }
10
+
11
+ export type { AngularLinterRulesPlugin } from './lib/types'
@@ -0,0 +1,16 @@
1
+ import type { TSESLint } from '@typescript-eslint/utils'
2
+ import { rules } from './rules'
3
+
4
+ const pluginName = '@onecx/angular-linter-rules'
5
+
6
+ const recommendedRules = Object.keys(rules).reduce<Record<string, TSESLint.Linter.RuleEntry>>((acc, ruleName) => {
7
+ acc[`${pluginName}/${ruleName}`] = 'warn'
8
+ return acc
9
+ }, {})
10
+
11
+ export const configs: Record<string, TSESLint.Linter.Config> = {
12
+ recommended: {
13
+ plugins: [pluginName],
14
+ rules: recommendedRules,
15
+ },
16
+ }
@@ -0,0 +1,9 @@
1
+ import { noSubscribeAssignment } from './no-subscribe-assignment.rule'
2
+ import { noTranslateInstant } from './no-translate-instant.rule'
3
+ import { preferTranslateParams } from './prefer-translate-params.rule'
4
+
5
+ export const rules = {
6
+ 'no-subscribe-assignment': noSubscribeAssignment,
7
+ 'no-translate-instant': noTranslateInstant,
8
+ 'prefer-translate-params': preferTranslateParams,
9
+ } as const
@@ -0,0 +1,118 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester'
2
+ import { noSubscribeAssignment } from './no-subscribe-assignment.rule'
3
+
4
+ RuleTester.afterAll = afterAll
5
+
6
+ const ruleTester = new RuleTester({
7
+ languageOptions: {
8
+ parser: require('@typescript-eslint/parser'),
9
+ parserOptions: {
10
+ ecmaVersion: 2022,
11
+ sourceType: 'module',
12
+ },
13
+ },
14
+ } as unknown as any)
15
+
16
+ const messageId = 'assignmentOutside'
17
+
18
+ ruleTester.run('no-subscribe-assignment', noSubscribeAssignment, {
19
+ valid: [
20
+ {
21
+ code: `
22
+ import { of } from 'rxjs'
23
+
24
+ function test() {
25
+ of(1).subscribe((value) => {
26
+ const local = value
27
+ console.log(local)
28
+ })
29
+ }
30
+ `,
31
+ },
32
+ {
33
+ code: `
34
+ import { of } from 'rxjs'
35
+
36
+ function test() {
37
+ let outer = 0
38
+ of(1).subscribe((value) => {
39
+ const outer = value
40
+ console.log(outer)
41
+ })
42
+ console.log(outer)
43
+ }
44
+ `,
45
+ },
46
+ {
47
+ code: `
48
+ import { of } from 'rxjs'
49
+
50
+ function test() {
51
+ of({ a: 1 }).subscribe((value) => {
52
+ const { a } = value
53
+ console.log(a)
54
+ })
55
+ }
56
+ `,
57
+ },
58
+ ],
59
+ invalid: [
60
+ {
61
+ code: `
62
+ import { of } from 'rxjs'
63
+
64
+ function test() {
65
+ let outer: number | undefined
66
+ of(1).subscribe((value) => {
67
+ outer = value
68
+ })
69
+ console.log(outer)
70
+ }
71
+ `,
72
+ errors: [{ messageId }],
73
+ },
74
+ {
75
+ code: `
76
+ import { of } from 'rxjs'
77
+
78
+ class A {
79
+ value?: number
80
+ test() {
81
+ of(1).subscribe((value) => {
82
+ this.value = value
83
+ })
84
+ }
85
+ }
86
+ `,
87
+ errors: [{ messageId }],
88
+ },
89
+ {
90
+ code: `
91
+ import { of } from 'rxjs'
92
+
93
+ function test() {
94
+ let outer = 0
95
+ of(1).subscribe(function (value) {
96
+ outer++
97
+ })
98
+ console.log(outer)
99
+ }
100
+ `,
101
+ errors: [{ messageId }],
102
+ },
103
+ {
104
+ code: `
105
+ import { of } from 'rxjs'
106
+
107
+ function test() {
108
+ let outer = 0
109
+ of(1).subscribe((value) => {
110
+ ;({ outer } = { outer: value })
111
+ })
112
+ console.log(outer)
113
+ }
114
+ `,
115
+ errors: [{ messageId }],
116
+ },
117
+ ],
118
+ })
@@ -0,0 +1,149 @@
1
+ import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'
2
+
3
+ const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/onecx/onecx-portal-ui-libs/tree/main/libs/angular-linter-rules#${name}`)
4
+
5
+ type Options = []
6
+ type MessageIds = 'assignmentOutside'
7
+
8
+ function isSubscribeCall(node: TSESTree.CallExpression): boolean {
9
+ return node.callee.type === AST_NODE_TYPES.MemberExpression &&
10
+ node.callee.property.type === AST_NODE_TYPES.Identifier &&
11
+ node.callee.property.name === 'subscribe'
12
+ }
13
+
14
+ function getFunctionArg(node: TSESTree.CallExpression): TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression | undefined {
15
+ for (const arg of node.arguments) {
16
+ if (arg.type === AST_NODE_TYPES.ArrowFunctionExpression || arg.type === AST_NODE_TYPES.FunctionExpression) {
17
+ return arg
18
+ }
19
+ }
20
+ return undefined
21
+ }
22
+
23
+ type PatternLike =
24
+ | TSESTree.Identifier
25
+ | TSESTree.ArrayPattern
26
+ | TSESTree.ObjectPattern
27
+ | TSESTree.RestElement
28
+ | TSESTree.AssignmentPattern
29
+
30
+ function getAssignedIdentifiers(pattern: PatternLike): TSESTree.Identifier[] {
31
+ switch (pattern.type) {
32
+ case AST_NODE_TYPES.Identifier:
33
+ return [pattern]
34
+ case AST_NODE_TYPES.ArrayPattern:
35
+ return pattern.elements.flatMap((el) => (el ? getAssignedIdentifiers(el as PatternLike) : []))
36
+ case AST_NODE_TYPES.ObjectPattern:
37
+ return pattern.properties.flatMap((p) => {
38
+ if (p.type === AST_NODE_TYPES.Property) return getAssignedIdentifiers(p.value as PatternLike)
39
+ if (p.type === AST_NODE_TYPES.RestElement) return getAssignedIdentifiers(p.argument as PatternLike)
40
+ return []
41
+ })
42
+ case AST_NODE_TYPES.RestElement:
43
+ return getAssignedIdentifiers(pattern.argument as PatternLike)
44
+ case AST_NODE_TYPES.AssignmentPattern:
45
+ return getAssignedIdentifiers(pattern.left as PatternLike)
46
+ }
47
+ }
48
+
49
+ function getDeclaredVariablesInFunctionBody(
50
+ body: TSESTree.BlockStatement | TSESTree.Expression,
51
+ ): Set<string> {
52
+ const names = new Set<string>()
53
+ if (body.type !== AST_NODE_TYPES.BlockStatement) return names
54
+
55
+ for (const stmt of body.body) {
56
+ if (stmt.type !== AST_NODE_TYPES.VariableDeclaration) continue
57
+ for (const decl of stmt.declarations) {
58
+ for (const id of getAssignedIdentifiers(decl.id as any)) {
59
+ names.add(id.name)
60
+ }
61
+ }
62
+ }
63
+
64
+ return names
65
+ }
66
+
67
+ export const noSubscribeAssignment = createRule<Options, MessageIds>({
68
+ name: 'no-subscribe-assignment',
69
+ meta: {
70
+ type: 'problem',
71
+ docs: {
72
+ description:
73
+ 'Warn when assigning inside an Observable subscribe callback to variables declared outside the callback (including class members).',
74
+ },
75
+ schema: [],
76
+ messages: {
77
+ assignmentOutside: 'Avoid assigning to outer-scope variables inside `subscribe`. Prefer `map/tap` + `async` pipe or return the value.',
78
+ },
79
+ },
80
+ defaultOptions: [],
81
+ create(context) {
82
+ return {
83
+ CallExpression(node) {
84
+ if (!isSubscribeCall(node)) return
85
+
86
+ const callback = getFunctionArg(node)
87
+ if (!callback) return
88
+
89
+ const callbackBody = callback.body
90
+ const localDeclarations = getDeclaredVariablesInFunctionBody(callbackBody)
91
+
92
+ const reportIfOuter = (identifier: TSESTree.Identifier) => {
93
+ if (localDeclarations.has(identifier.name)) return
94
+ context.report({
95
+ node: identifier,
96
+ messageId: 'assignmentOutside',
97
+ })
98
+ }
99
+
100
+ const sourceCode = context.sourceCode
101
+ const visitor = (n: TSESTree.Node) => {
102
+ if (n.type === AST_NODE_TYPES.AssignmentExpression) {
103
+ if (n.left.type === AST_NODE_TYPES.MemberExpression) {
104
+ if (n.left.object.type === AST_NODE_TYPES.ThisExpression) {
105
+ context.report({ node: n.left, messageId: 'assignmentOutside' })
106
+ }
107
+ return
108
+ }
109
+
110
+ if (n.left.type === AST_NODE_TYPES.Identifier) {
111
+ reportIfOuter(n.left)
112
+ return
113
+ }
114
+
115
+ if (n.left.type === AST_NODE_TYPES.ObjectPattern || n.left.type === AST_NODE_TYPES.ArrayPattern) {
116
+ for (const id of getAssignedIdentifiers(n.left)) {
117
+ reportIfOuter(id)
118
+ }
119
+ }
120
+ return
121
+ }
122
+
123
+ if (n.type === AST_NODE_TYPES.UpdateExpression) {
124
+ if (n.argument.type === AST_NODE_TYPES.Identifier) {
125
+ reportIfOuter(n.argument)
126
+ }
127
+ if (n.argument.type === AST_NODE_TYPES.MemberExpression && n.argument.object.type === AST_NODE_TYPES.ThisExpression) {
128
+ context.report({ node: n.argument, messageId: 'assignmentOutside' })
129
+ }
130
+ return
131
+ }
132
+
133
+ for (const child of sourceCode.visitorKeys[n.type] ?? []) {
134
+ const val = (n as any)[child]
135
+ if (Array.isArray(val)) {
136
+ for (const item of val) {
137
+ if (item && typeof item.type === 'string') visitor(item)
138
+ }
139
+ } else if (val && typeof val.type === 'string') {
140
+ visitor(val)
141
+ }
142
+ }
143
+ }
144
+
145
+ visitor(callbackBody)
146
+ },
147
+ }
148
+ },
149
+ })
@@ -0,0 +1,122 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester'
2
+ import { noTranslateInstant } from './no-translate-instant.rule'
3
+
4
+ RuleTester.afterAll = afterAll
5
+
6
+ const ruleTester = new RuleTester({
7
+ languageOptions: {
8
+ parser: require('@typescript-eslint/parser'),
9
+ parserOptions: {
10
+ ecmaVersion: 2022,
11
+ sourceType: 'module',
12
+ },
13
+ },
14
+ } as unknown as any)
15
+
16
+ const messageId = 'noTranslateInstant'
17
+
18
+ ruleTester.run('no-translate-instant', noTranslateInstant, {
19
+ valid: [
20
+ {
21
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/some.spec.ts`,
22
+ code: `
23
+ class A {
24
+ constructor(private translate: any) {}
25
+ test() {
26
+ return this.translate.instant('KEY')
27
+ }
28
+ }
29
+ `,
30
+ },
31
+ {
32
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/testing/test-helper.ts`,
33
+ code: `
34
+ const translateService: any = { instant: (k: string) => k }
35
+ translateService.instant('KEY')
36
+ `,
37
+ },
38
+ {
39
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/mocks/translate.mock.ts`,
40
+ code: `
41
+ const translate: any = { instant: (k: string) => k }
42
+ translate.instant('KEY')
43
+ `,
44
+ },
45
+ {
46
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/app/a.ts`,
47
+ code: `
48
+ const other = { instant: (k: string) => k }
49
+ other.instant('KEY')
50
+ `,
51
+ },
52
+ ],
53
+ invalid: [
54
+ {
55
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/app/a.ts`,
56
+ code: `
57
+ class A {
58
+ constructor(private translate: any) {}
59
+ test() {
60
+ return this.translate.instant('KEY')
61
+ }
62
+ }
63
+ `,
64
+ errors: [{ messageId }],
65
+ },
66
+ {
67
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/app/b.ts`,
68
+ code: `
69
+ function f(translate: any) {
70
+ return translate.instant('KEY')
71
+ }
72
+ `,
73
+ errors: [{ messageId }],
74
+ },
75
+ {
76
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/app/c.ts`,
77
+ code: `
78
+ function f(translateService: any) {
79
+ return translateService.instant('KEY')
80
+ }
81
+ `,
82
+ errors: [{ messageId }],
83
+ },
84
+ {
85
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/app/d.ts`,
86
+ code: `
87
+ class A {
88
+ private translateService: any
89
+ test() {
90
+ return this.translateService.instant('KEY')
91
+ }
92
+ }
93
+ `,
94
+ errors: [{ messageId }],
95
+ },
96
+ {
97
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/app/e.ts`,
98
+ code: `
99
+ import { TranslateService } from '@ngx-translate/core'
100
+
101
+ class A {
102
+ constructor(private translate: TranslateService) {}
103
+ test() {
104
+ return this.translate.instant('KEY')
105
+ }
106
+ }
107
+ `,
108
+ errors: [{ messageId }],
109
+ },
110
+ {
111
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/app/f.ts`,
112
+ code: `
113
+ import { TranslateService } from '@ngx-translate/core'
114
+
115
+ function f(translateService: TranslateService) {
116
+ return translateService.instant('KEY')
117
+ }
118
+ `,
119
+ errors: [{ messageId }],
120
+ },
121
+ ],
122
+ })
@@ -0,0 +1,98 @@
1
+ import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'
2
+ import { isTranslateServiceType } from '../utils/type-utils'
3
+
4
+ const createRule = ESLintUtils.RuleCreator(
5
+ (name) => `https://github.com/onecx/onecx-portal-ui-libs/tree/main/libs/angular-linter-rules#${name}`,
6
+ )
7
+
8
+ type Options = []
9
+ type MessageIds = 'noTranslateInstant'
10
+
11
+ const defaultAllowedFilePatterns = [/\.spec\.ts$/i, /\.test\.ts$/i, /\/testing\//i, /\/mocks\//i]
12
+
13
+ function isAllowedTestFile(filename: string): boolean {
14
+ if (!filename || filename === '<input>' || filename === '<text>') return false
15
+ return defaultAllowedFilePatterns.some((re) => re.test(filename))
16
+ }
17
+
18
+ function isInstantMemberCall(node: TSESTree.CallExpression): boolean {
19
+ if (node.callee.type !== AST_NODE_TYPES.MemberExpression) return false
20
+ const property = node.callee.property
21
+ if (property.type !== AST_NODE_TYPES.Identifier || property.name !== 'instant') return false
22
+
23
+ const obj = node.callee.object
24
+ if (obj.type === AST_NODE_TYPES.Identifier) {
25
+ return obj.name === 'translate' || obj.name === 'translateService'
26
+ }
27
+
28
+ if (obj.type === AST_NODE_TYPES.MemberExpression) {
29
+ if (obj.object.type !== AST_NODE_TYPES.ThisExpression) return false
30
+ if (obj.property.type !== AST_NODE_TYPES.Identifier) return false
31
+ return obj.property.name === 'translate' || obj.property.name === 'translateService'
32
+ }
33
+
34
+ return false
35
+ }
36
+
37
+ type ContextWithTypeInfo = {
38
+ sourceCode: {
39
+ parserServices?: {
40
+ program?: unknown
41
+ esTreeNodeToTSNodeMap?: unknown
42
+ }
43
+ }
44
+ }
45
+
46
+ function isTranslateServiceReceiver(context: ContextWithTypeInfo, node: TSESTree.CallExpression): boolean {
47
+ if (node.callee.type !== AST_NODE_TYPES.MemberExpression) return false
48
+ if (!context.sourceCode.parserServices?.program) return false
49
+
50
+ const { esTreeNodeToTSNodeMap, program } = context.sourceCode.parserServices
51
+ if (!esTreeNodeToTSNodeMap) return false
52
+ const checker = (program as any).getTypeChecker()
53
+
54
+ const receiverTsNode = (esTreeNodeToTSNodeMap as any).get(node.callee.object as any)
55
+ const receiverType = checker.getTypeAtLocation(receiverTsNode)
56
+ if (isTranslateServiceType(checker, receiverType)) return true
57
+
58
+ const receiverTypeText = checker.typeToString(receiverType)
59
+ return receiverTypeText === 'TranslateService'
60
+ }
61
+
62
+ export const noTranslateInstant = createRule<Options, MessageIds>({
63
+ name: 'no-translate-instant',
64
+ meta: {
65
+ type: 'problem',
66
+ docs: {
67
+ description: 'Disallow ngx-translate TranslateService.instant outside of tests.',
68
+ },
69
+ schema: [],
70
+ messages: {
71
+ noTranslateInstant:
72
+ 'Avoid `TranslateService.instant(...)` in production code. Use stream-based translation (`get`/`stream`) or the translate pipe instead.',
73
+ },
74
+ },
75
+ defaultOptions: [],
76
+ create(context) {
77
+ const filename = context.filename
78
+ const allowed = isAllowedTestFile(filename)
79
+
80
+ return {
81
+ CallExpression(node) {
82
+ if (allowed) return
83
+
84
+ if (isTranslateServiceReceiver(context as unknown as ContextWithTypeInfo, node)) {
85
+ context.report({ node: node.callee, messageId: 'noTranslateInstant' })
86
+ return
87
+ }
88
+
89
+ if (!isInstantMemberCall(node)) return
90
+
91
+ context.report({
92
+ node: node.callee,
93
+ messageId: 'noTranslateInstant',
94
+ })
95
+ },
96
+ }
97
+ },
98
+ })
@@ -0,0 +1,97 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester'
2
+ import { preferTranslateParams } from './prefer-translate-params.rule'
3
+
4
+ RuleTester.afterAll = afterAll
5
+
6
+ const ruleTester = new RuleTester({
7
+ languageOptions: {
8
+ parser: require('@typescript-eslint/parser'),
9
+ parserOptions: {
10
+ ecmaVersion: 2022,
11
+ sourceType: 'module',
12
+ },
13
+ },
14
+ } as unknown as any)
15
+
16
+ const messageId = 'preferTranslateParams'
17
+
18
+ ruleTester.run('prefer-translate-params', preferTranslateParams, {
19
+ valid: [
20
+ {
21
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/app/a.ts`,
22
+ code: `
23
+ function f(translate: any, name: string) {
24
+ return translate.get('HELLO_NAME', { name })
25
+ }
26
+ `,
27
+ },
28
+ {
29
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/app/b.ts`,
30
+ code: `
31
+ function f(translate: any, name: string) {
32
+ return translate.get('HELLO_' + name)
33
+ }
34
+ `,
35
+ },
36
+ {
37
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/app/b2.ts`,
38
+ code: `
39
+ function f(translate: any, name: string) {
40
+ return translate.stream('HELLO_' + name)
41
+ }
42
+ `,
43
+ },
44
+ {
45
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/some.spec.ts`,
46
+ code: `
47
+ function f(translate: any, name: string) {
48
+ return translate.get('HELLO')
49
+ }
50
+ `,
51
+ },
52
+ ],
53
+ invalid: [
54
+ {
55
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/app/d.ts`,
56
+ code: `
57
+ import { map } from 'rxjs'
58
+
59
+ function f(translateService: any, name: string) {
60
+ return translate.get('HELLO').pipe(map((t: string) => 'Hi ' + t))
61
+ }
62
+ `,
63
+ errors: [{ messageId }],
64
+ },
65
+ {
66
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/app/rx.ts`,
67
+ code: `
68
+ import { map } from 'rxjs'
69
+
70
+ function f(translate: any, name: string) {
71
+ return translate.get('HELLO').pipe(map((t: string) => t + name))
72
+ }
73
+ `,
74
+ errors: [{ messageId }],
75
+ },
76
+ {
77
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/app/rx2.ts`,
78
+ code: `
79
+ import { map } from 'rxjs'
80
+
81
+ function f(translate: any, name: string) {
82
+ return translate.stream('HELLO').pipe(map((t: string) => t + name))
83
+ }
84
+ `,
85
+ errors: [{ messageId }],
86
+ },
87
+ {
88
+ filename: `${process.cwd()}/libs/angular-linter-rules/src/app/e.ts`,
89
+ code: `
90
+ function f(translate: any, name: string) {
91
+ return ` + "`" + '${translate.instant(\'HELLO\')} ${name}' + "`" + `
92
+ }
93
+ `,
94
+ errors: [{ messageId }],
95
+ },
96
+ ],
97
+ })
@@ -0,0 +1,133 @@
1
+ import { AST_NODE_TYPES, ESLintUtils, TSESLint, TSESTree } from '@typescript-eslint/utils'
2
+
3
+ const createRule = ESLintUtils.RuleCreator(
4
+ (name) => `https://github.com/onecx/onecx-portal-ui-libs/tree/main/libs/angular-linter-rules#${name}`,
5
+ )
6
+
7
+ type Options = []
8
+ type MessageIds = 'preferTranslateParams'
9
+
10
+ const defaultAllowedFilePatterns = [/\.spec\.ts$/i, /\.test\.ts$/i, /\/testing\//i, /\/mocks\//i]
11
+
12
+ function isAllowedTestFile(filename: string): boolean {
13
+ if (!filename || filename === '<input>' || filename === '<text>') return false
14
+ return defaultAllowedFilePatterns.some((re) => re.test(filename))
15
+ }
16
+
17
+ function isTranslateGetOrStreamCallExpression(node: TSESTree.Node): node is TSESTree.CallExpression {
18
+ if (node.type !== AST_NODE_TYPES.CallExpression) return false
19
+ if (node.callee.type !== AST_NODE_TYPES.MemberExpression) return false
20
+ if (node.callee.property.type !== AST_NODE_TYPES.Identifier) return false
21
+ return node.callee.property.name === 'get' || node.callee.property.name === 'stream'
22
+ }
23
+
24
+ function isTranslateInstantCallExpression(node: TSESTree.Node): node is TSESTree.CallExpression {
25
+ if (node.type !== AST_NODE_TYPES.CallExpression) return false
26
+ if (node.callee.type !== AST_NODE_TYPES.MemberExpression) return false
27
+ if (node.callee.property.type !== AST_NODE_TYPES.Identifier) return false
28
+ return node.callee.property.name === 'instant'
29
+ }
30
+
31
+ function isTranslateGetOrStreamPipeCallExpression(node: TSESTree.Node): node is TSESTree.CallExpression {
32
+ if (node.type !== AST_NODE_TYPES.CallExpression) return false
33
+ if (node.callee.type !== AST_NODE_TYPES.MemberExpression) return false
34
+ if (node.callee.property.type !== AST_NODE_TYPES.Identifier) return false
35
+ if (node.callee.property.name !== 'pipe') return false
36
+
37
+ const obj = node.callee.object
38
+ return isTranslateGetOrStreamCallExpression(obj)
39
+ }
40
+
41
+ function containsBinaryPlus(context: TSESLint.RuleContext<MessageIds, Options>, node: TSESTree.Node): boolean {
42
+ let found = false
43
+
44
+ const visit = (n: TSESTree.Node): void => {
45
+ if (found) return
46
+
47
+ if (n.type === AST_NODE_TYPES.BinaryExpression && n.operator === '+') {
48
+ found = true
49
+ return
50
+ }
51
+
52
+ const keys = context.sourceCode.visitorKeys[n.type] ?? []
53
+ for (const key of keys) {
54
+ const value = (n as unknown as Record<string, unknown>)[key]
55
+ if (!value) continue
56
+
57
+ if (Array.isArray(value)) {
58
+ for (const child of value) {
59
+ if (found) return
60
+ if (child && typeof child === 'object' && 'type' in (child as Record<string, unknown>)) {
61
+ visit(child as TSESTree.Node)
62
+ }
63
+ }
64
+ continue
65
+ }
66
+
67
+ if (value && typeof value === 'object' && 'type' in (value as Record<string, unknown>)) {
68
+ visit(value as TSESTree.Node)
69
+ }
70
+ }
71
+ }
72
+
73
+ visit(node)
74
+ return found
75
+ }
76
+
77
+ function isTranslatePipeExpression(node: TSESTree.Expression): boolean {
78
+ if (node.type !== AST_NODE_TYPES.BinaryExpression || node.operator !== '|') return false
79
+ const right = node.right
80
+ return right.type === AST_NODE_TYPES.Identifier && right.name === 'translate'
81
+ }
82
+
83
+ export const preferTranslateParams = createRule<Options, MessageIds>({
84
+ name: 'prefer-translate-params',
85
+ meta: {
86
+ type: 'suggestion',
87
+ docs: {
88
+ description:
89
+ 'Prefer translation parameters instead of concatenating translated strings (use e.g. `translate.get(key, { param })`).',
90
+ },
91
+ schema: [],
92
+ messages: {
93
+ preferTranslateParams:
94
+ 'Avoid concatenating translated strings. Prefer translation parameters/placeholders instead (e.g. `translate.get(key, { value })`).',
95
+ },
96
+ },
97
+ defaultOptions: [],
98
+ create(context) {
99
+ const filename = context.filename
100
+ const allowed = isAllowedTestFile(filename)
101
+
102
+ if (allowed) {
103
+ return {}
104
+ }
105
+
106
+ return {
107
+ BinaryExpression(node) {
108
+ if (node.operator !== '+') return
109
+ if (
110
+ isTranslateGetOrStreamCallExpression(node.left) ||
111
+ isTranslateGetOrStreamCallExpression(node.right) ||
112
+ isTranslateInstantCallExpression(node.left) ||
113
+ isTranslateInstantCallExpression(node.right) ||
114
+ isTranslatePipeExpression(node.left) ||
115
+ isTranslatePipeExpression(node.right)
116
+ ) {
117
+ context.report({ node, messageId: 'preferTranslateParams' })
118
+ }
119
+ },
120
+ TemplateLiteral(node) {
121
+ if (!node.expressions.some((expr) => isTranslateGetOrStreamCallExpression(expr) || isTranslateInstantCallExpression(expr))) {
122
+ return
123
+ }
124
+ context.report({ node, messageId: 'preferTranslateParams' })
125
+ },
126
+ CallExpression(node) {
127
+ if (!isTranslateGetOrStreamPipeCallExpression(node)) return
128
+ if (!containsBinaryPlus(context, node)) return
129
+ context.report({ node, messageId: 'preferTranslateParams' })
130
+ },
131
+ }
132
+ },
133
+ })
@@ -0,0 +1,6 @@
1
+ import type { TSESLint } from '@typescript-eslint/utils'
2
+
3
+ export type AngularLinterRulesPlugin = {
4
+ rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>>
5
+ configs: Record<string, TSESLint.Linter.Config>
6
+ }
@@ -0,0 +1,15 @@
1
+ import type ts from 'typescript'
2
+
3
+ export function isTranslateServiceType(typeChecker: ts.TypeChecker, type: ts.Type): boolean {
4
+ const symbol = type.getSymbol() ?? type.aliasSymbol
5
+ if (!symbol) return false
6
+
7
+ const name = symbol.getName()
8
+ if (name !== 'TranslateService') return false
9
+
10
+ const declarations = symbol.getDeclarations() ?? []
11
+ return declarations.some((decl) => {
12
+ const sourceFileName = decl.getSourceFile().fileName
13
+ return sourceFileName.includes('/@ngx-translate/core/') || sourceFileName.includes('\\@ngx-translate\\core\\')
14
+ })
15
+ }
@@ -0,0 +1,3 @@
1
+ declare module '@typescript-eslint/rule-tester' {
2
+ export * from '@typescript-eslint/rule-tester/dist/index'
3
+ }
package/src/version.ts ADDED
@@ -0,0 +1,2 @@
1
+ export const LIB_NAME = '@onecx/angular-linter-rules'
2
+ export const LIB_VERSION = '0.0.0-PLACEHOLDER'
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "commonjs",
5
+ "forceConsistentCasingInFileNames": true,
6
+ "strict": true,
7
+ "importHelpers": true,
8
+ "noImplicitOverride": true,
9
+ "noImplicitReturns": true,
10
+ "noFallthroughCasesInSwitch": true,
11
+ "noPropertyAccessFromIndexSignature": true
12
+ },
13
+ "files": [],
14
+ "include": [],
15
+ "references": [
16
+ {
17
+ "path": "./tsconfig.lib.json"
18
+ },
19
+ {
20
+ "path": "./tsconfig.spec.json"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../dist/out-tsc",
5
+ "declaration": true,
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*.ts"],
9
+ "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
10
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "./tsconfig.spec.json",
3
+ "include": ["src/**/*.ts", "src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.d.ts", "jest.config.ts"],
4
+ "exclude": []
5
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../dist/out-tsc",
5
+ "module": "commonjs",
6
+ "moduleResolution": "node10",
7
+ "types": ["jest", "node"]
8
+ },
9
+ "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
10
+ }