@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 +17 -0
- package/eslint.config.cjs +19 -0
- package/jest.config.ts +10 -0
- package/package.json +13 -0
- package/project.json +28 -0
- package/src/index.ts +11 -0
- package/src/lib/configs.ts +16 -0
- package/src/lib/rules/index.ts +9 -0
- package/src/lib/rules/no-subscribe-assignment.rule.spec.ts +118 -0
- package/src/lib/rules/no-subscribe-assignment.rule.ts +149 -0
- package/src/lib/rules/no-translate-instant.rule.spec.ts +122 -0
- package/src/lib/rules/no-translate-instant.rule.ts +98 -0
- package/src/lib/rules/prefer-translate-params.rule.spec.ts +97 -0
- package/src/lib/rules/prefer-translate-params.rule.ts +133 -0
- package/src/lib/types.ts +6 -0
- package/src/lib/utils/type-utils.ts +15 -0
- package/src/types/rule-tester.d.ts +3 -0
- package/src/version.ts +2 -0
- package/tsconfig.json +23 -0
- package/tsconfig.lib.json +10 -0
- package/tsconfig.rule-tester.json +5 -0
- package/tsconfig.spec.json +10 -0
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
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,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
|
+
})
|
package/src/lib/types.ts
ADDED
|
@@ -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
|
+
}
|
package/src/version.ts
ADDED
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
|
+
"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
|
+
}
|