@alexgorbatchev/typescript-ai-policy 0.1.0

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 (62) hide show
  1. package/README.md +223 -0
  2. package/package.json +60 -0
  3. package/src/oxfmt/createOxfmtConfig.ts +26 -0
  4. package/src/oxlint/assertNoRuleCollisions.ts +40 -0
  5. package/src/oxlint/createOxlintConfig.ts +161 -0
  6. package/src/oxlint/oxlint.config.ts +3 -0
  7. package/src/oxlint/plugin.ts +90 -0
  8. package/src/oxlint/rules/component-directory-file-convention.ts +65 -0
  9. package/src/oxlint/rules/component-file-contract.ts +328 -0
  10. package/src/oxlint/rules/component-file-location-convention.ts +43 -0
  11. package/src/oxlint/rules/component-file-naming-convention.ts +260 -0
  12. package/src/oxlint/rules/component-story-file-convention.ts +108 -0
  13. package/src/oxlint/rules/fixture-export-naming-convention.ts +72 -0
  14. package/src/oxlint/rules/fixture-export-type-contract.ts +264 -0
  15. package/src/oxlint/rules/fixture-file-contract.ts +91 -0
  16. package/src/oxlint/rules/fixture-import-path-convention.ts +125 -0
  17. package/src/oxlint/rules/helpers.ts +544 -0
  18. package/src/oxlint/rules/hook-export-location-convention.ts +169 -0
  19. package/src/oxlint/rules/hook-file-contract.ts +179 -0
  20. package/src/oxlint/rules/hook-file-naming-convention.ts +151 -0
  21. package/src/oxlint/rules/hook-test-file-convention.ts +60 -0
  22. package/src/oxlint/rules/hooks-directory-file-convention.ts +75 -0
  23. package/src/oxlint/rules/index-file-contract.ts +177 -0
  24. package/src/oxlint/rules/interface-naming-convention.ts +72 -0
  25. package/src/oxlint/rules/no-conditional-logic-in-tests.ts +53 -0
  26. package/src/oxlint/rules/no-fixture-exports-outside-fixture-entrypoint.ts +68 -0
  27. package/src/oxlint/rules/no-imports-from-tests-directory.ts +114 -0
  28. package/src/oxlint/rules/no-inline-fixture-bindings-in-tests.ts +54 -0
  29. package/src/oxlint/rules/no-inline-type-expressions.ts +169 -0
  30. package/src/oxlint/rules/no-local-type-declarations-in-fixture-files.ts +55 -0
  31. package/src/oxlint/rules/no-module-mocking.ts +85 -0
  32. package/src/oxlint/rules/no-non-running-tests.ts +72 -0
  33. package/src/oxlint/rules/no-react-create-element.ts +59 -0
  34. package/src/oxlint/rules/no-test-file-exports.ts +52 -0
  35. package/src/oxlint/rules/no-throw-in-tests.ts +40 -0
  36. package/src/oxlint/rules/no-type-exports-from-constants.ts +97 -0
  37. package/src/oxlint/rules/no-type-imports-from-constants.ts +73 -0
  38. package/src/oxlint/rules/no-value-exports-from-types.ts +115 -0
  39. package/src/oxlint/rules/require-component-root-testid.ts +547 -0
  40. package/src/oxlint/rules/require-template-indent.ts +83 -0
  41. package/src/oxlint/rules/single-fixture-entrypoint.ts +142 -0
  42. package/src/oxlint/rules/stories-directory-file-convention.ts +55 -0
  43. package/src/oxlint/rules/story-export-contract.ts +343 -0
  44. package/src/oxlint/rules/story-file-location-convention.ts +64 -0
  45. package/src/oxlint/rules/story-meta-type-annotation.ts +129 -0
  46. package/src/oxlint/rules/test-file-location-convention.ts +115 -0
  47. package/src/oxlint/rules/testid-naming-convention.ts +63 -0
  48. package/src/oxlint/rules/tests-directory-file-convention.ts +55 -0
  49. package/src/oxlint/rules/types.ts +45 -0
  50. package/src/semantic-fixes/applyFileChanges.ts +81 -0
  51. package/src/semantic-fixes/applySemanticFixes.ts +239 -0
  52. package/src/semantic-fixes/applyTextEdits.ts +164 -0
  53. package/src/semantic-fixes/backends/tsgo-lsp/TsgoLspClient.ts +439 -0
  54. package/src/semantic-fixes/backends/tsgo-lsp/createTsgoLspSemanticFixBackend.ts +251 -0
  55. package/src/semantic-fixes/providers/createInterfaceNamingConventionSemanticFixProvider.ts +132 -0
  56. package/src/semantic-fixes/providers/createTestFileLocationConventionSemanticFixProvider.ts +52 -0
  57. package/src/semantic-fixes/readMovedFileTextEdits.ts +150 -0
  58. package/src/semantic-fixes/readSemanticFixRuntimePaths.ts +38 -0
  59. package/src/semantic-fixes/runApplySemanticFixes.ts +120 -0
  60. package/src/semantic-fixes/runOxlintJson.ts +139 -0
  61. package/src/semantic-fixes/types.ts +163 -0
  62. package/src/shared/mergeConfig.ts +38 -0
@@ -0,0 +1,72 @@
1
+ import type { TSESTree } from "@typescript-eslint/types";
2
+ import type { RuleModule } from "./types.ts";
3
+ import { isAstNode, isPascalCase } from "./helpers.ts";
4
+
5
+ function isExternallyOwnedAmbientInterface(node: TSESTree.TSInterfaceDeclaration): boolean {
6
+ if (node.declare === true && node.parent?.type !== "ExportNamedDeclaration") {
7
+ return true;
8
+ }
9
+
10
+ let current: TSESTree.Node | undefined = node.parent;
11
+
12
+ while (current) {
13
+ if (current.type === "TSModuleDeclaration") {
14
+ if (current.declare === true || current.kind === "global" || current.id?.type === "Literal") {
15
+ return true;
16
+ }
17
+ }
18
+
19
+ const parentNode: unknown = Reflect.get(current, "parent");
20
+ current = isAstNode(parentNode) ? parentNode : undefined;
21
+ }
22
+
23
+ return false;
24
+ }
25
+
26
+ function isValidInterfaceName(name: string): boolean {
27
+ if (!name.startsWith("I")) {
28
+ return false;
29
+ }
30
+
31
+ const interfaceBaseName = name.slice(1);
32
+
33
+ return interfaceBaseName.length > 0 && isPascalCase(interfaceBaseName);
34
+ }
35
+
36
+ const interfaceNamingConventionRule: RuleModule = {
37
+ meta: {
38
+ type: "problem" as const,
39
+ docs: {
40
+ description: 'Enforce repository-owned interface names to use the "I" prefix followed by PascalCase',
41
+ },
42
+ schema: [],
43
+ messages: {
44
+ unexpectedInterfaceName:
45
+ 'Rename interface "{{ name }}" to match "I[A-Z][A-Za-z0-9]*". Repository-owned interfaces must start with "I" and use PascalCase after the prefix.',
46
+ },
47
+ },
48
+ create(context) {
49
+ return {
50
+ TSInterfaceDeclaration(node) {
51
+ if (isExternallyOwnedAmbientInterface(node)) {
52
+ return;
53
+ }
54
+
55
+ const interfaceName = node.id?.name;
56
+ if (!interfaceName || isValidInterfaceName(interfaceName)) {
57
+ return;
58
+ }
59
+
60
+ context.report({
61
+ node: node.id ?? node,
62
+ messageId: "unexpectedInterfaceName",
63
+ data: {
64
+ name: interfaceName,
65
+ },
66
+ });
67
+ },
68
+ };
69
+ },
70
+ };
71
+
72
+ export default interfaceNamingConventionRule;
@@ -0,0 +1,53 @@
1
+ import type { RuleModule } from "./types.ts";
2
+ import { isTestFile } from "./helpers.ts";
3
+
4
+ const noConditionalLogicInTestsRule: RuleModule = {
5
+ meta: {
6
+ type: "problem" as const,
7
+ docs: {
8
+ description: "Ban conditional control flow inside committed test files",
9
+ },
10
+ schema: [],
11
+ messages: {
12
+ forbiddenConditionalLogic:
13
+ "Remove this {{ conditionalKind }} from the test. Committed tests must execute assertions deterministically, so use assert(...) for narrowing or failure instead of branching test control flow.",
14
+ },
15
+ },
16
+ create(context) {
17
+ if (!isTestFile(context.filename)) {
18
+ return {};
19
+ }
20
+
21
+ return {
22
+ ConditionalExpression(node) {
23
+ context.report({
24
+ node,
25
+ messageId: "forbiddenConditionalLogic",
26
+ data: {
27
+ conditionalKind: "ternary expression",
28
+ },
29
+ });
30
+ },
31
+ IfStatement(node) {
32
+ context.report({
33
+ node,
34
+ messageId: "forbiddenConditionalLogic",
35
+ data: {
36
+ conditionalKind: '"if" statement',
37
+ },
38
+ });
39
+ },
40
+ SwitchStatement(node) {
41
+ context.report({
42
+ node,
43
+ messageId: "forbiddenConditionalLogic",
44
+ data: {
45
+ conditionalKind: '"switch" statement',
46
+ },
47
+ });
48
+ },
49
+ };
50
+ },
51
+ };
52
+
53
+ export default noConditionalLogicInTestsRule;
@@ -0,0 +1,68 @@
1
+ import type { RuleModule } from "./types.ts";
2
+ import { isFixtureLikeName, isFixturesFile, readDeclarationIdentifierNames } from "./helpers.ts";
3
+
4
+ const noFixtureExportsOutsideFixtureEntrypointRule: RuleModule = {
5
+ meta: {
6
+ type: "problem" as const,
7
+ docs: {
8
+ description:
9
+ 'Disallow exporting fixture_ or factory_ bindings outside nested "fixtures.ts" or "fixtures.tsx" entrypoints under "__tests__/" or "stories/"',
10
+ },
11
+ schema: [],
12
+ messages: {
13
+ unexpectedFixtureExport:
14
+ 'Move "{{ name }}" into a nested "fixtures.ts" or "fixtures.tsx" entrypoint under "__tests__/" or "stories/" and export it only from there.',
15
+ },
16
+ },
17
+ create(context) {
18
+ if (isFixturesFile(context.filename)) {
19
+ return {};
20
+ }
21
+
22
+ return {
23
+ ExportNamedDeclaration(node) {
24
+ if (node.declaration) {
25
+ readDeclarationIdentifierNames(node.declaration).forEach((name) => {
26
+ if (!isFixtureLikeName(name)) {
27
+ return;
28
+ }
29
+
30
+ context.report({
31
+ node: node.declaration,
32
+ messageId: "unexpectedFixtureExport",
33
+ data: {
34
+ name,
35
+ },
36
+ });
37
+ });
38
+ }
39
+
40
+ node.specifiers.forEach((specifier) => {
41
+ if (specifier.type !== "ExportSpecifier") {
42
+ return;
43
+ }
44
+
45
+ const localName =
46
+ specifier.local.type === "Identifier" ? specifier.local.name : String(specifier.local.value);
47
+ const exportedName =
48
+ specifier.exported.type === "Identifier" ? specifier.exported.name : String(specifier.exported.value);
49
+ const fixtureLikeName = [exportedName, localName].find((name) => isFixtureLikeName(name));
50
+
51
+ if (!fixtureLikeName) {
52
+ return;
53
+ }
54
+
55
+ context.report({
56
+ node: specifier,
57
+ messageId: "unexpectedFixtureExport",
58
+ data: {
59
+ name: fixtureLikeName,
60
+ },
61
+ });
62
+ });
63
+ },
64
+ };
65
+ },
66
+ };
67
+
68
+ export default noFixtureExportsOutsideFixtureEntrypointRule;
@@ -0,0 +1,114 @@
1
+ import type { TSESTree } from "@typescript-eslint/types";
2
+ import type { RuleContext, RuleModule } from "./types.ts";
3
+ import { isInTestsDirectory, isTestsDirectoryPath } from "./helpers.ts";
4
+
5
+ type StringLiteralValueNode = TSESTree.Expression | TSESTree.Literal | null | undefined;
6
+
7
+ function readStringLiteralValue(node: StringLiteralValueNode): string | null {
8
+ if (node?.type === "Literal" && typeof node.value === "string") {
9
+ return node.value;
10
+ }
11
+
12
+ if (node?.type === "TemplateLiteral" && node.expressions.length === 0) {
13
+ return node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw ?? null;
14
+ }
15
+
16
+ return null;
17
+ }
18
+
19
+ function reportUnexpectedTestsDirectoryImport(context: RuleContext, node: TSESTree.Node, importPath: string): void {
20
+ context.report({
21
+ node,
22
+ messageId: "unexpectedTestsDirectoryImport",
23
+ data: {
24
+ importPath,
25
+ },
26
+ });
27
+ }
28
+
29
+ const noImportsFromTestsDirectoryRule: RuleModule = {
30
+ meta: {
31
+ type: "problem" as const,
32
+ docs: {
33
+ description:
34
+ 'Disallow files outside "__tests__" from importing, requiring, or re-exporting modules from a "__tests__" directory',
35
+ },
36
+ schema: [],
37
+ messages: {
38
+ unexpectedTestsDirectoryImport:
39
+ 'Remove this dependency on "{{ importPath }}". Files outside "__tests__" must not import, require, or re-export modules from a "__tests__" directory.',
40
+ },
41
+ },
42
+ create(context) {
43
+ if (isInTestsDirectory(context.filename)) {
44
+ return {};
45
+ }
46
+
47
+ return {
48
+ ImportDeclaration(node) {
49
+ const importPath = readStringLiteralValue(node.source);
50
+ if (!importPath || !isTestsDirectoryPath(importPath)) {
51
+ return;
52
+ }
53
+
54
+ reportUnexpectedTestsDirectoryImport(context, node.source, importPath);
55
+ },
56
+ ExportNamedDeclaration(node) {
57
+ const source = node.source;
58
+ const importPath = readStringLiteralValue(source);
59
+ if (!source || !importPath || !isTestsDirectoryPath(importPath)) {
60
+ return;
61
+ }
62
+
63
+ reportUnexpectedTestsDirectoryImport(context, source, importPath);
64
+ },
65
+ ExportAllDeclaration(node) {
66
+ const importPath = readStringLiteralValue(node.source);
67
+ if (!importPath || !isTestsDirectoryPath(importPath)) {
68
+ return;
69
+ }
70
+
71
+ reportUnexpectedTestsDirectoryImport(context, node.source, importPath);
72
+ },
73
+ ImportExpression(node) {
74
+ const importPath = readStringLiteralValue(node.source);
75
+ if (!importPath || !isTestsDirectoryPath(importPath)) {
76
+ return;
77
+ }
78
+
79
+ reportUnexpectedTestsDirectoryImport(context, node.source, importPath);
80
+ },
81
+ CallExpression(node) {
82
+ if (node.callee.type !== "Identifier" || node.callee.name !== "require") {
83
+ return;
84
+ }
85
+
86
+ const firstArgument = node.arguments[0];
87
+ if (!firstArgument || firstArgument.type === "SpreadElement") {
88
+ return;
89
+ }
90
+
91
+ const importPath = readStringLiteralValue(firstArgument);
92
+ if (!importPath || !isTestsDirectoryPath(importPath)) {
93
+ return;
94
+ }
95
+
96
+ reportUnexpectedTestsDirectoryImport(context, firstArgument, importPath);
97
+ },
98
+ TSImportEqualsDeclaration(node) {
99
+ if (node.moduleReference.type !== "TSExternalModuleReference") {
100
+ return;
101
+ }
102
+
103
+ const importPath = readStringLiteralValue(node.moduleReference.expression);
104
+ if (!importPath || !isTestsDirectoryPath(importPath)) {
105
+ return;
106
+ }
107
+
108
+ reportUnexpectedTestsDirectoryImport(context, node.moduleReference.expression, importPath);
109
+ },
110
+ };
111
+ },
112
+ };
113
+
114
+ export default noImportsFromTestsDirectoryRule;
@@ -0,0 +1,54 @@
1
+ import type { RuleModule } from "./types.ts";
2
+ import { isFixtureConsumerFile, isFixtureLikeName, readPatternIdentifierNames } from "./helpers.ts";
3
+
4
+ const noInlineFixtureBindingsInTestsRule: RuleModule = {
5
+ meta: {
6
+ type: "problem" as const,
7
+ docs: {
8
+ description: "Disallow inline fixture_ and factory_ bindings inside test and story files",
9
+ },
10
+ schema: [],
11
+ messages: {
12
+ unexpectedInlineFixtureBinding:
13
+ 'Delete the inline "{{ name }}" declaration from this file and import it from a relative "fixtures" module under the same "__tests__/" or "stories/" tree instead.',
14
+ },
15
+ },
16
+ create(context) {
17
+ if (!isFixtureConsumerFile(context.filename)) {
18
+ return {};
19
+ }
20
+
21
+ return {
22
+ FunctionDeclaration(node) {
23
+ if (!node.id || !isFixtureLikeName(node.id.name)) {
24
+ return;
25
+ }
26
+
27
+ context.report({
28
+ node: node.id,
29
+ messageId: "unexpectedInlineFixtureBinding",
30
+ data: {
31
+ name: node.id.name,
32
+ },
33
+ });
34
+ },
35
+ VariableDeclarator(node) {
36
+ readPatternIdentifierNames(node.id).forEach((name) => {
37
+ if (!isFixtureLikeName(name)) {
38
+ return;
39
+ }
40
+
41
+ context.report({
42
+ node,
43
+ messageId: "unexpectedInlineFixtureBinding",
44
+ data: {
45
+ name,
46
+ },
47
+ });
48
+ });
49
+ },
50
+ };
51
+ },
52
+ };
53
+
54
+ export default noInlineFixtureBindingsInTestsRule;
@@ -0,0 +1,169 @@
1
+ import type { TSESTree } from "@typescript-eslint/types";
2
+ import type { RuleContext, RuleModule } from "./types.ts";
3
+ import { isAstNode, isTypeDeclaration } from "./helpers.ts";
4
+
5
+ type InlineTypeExpressionNode =
6
+ | TSESTree.TSConditionalType
7
+ | TSESTree.TSConstructorType
8
+ | TSESTree.TSFunctionType
9
+ | TSESTree.TSIntersectionType
10
+ | TSESTree.TSMappedType
11
+ | TSESTree.TSTupleType
12
+ | TSESTree.TSTypeLiteral
13
+ | TSESTree.TSUnionType;
14
+
15
+ const INLINE_TYPE_LABEL_BY_NODE_TYPE: Readonly<Record<InlineTypeExpressionNode["type"], string>> = {
16
+ TSConditionalType: "conditional type expression",
17
+ TSConstructorType: "constructor-signature type expression",
18
+ TSFunctionType: "function-signature type expression",
19
+ TSIntersectionType: "intersection type expression",
20
+ TSMappedType: "mapped type expression",
21
+ TSTupleType: "tuple type expression",
22
+ TSTypeLiteral: "object-literal type expression",
23
+ TSUnionType: "union type expression",
24
+ };
25
+
26
+ function isInlineTypeExpressionNode(node: TSESTree.Node | null | undefined): node is InlineTypeExpressionNode {
27
+ return (
28
+ node?.type === "TSConditionalType" ||
29
+ node?.type === "TSConstructorType" ||
30
+ node?.type === "TSFunctionType" ||
31
+ node?.type === "TSIntersectionType" ||
32
+ node?.type === "TSMappedType" ||
33
+ node?.type === "TSTupleType" ||
34
+ node?.type === "TSTypeLiteral" ||
35
+ node?.type === "TSUnionType"
36
+ );
37
+ }
38
+
39
+ function isNullishTypeNode(node: TSESTree.TypeNode | null | undefined): boolean {
40
+ return node?.type === "TSNullKeyword" || node?.type === "TSUndefinedKeyword";
41
+ }
42
+
43
+ function isAllowedNullableUnionType(node: TSESTree.TypeNode | null | undefined): node is TSESTree.TSUnionType {
44
+ if (node?.type !== "TSUnionType") {
45
+ return false;
46
+ }
47
+
48
+ let nonNullishTypeCount = 0;
49
+ let hasNullishType = false;
50
+
51
+ for (const memberType of node.types) {
52
+ if (isNullishTypeNode(memberType)) {
53
+ hasNullishType = true;
54
+ continue;
55
+ }
56
+
57
+ nonNullishTypeCount += 1;
58
+ if (nonNullishTypeCount > 1) {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ return hasNullishType && nonNullishTypeCount === 1;
64
+ }
65
+
66
+ function readParentNode(node: TSESTree.Node): TSESTree.Node | undefined {
67
+ const parentNode: unknown = Reflect.get(node, "parent");
68
+ return isAstNode(parentNode) ? parentNode : undefined;
69
+ }
70
+
71
+ function isInsideAllowedTypeDeclaration(node: TSESTree.Node): boolean {
72
+ let current = readParentNode(node);
73
+
74
+ while (current) {
75
+ if (isTypeDeclaration(current)) {
76
+ return true;
77
+ }
78
+
79
+ current = readParentNode(current);
80
+ }
81
+
82
+ return false;
83
+ }
84
+
85
+ function hasInlineTypeExpressionAncestor(node: TSESTree.Node): boolean {
86
+ let current = readParentNode(node);
87
+
88
+ while (current) {
89
+ if (isTypeDeclaration(current)) {
90
+ return false;
91
+ }
92
+
93
+ if (isInlineTypeExpressionNode(current)) {
94
+ if (isAllowedNullableUnionType(current)) {
95
+ current = readParentNode(current);
96
+ continue;
97
+ }
98
+
99
+ return true;
100
+ }
101
+
102
+ current = readParentNode(current);
103
+ }
104
+
105
+ return false;
106
+ }
107
+
108
+ function reportUnexpectedInlineTypeExpression(context: RuleContext, node: InlineTypeExpressionNode): void {
109
+ if (
110
+ isInsideAllowedTypeDeclaration(node) ||
111
+ hasInlineTypeExpressionAncestor(node) ||
112
+ isAllowedNullableUnionType(node)
113
+ ) {
114
+ return;
115
+ }
116
+
117
+ context.report({
118
+ node,
119
+ messageId: "unexpectedInlineTypeExpression",
120
+ data: {
121
+ kind: INLINE_TYPE_LABEL_BY_NODE_TYPE[node.type],
122
+ },
123
+ });
124
+ }
125
+
126
+ const noInlineTypeExpressionsRule: RuleModule = {
127
+ meta: {
128
+ type: "problem" as const,
129
+ docs: {
130
+ description:
131
+ "Disallow inline type expressions outside type declarations; require named type declarations or inference instead, except for nullable or undefinable wrappers around a single existing type",
132
+ },
133
+ schema: [],
134
+ messages: {
135
+ unexpectedInlineTypeExpression:
136
+ "Do not define an inline {{ kind }} here. First, reuse an existing named type declaration if one already models this contract. If no suitable named type exists, extract this contract into a named type declaration and reference that declaration. Only remove the annotation and let TypeScript infer the type when the type is already obvious from context.",
137
+ },
138
+ },
139
+ create(context) {
140
+ return {
141
+ TSConditionalType(node) {
142
+ reportUnexpectedInlineTypeExpression(context, node);
143
+ },
144
+ TSConstructorType(node) {
145
+ reportUnexpectedInlineTypeExpression(context, node);
146
+ },
147
+ TSFunctionType(node) {
148
+ reportUnexpectedInlineTypeExpression(context, node);
149
+ },
150
+ TSIntersectionType(node) {
151
+ reportUnexpectedInlineTypeExpression(context, node);
152
+ },
153
+ TSMappedType(node) {
154
+ reportUnexpectedInlineTypeExpression(context, node);
155
+ },
156
+ TSTupleType(node) {
157
+ reportUnexpectedInlineTypeExpression(context, node);
158
+ },
159
+ TSTypeLiteral(node) {
160
+ reportUnexpectedInlineTypeExpression(context, node);
161
+ },
162
+ TSUnionType(node) {
163
+ reportUnexpectedInlineTypeExpression(context, node);
164
+ },
165
+ };
166
+ },
167
+ };
168
+
169
+ export default noInlineTypeExpressionsRule;
@@ -0,0 +1,55 @@
1
+ import type { RuleModule } from "./types.ts";
2
+ import { isInFixturesArea } from "./helpers.ts";
3
+
4
+ const DECLARATION_MESSAGE_ID_BY_TYPE = {
5
+ TSEnumDeclaration: "unexpectedEnumDeclaration",
6
+ TSInterfaceDeclaration: "unexpectedInterfaceDeclaration",
7
+ TSTypeAliasDeclaration: "unexpectedTypeAliasDeclaration",
8
+ };
9
+
10
+ const noLocalTypeDeclarationsInFixtureFilesRule: RuleModule = {
11
+ meta: {
12
+ type: "problem" as const,
13
+ docs: {
14
+ description:
15
+ 'Disallow local type aliases, interfaces, and enums anywhere in nested fixture files under "__tests__/" or "stories/"',
16
+ },
17
+ schema: [],
18
+ messages: {
19
+ unexpectedEnumDeclaration:
20
+ "Remove this local enum declaration from the fixture file. Define it elsewhere and import it here.",
21
+ unexpectedInterfaceDeclaration:
22
+ "Remove this local interface declaration from the fixture file. Define it elsewhere and import it here.",
23
+ unexpectedTypeAliasDeclaration:
24
+ "Remove this local type alias declaration from the fixture file. Define it elsewhere and import it here.",
25
+ },
26
+ },
27
+ create(context) {
28
+ if (!isInFixturesArea(context.filename)) {
29
+ return {};
30
+ }
31
+
32
+ return {
33
+ TSEnumDeclaration(node) {
34
+ context.report({
35
+ node,
36
+ messageId: DECLARATION_MESSAGE_ID_BY_TYPE[node.type],
37
+ });
38
+ },
39
+ TSInterfaceDeclaration(node) {
40
+ context.report({
41
+ node,
42
+ messageId: DECLARATION_MESSAGE_ID_BY_TYPE[node.type],
43
+ });
44
+ },
45
+ TSTypeAliasDeclaration(node) {
46
+ context.report({
47
+ node,
48
+ messageId: DECLARATION_MESSAGE_ID_BY_TYPE[node.type],
49
+ });
50
+ },
51
+ };
52
+ },
53
+ };
54
+
55
+ export default noLocalTypeDeclarationsInFixtureFilesRule;
@@ -0,0 +1,85 @@
1
+ import type { AstExpression, RuleModule } from "./types.ts";
2
+
3
+ type CalleePath = string[];
4
+
5
+ function readCalleePath(node: AstExpression): CalleePath | null {
6
+ if (node.type === "Identifier") {
7
+ return [node.name];
8
+ }
9
+
10
+ if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier") {
11
+ return null;
12
+ }
13
+
14
+ if (node.object.type !== "Identifier" && node.object.type !== "MemberExpression") {
15
+ return null;
16
+ }
17
+
18
+ const objectPath = readCalleePath(node.object);
19
+ if (!objectPath) {
20
+ return null;
21
+ }
22
+
23
+ return [...objectPath, node.property.name];
24
+ }
25
+
26
+ function hasPathSuffix(path: readonly string[], suffix: readonly string[]): boolean {
27
+ if (path.length < suffix.length) {
28
+ return false;
29
+ }
30
+
31
+ return suffix.every((segment, index) => path[path.length - suffix.length + index] === segment);
32
+ }
33
+
34
+ const MODULE_MOCKING_SUFFIXES = [
35
+ ["jest", "mock"],
36
+ ["jest", "doMock"],
37
+ ["jest", "setMock"],
38
+ ["jest", "createMockFromModule"],
39
+ ["jest", "enableAutomock"],
40
+ ["jest", "unstable_mockModule"],
41
+ ["vi", "mock"],
42
+ ["vi", "doMock"],
43
+ ["vi", "importMock"],
44
+ ["mock", "module"],
45
+ ] as const;
46
+
47
+ const noModuleMockingRule: RuleModule = {
48
+ meta: {
49
+ type: "problem" as const,
50
+ docs: {
51
+ description: "Disallow module-mocking APIs across common test interfaces; use dependency injection instead",
52
+ },
53
+ schema: [],
54
+ messages: {
55
+ noModuleMocking:
56
+ 'Remove "{{ fullName }}". Pass collaborators into the unit under test and stub those injected dependencies in the test instead of mocking the whole module.',
57
+ },
58
+ },
59
+ create(context) {
60
+ return {
61
+ CallExpression(node) {
62
+ const calleePath = readCalleePath(node.callee);
63
+
64
+ if (!calleePath) {
65
+ return;
66
+ }
67
+
68
+ const isModuleMockingCall = MODULE_MOCKING_SUFFIXES.some((suffix) => hasPathSuffix(calleePath, suffix));
69
+ if (!isModuleMockingCall) {
70
+ return;
71
+ }
72
+
73
+ context.report({
74
+ node,
75
+ messageId: "noModuleMocking",
76
+ data: {
77
+ fullName: calleePath.join("."),
78
+ },
79
+ });
80
+ },
81
+ };
82
+ },
83
+ };
84
+
85
+ export default noModuleMockingRule;