@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.
- package/README.md +223 -0
- package/package.json +60 -0
- package/src/oxfmt/createOxfmtConfig.ts +26 -0
- package/src/oxlint/assertNoRuleCollisions.ts +40 -0
- package/src/oxlint/createOxlintConfig.ts +161 -0
- package/src/oxlint/oxlint.config.ts +3 -0
- package/src/oxlint/plugin.ts +90 -0
- package/src/oxlint/rules/component-directory-file-convention.ts +65 -0
- package/src/oxlint/rules/component-file-contract.ts +328 -0
- package/src/oxlint/rules/component-file-location-convention.ts +43 -0
- package/src/oxlint/rules/component-file-naming-convention.ts +260 -0
- package/src/oxlint/rules/component-story-file-convention.ts +108 -0
- package/src/oxlint/rules/fixture-export-naming-convention.ts +72 -0
- package/src/oxlint/rules/fixture-export-type-contract.ts +264 -0
- package/src/oxlint/rules/fixture-file-contract.ts +91 -0
- package/src/oxlint/rules/fixture-import-path-convention.ts +125 -0
- package/src/oxlint/rules/helpers.ts +544 -0
- package/src/oxlint/rules/hook-export-location-convention.ts +169 -0
- package/src/oxlint/rules/hook-file-contract.ts +179 -0
- package/src/oxlint/rules/hook-file-naming-convention.ts +151 -0
- package/src/oxlint/rules/hook-test-file-convention.ts +60 -0
- package/src/oxlint/rules/hooks-directory-file-convention.ts +75 -0
- package/src/oxlint/rules/index-file-contract.ts +177 -0
- package/src/oxlint/rules/interface-naming-convention.ts +72 -0
- package/src/oxlint/rules/no-conditional-logic-in-tests.ts +53 -0
- package/src/oxlint/rules/no-fixture-exports-outside-fixture-entrypoint.ts +68 -0
- package/src/oxlint/rules/no-imports-from-tests-directory.ts +114 -0
- package/src/oxlint/rules/no-inline-fixture-bindings-in-tests.ts +54 -0
- package/src/oxlint/rules/no-inline-type-expressions.ts +169 -0
- package/src/oxlint/rules/no-local-type-declarations-in-fixture-files.ts +55 -0
- package/src/oxlint/rules/no-module-mocking.ts +85 -0
- package/src/oxlint/rules/no-non-running-tests.ts +72 -0
- package/src/oxlint/rules/no-react-create-element.ts +59 -0
- package/src/oxlint/rules/no-test-file-exports.ts +52 -0
- package/src/oxlint/rules/no-throw-in-tests.ts +40 -0
- package/src/oxlint/rules/no-type-exports-from-constants.ts +97 -0
- package/src/oxlint/rules/no-type-imports-from-constants.ts +73 -0
- package/src/oxlint/rules/no-value-exports-from-types.ts +115 -0
- package/src/oxlint/rules/require-component-root-testid.ts +547 -0
- package/src/oxlint/rules/require-template-indent.ts +83 -0
- package/src/oxlint/rules/single-fixture-entrypoint.ts +142 -0
- package/src/oxlint/rules/stories-directory-file-convention.ts +55 -0
- package/src/oxlint/rules/story-export-contract.ts +343 -0
- package/src/oxlint/rules/story-file-location-convention.ts +64 -0
- package/src/oxlint/rules/story-meta-type-annotation.ts +129 -0
- package/src/oxlint/rules/test-file-location-convention.ts +115 -0
- package/src/oxlint/rules/testid-naming-convention.ts +63 -0
- package/src/oxlint/rules/tests-directory-file-convention.ts +55 -0
- package/src/oxlint/rules/types.ts +45 -0
- package/src/semantic-fixes/applyFileChanges.ts +81 -0
- package/src/semantic-fixes/applySemanticFixes.ts +239 -0
- package/src/semantic-fixes/applyTextEdits.ts +164 -0
- package/src/semantic-fixes/backends/tsgo-lsp/TsgoLspClient.ts +439 -0
- package/src/semantic-fixes/backends/tsgo-lsp/createTsgoLspSemanticFixBackend.ts +251 -0
- package/src/semantic-fixes/providers/createInterfaceNamingConventionSemanticFixProvider.ts +132 -0
- package/src/semantic-fixes/providers/createTestFileLocationConventionSemanticFixProvider.ts +52 -0
- package/src/semantic-fixes/readMovedFileTextEdits.ts +150 -0
- package/src/semantic-fixes/readSemanticFixRuntimePaths.ts +38 -0
- package/src/semantic-fixes/runApplySemanticFixes.ts +120 -0
- package/src/semantic-fixes/runOxlintJson.ts +139 -0
- package/src/semantic-fixes/types.ts +163 -0
- package/src/shared/mergeConfig.ts +38 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { RuleModule } from "./types.ts";
|
|
2
|
+
import { getBaseName, getExtension, isExemptSupportBasename, readPathFromFirstMatchingDirectory } from "./helpers.ts";
|
|
3
|
+
|
|
4
|
+
const COMPONENT_DIRECTORY_NAMES = new Set(["components", "templates", "layouts"]);
|
|
5
|
+
const COMPONENT_ALLOWED_SUPPORT_FILES = new Set(["constants.ts", "index.ts", "types.ts"]);
|
|
6
|
+
|
|
7
|
+
function isAllowedComponentDirectoryRelativePath(relativePath: string, filename: string): boolean {
|
|
8
|
+
if (!relativePath) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (relativePath.startsWith("stories/")) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (relativePath.includes("/")) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (COMPONENT_ALLOWED_SUPPORT_FILES.has(getBaseName(filename))) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return getExtension(filename) === ".tsx" && !isExemptSupportBasename(filename);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const componentDirectoryFileConventionRule: RuleModule = {
|
|
28
|
+
meta: {
|
|
29
|
+
type: "problem" as const,
|
|
30
|
+
docs: {
|
|
31
|
+
description:
|
|
32
|
+
'Restrict "components", "templates", and "layouts" directories to direct-child component files, direct-child support files (`constants.ts`, `index.ts`, `types.ts`), and sibling "stories/" trees',
|
|
33
|
+
},
|
|
34
|
+
schema: [],
|
|
35
|
+
messages: {
|
|
36
|
+
invalidComponentDirectoryFile:
|
|
37
|
+
'Move or rename "{{ relativePath }}". A "{{ directoryName }}/" directory may contain only direct-child component ".tsx" files, direct-child "constants.ts", "index.ts", or "types.ts" files, or a direct-child "stories/" tree.',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
create(context) {
|
|
41
|
+
return {
|
|
42
|
+
Program(node) {
|
|
43
|
+
const componentDirectoryMatch = readPathFromFirstMatchingDirectory(context.filename, COMPONENT_DIRECTORY_NAMES);
|
|
44
|
+
if (!componentDirectoryMatch) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (isAllowedComponentDirectoryRelativePath(componentDirectoryMatch.relativePath, context.filename)) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
context.report({
|
|
53
|
+
node,
|
|
54
|
+
messageId: "invalidComponentDirectoryFile",
|
|
55
|
+
data: {
|
|
56
|
+
directoryName: componentDirectoryMatch.directoryName,
|
|
57
|
+
relativePath: componentDirectoryMatch.relativePath || ".",
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export default componentDirectoryFileConventionRule;
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import type { TSESTree } from "@typescript-eslint/types";
|
|
2
|
+
import type {
|
|
3
|
+
AstDeclarationWithIdentifiers,
|
|
4
|
+
AstExportNamedDeclaration,
|
|
5
|
+
AstExportSpecifier,
|
|
6
|
+
AstNode,
|
|
7
|
+
AstProgram,
|
|
8
|
+
AstProgramStatement,
|
|
9
|
+
AstVariableDeclarator,
|
|
10
|
+
RuleModule,
|
|
11
|
+
} from "./types.ts";
|
|
12
|
+
import {
|
|
13
|
+
isExemptSupportBasename,
|
|
14
|
+
isInStoriesDirectory,
|
|
15
|
+
isInTestsDirectory,
|
|
16
|
+
isTypeDeclaration,
|
|
17
|
+
readDeclarationIdentifierNames,
|
|
18
|
+
readMultipartComponentRootName,
|
|
19
|
+
readPatternIdentifierNames,
|
|
20
|
+
unwrapExpression,
|
|
21
|
+
} from "./helpers.ts";
|
|
22
|
+
|
|
23
|
+
type WrappedFunctionExpressionState = TSESTree.FunctionExpression | false | null;
|
|
24
|
+
|
|
25
|
+
type ComponentRuntimeExportEntry = {
|
|
26
|
+
declarationKind?: TSESTree.VariableDeclaration["kind"];
|
|
27
|
+
declarator?: AstVariableDeclarator;
|
|
28
|
+
kind:
|
|
29
|
+
| "class-declaration"
|
|
30
|
+
| "const-variable"
|
|
31
|
+
| "default-export"
|
|
32
|
+
| "enum-declaration"
|
|
33
|
+
| "export-all"
|
|
34
|
+
| "function-declaration"
|
|
35
|
+
| "indirect-export"
|
|
36
|
+
| "variable-declaration";
|
|
37
|
+
name: string;
|
|
38
|
+
node: AstNode;
|
|
39
|
+
reportNode: AstNode;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function readExportedSpecifierName(specifier: AstExportSpecifier): string {
|
|
43
|
+
if (specifier.exported.type === "Identifier") {
|
|
44
|
+
return specifier.exported.name;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return String(specifier.exported.value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isTypeOnlyExportSpecifier(
|
|
51
|
+
specifier: AstExportSpecifier,
|
|
52
|
+
exportDeclaration: AstExportNamedDeclaration,
|
|
53
|
+
): boolean {
|
|
54
|
+
return exportDeclaration.exportKind === "type" || specifier.exportKind === "type";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isTypeOnlyExportNamedDeclaration(node: AstExportNamedDeclaration): boolean {
|
|
58
|
+
if (node.declaration) {
|
|
59
|
+
return node.exportKind === "type" || isTypeDeclaration(node.declaration);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return node.exportKind === "type" || node.specifiers.every((specifier) => isTypeOnlyExportSpecifier(specifier, node));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readWrappedNamedFunctionExpression(
|
|
66
|
+
initializer: TSESTree.Expression | null | undefined,
|
|
67
|
+
): TSESTree.FunctionExpression | null {
|
|
68
|
+
if (!initializer) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const currentInitializer = unwrapExpression(initializer);
|
|
73
|
+
if (currentInitializer.type === "FunctionExpression") {
|
|
74
|
+
return currentInitializer;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (currentInitializer.type !== "CallExpression") {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let wrappedFunctionExpression: WrappedFunctionExpressionState = null;
|
|
82
|
+
|
|
83
|
+
currentInitializer.arguments.forEach((argument) => {
|
|
84
|
+
if (argument.type === "SpreadElement") {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const candidate = readWrappedNamedFunctionExpression(argument);
|
|
89
|
+
if (!candidate) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (wrappedFunctionExpression) {
|
|
94
|
+
wrappedFunctionExpression = false;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
wrappedFunctionExpression = candidate;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return wrappedFunctionExpression || null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readRuntimeExportEntries(program: AstProgram): ComponentRuntimeExportEntry[] {
|
|
105
|
+
return program.body.flatMap((statement) => readStatementRuntimeExportEntries(statement));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readStatementRuntimeExportEntries(statement: AstProgramStatement): ComponentRuntimeExportEntry[] {
|
|
109
|
+
if (statement.type === "ExportDefaultDeclaration") {
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
kind: "default-export",
|
|
113
|
+
name: readDefaultExportName(statement.declaration) ?? "default",
|
|
114
|
+
node: statement,
|
|
115
|
+
reportNode: statement,
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (statement.type === "TSExportAssignment") {
|
|
121
|
+
return [{ kind: "default-export", name: "default", node: statement, reportNode: statement }];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (statement.type === "ExportAllDeclaration") {
|
|
125
|
+
if (statement.exportKind === "type") {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return [{ kind: "export-all", name: "*", node: statement, reportNode: statement }];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (statement.type !== "ExportNamedDeclaration") {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (isTypeOnlyExportNamedDeclaration(statement)) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!statement.declaration) {
|
|
141
|
+
return statement.specifiers
|
|
142
|
+
.filter((specifier) => !isTypeOnlyExportSpecifier(specifier, statement))
|
|
143
|
+
.map((specifier) => ({
|
|
144
|
+
kind: "indirect-export" as const,
|
|
145
|
+
name: readExportedSpecifierName(specifier),
|
|
146
|
+
node: specifier,
|
|
147
|
+
reportNode: specifier.exported.type === "Identifier" ? specifier.exported : specifier,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return readDeclarationRuntimeExportEntries(statement.declaration);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readDefaultExportName(declaration: TSESTree.ExportDefaultDeclaration["declaration"]): string | null {
|
|
155
|
+
if (declaration.type === "Identifier") {
|
|
156
|
+
return declaration.name;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (declaration.type === "VariableDeclaration") {
|
|
160
|
+
const firstDeclarator = declaration.declarations[0];
|
|
161
|
+
return firstDeclarator?.id.type === "Identifier" ? firstDeclarator.id.name : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (
|
|
165
|
+
declaration.type === "FunctionDeclaration" ||
|
|
166
|
+
declaration.type === "ClassDeclaration" ||
|
|
167
|
+
declaration.type === "TSEnumDeclaration" ||
|
|
168
|
+
declaration.type === "TSInterfaceDeclaration" ||
|
|
169
|
+
declaration.type === "TSTypeAliasDeclaration"
|
|
170
|
+
) {
|
|
171
|
+
return readDeclarationIdentifierNames(declaration)[0] ?? null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readDeclarationRuntimeExportEntries(
|
|
178
|
+
declaration: AstDeclarationWithIdentifiers,
|
|
179
|
+
): ComponentRuntimeExportEntry[] {
|
|
180
|
+
if (declaration.type === "FunctionDeclaration") {
|
|
181
|
+
return declaration.id
|
|
182
|
+
? [{ kind: "function-declaration", name: declaration.id.name, node: declaration, reportNode: declaration.id }]
|
|
183
|
+
: [];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (declaration.type === "ClassDeclaration") {
|
|
187
|
+
return declaration.id
|
|
188
|
+
? [{ kind: "class-declaration", name: declaration.id.name, node: declaration, reportNode: declaration.id }]
|
|
189
|
+
: [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (declaration.type === "TSEnumDeclaration") {
|
|
193
|
+
return declaration.id
|
|
194
|
+
? [{ kind: "enum-declaration", name: declaration.id.name, node: declaration, reportNode: declaration.id }]
|
|
195
|
+
: [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (declaration.type !== "VariableDeclaration") {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return declaration.declarations.flatMap((declarator) => {
|
|
203
|
+
const declarationNames = readPatternIdentifierNames(declarator.id);
|
|
204
|
+
|
|
205
|
+
return declarationNames.map((name) => ({
|
|
206
|
+
declarationKind: declaration.kind,
|
|
207
|
+
declarator,
|
|
208
|
+
kind: declaration.kind === "const" ? ("const-variable" as const) : ("variable-declaration" as const),
|
|
209
|
+
name,
|
|
210
|
+
node: declarator,
|
|
211
|
+
reportNode: declarator.id.type === "Identifier" ? declarator.id : declarator,
|
|
212
|
+
}));
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isValidWrappedComponentExport(entry: ComponentRuntimeExportEntry): boolean {
|
|
217
|
+
if (entry.kind !== "const-variable" || !entry.declarator) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (entry.declarator.id.type !== "Identifier") {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const wrappedFunctionExpression = readWrappedNamedFunctionExpression(entry.declarator.init);
|
|
226
|
+
if (!wrappedFunctionExpression || !wrappedFunctionExpression.id) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return wrappedFunctionExpression.id.name === entry.name;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isValidMainComponentRuntimeExport(entry: ComponentRuntimeExportEntry): boolean {
|
|
234
|
+
return entry.kind === "function-declaration" || isValidWrappedComponentExport(entry);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isValidMultipartComponentRuntimeExportFamily(
|
|
238
|
+
runtimeExportEntries: readonly ComponentRuntimeExportEntry[],
|
|
239
|
+
): boolean {
|
|
240
|
+
if (runtimeExportEntries.length < 2) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const validComponentRuntimeExportEntries = runtimeExportEntries.filter(isValidMainComponentRuntimeExport);
|
|
245
|
+
if (validComponentRuntimeExportEntries.length !== runtimeExportEntries.length) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const multipartComponentRootName = readMultipartComponentRootName(
|
|
250
|
+
validComponentRuntimeExportEntries.map((runtimeExportEntry) => runtimeExportEntry.name),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
return multipartComponentRootName !== null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const componentFileContractRule: RuleModule = {
|
|
257
|
+
meta: {
|
|
258
|
+
type: "problem" as const,
|
|
259
|
+
docs: {
|
|
260
|
+
description:
|
|
261
|
+
"Require component ownership files to export exactly one named runtime component or one multipart component family and allow only type-only secondary exports",
|
|
262
|
+
},
|
|
263
|
+
schema: [],
|
|
264
|
+
messages: {
|
|
265
|
+
missingMainComponentExport:
|
|
266
|
+
"Export exactly one main runtime component from this file. Component ownership files must use one direct named export plus optional type-only exports.",
|
|
267
|
+
invalidMainComponentExport:
|
|
268
|
+
"Replace this export with a valid component ownership export. Use `export function ComponentName() {}` for plain components, or `export const ComponentName = wrapper(function ComponentName() {})` for wrapped components.",
|
|
269
|
+
invalidIndirectComponentExport:
|
|
270
|
+
"Export this component directly from its declaration. Component ownership files must use `export function ComponentName() {}` or a direct named wrapped `export const` binding, not an `export { ComponentName }` list.",
|
|
271
|
+
unexpectedAdditionalRuntimeExport:
|
|
272
|
+
"Extract this runtime export to its own ownership file. Component ownership files may export only one main runtime component, or one multipart component family whose members share the base component name, plus unrestricted type-only API.",
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
create(context) {
|
|
276
|
+
if (
|
|
277
|
+
isExemptSupportBasename(context.filename) ||
|
|
278
|
+
isInStoriesDirectory(context.filename) ||
|
|
279
|
+
isInTestsDirectory(context.filename)
|
|
280
|
+
) {
|
|
281
|
+
return {};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
Program(node) {
|
|
286
|
+
const runtimeExportEntries = readRuntimeExportEntries(node);
|
|
287
|
+
if (runtimeExportEntries.length === 0) {
|
|
288
|
+
context.report({
|
|
289
|
+
node,
|
|
290
|
+
messageId: "missingMainComponentExport",
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (isValidMultipartComponentRuntimeExportFamily(runtimeExportEntries)) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const [mainRuntimeExportEntry, ...additionalRuntimeExportEntries] = runtimeExportEntries;
|
|
300
|
+
if (!mainRuntimeExportEntry) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!isValidMainComponentRuntimeExport(mainRuntimeExportEntry)) {
|
|
305
|
+
context.report({
|
|
306
|
+
node: mainRuntimeExportEntry.reportNode,
|
|
307
|
+
messageId:
|
|
308
|
+
mainRuntimeExportEntry.kind === "indirect-export"
|
|
309
|
+
? "invalidIndirectComponentExport"
|
|
310
|
+
: "invalidMainComponentExport",
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
additionalRuntimeExportEntries.forEach((runtimeExportEntry) => {
|
|
315
|
+
context.report({
|
|
316
|
+
node: runtimeExportEntry.reportNode,
|
|
317
|
+
messageId:
|
|
318
|
+
runtimeExportEntry.kind === "indirect-export"
|
|
319
|
+
? "invalidIndirectComponentExport"
|
|
320
|
+
: "unexpectedAdditionalRuntimeExport",
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
export default componentFileContractRule;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RuleModule } from "./types.ts";
|
|
2
|
+
import { getExtension, hasPathSegment, isInTestsDirectory } from "./helpers.ts";
|
|
3
|
+
|
|
4
|
+
const COMPONENT_DIRECTORY_NAMES = new Set(["components", "templates", "layouts"]);
|
|
5
|
+
|
|
6
|
+
const componentFileLocationConventionRule: RuleModule = {
|
|
7
|
+
meta: {
|
|
8
|
+
type: "problem" as const,
|
|
9
|
+
docs: {
|
|
10
|
+
description:
|
|
11
|
+
'Require non-hook, non-test ".tsx" files to live under a "components", "templates", or "layouts" directory',
|
|
12
|
+
},
|
|
13
|
+
schema: [],
|
|
14
|
+
messages: {
|
|
15
|
+
unexpectedComponentFileLocation:
|
|
16
|
+
'Move this ".tsx" file under a "components", "templates", or "layouts" directory. Files inside "hooks/" and "__tests__/" are exempt from this placement rule.',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
create(context) {
|
|
20
|
+
if (getExtension(context.filename) !== ".tsx") {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
Program(node) {
|
|
26
|
+
if (isInTestsDirectory(context.filename) || hasPathSegment(context.filename, "hooks")) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if ([...COMPONENT_DIRECTORY_NAMES].some((directoryName) => hasPathSegment(context.filename, directoryName))) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
context.report({
|
|
35
|
+
node,
|
|
36
|
+
messageId: "unexpectedComponentFileLocation",
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export default componentFileLocationConventionRule;
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { TSESTree } from "@typescript-eslint/types";
|
|
2
|
+
import type {
|
|
3
|
+
AstDeclarationWithIdentifiers,
|
|
4
|
+
AstExportNamedDeclaration,
|
|
5
|
+
AstExportSpecifier,
|
|
6
|
+
AstProgram,
|
|
7
|
+
AstProgramStatement,
|
|
8
|
+
RuleModule,
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
import {
|
|
11
|
+
getFilenameWithoutExtension,
|
|
12
|
+
isExemptSupportBasename,
|
|
13
|
+
isInStoriesDirectory,
|
|
14
|
+
isInTestsDirectory,
|
|
15
|
+
isPascalCase,
|
|
16
|
+
readDeclarationIdentifierNames,
|
|
17
|
+
readMultipartComponentRootName,
|
|
18
|
+
} from "./helpers.ts";
|
|
19
|
+
|
|
20
|
+
function isTypeOnlyExportSpecifier(
|
|
21
|
+
specifier: AstExportSpecifier,
|
|
22
|
+
exportDeclaration: AstExportNamedDeclaration,
|
|
23
|
+
): boolean {
|
|
24
|
+
return exportDeclaration.exportKind === "type" || specifier.exportKind === "type";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readExportedSpecifierName(specifier: AstExportSpecifier): string {
|
|
28
|
+
if (specifier.exported.type === "Identifier") {
|
|
29
|
+
return specifier.exported.name;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return String(specifier.exported.value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type RuntimeExportNameEntry = {
|
|
36
|
+
name: string;
|
|
37
|
+
reportNode: TSESTree.Node;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function readRuntimeExportNameEntries(program: AstProgram): RuntimeExportNameEntry[] {
|
|
41
|
+
return program.body.flatMap((statement) => {
|
|
42
|
+
const exportEntry = readStatementRuntimeExportEntry(statement);
|
|
43
|
+
return exportEntry === null ? [] : [exportEntry];
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readCanonicalRuntimeExportEntry(program: AstProgram): RuntimeExportNameEntry | null {
|
|
48
|
+
const runtimeExportEntries = readRuntimeExportNameEntries(program);
|
|
49
|
+
if (runtimeExportEntries.length === 0) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const multipartComponentRootName = readMultipartComponentRootName(
|
|
54
|
+
runtimeExportEntries.map((runtimeExportEntry) => runtimeExportEntry.name),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (!multipartComponentRootName) {
|
|
58
|
+
return runtimeExportEntries[0] ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
runtimeExportEntries.find((runtimeExportEntry) => runtimeExportEntry.name === multipartComponentRootName) ??
|
|
63
|
+
runtimeExportEntries[0] ??
|
|
64
|
+
null
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readStatementRuntimeExportEntry(statement: AstProgramStatement): RuntimeExportNameEntry | null {
|
|
69
|
+
if (statement.type === "ExportDefaultDeclaration") {
|
|
70
|
+
return readDefaultExportEntry(statement.declaration);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (statement.type !== "ExportNamedDeclaration") {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (statement.declaration) {
|
|
78
|
+
if (statement.exportKind === "type") {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return readDeclarationRuntimeExportEntry(statement.declaration);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const runtimeSpecifier = statement.specifiers.find((specifier) => !isTypeOnlyExportSpecifier(specifier, statement));
|
|
86
|
+
if (!runtimeSpecifier) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
name: readExportedSpecifierName(runtimeSpecifier),
|
|
92
|
+
reportNode: runtimeSpecifier.exported.type === "Identifier" ? runtimeSpecifier.exported : runtimeSpecifier,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readDefaultExportEntry(
|
|
97
|
+
declaration: TSESTree.ExportDefaultDeclaration["declaration"],
|
|
98
|
+
): RuntimeExportNameEntry | null {
|
|
99
|
+
if (declaration.type === "Identifier") {
|
|
100
|
+
return {
|
|
101
|
+
name: declaration.name,
|
|
102
|
+
reportNode: declaration,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (
|
|
107
|
+
declaration.type === "FunctionDeclaration" ||
|
|
108
|
+
declaration.type === "ClassDeclaration" ||
|
|
109
|
+
declaration.type === "TSEnumDeclaration" ||
|
|
110
|
+
declaration.type === "TSInterfaceDeclaration" ||
|
|
111
|
+
declaration.type === "TSTypeAliasDeclaration"
|
|
112
|
+
) {
|
|
113
|
+
return declaration.id
|
|
114
|
+
? {
|
|
115
|
+
name: declaration.id.name,
|
|
116
|
+
reportNode: declaration.id,
|
|
117
|
+
}
|
|
118
|
+
: null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (declaration.type === "VariableDeclaration") {
|
|
122
|
+
return readVariableDeclarationExportEntry(declaration);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readDeclarationRuntimeExportEntry(declaration: AstDeclarationWithIdentifiers): RuntimeExportNameEntry | null {
|
|
129
|
+
if (
|
|
130
|
+
declaration.type === "TSTypeAliasDeclaration" ||
|
|
131
|
+
declaration.type === "TSInterfaceDeclaration" ||
|
|
132
|
+
declaration.type === "TSModuleDeclaration"
|
|
133
|
+
) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (declaration.type === "VariableDeclaration") {
|
|
138
|
+
return readVariableDeclarationExportEntry(declaration);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const declarationName = readDeclarationIdentifierNames(declaration)[0];
|
|
142
|
+
if (!declarationName) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
declaration.type === "FunctionDeclaration" ||
|
|
148
|
+
declaration.type === "ClassDeclaration" ||
|
|
149
|
+
declaration.type === "TSEnumDeclaration"
|
|
150
|
+
) {
|
|
151
|
+
return declaration.id
|
|
152
|
+
? {
|
|
153
|
+
name: declaration.id.name,
|
|
154
|
+
reportNode: declaration.id,
|
|
155
|
+
}
|
|
156
|
+
: null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function readVariableDeclarationExportEntry(declaration: TSESTree.VariableDeclaration): RuntimeExportNameEntry | null {
|
|
163
|
+
const firstDeclarator = declaration.declarations[0];
|
|
164
|
+
if (!firstDeclarator || firstDeclarator.id.type !== "Identifier") {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
name: firstDeclarator.id.name,
|
|
170
|
+
reportNode: firstDeclarator.id,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function readExpectedComponentNameFromFilename(filename: string): string | null {
|
|
175
|
+
const fileStem = getFilenameWithoutExtension(filename);
|
|
176
|
+
if (isPascalCase(fileStem)) {
|
|
177
|
+
return fileStem;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(fileStem)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return fileStem
|
|
185
|
+
.split("-")
|
|
186
|
+
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
|
|
187
|
+
.join("");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const componentFileNamingConventionRule: RuleModule = {
|
|
191
|
+
meta: {
|
|
192
|
+
type: "problem" as const,
|
|
193
|
+
docs: {
|
|
194
|
+
description:
|
|
195
|
+
"Require component ownership filenames to match their exported PascalCase component name, or multipart family root name, in either PascalCase or kebab-case form",
|
|
196
|
+
},
|
|
197
|
+
schema: [],
|
|
198
|
+
messages: {
|
|
199
|
+
invalidComponentFileName:
|
|
200
|
+
'Rename this file to either "ComponentName.tsx" or "component-name.tsx" so its basename can map deterministically to the exported component name.',
|
|
201
|
+
invalidComponentExportName:
|
|
202
|
+
"Rename the exported component to PascalCase. Component ownership files must export a PascalCase component name.",
|
|
203
|
+
mismatchedComponentFileName:
|
|
204
|
+
'Rename this file or the exported component so they match exactly. "{{ exportedName }}" must live in either "{{ pascalFilename }}" or "{{ kebabFilename }}".',
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
create(context) {
|
|
208
|
+
if (
|
|
209
|
+
isExemptSupportBasename(context.filename) ||
|
|
210
|
+
isInStoriesDirectory(context.filename) ||
|
|
211
|
+
isInTestsDirectory(context.filename)
|
|
212
|
+
) {
|
|
213
|
+
return {};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
Program(node) {
|
|
218
|
+
const exportedComponentEntry = readCanonicalRuntimeExportEntry(node);
|
|
219
|
+
if (!exportedComponentEntry) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const { name: exportedComponentName, reportNode } = exportedComponentEntry;
|
|
224
|
+
const expectedComponentName = readExpectedComponentNameFromFilename(context.filename);
|
|
225
|
+
if (!expectedComponentName) {
|
|
226
|
+
context.report({
|
|
227
|
+
node,
|
|
228
|
+
messageId: "invalidComponentFileName",
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!isPascalCase(exportedComponentName)) {
|
|
234
|
+
context.report({
|
|
235
|
+
node: reportNode,
|
|
236
|
+
messageId: "invalidComponentExportName",
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (exportedComponentName === expectedComponentName) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const kebabFilename = `${exportedComponentName.replaceAll(/([a-z0-9])([A-Z])/gu, "$1-$2").toLowerCase()}.tsx`;
|
|
245
|
+
|
|
246
|
+
context.report({
|
|
247
|
+
node: reportNode,
|
|
248
|
+
messageId: "mismatchedComponentFileName",
|
|
249
|
+
data: {
|
|
250
|
+
exportedName: exportedComponentName,
|
|
251
|
+
pascalFilename: `${exportedComponentName}.tsx`,
|
|
252
|
+
kebabFilename,
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
export default componentFileNamingConventionRule;
|