@simplysm/lint 13.0.100 → 14.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -40
- package/dist/eslint-plugin.d.ts +12 -0
- package/dist/eslint-plugin.d.ts.map +1 -1
- package/dist/eslint-plugin.js +15 -10
- package/dist/eslint-plugin.js.map +1 -6
- package/dist/eslint-recommended.d.ts +2 -1
- package/dist/eslint-recommended.d.ts.map +1 -1
- package/dist/eslint-recommended.js +159 -242
- package/dist/eslint-recommended.js.map +1 -6
- package/dist/rules/ng-template-no-todo-comments.d.ts +12 -0
- package/dist/rules/ng-template-no-todo-comments.d.ts.map +1 -0
- package/dist/rules/ng-template-no-todo-comments.js +46 -0
- package/dist/rules/ng-template-no-todo-comments.js.map +1 -0
- package/dist/rules/ng-template-sd-require-binding-attrs.d.ts +18 -0
- package/dist/rules/ng-template-sd-require-binding-attrs.d.ts.map +1 -0
- package/dist/rules/ng-template-sd-require-binding-attrs.js +88 -0
- package/dist/rules/ng-template-sd-require-binding-attrs.js.map +1 -0
- package/dist/rules/no-hard-private.d.ts +6 -6
- package/dist/rules/no-hard-private.js +109 -88
- package/dist/rules/no-hard-private.js.map +1 -6
- package/dist/rules/no-subpath-imports-from-simplysm.d.ts +5 -5
- package/dist/rules/no-subpath-imports-from-simplysm.js +72 -60
- package/dist/rules/no-subpath-imports-from-simplysm.js.map +1 -6
- package/dist/rules/ts-no-throw-not-implemented-error.d.ts +5 -5
- package/dist/rules/ts-no-throw-not-implemented-error.js +92 -58
- package/dist/rules/ts-no-throw-not-implemented-error.js.map +1 -6
- package/dist/rules/ts-no-unused-injects.d.ts +13 -0
- package/dist/rules/ts-no-unused-injects.d.ts.map +1 -0
- package/dist/rules/ts-no-unused-injects.js +81 -0
- package/dist/rules/ts-no-unused-injects.js.map +1 -0
- package/dist/rules/ts-no-unused-protected-readonly.d.ts +13 -0
- package/dist/rules/ts-no-unused-protected-readonly.d.ts.map +1 -0
- package/dist/rules/ts-no-unused-protected-readonly.js +127 -0
- package/dist/rules/ts-no-unused-protected-readonly.js.map +1 -0
- package/dist/utils/create-rule.d.ts +3 -3
- package/dist/utils/create-rule.js +19 -7
- package/dist/utils/create-rule.js.map +1 -6
- package/package.json +10 -11
- package/src/eslint-plugin.ts +8 -0
- package/src/eslint-recommended.ts +43 -116
- package/src/rules/ng-template-no-todo-comments.ts +48 -0
- package/src/rules/ng-template-sd-require-binding-attrs.ts +111 -0
- package/src/rules/no-hard-private.ts +23 -23
- package/src/rules/no-subpath-imports-from-simplysm.ts +13 -13
- package/src/rules/ts-no-throw-not-implemented-error.ts +14 -14
- package/src/rules/ts-no-unused-injects.ts +88 -0
- package/src/rules/ts-no-unused-protected-readonly.ts +151 -0
- package/src/utils/create-rule.ts +3 -3
- package/tests/no-hard-private.spec.ts +0 -888
- package/tests/no-subpath-imports-from-simplysm.spec.ts +0 -311
- package/tests/recommended.spec.ts +0 -145
- package/tests/ts-no-throw-not-implemented-error.spec.ts +0 -245
- package/tests/vitest.setup.ts +0 -10
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createRule } from "../utils/create-rule";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HTML 템플릿 내 TODO 주석을 감지하여 경고하는 ESLint 규칙.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* `<!-- TODO: ... -->` 형태의 HTML 주석을 찾아 보고합니다.
|
|
8
|
+
* raw text regex 방식으로 동작하므로 AST 노드 방문자가 아닌 빈 객체를 반환합니다.
|
|
9
|
+
*/
|
|
10
|
+
export default createRule({
|
|
11
|
+
name: "ng-template-no-todo-comments",
|
|
12
|
+
meta: {
|
|
13
|
+
type: "problem",
|
|
14
|
+
docs: {
|
|
15
|
+
description: "HTML 템플릿 내 TODO 주석을 경고합니다.",
|
|
16
|
+
},
|
|
17
|
+
schema: [],
|
|
18
|
+
messages: {
|
|
19
|
+
noTodo: "{{content}}",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultOptions: [],
|
|
23
|
+
create(context) {
|
|
24
|
+
const sourceCode = context.sourceCode;
|
|
25
|
+
const source = sourceCode.getText();
|
|
26
|
+
const commentRegex = /<!--([\s\S]*?)-->/g;
|
|
27
|
+
let match;
|
|
28
|
+
while ((match = commentRegex.exec(source)) !== null) {
|
|
29
|
+
const commentContent = match[1];
|
|
30
|
+
const todoIndex = commentContent.indexOf("TODO:");
|
|
31
|
+
if (todoIndex < 0) continue;
|
|
32
|
+
|
|
33
|
+
const start = match.index;
|
|
34
|
+
const end = start + match[0].length;
|
|
35
|
+
const content = commentContent.slice(todoIndex + 5).trim();
|
|
36
|
+
const loc = sourceCode.getLocFromIndex(start);
|
|
37
|
+
const endLoc = sourceCode.getLocFromIndex(end);
|
|
38
|
+
|
|
39
|
+
context.report({
|
|
40
|
+
loc: { start: loc, end: endLoc },
|
|
41
|
+
messageId: "noTodo",
|
|
42
|
+
data: { content },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {};
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { getTemplateParserServices } from "@angular-eslint/utils";
|
|
2
|
+
import { createRule } from "../utils/create-rule";
|
|
3
|
+
|
|
4
|
+
export interface RuleOptions {
|
|
5
|
+
selectorPrefixes?: string[];
|
|
6
|
+
allowAttributes?: string[];
|
|
7
|
+
allowAttributePrefixes?: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_OPTIONS: Required<RuleOptions> = {
|
|
11
|
+
selectorPrefixes: ["sd-"],
|
|
12
|
+
allowAttributes: ["id", "class", "style", "title", "tabindex", "role"],
|
|
13
|
+
allowAttributePrefixes: ["aria-", "data-", "sd-"],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* sd-* 컴포넌트에서 plain attribute 사용을 제한하고 Angular property binding을 강제하는 ESLint 규칙.
|
|
18
|
+
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* `sd-` 접두사를 가진 커스텀 컴포넌트에서 허용되지 않은 plain attribute를 감지합니다.
|
|
21
|
+
* 허용 목록(id, class, style, title, tabindex, role)과 허용 접두사(aria-, data-, sd-)에
|
|
22
|
+
* 해당하지 않는 attribute는 `[attr]="..."` 형태의 property binding으로 변환하도록 autofix합니다.
|
|
23
|
+
*/
|
|
24
|
+
export default createRule({
|
|
25
|
+
name: "ng-template-sd-require-binding-attrs",
|
|
26
|
+
meta: {
|
|
27
|
+
type: "problem",
|
|
28
|
+
docs: {
|
|
29
|
+
description:
|
|
30
|
+
'Disallow non-whitelisted plain attributes on prefixed components (e.g. sd-*) and require using Angular property bindings instead.',
|
|
31
|
+
},
|
|
32
|
+
fixable: "code",
|
|
33
|
+
schema: [
|
|
34
|
+
{
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: {
|
|
37
|
+
selectorPrefixes: { type: "array", items: { type: "string" } },
|
|
38
|
+
allowAttributes: { type: "array", items: { type: "string" } },
|
|
39
|
+
allowAttributePrefixes: { type: "array", items: { type: "string" } },
|
|
40
|
+
},
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
messages: {
|
|
45
|
+
requireBindingForAttribute:
|
|
46
|
+
'Attribute "{{attrName}}" is not allowed as a plain attribute on "{{elementName}}". Use a property binding instead, e.g. [{{attrName}}]="…".',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
defaultOptions: [{}] as [RuleOptions],
|
|
50
|
+
create(context) {
|
|
51
|
+
const parserServices = getTemplateParserServices(context as never);
|
|
52
|
+
const userOptions = context.options.at(0) ?? {};
|
|
53
|
+
const selectorPrefixes = userOptions.selectorPrefixes ?? DEFAULT_OPTIONS.selectorPrefixes;
|
|
54
|
+
const allowAttributes = userOptions.allowAttributes ?? DEFAULT_OPTIONS.allowAttributes;
|
|
55
|
+
const allowAttributePrefixes =
|
|
56
|
+
userOptions.allowAttributePrefixes ?? DEFAULT_OPTIONS.allowAttributePrefixes;
|
|
57
|
+
|
|
58
|
+
const allowedAttrSet = new Set(allowAttributes.map((attr) => attr.toLowerCase()));
|
|
59
|
+
|
|
60
|
+
function isTargetElement(node: { name: string }): boolean {
|
|
61
|
+
const tagName = node.name.toLowerCase();
|
|
62
|
+
return selectorPrefixes.some((prefix) => tagName.startsWith(prefix.toLowerCase()));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isWhitelistedPlainAttr(attr: { name: string }): boolean {
|
|
66
|
+
const name = attr.name.toLowerCase();
|
|
67
|
+
if (allowedAttrSet.has(name)) return true;
|
|
68
|
+
return allowAttributePrefixes.some((prefix) => name.startsWith(prefix.toLowerCase()));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
Element(node: {
|
|
73
|
+
name: string;
|
|
74
|
+
attributes: Array<{
|
|
75
|
+
name: string;
|
|
76
|
+
value: string;
|
|
77
|
+
sourceSpan: { start: { offset: number }; end: { offset: number } };
|
|
78
|
+
}>;
|
|
79
|
+
sourceSpan: { start: { offset: number }; end: { offset: number } };
|
|
80
|
+
}) {
|
|
81
|
+
if (!isTargetElement(node)) return;
|
|
82
|
+
|
|
83
|
+
for (const attr of node.attributes) {
|
|
84
|
+
if (isWhitelistedPlainAttr(attr)) continue;
|
|
85
|
+
|
|
86
|
+
const span = attr.sourceSpan;
|
|
87
|
+
const loc = parserServices.convertNodeSourceSpanToLoc(span as never);
|
|
88
|
+
|
|
89
|
+
context.report({
|
|
90
|
+
loc,
|
|
91
|
+
messageId: "requireBindingForAttribute",
|
|
92
|
+
data: { attrName: attr.name, elementName: node.name },
|
|
93
|
+
fix(fixer) {
|
|
94
|
+
const start = span.start.offset;
|
|
95
|
+
const end = span.end.offset;
|
|
96
|
+
if (start >= end) return null;
|
|
97
|
+
|
|
98
|
+
const rawValue = attr.value;
|
|
99
|
+
if (rawValue === "") {
|
|
100
|
+
return fixer.replaceTextRange([start, end], `[${attr.name}]="true"`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const escaped = rawValue.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
104
|
+
return fixer.replaceTextRange([start, end], `[${attr.name}]="'${escaped}'"`);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
} as never;
|
|
110
|
+
},
|
|
111
|
+
});
|
|
@@ -18,27 +18,27 @@ function isClassMemberWithAccessibility(
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
21
|
+
* ECMAScript private 필드(`#field`)를 제한하고 TypeScript `private` 키워드 사용을 강제하는 ESLint 규칙.
|
|
22
22
|
*
|
|
23
23
|
* @remarks
|
|
24
|
-
*
|
|
25
|
-
* -
|
|
26
|
-
* -
|
|
27
|
-
* -
|
|
28
|
-
* -
|
|
24
|
+
* 이 규칙이 검사하는 항목:
|
|
25
|
+
* - 클래스 필드 선언: `#field`
|
|
26
|
+
* - 클래스 메서드 선언: `#method()`
|
|
27
|
+
* - 클래스 접근자 선언: `accessor #field`
|
|
28
|
+
* - 멤버 접근 표현식: `this.#field`
|
|
29
29
|
*/
|
|
30
30
|
export default createRule({
|
|
31
31
|
name: "no-hard-private",
|
|
32
32
|
meta: {
|
|
33
33
|
type: "problem",
|
|
34
34
|
docs: {
|
|
35
|
-
description: '
|
|
35
|
+
description: 'hard private 필드(#) 대신 TypeScript "private _" 스타일을 강제합니다.',
|
|
36
36
|
},
|
|
37
37
|
messages: {
|
|
38
38
|
preferSoftPrivate:
|
|
39
|
-
'
|
|
39
|
+
'hard private 필드(#)는 사용할 수 없습니다. "private _" 스타일을 사용하세요.',
|
|
40
40
|
nameConflict:
|
|
41
|
-
'
|
|
41
|
+
'hard private 필드 "#{{name}}"을(를) "_{{name}}"(으)로 변환할 수 없습니다. 동일한 이름의 멤버가 이미 존재합니다.',
|
|
42
42
|
},
|
|
43
43
|
fixable: "code",
|
|
44
44
|
schema: [],
|
|
@@ -46,11 +46,11 @@ export default createRule({
|
|
|
46
46
|
defaultOptions: [],
|
|
47
47
|
create(context) {
|
|
48
48
|
const sourceCode = context.sourceCode;
|
|
49
|
-
//
|
|
49
|
+
// 중첩 클래스 지원을 위한 스택 구조
|
|
50
50
|
const classStack: Set<string>[] = [];
|
|
51
51
|
|
|
52
52
|
return {
|
|
53
|
-
// 0.
|
|
53
|
+
// 0. 클래스 진입 시 멤버 이름 수집
|
|
54
54
|
"ClassBody"(node: TSESTree.ClassBody) {
|
|
55
55
|
const memberNames = new Set<string>();
|
|
56
56
|
for (const member of node.body) {
|
|
@@ -67,7 +67,7 @@ export default createRule({
|
|
|
67
67
|
classStack.pop();
|
|
68
68
|
},
|
|
69
69
|
|
|
70
|
-
// 1.
|
|
70
|
+
// 1. 선언 감지 (PropertyDefinition, MethodDefinition, AccessorProperty)
|
|
71
71
|
"PropertyDefinition > PrivateIdentifier, MethodDefinition > PrivateIdentifier, AccessorProperty > PrivateIdentifier"(
|
|
72
72
|
node: TSESTree.PrivateIdentifier,
|
|
73
73
|
) {
|
|
@@ -76,11 +76,11 @@ export default createRule({
|
|
|
76
76
|
return;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
const identifierName = node.name; //
|
|
79
|
+
const identifierName = node.name; // '#' 문자를 제외한 이름
|
|
80
80
|
const targetName = `_${identifierName}`;
|
|
81
81
|
const currentClassMembers = classStack.at(-1);
|
|
82
82
|
|
|
83
|
-
//
|
|
83
|
+
// 이름 충돌 확인
|
|
84
84
|
if (currentClassMembers?.has(targetName)) {
|
|
85
85
|
context.report({
|
|
86
86
|
node,
|
|
@@ -96,25 +96,25 @@ export default createRule({
|
|
|
96
96
|
fix(fixer) {
|
|
97
97
|
const fixes: RuleFix[] = [];
|
|
98
98
|
|
|
99
|
-
// 1-1.
|
|
99
|
+
// 1-1. 이름 변경 (#a -> _a)
|
|
100
100
|
fixes.push(fixer.replaceText(node, targetName));
|
|
101
101
|
|
|
102
|
-
// 1-2.
|
|
102
|
+
// 1-2. 'private' 접근 제어자를 추가할 위치 계산
|
|
103
103
|
if (parent.accessibility == null) {
|
|
104
|
-
//
|
|
104
|
+
// 기본 삽입 위치: 부모 노드의 시작 (static, async 등 포함)
|
|
105
105
|
let tokenToInsertBefore = sourceCode.getFirstToken(parent);
|
|
106
106
|
|
|
107
|
-
//
|
|
107
|
+
// 데코레이터가 있으면 마지막 데코레이터 다음 토큰 앞에 삽입
|
|
108
108
|
// (@Deco private static _foo)
|
|
109
109
|
if (parent.decorators.length > 0) {
|
|
110
110
|
const lastDecorator = parent.decorators.at(-1)!;
|
|
111
111
|
tokenToInsertBefore = sourceCode.getTokenAfter(lastDecorator);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
// tokenToInsertBefore
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
114
|
+
// tokenToInsertBefore는 현재 'static', 'async', 'readonly', 또는 변수명('_foo')입니다.
|
|
115
|
+
// 앞에 'private '를 삽입하면 자연스럽게 'private static ...' 순서가 됩니다.
|
|
116
|
+
// tokenToInsertBefore가 null이면 AST 파싱 오류 등의 예외 상황을 나타냅니다.
|
|
117
|
+
// 이런 경우 이름만 변경되는 불완전한 수정을 방지하기 위해 전체 수정을 건너뜁니다.
|
|
118
118
|
if (tokenToInsertBefore == null) {
|
|
119
119
|
return [];
|
|
120
120
|
}
|
|
@@ -126,7 +126,7 @@ export default createRule({
|
|
|
126
126
|
});
|
|
127
127
|
},
|
|
128
128
|
|
|
129
|
-
// 2.
|
|
129
|
+
// 2. 사용 감지 (this.#field)
|
|
130
130
|
"MemberExpression > PrivateIdentifier"(node: TSESTree.PrivateIdentifier) {
|
|
131
131
|
const identifierName = node.name;
|
|
132
132
|
context.report({
|
|
@@ -2,13 +2,13 @@ import { AST_NODE_TYPES, TSESTree } from "@typescript-eslint/utils";
|
|
|
2
2
|
import { createRule } from "../utils/create-rule";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* `@simplysm/*` 패키지에서 'src' 하위 경로 import를 금지하는 ESLint 규칙.
|
|
6
6
|
*
|
|
7
7
|
* @remarks
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
8
|
+
* 이 규칙이 검사하는 항목:
|
|
9
|
+
* - 정적 import 문: `import ... from '...'`
|
|
10
|
+
* - 동적 import: `import('...')`
|
|
11
|
+
* - 재내보내기 문: `export { ... } from '...'`, `export * from '...'`
|
|
12
12
|
*/
|
|
13
13
|
export default createRule({
|
|
14
14
|
name: "no-subpath-imports-from-simplysm",
|
|
@@ -16,13 +16,13 @@ export default createRule({
|
|
|
16
16
|
type: "problem",
|
|
17
17
|
docs: {
|
|
18
18
|
description:
|
|
19
|
-
"
|
|
19
|
+
"@simplysm 패키지에서 'src' 하위 경로 import를 금지합니다. (예: @simplysm/pkg/src/x → 금지)",
|
|
20
20
|
},
|
|
21
21
|
fixable: "code",
|
|
22
22
|
schema: [],
|
|
23
23
|
messages: {
|
|
24
24
|
noSubpathImport:
|
|
25
|
-
"
|
|
25
|
+
"'@simplysm/{{pkg}}' 패키지에서 'src' 하위 경로를 import할 수 없습니다: '{{importPath}}'",
|
|
26
26
|
},
|
|
27
27
|
},
|
|
28
28
|
defaultOptions: [],
|
|
@@ -32,8 +32,8 @@ export default createRule({
|
|
|
32
32
|
|
|
33
33
|
const parts = importPath.split("/");
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
35
|
+
// 허용: @simplysm/pkg, @simplysm/pkg/xxx, @simplysm/pkg/xxx/yyy
|
|
36
|
+
// 금지: @simplysm/pkg/src, @simplysm/pkg/src/xxx
|
|
37
37
|
if (parts.length >= 3 && parts[2] === "src") {
|
|
38
38
|
const fixedPath = `@simplysm/${parts[1]}`;
|
|
39
39
|
context.report({
|
|
@@ -52,12 +52,12 @@ export default createRule({
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
return {
|
|
55
|
-
//
|
|
55
|
+
// 정적 import: import { x } from '...'
|
|
56
56
|
ImportDeclaration(node) {
|
|
57
57
|
checkAndReport(node.source, node.source.value);
|
|
58
58
|
},
|
|
59
59
|
|
|
60
|
-
//
|
|
60
|
+
// 동적 import: import('...')
|
|
61
61
|
ImportExpression(node) {
|
|
62
62
|
if (node.source.type !== AST_NODE_TYPES.Literal) return;
|
|
63
63
|
const importPath = node.source.value;
|
|
@@ -65,13 +65,13 @@ export default createRule({
|
|
|
65
65
|
checkAndReport(node.source, importPath);
|
|
66
66
|
},
|
|
67
67
|
|
|
68
|
-
//
|
|
68
|
+
// 재내보내기: export { x } from '...'
|
|
69
69
|
ExportNamedDeclaration(node) {
|
|
70
70
|
if (!node.source) return;
|
|
71
71
|
checkAndReport(node.source, node.source.value);
|
|
72
72
|
},
|
|
73
73
|
|
|
74
|
-
//
|
|
74
|
+
// 전체 재내보내기: export * from '...'
|
|
75
75
|
ExportAllDeclaration(node) {
|
|
76
76
|
checkAndReport(node.source, node.source.value);
|
|
77
77
|
},
|
|
@@ -2,25 +2,25 @@ import { AST_NODE_TYPES, ASTUtils, type TSESTree } from "@typescript-eslint/util
|
|
|
2
2
|
import { createRule } from "../utils/create-rule";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* `@simplysm/core-common`의 `NotImplementedError` 사용을 감지하고 경고하는 ESLint 규칙.
|
|
6
6
|
*
|
|
7
7
|
* @remarks
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* `@simplysm/core-common`에서 import한 `NotImplementedError`를 `new`로 인스턴스화하는 코드를 감지합니다.
|
|
9
|
+
* 미구현 코드가 프로덕션에 포함되는 것을 방지합니다.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* 지원하는 import 형태:
|
|
12
12
|
* - named import: `import { NotImplementedError } from "@simplysm/core-common"`
|
|
13
13
|
* - aliased import: `import { NotImplementedError as NIE } from "@simplysm/core-common"`
|
|
14
14
|
* - namespace import: `import * as CC from "@simplysm/core-common"` → `new CC.NotImplementedError()`
|
|
15
15
|
*
|
|
16
|
-
*
|
|
16
|
+
* 동적 import (`await import(...)`)는 감지하지 않습니다.
|
|
17
17
|
*/
|
|
18
18
|
export default createRule({
|
|
19
19
|
name: "ts-no-throw-not-implemented-error",
|
|
20
20
|
meta: {
|
|
21
21
|
type: "suggestion",
|
|
22
22
|
docs: {
|
|
23
|
-
description: "
|
|
23
|
+
description: "'NotImplementedError' 사용에 대해 경고합니다",
|
|
24
24
|
},
|
|
25
25
|
schema: [],
|
|
26
26
|
messages: {
|
|
@@ -30,10 +30,10 @@ export default createRule({
|
|
|
30
30
|
defaultOptions: [],
|
|
31
31
|
create(context) {
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
* @param identifier -
|
|
35
|
-
* @param expectedImportedName -
|
|
36
|
-
* @returns
|
|
33
|
+
* 식별자가 @simplysm/core-common에서 import되었는지 확인합니다.
|
|
34
|
+
* @param identifier - 확인할 식별자
|
|
35
|
+
* @param expectedImportedName - named import에서 확인할 원래 이름 (namespace import의 경우 undefined)
|
|
36
|
+
* @returns import 소스가 @simplysm/core-common이면 true, 아니면 false
|
|
37
37
|
*/
|
|
38
38
|
function isImportedFromSimplysm(
|
|
39
39
|
identifier: TSESTree.Identifier,
|
|
@@ -48,7 +48,7 @@ export default createRule({
|
|
|
48
48
|
if (def.parent.type !== AST_NODE_TYPES.ImportDeclaration) continue;
|
|
49
49
|
if (def.parent.source.value !== "@simplysm/core-common") continue;
|
|
50
50
|
|
|
51
|
-
// named/aliased import: import { NotImplementedError }
|
|
51
|
+
// named/aliased import: import { NotImplementedError } 또는 import { NotImplementedError as NIE }
|
|
52
52
|
if (def.node.type === AST_NODE_TYPES.ImportSpecifier && expectedImportedName != null) {
|
|
53
53
|
const imported = def.node.imported;
|
|
54
54
|
if (
|
|
@@ -75,12 +75,12 @@ export default createRule({
|
|
|
75
75
|
NewExpression(node: TSESTree.NewExpression) {
|
|
76
76
|
let shouldReport = false;
|
|
77
77
|
|
|
78
|
-
//
|
|
78
|
+
// 케이스 1: new NotImplementedError() 또는 new NIE() (named/aliased import)
|
|
79
79
|
if (node.callee.type === AST_NODE_TYPES.Identifier) {
|
|
80
80
|
shouldReport = isImportedFromSimplysm(node.callee, "NotImplementedError");
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
//
|
|
83
|
+
// 케이스 2: new CC.NotImplementedError() (namespace import)
|
|
84
84
|
else if (
|
|
85
85
|
node.callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
86
86
|
node.callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
@@ -92,7 +92,7 @@ export default createRule({
|
|
|
92
92
|
|
|
93
93
|
if (!shouldReport) return;
|
|
94
94
|
|
|
95
|
-
let msg = "
|
|
95
|
+
let msg = "미구현";
|
|
96
96
|
const firstArg = node.arguments.at(0);
|
|
97
97
|
if (
|
|
98
98
|
firstArg?.type === AST_NODE_TYPES.Literal &&
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
import { createRule } from "../utils/create-rule";
|
|
3
|
+
|
|
4
|
+
function traverseNode(node: TSESTree.Node, callback: (n: TSESTree.Node) => void): void {
|
|
5
|
+
callback(node);
|
|
6
|
+
for (const key of Object.keys(node)) {
|
|
7
|
+
if (key === "parent") continue;
|
|
8
|
+
const child = (node as unknown as Record<string, unknown>)[key];
|
|
9
|
+
if (Array.isArray(child)) {
|
|
10
|
+
for (const c of child) {
|
|
11
|
+
if (c != null && typeof c === "object" && "type" in c) {
|
|
12
|
+
traverseNode(c as TSESTree.Node, callback);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
} else if (child != null && typeof child === "object" && "type" in child) {
|
|
16
|
+
traverseNode(child as TSESTree.Node, callback);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 미사용 Angular `inject()` 필드를 감지하는 ESLint 규칙.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* 클래스 내에서 `inject()` 호출로 초기화된 프로퍼티 중
|
|
26
|
+
* 같은 클래스 내 다른 곳에서 참조되지 않는 필드를 보고합니다.
|
|
27
|
+
* autofix로 해당 필드를 제거합니다.
|
|
28
|
+
*/
|
|
29
|
+
export default createRule({
|
|
30
|
+
name: "ts-no-unused-injects",
|
|
31
|
+
meta: {
|
|
32
|
+
type: "problem",
|
|
33
|
+
docs: {
|
|
34
|
+
description: "Disallow unused Angular inject() fields",
|
|
35
|
+
},
|
|
36
|
+
fixable: "code",
|
|
37
|
+
messages: {
|
|
38
|
+
unusedInject: 'inject() field "{{name}}" is never used.',
|
|
39
|
+
},
|
|
40
|
+
schema: [],
|
|
41
|
+
},
|
|
42
|
+
defaultOptions: [],
|
|
43
|
+
create(context) {
|
|
44
|
+
const sourceCode = context.sourceCode;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
ClassBody(classBody: TSESTree.ClassBody) {
|
|
48
|
+
const injectFields = classBody.body.filter(
|
|
49
|
+
(node): node is TSESTree.PropertyDefinition =>
|
|
50
|
+
node.type === AST_NODE_TYPES.PropertyDefinition &&
|
|
51
|
+
node.value != null &&
|
|
52
|
+
node.value.type === AST_NODE_TYPES.CallExpression &&
|
|
53
|
+
node.value.callee.type === AST_NODE_TYPES.Identifier &&
|
|
54
|
+
node.value.callee.name === "inject" &&
|
|
55
|
+
node.key.type === AST_NODE_TYPES.Identifier,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
for (const field of injectFields) {
|
|
59
|
+
const fieldName = (field.key as TSESTree.Identifier).name;
|
|
60
|
+
|
|
61
|
+
const allIdentifiers: TSESTree.Identifier[] = [];
|
|
62
|
+
traverseNode(classBody, (node) => {
|
|
63
|
+
if (node.type === AST_NODE_TYPES.Identifier && node.name === fieldName) {
|
|
64
|
+
allIdentifiers.push(node);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const references = allIdentifiers.filter((id) => id !== field.key);
|
|
69
|
+
|
|
70
|
+
if (references.length === 0) {
|
|
71
|
+
context.report({
|
|
72
|
+
node: field,
|
|
73
|
+
messageId: "unusedInject",
|
|
74
|
+
data: { name: fieldName },
|
|
75
|
+
fix(fixer) {
|
|
76
|
+
const tokenBefore = sourceCode.getTokenBefore(field);
|
|
77
|
+
const tokenAfter = sourceCode.getTokenAfter(field);
|
|
78
|
+
const start = tokenBefore ? tokenBefore.range[1] : field.range[0];
|
|
79
|
+
const end = tokenAfter ? field.range[1] : field.range[1];
|
|
80
|
+
return fixer.removeRange([start, end]);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
import { createRule } from "../utils/create-rule";
|
|
3
|
+
|
|
4
|
+
function traverseNode(
|
|
5
|
+
node: TSESTree.Node,
|
|
6
|
+
callback: (n: TSESTree.Node) => void,
|
|
7
|
+
): void {
|
|
8
|
+
callback(node);
|
|
9
|
+
for (const key of Object.keys(node)) {
|
|
10
|
+
if (key === "parent" || key === "range" || key === "loc") continue;
|
|
11
|
+
const child = (node as unknown as Record<string, unknown>)[key];
|
|
12
|
+
if (Array.isArray(child)) {
|
|
13
|
+
for (const c of child) {
|
|
14
|
+
if (c != null && typeof c === "object" && "type" in c) {
|
|
15
|
+
traverseNode(c as TSESTree.Node, callback);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
} else if (child != null && typeof child === "object" && "type" in child) {
|
|
19
|
+
traverseNode(child as TSESTree.Node, callback);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function escapeRegExp(string: string): string {
|
|
25
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Angular `@Component` 내 미사용 `protected readonly` 필드를 감지하는 ESLint 규칙.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* `@Component` 데코레이터가 있는 클래스에서 `protected readonly` 필드가
|
|
33
|
+
* 인라인 템플릿과 클래스 본문 어디에서도 참조되지 않으면 보고합니다.
|
|
34
|
+
* autofix로 해당 필드를 제거합니다.
|
|
35
|
+
*/
|
|
36
|
+
export default createRule({
|
|
37
|
+
name: "ts-no-unused-protected-readonly",
|
|
38
|
+
meta: {
|
|
39
|
+
type: "problem",
|
|
40
|
+
docs: {
|
|
41
|
+
description: "Disallow unused protected readonly fields in Angular components",
|
|
42
|
+
},
|
|
43
|
+
fixable: "code",
|
|
44
|
+
messages: {
|
|
45
|
+
unusedField: 'Protected readonly field "{{name}}" is not used in class or template.',
|
|
46
|
+
},
|
|
47
|
+
schema: [],
|
|
48
|
+
},
|
|
49
|
+
defaultOptions: [],
|
|
50
|
+
create(context) {
|
|
51
|
+
const sourceCode = context.sourceCode;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
"ClassDeclaration, ClassExpression"(
|
|
55
|
+
classNode: TSESTree.ClassDeclaration | TSESTree.ClassExpression,
|
|
56
|
+
) {
|
|
57
|
+
const componentDecorator = classNode.decorators.find((d) => {
|
|
58
|
+
if (d.expression.type === AST_NODE_TYPES.CallExpression) {
|
|
59
|
+
const callee = d.expression.callee;
|
|
60
|
+
return callee.type === AST_NODE_TYPES.Identifier && callee.name === "Component";
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (componentDecorator == null) return;
|
|
66
|
+
|
|
67
|
+
const expr = componentDecorator.expression as TSESTree.CallExpression;
|
|
68
|
+
const args = expr.arguments;
|
|
69
|
+
const firstArg = args.at(0);
|
|
70
|
+
if (firstArg == null || firstArg.type !== AST_NODE_TYPES.ObjectExpression) return;
|
|
71
|
+
|
|
72
|
+
const templateProp = firstArg.properties.find(
|
|
73
|
+
(p): p is TSESTree.Property =>
|
|
74
|
+
p.type === AST_NODE_TYPES.Property &&
|
|
75
|
+
p.key.type === AST_NODE_TYPES.Identifier &&
|
|
76
|
+
p.key.name === "template",
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (templateProp == null) return;
|
|
80
|
+
|
|
81
|
+
let templateText = "";
|
|
82
|
+
const templateValue = templateProp.value;
|
|
83
|
+
if (templateValue.type === AST_NODE_TYPES.TemplateLiteral) {
|
|
84
|
+
templateText = templateValue.quasis.map((q) => q.value.raw).join("");
|
|
85
|
+
} else if (
|
|
86
|
+
templateValue.type === AST_NODE_TYPES.Literal &&
|
|
87
|
+
typeof templateValue.value === "string"
|
|
88
|
+
) {
|
|
89
|
+
templateText = templateValue.value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (templateText === "") return;
|
|
93
|
+
|
|
94
|
+
const protectedReadonlyFields = classNode.body.body.filter(
|
|
95
|
+
(node): node is TSESTree.PropertyDefinition =>
|
|
96
|
+
node.type === AST_NODE_TYPES.PropertyDefinition &&
|
|
97
|
+
node.accessibility === "protected" &&
|
|
98
|
+
node.readonly === true &&
|
|
99
|
+
!node.static &&
|
|
100
|
+
node.key.type === AST_NODE_TYPES.Identifier,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
for (const field of protectedReadonlyFields) {
|
|
104
|
+
const fieldName = (field.key as TSESTree.Identifier).name;
|
|
105
|
+
|
|
106
|
+
const identifierPattern = new RegExp(
|
|
107
|
+
`(?<![a-zA-Z0-9_$])${escapeRegExp(fieldName)}(?![a-zA-Z0-9_$])`,
|
|
108
|
+
);
|
|
109
|
+
const usedInTemplate = identifierPattern.test(templateText);
|
|
110
|
+
|
|
111
|
+
const usedInClass = classNode.body.body.some((member) => {
|
|
112
|
+
if (member === field) return false;
|
|
113
|
+
let found = false;
|
|
114
|
+
traverseNode(member, (node) => {
|
|
115
|
+
if (node.type === AST_NODE_TYPES.Identifier && node.name === fieldName) {
|
|
116
|
+
found = true;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return found;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (!usedInTemplate && !usedInClass) {
|
|
123
|
+
context.report({
|
|
124
|
+
node: field,
|
|
125
|
+
messageId: "unusedField",
|
|
126
|
+
data: { name: fieldName },
|
|
127
|
+
fix(fixer) {
|
|
128
|
+
let start = field.range[0];
|
|
129
|
+
let end = field.range[1];
|
|
130
|
+
|
|
131
|
+
const textBefore = sourceCode.text.slice(0, start);
|
|
132
|
+
const leadingMatch = textBefore.match(/\n[ \t]*$/);
|
|
133
|
+
if (leadingMatch) {
|
|
134
|
+
start -= leadingMatch[0].length - 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const afterText = sourceCode.text.slice(end);
|
|
138
|
+
const trailingMatch = afterText.match(/^;?[ \t]*\r?\n/);
|
|
139
|
+
if (trailingMatch) {
|
|
140
|
+
end += trailingMatch[0].length;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return fixer.removeRange([start, end]);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
});
|