@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,547 @@
|
|
|
1
|
+
import type { TSESTree } from "@typescript-eslint/types";
|
|
2
|
+
import type { AstProgram, AstProgramStatement, RuleContext, RuleModule } from "./types.ts";
|
|
3
|
+
import {
|
|
4
|
+
isAstNode,
|
|
5
|
+
isNestedClassNode,
|
|
6
|
+
isNestedFunctionNode,
|
|
7
|
+
isNullLiteral,
|
|
8
|
+
isPascalCase,
|
|
9
|
+
isTestIdAttributeName,
|
|
10
|
+
readChildNodes,
|
|
11
|
+
readJsxAttributeName,
|
|
12
|
+
unwrapExpression,
|
|
13
|
+
} from "./helpers.ts";
|
|
14
|
+
|
|
15
|
+
type ComponentFunctionNode =
|
|
16
|
+
| TSESTree.ArrowFunctionExpression
|
|
17
|
+
| TSESTree.FunctionDeclaration
|
|
18
|
+
| TSESTree.FunctionExpression;
|
|
19
|
+
type ComponentClassNode = TSESTree.ClassDeclaration;
|
|
20
|
+
type ComponentDefinition =
|
|
21
|
+
| {
|
|
22
|
+
isExported: boolean;
|
|
23
|
+
kind: "class";
|
|
24
|
+
name: string;
|
|
25
|
+
node: ComponentClassNode;
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
isExported: boolean;
|
|
29
|
+
kind: "function";
|
|
30
|
+
name: string;
|
|
31
|
+
node: ComponentFunctionNode;
|
|
32
|
+
};
|
|
33
|
+
type TestIdEntry = {
|
|
34
|
+
candidates: string[];
|
|
35
|
+
node: TSESTree.JSXAttribute;
|
|
36
|
+
};
|
|
37
|
+
type RootBranch =
|
|
38
|
+
| {
|
|
39
|
+
kind: "fragment";
|
|
40
|
+
node: TSESTree.JSXFragment;
|
|
41
|
+
testIdEntries: TestIdEntry[];
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
isDomElement: boolean;
|
|
45
|
+
kind: "jsx";
|
|
46
|
+
node: TSESTree.JSXElement;
|
|
47
|
+
testIdEntries: TestIdEntry[];
|
|
48
|
+
}
|
|
49
|
+
| {
|
|
50
|
+
kind: "null";
|
|
51
|
+
node: TSESTree.Literal;
|
|
52
|
+
testIdEntries: TestIdEntry[];
|
|
53
|
+
}
|
|
54
|
+
| {
|
|
55
|
+
kind: "other";
|
|
56
|
+
node: TSESTree.Expression;
|
|
57
|
+
summary: string;
|
|
58
|
+
testIdEntries: TestIdEntry[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type ComponentDefinitionDeclaration =
|
|
62
|
+
| TSESTree.ClassDeclaration
|
|
63
|
+
| TSESTree.FunctionDeclaration
|
|
64
|
+
| TSESTree.VariableDeclaration;
|
|
65
|
+
type ComponentDefinitionStatement = ComponentDefinitionDeclaration | AstProgramStatement;
|
|
66
|
+
|
|
67
|
+
const requireComponentRootTestIdRule: RuleModule = {
|
|
68
|
+
meta: {
|
|
69
|
+
type: "problem" as const,
|
|
70
|
+
docs: {
|
|
71
|
+
description:
|
|
72
|
+
"Enforce direct exported DOM roots to use ComponentName and child test ids to use ComponentName--thing, while allowing exported components to delegate their root rendering to another component",
|
|
73
|
+
},
|
|
74
|
+
messages: {
|
|
75
|
+
invalidChildTestId:
|
|
76
|
+
'Rename this child test id to the "{{ componentName }}--thing" form, for example "{{ componentName }}--label". Received "{{ candidate }}".',
|
|
77
|
+
exportedFragmentRoot:
|
|
78
|
+
'Exported component "{{ componentName }}" must return a DOM element as its root, not a fragment. Wrap the fragment in an element with data-testid="{{ componentName }}".',
|
|
79
|
+
exportedOtherRoot:
|
|
80
|
+
'Exported component "{{ componentName }}" must render a DOM element as its root with data-testid="{{ componentName }}". Returning {{ summary }} is not allowed.',
|
|
81
|
+
missingExportedRootTestId:
|
|
82
|
+
'Add data-testid="{{ componentName }}" or testId="{{ componentName }}" to the exported component\'s root element.',
|
|
83
|
+
invalidLocalRootTestId:
|
|
84
|
+
'Rename the root test id of component "{{ componentName }}" to exactly "{{ componentName }}". Received "{{ candidate }}".',
|
|
85
|
+
},
|
|
86
|
+
schema: [],
|
|
87
|
+
},
|
|
88
|
+
create(context) {
|
|
89
|
+
return {
|
|
90
|
+
Program(program) {
|
|
91
|
+
const componentDefinitions = readComponentDefinitions(program);
|
|
92
|
+
|
|
93
|
+
componentDefinitions.forEach((componentDefinition) => {
|
|
94
|
+
const rootBranches = readRootBranchesForComponent(componentDefinition);
|
|
95
|
+
const rootNodes = new Set<TSESTree.JSXAttribute>();
|
|
96
|
+
|
|
97
|
+
rootBranches.forEach((rootBranch) => {
|
|
98
|
+
rootBranch.testIdEntries.forEach((testIdEntry) => {
|
|
99
|
+
rootNodes.add(testIdEntry.node);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (componentDefinition.isExported) {
|
|
103
|
+
reportInvalidExportedRoot(context, componentDefinition.name, rootBranch);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
reportInvalidLocalRoot(context, componentDefinition.name, rootBranch);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const componentTestIdEntries = readComponentTestIdEntries(componentDefinition);
|
|
111
|
+
componentTestIdEntries.forEach((testIdEntry) => {
|
|
112
|
+
if (rootNodes.has(testIdEntry.node)) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
testIdEntry.candidates.forEach((candidate) => {
|
|
117
|
+
if (isValidChildTestId(candidate, componentDefinition.name)) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
context.report({
|
|
122
|
+
node: testIdEntry.node,
|
|
123
|
+
messageId: "invalidChildTestId",
|
|
124
|
+
data: {
|
|
125
|
+
componentName: componentDefinition.name,
|
|
126
|
+
candidate,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export default requireComponentRootTestIdRule;
|
|
138
|
+
|
|
139
|
+
function readComponentDefinitions(program: AstProgram): ComponentDefinition[] {
|
|
140
|
+
return program.body.flatMap((statement) => readStatementComponentDefinitions(statement));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readStatementComponentDefinitions(statement: AstProgramStatement): ComponentDefinition[] {
|
|
144
|
+
if (statement.type === "ExportNamedDeclaration") {
|
|
145
|
+
return statement.declaration ? readDeclarationComponentDefinitions(statement.declaration, true) : [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (statement.type === "ExportDefaultDeclaration") {
|
|
149
|
+
return readDefaultComponentDefinitions(statement.declaration, true);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return readDeclarationComponentDefinitions(statement, false);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function readDeclarationComponentDefinitions(
|
|
156
|
+
declaration: ComponentDefinitionStatement,
|
|
157
|
+
isExported: boolean,
|
|
158
|
+
): ComponentDefinition[] {
|
|
159
|
+
if (declaration.type === "FunctionDeclaration") {
|
|
160
|
+
if (!declaration.id || !isPascalCase(declaration.id.name)) {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return [{ name: declaration.id.name, kind: "function", node: declaration, isExported }];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (declaration.type === "ClassDeclaration") {
|
|
168
|
+
if (!declaration.id || !isPascalCase(declaration.id.name)) {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return [{ name: declaration.id.name, kind: "class", node: declaration, isExported }];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (declaration.type !== "VariableDeclaration") {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return declaration.declarations.flatMap((declarator) => {
|
|
180
|
+
if (declarator.id.type !== "Identifier" || !isPascalCase(declarator.id.name)) {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const componentNode = readWrappedFunctionLike(declarator.init);
|
|
185
|
+
if (!componentNode) {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return [{ name: declarator.id.name, kind: "function", node: componentNode, isExported }];
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function readDefaultComponentDefinitions(
|
|
194
|
+
declaration: TSESTree.ExportDefaultDeclaration["declaration"],
|
|
195
|
+
isExported: boolean,
|
|
196
|
+
): ComponentDefinition[] {
|
|
197
|
+
if (declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration") {
|
|
198
|
+
if (!declaration.id || !isPascalCase(declaration.id.name)) {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return declaration.type === "FunctionDeclaration"
|
|
203
|
+
? [{ name: declaration.id.name, kind: "function", node: declaration, isExported }]
|
|
204
|
+
: [{ name: declaration.id.name, kind: "class", node: declaration, isExported }];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (
|
|
208
|
+
declaration.type === "Identifier" ||
|
|
209
|
+
declaration.type === "VariableDeclaration" ||
|
|
210
|
+
declaration.type === "TSDeclareFunction" ||
|
|
211
|
+
declaration.type === "TSEnumDeclaration" ||
|
|
212
|
+
declaration.type === "TSInterfaceDeclaration" ||
|
|
213
|
+
declaration.type === "TSModuleDeclaration" ||
|
|
214
|
+
declaration.type === "TSTypeAliasDeclaration"
|
|
215
|
+
) {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const componentNode = readWrappedFunctionLike(declaration);
|
|
220
|
+
if (!componentNode || !componentNode.id || !isPascalCase(componentNode.id.name)) {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return [{ name: componentNode.id.name, kind: "function", node: componentNode, isExported }];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function readWrappedFunctionLike(initializer: TSESTree.Expression | null | undefined): ComponentFunctionNode | null {
|
|
228
|
+
if (!initializer) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (initializer.type === "ArrowFunctionExpression" || initializer.type === "FunctionExpression") {
|
|
233
|
+
return initializer;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (initializer.type !== "CallExpression") {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const wrappedInitializer = initializer.arguments[0];
|
|
241
|
+
if (!wrappedInitializer || wrappedInitializer.type === "SpreadElement") {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return readWrappedFunctionLike(wrappedInitializer);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function readRootBranchesForComponent(componentDefinition: ComponentDefinition): RootBranch[] {
|
|
249
|
+
const returnExpressions =
|
|
250
|
+
componentDefinition.kind === "class"
|
|
251
|
+
? readClassRenderReturnExpressions(componentDefinition.node)
|
|
252
|
+
: readFunctionReturnExpressions(componentDefinition.node);
|
|
253
|
+
|
|
254
|
+
return returnExpressions.flatMap((returnExpression) => readRootBranches(returnExpression));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isRenderMethodDefinition(member: TSESTree.ClassElement): member is TSESTree.MethodDefinition {
|
|
258
|
+
return (
|
|
259
|
+
member.type === "MethodDefinition" &&
|
|
260
|
+
!member.computed &&
|
|
261
|
+
member.key.type === "Identifier" &&
|
|
262
|
+
member.key.name === "render"
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function readClassRenderReturnExpressions(classDeclaration: ComponentClassNode): TSESTree.Expression[] {
|
|
267
|
+
const renderMethod = classDeclaration.body.body.find(isRenderMethodDefinition);
|
|
268
|
+
|
|
269
|
+
if (!renderMethod || !renderMethod.value.body) {
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return readReturnExpressionsFromBlock(renderMethod.value.body, renderMethod.value);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function readFunctionReturnExpressions(functionNode: ComponentFunctionNode): TSESTree.Expression[] {
|
|
277
|
+
if (functionNode.body.type !== "BlockStatement") {
|
|
278
|
+
return [functionNode.body];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return readReturnExpressionsFromBlock(functionNode.body, functionNode);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function readReturnExpressionsFromBlock(
|
|
285
|
+
blockNode: TSESTree.BlockStatement,
|
|
286
|
+
rootFunctionNode: TSESTree.Node,
|
|
287
|
+
): TSESTree.Expression[] {
|
|
288
|
+
const returnExpressions: TSESTree.Expression[] = [];
|
|
289
|
+
|
|
290
|
+
visitNode(blockNode);
|
|
291
|
+
return returnExpressions;
|
|
292
|
+
|
|
293
|
+
function visitNode(node: TSESTree.Node | null | undefined): void {
|
|
294
|
+
if (!node) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (node !== rootFunctionNode && isNestedFunctionNode(node)) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (node !== rootFunctionNode && isNestedClassNode(node)) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (node.type === "ReturnStatement") {
|
|
307
|
+
if (node.argument) {
|
|
308
|
+
returnExpressions.push(node.argument);
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
readChildNodes(node).forEach(visitNode);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function isDomJsxRootElement(node: TSESTree.JSXElement): boolean {
|
|
318
|
+
const openingElementName = node.openingElement.name;
|
|
319
|
+
return openingElementName.type === "JSXIdentifier" && /^[a-z]/u.test(openingElementName.name);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function readRootBranches(expression: TSESTree.Expression): RootBranch[] {
|
|
323
|
+
const unwrappedExpression = unwrapExpression(expression);
|
|
324
|
+
|
|
325
|
+
if (unwrappedExpression.type === "ConditionalExpression") {
|
|
326
|
+
return [...readRootBranches(unwrappedExpression.consequent), ...readRootBranches(unwrappedExpression.alternate)];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (unwrappedExpression.type === "LogicalExpression" && unwrappedExpression.operator === "&&") {
|
|
330
|
+
return readRootBranches(unwrappedExpression.right);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (isNullLiteral(unwrappedExpression)) {
|
|
334
|
+
return [{ kind: "null", node: unwrappedExpression, testIdEntries: [] }];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (unwrappedExpression.type === "JSXFragment") {
|
|
338
|
+
return [{ kind: "fragment", node: unwrappedExpression, testIdEntries: [] }];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (unwrappedExpression.type === "JSXElement") {
|
|
342
|
+
return [
|
|
343
|
+
{
|
|
344
|
+
kind: "jsx",
|
|
345
|
+
isDomElement: isDomJsxRootElement(unwrappedExpression),
|
|
346
|
+
node: unwrappedExpression,
|
|
347
|
+
testIdEntries: readJsxAttributeTestIdEntries(unwrappedExpression.openingElement.attributes),
|
|
348
|
+
},
|
|
349
|
+
];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return [
|
|
353
|
+
{
|
|
354
|
+
kind: "other",
|
|
355
|
+
node: unwrappedExpression,
|
|
356
|
+
testIdEntries: [],
|
|
357
|
+
summary: summarizeNode(unwrappedExpression),
|
|
358
|
+
},
|
|
359
|
+
];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function readComponentTestIdEntries(componentDefinition: ComponentDefinition): TestIdEntry[] {
|
|
363
|
+
const componentBody =
|
|
364
|
+
componentDefinition.kind === "class"
|
|
365
|
+
? readClassRenderBody(componentDefinition.node)
|
|
366
|
+
: componentDefinition.node.body;
|
|
367
|
+
|
|
368
|
+
if (!componentBody || componentBody.type !== "BlockStatement") {
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return readTestIdEntriesFromNode(componentBody, componentBody);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function readClassRenderBody(classDeclaration: ComponentClassNode): TSESTree.BlockStatement | null {
|
|
376
|
+
const renderMethod = classDeclaration.body.body.find(isRenderMethodDefinition);
|
|
377
|
+
|
|
378
|
+
return renderMethod?.value.body ?? null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function readTestIdEntriesFromNode(rootNode: TSESTree.Node, boundaryNode: TSESTree.Node): TestIdEntry[] {
|
|
382
|
+
const testIdEntries: TestIdEntry[] = [];
|
|
383
|
+
|
|
384
|
+
visitNode(rootNode);
|
|
385
|
+
return testIdEntries;
|
|
386
|
+
|
|
387
|
+
function visitNode(node: TSESTree.Node | null | undefined): void {
|
|
388
|
+
if (!node) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (node !== boundaryNode && isNestedFunctionNode(node)) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (node !== boundaryNode && isNestedClassNode(node)) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (node.type === "JSXElement") {
|
|
401
|
+
testIdEntries.push(...readJsxAttributeTestIdEntries(node.openingElement.attributes));
|
|
402
|
+
node.children.forEach((childNode) => {
|
|
403
|
+
if (isAstNode(childNode)) {
|
|
404
|
+
visitNode(childNode);
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
readChildNodes(node).forEach(visitNode);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function readJsxAttributeTestIdEntries(attributesNode: TSESTree.JSXOpeningElement["attributes"]): TestIdEntry[] {
|
|
415
|
+
return attributesNode
|
|
416
|
+
.filter((attribute): attribute is TSESTree.JSXAttribute => {
|
|
417
|
+
return attribute.type === "JSXAttribute" && isTestIdAttributeName(readJsxAttributeName(attribute.name));
|
|
418
|
+
})
|
|
419
|
+
.map((attribute) => ({
|
|
420
|
+
node: attribute,
|
|
421
|
+
candidates: readTestIdCandidatesFromJsxAttribute(attribute),
|
|
422
|
+
}));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function readTestIdCandidatesFromJsxAttribute(attribute: TSESTree.JSXAttribute): string[] {
|
|
426
|
+
const initializer = attribute.value;
|
|
427
|
+
if (!initializer) {
|
|
428
|
+
return [];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (initializer.type === "Literal" && typeof initializer.value === "string") {
|
|
432
|
+
return [initializer.value];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (initializer.type !== "JSXExpressionContainer" || initializer.expression.type === "JSXEmptyExpression") {
|
|
436
|
+
return [];
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return readExpressionStringCandidates(initializer.expression);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function readExpressionStringCandidates(expression: TSESTree.Expression): string[] {
|
|
443
|
+
const unwrappedExpression = unwrapExpression(expression);
|
|
444
|
+
|
|
445
|
+
if (unwrappedExpression.type === "Literal") {
|
|
446
|
+
return typeof unwrappedExpression.value === "string" ? [unwrappedExpression.value] : [];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (unwrappedExpression.type === "TemplateLiteral") {
|
|
450
|
+
if (unwrappedExpression.expressions.length !== 0 || unwrappedExpression.quasis.length !== 1) {
|
|
451
|
+
return [];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const cookedValue = unwrappedExpression.quasis[0]?.value.cooked;
|
|
455
|
+
return typeof cookedValue === "string" ? [cookedValue] : [];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (unwrappedExpression.type === "ConditionalExpression") {
|
|
459
|
+
return [
|
|
460
|
+
...readExpressionStringCandidates(unwrappedExpression.consequent),
|
|
461
|
+
...readExpressionStringCandidates(unwrappedExpression.alternate),
|
|
462
|
+
];
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function reportInvalidExportedRoot(context: RuleContext, componentName: string, rootBranch: RootBranch): void {
|
|
469
|
+
if (rootBranch.kind === "null") {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (rootBranch.kind === "fragment") {
|
|
474
|
+
context.report({
|
|
475
|
+
node: rootBranch.node,
|
|
476
|
+
messageId: "exportedFragmentRoot",
|
|
477
|
+
data: {
|
|
478
|
+
componentName,
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (rootBranch.kind === "other") {
|
|
485
|
+
context.report({
|
|
486
|
+
node: rootBranch.node,
|
|
487
|
+
messageId: "exportedOtherRoot",
|
|
488
|
+
data: {
|
|
489
|
+
componentName,
|
|
490
|
+
summary: rootBranch.summary,
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (!rootBranch.isDomElement) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const hasExactRootTestId = rootBranch.testIdEntries.some((testIdEntry) => {
|
|
501
|
+
return testIdEntry.candidates.some((candidate) => candidate === componentName);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
if (hasExactRootTestId) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
context.report({
|
|
509
|
+
node: rootBranch.node.openingElement,
|
|
510
|
+
messageId: "missingExportedRootTestId",
|
|
511
|
+
data: {
|
|
512
|
+
componentName,
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function reportInvalidLocalRoot(context: RuleContext, componentName: string, rootBranch: RootBranch): void {
|
|
518
|
+
if (rootBranch.kind === "null" || rootBranch.testIdEntries.length === 0) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
rootBranch.testIdEntries.forEach((testIdEntry) => {
|
|
523
|
+
testIdEntry.candidates.forEach((candidate) => {
|
|
524
|
+
if (candidate === componentName) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
context.report({
|
|
529
|
+
node: testIdEntry.node,
|
|
530
|
+
messageId: "invalidLocalRootTestId",
|
|
531
|
+
data: {
|
|
532
|
+
componentName,
|
|
533
|
+
candidate,
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function isValidChildTestId(value: string, componentName: string): boolean {
|
|
541
|
+
return new RegExp(`^${componentName}--[a-z0-9]+(?:-[a-z0-9]+)*$`, "u").test(value);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function summarizeNode(node: TSESTree.Node): string {
|
|
545
|
+
const rawName = node.type.replace(/Expression$/u, " expression");
|
|
546
|
+
return rawName.toLowerCase();
|
|
547
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { TSESTree } from "@typescript-eslint/types";
|
|
2
|
+
import type { RuleModule } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
function readIndentSize(line: string): number {
|
|
5
|
+
const indentMatch = line.match(/^[ \t]*/u);
|
|
6
|
+
return indentMatch ? indentMatch[0].length : 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function readMinimumContentIndent(content: string): number {
|
|
10
|
+
const contentLines = content.split("\n");
|
|
11
|
+
let minimumIndent = Number.POSITIVE_INFINITY;
|
|
12
|
+
|
|
13
|
+
for (const contentLine of contentLines) {
|
|
14
|
+
if (contentLine.trim().length === 0) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
minimumIndent = Math.min(minimumIndent, readIndentSize(contentLine));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return Number.isFinite(minimumIndent) ? minimumIndent : 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readTemplateContent(node: TSESTree.TemplateLiteral): string {
|
|
25
|
+
return node.quasis.map((quasi) => quasi.value.raw).join("${...}");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function startsWithNewline(templateContent: string): boolean {
|
|
29
|
+
return templateContent.startsWith("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hasNonEmptyContent(templateContent: string): boolean {
|
|
33
|
+
return templateContent.replace(/^\n/u, "").trim().length > 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const requireTemplateIndentRule: RuleModule = {
|
|
37
|
+
meta: {
|
|
38
|
+
type: "problem" as const,
|
|
39
|
+
docs: {
|
|
40
|
+
description: "Require multiline template literals to keep their content indented with the surrounding code",
|
|
41
|
+
},
|
|
42
|
+
schema: [],
|
|
43
|
+
messages: {
|
|
44
|
+
badIndent:
|
|
45
|
+
"Indent this multiline template literal to match the surrounding code. If leading whitespace is part of the intended value, normalize the string explicitly instead of relying on under-indented source text.",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
create(context) {
|
|
49
|
+
const sourceLines = context.sourceCode.lines;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
TemplateLiteral(node) {
|
|
53
|
+
const templateContent = readTemplateContent(node);
|
|
54
|
+
if (!startsWithNewline(templateContent) || !hasNonEmptyContent(templateContent)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const startLine = node.loc?.start.line;
|
|
59
|
+
if (!startLine) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sourceLine = sourceLines[startLine - 1];
|
|
64
|
+
if (!sourceLine) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const lineIndent = readIndentSize(sourceLine);
|
|
69
|
+
const contentIndent = readMinimumContentIndent(templateContent.replace(/^\n/u, ""));
|
|
70
|
+
if (contentIndent >= lineIndent) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
context.report({
|
|
75
|
+
node,
|
|
76
|
+
messageId: "badIndent",
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export default requireTemplateIndentRule;
|