@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.
Files changed (53) hide show
  1. package/README.md +66 -40
  2. package/dist/eslint-plugin.d.ts +12 -0
  3. package/dist/eslint-plugin.d.ts.map +1 -1
  4. package/dist/eslint-plugin.js +15 -10
  5. package/dist/eslint-plugin.js.map +1 -6
  6. package/dist/eslint-recommended.d.ts +2 -1
  7. package/dist/eslint-recommended.d.ts.map +1 -1
  8. package/dist/eslint-recommended.js +159 -242
  9. package/dist/eslint-recommended.js.map +1 -6
  10. package/dist/rules/ng-template-no-todo-comments.d.ts +12 -0
  11. package/dist/rules/ng-template-no-todo-comments.d.ts.map +1 -0
  12. package/dist/rules/ng-template-no-todo-comments.js +46 -0
  13. package/dist/rules/ng-template-no-todo-comments.js.map +1 -0
  14. package/dist/rules/ng-template-sd-require-binding-attrs.d.ts +18 -0
  15. package/dist/rules/ng-template-sd-require-binding-attrs.d.ts.map +1 -0
  16. package/dist/rules/ng-template-sd-require-binding-attrs.js +88 -0
  17. package/dist/rules/ng-template-sd-require-binding-attrs.js.map +1 -0
  18. package/dist/rules/no-hard-private.d.ts +6 -6
  19. package/dist/rules/no-hard-private.js +109 -88
  20. package/dist/rules/no-hard-private.js.map +1 -6
  21. package/dist/rules/no-subpath-imports-from-simplysm.d.ts +5 -5
  22. package/dist/rules/no-subpath-imports-from-simplysm.js +72 -60
  23. package/dist/rules/no-subpath-imports-from-simplysm.js.map +1 -6
  24. package/dist/rules/ts-no-throw-not-implemented-error.d.ts +5 -5
  25. package/dist/rules/ts-no-throw-not-implemented-error.js +92 -58
  26. package/dist/rules/ts-no-throw-not-implemented-error.js.map +1 -6
  27. package/dist/rules/ts-no-unused-injects.d.ts +13 -0
  28. package/dist/rules/ts-no-unused-injects.d.ts.map +1 -0
  29. package/dist/rules/ts-no-unused-injects.js +81 -0
  30. package/dist/rules/ts-no-unused-injects.js.map +1 -0
  31. package/dist/rules/ts-no-unused-protected-readonly.d.ts +13 -0
  32. package/dist/rules/ts-no-unused-protected-readonly.d.ts.map +1 -0
  33. package/dist/rules/ts-no-unused-protected-readonly.js +127 -0
  34. package/dist/rules/ts-no-unused-protected-readonly.js.map +1 -0
  35. package/dist/utils/create-rule.d.ts +3 -3
  36. package/dist/utils/create-rule.js +19 -7
  37. package/dist/utils/create-rule.js.map +1 -6
  38. package/package.json +10 -11
  39. package/src/eslint-plugin.ts +8 -0
  40. package/src/eslint-recommended.ts +43 -116
  41. package/src/rules/ng-template-no-todo-comments.ts +48 -0
  42. package/src/rules/ng-template-sd-require-binding-attrs.ts +111 -0
  43. package/src/rules/no-hard-private.ts +23 -23
  44. package/src/rules/no-subpath-imports-from-simplysm.ts +13 -13
  45. package/src/rules/ts-no-throw-not-implemented-error.ts +14 -14
  46. package/src/rules/ts-no-unused-injects.ts +88 -0
  47. package/src/rules/ts-no-unused-protected-readonly.ts +151 -0
  48. package/src/utils/create-rule.ts +3 -3
  49. package/tests/no-hard-private.spec.ts +0 -888
  50. package/tests/no-subpath-imports-from-simplysm.spec.ts +0 -311
  51. package/tests/recommended.spec.ts +0 -145
  52. package/tests/ts-no-throw-not-implemented-error.spec.ts +0 -245
  53. 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
- * ESLint rule that restricts ECMAScript private fields (`#field`) and enforces TypeScript `private` keyword usage.
21
+ * ECMAScript private 필드(`#field`) 제한하고 TypeScript `private` 키워드 사용을 강제하는 ESLint 규칙.
22
22
  *
23
23
  * @remarks
24
- * This rule checks:
25
- * - Class field declarations: `#field`
26
- * - Class method declarations: `#method()`
27
- * - Class accessor declarations: `accessor #field`
28
- * - Member access expressions: `this.#field`
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: 'Enforces TypeScript "private _" style instead of hard private fields (#).',
35
+ description: 'hard private 필드(#) 대신 TypeScript "private _" 스타일을 강제합니다.',
36
36
  },
37
37
  messages: {
38
38
  preferSoftPrivate:
39
- 'Hard private fields (#) are not allowed. Use the "private _" style instead.',
39
+ 'hard private 필드(#) 사용할 없습니다. "private _" 스타일을 사용하세요.',
40
40
  nameConflict:
41
- 'Cannot convert hard private field "#{{name}}" to "_{{name}}". A member with the same name already exists.',
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
- // Stack structure for supporting nested classes
49
+ // 중첩 클래스 지원을 위한 스택 구조
50
50
  const classStack: Set<string>[] = [];
51
51
 
52
52
  return {
53
- // 0. Collect member names when entering a class
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. Detect declarations (PropertyDefinition, MethodDefinition, AccessorProperty)
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; // Name without the '#' character
79
+ const identifierName = node.name; // '#' 문자를 제외한 이름
80
80
  const targetName = `_${identifierName}`;
81
81
  const currentClassMembers = classStack.at(-1);
82
82
 
83
- // Check for name conflicts
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. Rename (#a -> _a)
99
+ // 1-1. 이름 변경 (#a -> _a)
100
100
  fixes.push(fixer.replaceText(node, targetName));
101
101
 
102
- // 1-2. Calculate the position to add the 'private' access modifier
102
+ // 1-2. 'private' 접근 제어자를 추가할 위치 계산
103
103
  if (parent.accessibility == null) {
104
- // Default insertion position: beginning of parent node (including static, async, etc)
104
+ // 기본 삽입 위치: 부모 노드의 시작 (static, async 등 포함)
105
105
  let tokenToInsertBefore = sourceCode.getFirstToken(parent);
106
106
 
107
- // If decorators exist, insert before the token after the last decorator
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 is now 'static', 'async', 'readonly', or a variable name ('_foo').
115
- // Inserting 'private ' before it naturally results in the correct order 'private static ...'.
116
- // If tokenToInsertBefore is null, it indicates an exceptional situation such as an AST parsing error.
117
- // In such cases, skip the entire fix to prevent an incomplete fix that only renames.
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. Detect usage (this.#field)
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
- * ESLint rule that prohibits 'src' subpath imports from `@simplysm/*` packages.
5
+ * `@simplysm/*` 패키지에서 'src' 하위 경로 import를 금지하는 ESLint 규칙.
6
6
  *
7
7
  * @remarks
8
- * This rule checks:
9
- * - Static import statements: `import ... from '...'`
10
- * - Dynamic imports: `import('...')`
11
- * - Re-export statements: `export { ... } from '...'`, `export * from '...'`
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
- "Prohibits 'src' subpath imports from @simplysm packages. (e.g., @simplysm/pkg/src/x → prohibited)",
19
+ "@simplysm 패키지에서 'src' 하위 경로 import를 금지합니다. (예: @simplysm/pkg/src/x → 금지)",
20
20
  },
21
21
  fixable: "code",
22
22
  schema: [],
23
23
  messages: {
24
24
  noSubpathImport:
25
- "Cannot import 'src' subpath from '@simplysm/{{pkg}}' package: '{{importPath}}'",
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
- // Allowed: @simplysm/pkg, @simplysm/pkg/xxx, @simplysm/pkg/xxx/yyy
36
- // Prohibited: @simplysm/pkg/src, @simplysm/pkg/src/xxx
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
- // Static import: import { x } from '...'
55
+ // 정적 import: import { x } from '...'
56
56
  ImportDeclaration(node) {
57
57
  checkAndReport(node.source, node.source.value);
58
58
  },
59
59
 
60
- // Dynamic import: import('...')
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
- // Re-export: export { x } from '...'
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
- // Re-export all: export * from '...'
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
- * ESLint rule that detects and warns about the use of `NotImplementedError` from `@simplysm/core-common`.
5
+ * `@simplysm/core-common`의 `NotImplementedError` 사용을 감지하고 경고하는 ESLint 규칙.
6
6
  *
7
7
  * @remarks
8
- * This rule detects code that instantiates `NotImplementedError` imported from `@simplysm/core-common` using `new`.
9
- * It prevents unimplemented code from being included in production.
8
+ * `@simplysm/core-common`에서 import한 `NotImplementedError`를 `new`로 인스턴스화하는 코드를 감지합니다.
9
+ * 미구현 코드가 프로덕션에 포함되는 것을 방지합니다.
10
10
  *
11
- * Supported import forms:
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
- * Dynamic imports (`await import(...)`) are not detected.
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: "Warns about 'NotImplementedError' usage",
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
- * Checks if an identifier is imported from @simplysm/core-common.
34
- * @param identifier - The identifier to check
35
- * @param expectedImportedName - The original name to check for named imports (undefined for namespace imports)
36
- * @returns true if the import source is @simplysm/core-common, false otherwise
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 } or import { NotImplementedError as NIE }
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
- // Case 1: new NotImplementedError() or new NIE() (named/aliased import)
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
- // Case 2: new CC.NotImplementedError() (namespace import)
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 = "Not implemented";
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
+ });