pasika 0.3.1 → 0.3.2
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 +7 -5
- package/dist/cli/index.js +32 -38
- package/dist/cli/output.d.ts +3 -0
- package/dist/cli/output.js +11 -0
- package/dist/enforcement/coverage.js +25 -29
- package/dist/enforcement/doctor.d.ts +16 -0
- package/dist/enforcement/doctor.js +249 -0
- package/dist/enforcement/types.d.ts +0 -3
- package/dist/enforcement/types.js +4 -13
- package/dist/eslint/pasika/ast-types.d.ts +122 -0
- package/dist/eslint/pasika/ast-types.js +1 -0
- package/dist/eslint/pasika/index.d.ts +58 -3
- package/dist/eslint/pasika/index.js +61 -1
- package/dist/eslint/pasika/rules/application-structure.d.ts +2 -0
- package/dist/eslint/pasika/rules/application-structure.js +152 -0
- package/dist/eslint/pasika/rules/component-conventions.d.ts +16 -0
- package/dist/eslint/pasika/rules/component-conventions.js +145 -0
- package/dist/eslint/pasika/rules/component-nesting.d.ts +12 -0
- package/dist/eslint/pasika/rules/component-nesting.js +69 -0
- package/dist/eslint/pasika/rules/config-extraction.d.ts +14 -0
- package/dist/eslint/pasika/rules/config-extraction.js +74 -0
- package/dist/eslint/pasika/rules/cross-feature-import.d.ts +2 -0
- package/dist/eslint/pasika/rules/cross-feature-import.js +75 -0
- package/dist/eslint/pasika/rules/css/apply-usage.d.ts +10 -0
- package/dist/eslint/pasika/rules/css/apply-usage.js +45 -0
- package/dist/eslint/pasika/rules/css/base-layer-pair.d.ts +10 -0
- package/dist/eslint/pasika/rules/css/base-layer-pair.js +49 -0
- package/dist/eslint/pasika/rules/css/css-variable-naming.d.ts +10 -0
- package/dist/eslint/pasika/rules/css/css-variable-naming.js +39 -0
- package/dist/eslint/pasika/rules/css/custom-utility-apply.d.ts +11 -0
- package/dist/eslint/pasika/rules/css/custom-utility-apply.js +52 -0
- package/dist/eslint/pasika/rules/css/global-css-location.d.ts +10 -0
- package/dist/eslint/pasika/rules/css/global-css-location.js +42 -0
- package/dist/eslint/pasika/rules/css/helpers.d.ts +29 -0
- package/dist/eslint/pasika/rules/css/helpers.js +93 -0
- package/dist/eslint/pasika/rules/css/index.d.ts +13 -0
- package/dist/eslint/pasika/rules/css/index.js +25 -0
- package/dist/eslint/pasika/rules/css/root-variables.d.ts +11 -0
- package/dist/eslint/pasika/rules/css/root-variables.js +53 -0
- package/dist/eslint/pasika/rules/css/rule-tester.d.ts +11 -0
- package/dist/eslint/pasika/rules/css/rule-tester.js +22 -0
- package/dist/eslint/pasika/rules/css/stylesheet-ordering.d.ts +11 -0
- package/dist/eslint/pasika/rules/css/stylesheet-ordering.js +76 -0
- package/dist/eslint/pasika/rules/css/surface-utility.d.ts +10 -0
- package/dist/eslint/pasika/rules/css/surface-utility.js +41 -0
- package/dist/eslint/pasika/rules/css/theme-reset.d.ts +9 -0
- package/dist/eslint/pasika/rules/css/theme-reset.js +36 -0
- package/dist/eslint/pasika/rules/css/theme-variable-namespace.d.ts +11 -0
- package/dist/eslint/pasika/rules/css/theme-variable-namespace.js +53 -0
- package/dist/eslint/pasika/rules/cva-appearance-props.d.ts +10 -0
- package/dist/eslint/pasika/rules/cva-appearance-props.js +77 -0
- package/dist/eslint/pasika/rules/cva-boolean-variants.d.ts +11 -0
- package/dist/eslint/pasika/rules/cva-boolean-variants.js +97 -0
- package/dist/eslint/pasika/rules/data-testid-case.d.ts +2 -0
- package/dist/eslint/pasika/rules/data-testid-case.js +76 -0
- package/dist/eslint/pasika/rules/enforce-cn-merge.d.ts +13 -1
- package/dist/eslint/pasika/rules/enforce-cn-merge.js +46 -44
- package/dist/eslint/pasika/rules/enforce-cva-variant-props.js +53 -48
- package/dist/eslint/pasika/rules/filename-case.js +28 -5
- package/dist/eslint/pasika/rules/hook-complexity.d.ts +12 -0
- package/dist/eslint/pasika/rules/hook-complexity.js +114 -0
- package/dist/eslint/pasika/rules/hook-extraction.d.ts +11 -0
- package/dist/eslint/pasika/rules/hook-extraction.js +55 -0
- package/dist/eslint/pasika/rules/import-through-index.d.ts +2 -0
- package/dist/eslint/pasika/rules/import-through-index.js +52 -0
- package/dist/eslint/pasika/rules/interactive-component.d.ts +10 -0
- package/dist/eslint/pasika/rules/interactive-component.js +76 -0
- package/dist/eslint/pasika/rules/json/index.d.ts +5 -0
- package/dist/eslint/pasika/rules/json/index.js +9 -0
- package/dist/eslint/pasika/rules/json/no-cache-flag.d.ts +10 -0
- package/dist/eslint/pasika/rules/json/no-cache-flag.js +45 -0
- package/dist/eslint/pasika/rules/json/no-vulyk-dependency.d.ts +10 -0
- package/dist/eslint/pasika/rules/json/no-vulyk-dependency.js +41 -0
- package/dist/eslint/pasika/rules/json/rule-tester.d.ts +11 -0
- package/dist/eslint/pasika/rules/json/rule-tester.js +21 -0
- package/dist/eslint/pasika/rules/jsx-hygiene.d.ts +9 -0
- package/dist/eslint/pasika/rules/jsx-hygiene.js +108 -0
- package/dist/eslint/pasika/rules/locale-dotted-path.d.ts +10 -0
- package/dist/eslint/pasika/rules/locale-dotted-path.js +65 -0
- package/dist/eslint/pasika/rules/locale-placement.d.ts +13 -0
- package/dist/eslint/pasika/rules/locale-placement.js +163 -0
- package/dist/eslint/pasika/rules/locales-location.d.ts +11 -0
- package/dist/eslint/pasika/rules/locales-location.js +56 -0
- package/dist/eslint/pasika/rules/md/doc-kind-suffix.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/doc-kind-suffix.js +29 -0
- package/dist/eslint/pasika/rules/md/example-heading-description.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/example-heading-description.js +30 -0
- package/dist/eslint/pasika/rules/md/glossary-term-linking.d.ts +2 -0
- package/dist/eslint/pasika/rules/md/glossary-term-linking.js +99 -0
- package/dist/eslint/pasika/rules/md/guide-folder-entry-point.d.ts +2 -0
- package/dist/eslint/pasika/rules/md/guide-folder-entry-point.js +46 -0
- package/dist/eslint/pasika/rules/md/guide-link-anchors.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/guide-link-anchors.js +50 -0
- package/dist/eslint/pasika/rules/md/guide-overview-no-links.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/guide-overview-no-links.js +51 -0
- package/dist/eslint/pasika/rules/md/guide-states-no-requirement.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/guide-states-no-requirement.js +26 -0
- package/dist/eslint/pasika/rules/md/guide-step-single-link.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/guide-step-single-link.js +48 -0
- package/dist/eslint/pasika/rules/md/guide-step-single-sentence.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/guide-step-single-sentence.js +42 -0
- package/dist/eslint/pasika/rules/md/helpers.d.ts +25 -0
- package/dist/eslint/pasika/rules/md/helpers.js +37 -0
- package/dist/eslint/pasika/rules/md/index.d.ts +27 -0
- package/dist/eslint/pasika/rules/md/index.js +53 -0
- package/dist/eslint/pasika/rules/md/no-cross-document-link.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/no-cross-document-link.js +36 -0
- package/dist/eslint/pasika/rules/md/no-nested-how-to.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/no-nested-how-to.js +39 -0
- package/dist/eslint/pasika/rules/md/no-template-prompt.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/no-template-prompt.js +22 -0
- package/dist/eslint/pasika/rules/md/overview-length.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/overview-length.js +47 -0
- package/dist/eslint/pasika/rules/md/overview-present.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/overview-present.js +41 -0
- package/dist/eslint/pasika/rules/md/policy-no-examples.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/policy-no-examples.js +28 -0
- package/dist/eslint/pasika/rules/md/policy-single-document.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/policy-single-document.js +34 -0
- package/dist/eslint/pasika/rules/md/policy-subject-headings.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/policy-subject-headings.js +58 -0
- package/dist/eslint/pasika/rules/md/project-index.d.ts +17 -0
- package/dist/eslint/pasika/rules/md/project-index.js +67 -0
- package/dist/eslint/pasika/rules/md/reference-block-headings.d.ts +6 -0
- package/dist/eslint/pasika/rules/md/reference-block-headings.js +31 -0
- package/dist/eslint/pasika/rules/md/reference-no-rfc-vocabulary.d.ts +6 -0
- package/dist/eslint/pasika/rules/md/reference-no-rfc-vocabulary.js +26 -0
- package/dist/eslint/pasika/rules/md/requirement-present.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/requirement-present.js +41 -0
- package/dist/eslint/pasika/rules/md/rfc-only-in-bullets.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/rfc-only-in-bullets.js +46 -0
- package/dist/eslint/pasika/rules/md/rule-paired-examples.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/rule-paired-examples.js +36 -0
- package/dist/eslint/pasika/rules/md/rule-tester.d.ts +11 -0
- package/dist/eslint/pasika/rules/md/rule-tester.js +21 -0
- package/dist/eslint/pasika/rules/md/support-document-placement.d.ts +2 -0
- package/dist/eslint/pasika/rules/md/support-document-placement.js +36 -0
- package/dist/eslint/pasika/rules/md/title-matches-file-name.d.ts +2 -0
- package/dist/eslint/pasika/rules/md/title-matches-file-name.js +46 -0
- package/dist/eslint/pasika/rules/named-exports.d.ts +2 -0
- package/dist/eslint/pasika/rules/named-exports.js +49 -0
- package/dist/eslint/pasika/rules/no-eslint-disable.d.ts +10 -0
- package/dist/eslint/pasika/rules/no-eslint-disable.js +33 -0
- package/dist/eslint/pasika/rules/no-mixed-concerns.js +9 -22
- package/dist/eslint/pasika/rules/no-util-barrel.d.ts +2 -0
- package/dist/eslint/pasika/rules/no-util-barrel.js +48 -0
- package/dist/eslint/pasika/rules/pure-function-extract.d.ts +9 -0
- package/dist/eslint/pasika/rules/pure-function-extract.js +92 -0
- package/dist/eslint/pasika/rules/shared-style-dedup.d.ts +13 -0
- package/dist/eslint/pasika/rules/shared-style-dedup.js +91 -0
- package/dist/eslint/pasika/rules/stay-flat.d.ts +12 -0
- package/dist/eslint/pasika/rules/stay-flat.js +81 -0
- package/dist/eslint/pasika/rules/support-folder-shape.d.ts +2 -0
- package/dist/eslint/pasika/rules/support-folder-shape.js +59 -0
- package/dist/eslint/pasika/rules/type-extraction.d.ts +13 -0
- package/dist/eslint/pasika/rules/type-extraction.js +85 -0
- package/dist/eslint/pasika/rules/ui-state.d.ts +9 -0
- package/dist/eslint/pasika/rules/ui-state.js +99 -0
- package/dist/eslint/pasika/rules/util-file-name.d.ts +2 -0
- package/dist/eslint/pasika/rules/util-file-name.js +48 -0
- package/dist/eslint/pasika/rules/value-extraction.d.ts +13 -0
- package/dist/eslint/pasika/rules/value-extraction.js +48 -0
- package/enforcement/registry.json +196 -190
- package/package.json +13 -5
- package/dist/enforcement/docs-check.d.ts +0 -17
- package/dist/enforcement/docs-check.js +0 -162
|
@@ -6,4 +6,16 @@
|
|
|
6
6
|
* @see docs/styling-guide/rules/class-composition-rule.md
|
|
7
7
|
*/
|
|
8
8
|
import type { Rule } from "eslint";
|
|
9
|
-
|
|
9
|
+
import type { JsxAttributeNode } from "../ast-types.js";
|
|
10
|
+
export declare const enforceCnMergeRule: {
|
|
11
|
+
meta: {
|
|
12
|
+
schema: never[];
|
|
13
|
+
type: "problem";
|
|
14
|
+
docs: {
|
|
15
|
+
description: string;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
create(context: Rule.RuleContext): {
|
|
19
|
+
JSXAttribute(node: JsxAttributeNode): void;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
@@ -8,72 +8,75 @@
|
|
|
8
8
|
function classCount(str) {
|
|
9
9
|
return str.split(/\s+/).filter(Boolean).length;
|
|
10
10
|
}
|
|
11
|
+
function isComponentName(name) {
|
|
12
|
+
return name?.type === "JSXIdentifier" && /^[A-Z]/.test(name.name);
|
|
13
|
+
}
|
|
14
|
+
function isOuterLayoutClass(className) {
|
|
15
|
+
const utility = className.split(":").pop() ?? className;
|
|
16
|
+
return /^(?:-?m[trblsexy]?-.+|(?:min-|max-)?[wh]-.+|size-.+|(?:flex-(?:1|auto|initial|none|grow|shrink|basis-.+)|grow(?:-.+)?|shrink(?:-.+)?|basis-.+|order-.+|(?:col|row)-(?:auto|span-.+|start-.+|end-.+)|(?:self|place-self|justify-self)-.+|z-.+|gap-.+|space-[xy]-.+))$/.test(utility);
|
|
17
|
+
}
|
|
18
|
+
function stringArguments(node) {
|
|
19
|
+
if (node?.type === "Literal" && typeof node.value === "string")
|
|
20
|
+
return [node.value];
|
|
21
|
+
if (node?.type === "TemplateLiteral")
|
|
22
|
+
return node.quasis.map((quasi) => quasi.value.raw);
|
|
23
|
+
if (node?.type === "ConditionalExpression")
|
|
24
|
+
return [...stringArguments(node.consequent), ...stringArguments(node.alternate)];
|
|
25
|
+
if (node?.type === "LogicalExpression")
|
|
26
|
+
return [...stringArguments(node.left), ...stringArguments(node.right)];
|
|
27
|
+
if (node?.type === "CallExpression" && node.callee.type === "Identifier" && node.callee.name === "cn") {
|
|
28
|
+
return node.arguments.flatMap((argument) => stringArguments(argument));
|
|
29
|
+
}
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
11
32
|
export const enforceCnMergeRule = {
|
|
12
|
-
meta: {
|
|
13
|
-
schema: [],
|
|
14
|
-
type: "problem",
|
|
15
|
-
docs: {
|
|
16
|
-
description: "Enforce cn() for conditional class merging.",
|
|
17
|
-
},
|
|
18
|
-
},
|
|
33
|
+
meta: { schema: [], type: "problem", docs: { description: "Enforce cn() for conditional class merging." } },
|
|
19
34
|
create(context) {
|
|
20
35
|
return {
|
|
21
36
|
JSXAttribute(node) {
|
|
22
|
-
|
|
37
|
+
const attributeName = node.name?.name ?? "";
|
|
38
|
+
const element = node.parent.parent;
|
|
39
|
+
const isComponentProp = isComponentName(element.openingElement?.name);
|
|
40
|
+
if (isComponentProp && attributeName !== "className" && attributeName.endsWith("ClassName")) {
|
|
41
|
+
context.report({ node, message: "Expose appearance through typed variant props instead of separate internal class-name props. See docs/styling-guide/rules/class-composition-rule.md" });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (attributeName !== "className" && attributeName !== "class")
|
|
23
45
|
return;
|
|
24
46
|
const valueNode = node.value;
|
|
25
47
|
if (!valueNode)
|
|
26
48
|
return;
|
|
49
|
+
if (isComponentProp && attributeName === "className") {
|
|
50
|
+
const expression = valueNode.type === "JSXExpressionContainer" ? valueNode.expression : valueNode;
|
|
51
|
+
const invalidClass = stringArguments(expression).flatMap((value) => value.split(/\s+/).filter(Boolean)).find((className) => !isOuterLayoutClass(className));
|
|
52
|
+
if (invalidClass)
|
|
53
|
+
context.report({ node, message: `Passed className contains non-layout utility "${invalidClass}". Expose appearance through typed variant props. See docs/styling-guide/rules/class-composition-rule.md` });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
27
56
|
if (valueNode.type === "Literal" && typeof valueNode.value === "string") {
|
|
28
|
-
if (classCount(valueNode.value) > 5)
|
|
29
|
-
context.report({
|
|
30
|
-
node,
|
|
31
|
-
message: "Static className with more than 5 classes must use cn() with grouped string literals. " +
|
|
32
|
-
"See docs/styling-guide/rules/class-composition-rule.md",
|
|
33
|
-
});
|
|
34
|
-
}
|
|
57
|
+
if (classCount(valueNode.value) > 5)
|
|
58
|
+
context.report({ node, message: "Static className with more than 5 classes must use cn() with grouped string literals. See docs/styling-guide/rules/class-composition-rule.md" });
|
|
35
59
|
return;
|
|
36
60
|
}
|
|
37
|
-
if (valueNode.type !== "JSXExpressionContainer"
|
|
61
|
+
if (valueNode.type !== "JSXExpressionContainer")
|
|
38
62
|
return;
|
|
39
63
|
const expr = valueNode.expression;
|
|
40
64
|
if (expr.type === "BinaryExpression" && expr.operator === "+") {
|
|
41
|
-
context.report({
|
|
42
|
-
node,
|
|
43
|
-
message: "Use cn() instead of + operator for className. " +
|
|
44
|
-
"See docs/styling-guide/rules/class-composition-rule.md",
|
|
45
|
-
});
|
|
65
|
+
context.report({ node, message: "Use cn() instead of + operator for className. See docs/styling-guide/rules/class-composition-rule.md" });
|
|
46
66
|
return;
|
|
47
67
|
}
|
|
48
68
|
if (expr.type === "TemplateLiteral" && expr.expressions.length > 0) {
|
|
49
|
-
context.report({
|
|
50
|
-
node,
|
|
51
|
-
message: "Use cn() instead of template literals with conditionals for className. " +
|
|
52
|
-
"See docs/styling-guide/rules/class-composition-rule.md",
|
|
53
|
-
});
|
|
69
|
+
context.report({ node, message: "Use cn() instead of template literals with conditionals for className. See docs/styling-guide/rules/class-composition-rule.md" });
|
|
54
70
|
return;
|
|
55
71
|
}
|
|
56
|
-
if (expr.type === "Literal" && typeof expr.value === "string") {
|
|
57
|
-
|
|
58
|
-
context.report({
|
|
59
|
-
node,
|
|
60
|
-
message: "Static className with more than 5 classes must use cn() with grouped string literals. " +
|
|
61
|
-
"See docs/styling-guide/rules/class-composition-rule.md",
|
|
62
|
-
});
|
|
63
|
-
}
|
|
72
|
+
if (expr.type === "Literal" && typeof expr.value === "string" && classCount(expr.value) > 5) {
|
|
73
|
+
context.report({ node, message: "Static className with more than 5 classes must use cn() with grouped string literals. See docs/styling-guide/rules/class-composition-rule.md" });
|
|
64
74
|
return;
|
|
65
75
|
}
|
|
66
76
|
if (expr.type === "CallExpression" && expr.callee.type === "Identifier" && expr.callee.name === "cn") {
|
|
67
77
|
for (const arg of expr.arguments) {
|
|
68
|
-
if (arg.type === "Literal" && typeof arg.value === "string") {
|
|
69
|
-
|
|
70
|
-
context.report({
|
|
71
|
-
node: arg,
|
|
72
|
-
message: "Each cn() string argument must contain at most 5 class names. " +
|
|
73
|
-
"Group by styling concern. " +
|
|
74
|
-
"See docs/styling-guide/rules/class-composition-rule.md",
|
|
75
|
-
});
|
|
76
|
-
}
|
|
78
|
+
if (arg.type === "Literal" && typeof arg.value === "string" && classCount(arg.value) > 5) {
|
|
79
|
+
context.report({ node: arg, message: "Each cn() string argument must contain at most 5 class names. Group by styling concern. See docs/styling-guide/rules/class-composition-rule.md" });
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
82
|
}
|
|
@@ -81,4 +84,3 @@ export const enforceCnMergeRule = {
|
|
|
81
84
|
};
|
|
82
85
|
},
|
|
83
86
|
};
|
|
84
|
-
/* eslint-enable -- re-enable rules disabled for AST access @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */
|
|
@@ -17,59 +17,65 @@ export const enforceCvaVariantPropsRule = {
|
|
|
17
17
|
const cvaDefinitions = new Map();
|
|
18
18
|
return {
|
|
19
19
|
VariableDeclarator(node) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
20
|
+
const init = node.init;
|
|
21
|
+
if (init?.type !== "CallExpression" ||
|
|
22
|
+
init.callee.type !== "Identifier" ||
|
|
23
|
+
init.callee.name !== "cva" ||
|
|
24
|
+
node.id.type !== "Identifier") {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const args = init.arguments;
|
|
28
|
+
if (args.length < 2)
|
|
29
|
+
return;
|
|
30
|
+
const options = args[1];
|
|
31
|
+
if (options?.type !== "ObjectExpression")
|
|
32
|
+
return;
|
|
33
|
+
for (const prop of options.properties) {
|
|
34
|
+
if (prop.type !== "Property" ||
|
|
35
|
+
prop.key.type !== "Identifier" ||
|
|
36
|
+
prop.key.name !== "variants" ||
|
|
37
|
+
prop.value.type !== "ObjectExpression") {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const variantNames = prop.value.properties
|
|
41
|
+
.filter((p) => p.type === "Property" && p.key.type === "Identifier")
|
|
42
|
+
.map((p) => p.key.name);
|
|
43
|
+
if (variantNames.length > 0) {
|
|
44
|
+
cvaDefinitions.set(node.id.name, variantNames);
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
},
|
|
45
48
|
TSTypeAliasDeclaration(node) {
|
|
46
|
-
|
|
49
|
+
const aliasName = node.id?.name;
|
|
50
|
+
if (!aliasName?.endsWith("Props"))
|
|
47
51
|
return;
|
|
48
|
-
if (node.typeAnnotation
|
|
52
|
+
if (node.typeAnnotation?.type !== "TSTypeLiteral")
|
|
49
53
|
return;
|
|
50
|
-
for (const member of node.typeAnnotation.members) {
|
|
51
|
-
if (member.type
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
54
|
+
for (const member of node.typeAnnotation.members ?? []) {
|
|
55
|
+
if (member.type !== "TSPropertySignature")
|
|
56
|
+
continue;
|
|
57
|
+
const keyName = member.key?.type === "Identifier" ? member.key.name : undefined;
|
|
58
|
+
if (!keyName)
|
|
59
|
+
continue;
|
|
60
|
+
const typeAnn = member.typeAnnotation?.typeAnnotation;
|
|
61
|
+
if (typeAnn?.type !== "TSUnionType")
|
|
62
|
+
continue;
|
|
63
|
+
// typescript-eslint emits ESTree `Literal` nodes; other TypeScript
|
|
64
|
+
// parsers emit `StringLiteral`. Accept both so the rule works
|
|
65
|
+
// whichever parser the consuming config installs.
|
|
66
|
+
const allStringLiterals = (typeAnn.types ?? []).every((t) => t.type === "TSLiteralType" &&
|
|
67
|
+
(t.literal?.type === "StringLiteral" ||
|
|
68
|
+
(t.literal?.type === "Literal" && typeof t.literal.value === "string")));
|
|
69
|
+
if (allStringLiterals && (typeAnn.types?.length ?? 0) >= 2) {
|
|
70
|
+
for (const variantNames of cvaDefinitions.values()) {
|
|
71
|
+
if (variantNames.includes(keyName)) {
|
|
72
|
+
context.report({
|
|
73
|
+
node,
|
|
74
|
+
message: `Variant prop "${keyName}" duplicates CVA variant values. ` +
|
|
75
|
+
"Use VariantProps<typeof variants> instead of manually writing union types. " +
|
|
76
|
+
"See docs/styling-guide/rules/component-variant-rule.md",
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
73
79
|
}
|
|
74
80
|
}
|
|
75
81
|
}
|
|
@@ -78,4 +84,3 @@ export const enforceCvaVariantPropsRule = {
|
|
|
78
84
|
};
|
|
79
85
|
},
|
|
80
86
|
};
|
|
81
|
-
/* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any -- re-enable after AST node access block */
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @see docs/code-organization-guide/rules/smart-vs-dumb-component-rule.md
|
|
10
10
|
*/
|
|
11
11
|
import path from "node:path";
|
|
12
|
+
import { parseComponentInfo } from "./component-conventions.js";
|
|
12
13
|
/**
|
|
13
14
|
* Next.js App Router routing files that are exempt from filename-case checks.
|
|
14
15
|
* https://nextjs.org/docs/app/getting-started/project-structure#routing-files
|
|
@@ -70,12 +71,34 @@ export const filenameCaseRule = {
|
|
|
70
71
|
if (NEXT_ROUTING_FILES.has(base)) {
|
|
71
72
|
return {};
|
|
72
73
|
}
|
|
73
|
-
// .tsx components
|
|
74
|
+
// .tsx components use PascalCase when smart and kebab-case when dumb.
|
|
74
75
|
if (ext === ".tsx") {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
return {
|
|
77
|
+
Program(node) {
|
|
78
|
+
const components = parseComponentInfo(context.sourceCode.text, filename);
|
|
79
|
+
const component = components[0];
|
|
80
|
+
if (component?.smart && !isPascalCase(base)) {
|
|
81
|
+
context.report({
|
|
82
|
+
node,
|
|
83
|
+
message: `Smart component files must use PascalCase.tsx. Filename "${path.basename(filename)}" is not PascalCase.`,
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (component && !component.smart && !isKebabCase(base)) {
|
|
88
|
+
context.report({
|
|
89
|
+
node,
|
|
90
|
+
message: `Dumb component files must use kebab-case.tsx. Filename "${path.basename(filename)}" is not kebab-case.`,
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!component && !(isPascalCase(base) || isKebabCase(base))) {
|
|
95
|
+
context.report({
|
|
96
|
+
node,
|
|
97
|
+
message: `Filename "${path.basename(filename)}" does not match pasika conventions. Component files must be PascalCase.tsx (smart) or kebab-case.tsx (dumb).`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
79
102
|
}
|
|
80
103
|
// Everything else must be kebab-case
|
|
81
104
|
if (!isKebabCase(base)) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/hook-complexity
|
|
3
|
+
*
|
|
4
|
+
* - A custom hook with exactly one consumer MUST be extracted when it contains
|
|
5
|
+
* two or more imperative categories.
|
|
6
|
+
* - A custom hook with one consumer that contains fewer than two imperative
|
|
7
|
+
* categories MUST stay inline in its consumer file.
|
|
8
|
+
*
|
|
9
|
+
* @see docs/code-organization-guide/rules/hook-extraction-rule.md
|
|
10
|
+
*/
|
|
11
|
+
import type { Rule } from "eslint";
|
|
12
|
+
export declare const hookComplexityRule: Rule.RuleModule;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/hook-complexity
|
|
3
|
+
*
|
|
4
|
+
* - A custom hook with exactly one consumer MUST be extracted when it contains
|
|
5
|
+
* two or more imperative categories.
|
|
6
|
+
* - A custom hook with one consumer that contains fewer than two imperative
|
|
7
|
+
* categories MUST stay inline in its consumer file.
|
|
8
|
+
*
|
|
9
|
+
* @see docs/code-organization-guide/rules/hook-extraction-rule.md
|
|
10
|
+
*/
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import ts from "typescript";
|
|
13
|
+
const REACT_HOOKS = new Set([
|
|
14
|
+
"useState",
|
|
15
|
+
"useEffect",
|
|
16
|
+
"useContext",
|
|
17
|
+
"useReducer",
|
|
18
|
+
"useCallback",
|
|
19
|
+
"useMemo",
|
|
20
|
+
"useRef",
|
|
21
|
+
"useLayoutEffect",
|
|
22
|
+
"useImperativeHandle",
|
|
23
|
+
"useDebugValue",
|
|
24
|
+
"useDeferredValue",
|
|
25
|
+
"useTransition",
|
|
26
|
+
"useId",
|
|
27
|
+
"useSyncExternalStore",
|
|
28
|
+
"useInsertionEffect",
|
|
29
|
+
]);
|
|
30
|
+
function isHookName(name) {
|
|
31
|
+
return /^use[A-Z]/.test(name);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Counts the distinct React hook categories called anywhere inside a hook body.
|
|
35
|
+
* Re-parses just the body's source slice with the TypeScript compiler so the
|
|
36
|
+
* walk stays fully typed instead of unrolling ESTree unions by hand.
|
|
37
|
+
*/
|
|
38
|
+
function countImperativeCategories(body, sourceText) {
|
|
39
|
+
const start = body.range?.[0] ?? 0;
|
|
40
|
+
const end = body.range?.[1] ?? sourceText.length;
|
|
41
|
+
const sourceFile = ts.createSourceFile("hook.ts", sourceText.slice(start, end), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
42
|
+
const categories = new Set();
|
|
43
|
+
const visit = (node) => {
|
|
44
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && REACT_HOOKS.has(node.expression.text)) {
|
|
45
|
+
categories.add(node.expression.text);
|
|
46
|
+
}
|
|
47
|
+
ts.forEachChild(node, visit);
|
|
48
|
+
};
|
|
49
|
+
visit(sourceFile);
|
|
50
|
+
return categories.size;
|
|
51
|
+
}
|
|
52
|
+
export const hookComplexityRule = {
|
|
53
|
+
meta: {
|
|
54
|
+
schema: [],
|
|
55
|
+
type: "problem",
|
|
56
|
+
docs: {
|
|
57
|
+
description: "Require extraction of complex single-consumer hooks and inline of simple ones.",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
create(context) {
|
|
61
|
+
const filename = context.filename;
|
|
62
|
+
const sourceRoot = path.resolve("src");
|
|
63
|
+
const relative = path.relative(sourceRoot, filename);
|
|
64
|
+
if (relative.startsWith(".."))
|
|
65
|
+
return {};
|
|
66
|
+
const segments = relative.split(path.sep);
|
|
67
|
+
const sourceText = context.sourceCode.text;
|
|
68
|
+
function checkHook(node, name, body, exported) {
|
|
69
|
+
if (!exported)
|
|
70
|
+
return;
|
|
71
|
+
if (!name || !isHookName(name))
|
|
72
|
+
return;
|
|
73
|
+
if (!body)
|
|
74
|
+
return;
|
|
75
|
+
const imperativeCount = countImperativeCategories(body, sourceText);
|
|
76
|
+
const parentFolder = segments.length >= 2 ? segments[segments.length - 2] : undefined;
|
|
77
|
+
const inSupportFolder = parentFolder === "hooks";
|
|
78
|
+
if (imperativeCount >= 2 && !inSupportFolder) {
|
|
79
|
+
context.report({
|
|
80
|
+
node,
|
|
81
|
+
message: `Hook "${name}" has ${String(imperativeCount)} imperative categories and must be extracted to a hooks/ folder. ` +
|
|
82
|
+
"See docs/code-organization-guide/rules/hook-extraction-rule.md",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
else if (imperativeCount < 2 && inSupportFolder) {
|
|
86
|
+
context.report({
|
|
87
|
+
node,
|
|
88
|
+
message: `Hook "${name}" has fewer than two imperative categories and must stay inline in its consumer file. ` +
|
|
89
|
+
"See docs/code-organization-guide/rules/hook-extraction-rule.md",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
FunctionDeclaration(node) {
|
|
95
|
+
const exported = node.parent?.type === "ExportNamedDeclaration";
|
|
96
|
+
checkHook(node, node.id?.name, node.body, exported);
|
|
97
|
+
},
|
|
98
|
+
VariableDeclarator(node) {
|
|
99
|
+
if (node.id.type !== "Identifier")
|
|
100
|
+
return;
|
|
101
|
+
const exported = node.parent.parent?.type === "ExportNamedDeclaration";
|
|
102
|
+
if (!exported)
|
|
103
|
+
return;
|
|
104
|
+
const init = node.init;
|
|
105
|
+
if (!init || (init.type !== "ArrowFunctionExpression" && init.type !== "FunctionExpression")) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (init.body.type !== "BlockStatement")
|
|
109
|
+
return;
|
|
110
|
+
checkHook(node, node.id.name, init.body, true);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/hook-extraction
|
|
3
|
+
*
|
|
4
|
+
* A custom hook MUST be extracted to its own file when two or more consumers
|
|
5
|
+
* use it. The file must be read across the tree, because how many files import
|
|
6
|
+
* a hook is not visible from one file.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/code-organization-guide/rules/hook-extraction-rule.md
|
|
9
|
+
*/
|
|
10
|
+
import type { Rule } from "eslint";
|
|
11
|
+
export declare const hookExtractionRule: Rule.RuleModule;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/hook-extraction
|
|
3
|
+
*
|
|
4
|
+
* A custom hook MUST be extracted to its own file when two or more consumers
|
|
5
|
+
* use it. The file must be read across the tree, because how many files import
|
|
6
|
+
* a hook is not visible from one file.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/code-organization-guide/rules/hook-extraction-rule.md
|
|
9
|
+
*/
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { getProjectIndex, symbolKey } from "../project/index.js";
|
|
12
|
+
import { folderSegmentsOf, segmentsOf } from "../project/ccf.js";
|
|
13
|
+
export const hookExtractionRule = {
|
|
14
|
+
meta: {
|
|
15
|
+
schema: [],
|
|
16
|
+
type: "problem",
|
|
17
|
+
docs: {
|
|
18
|
+
description: "Require a custom hook with two or more consumers to live in its own hooks/ file.",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
create(context) {
|
|
22
|
+
const sourceRoot = path.resolve("src");
|
|
23
|
+
const file = path.resolve(context.filename);
|
|
24
|
+
const segments = segmentsOf(file, sourceRoot);
|
|
25
|
+
if (segments.length === 0)
|
|
26
|
+
return {};
|
|
27
|
+
const index = getProjectIndex(sourceRoot);
|
|
28
|
+
if (!index)
|
|
29
|
+
return {};
|
|
30
|
+
const module = index.modules.get(file);
|
|
31
|
+
if (!module)
|
|
32
|
+
return {};
|
|
33
|
+
const folder = folderSegmentsOf(file, sourceRoot);
|
|
34
|
+
const alreadyInHooksFolder = folder.length > 0 && folder[folder.length - 1] === "hooks";
|
|
35
|
+
const reusedHooks = module.exports
|
|
36
|
+
.filter((exp) => exp.kind === "hook" && !alreadyInHooksFolder)
|
|
37
|
+
.map((exp) => ({ exp, consumers: index.symbolConsumers.get(symbolKey(file, exp.name)) }))
|
|
38
|
+
.filter(({ consumers }) => (consumers?.size ?? 0) >= 2);
|
|
39
|
+
if (reusedHooks.length === 0)
|
|
40
|
+
return {};
|
|
41
|
+
return {
|
|
42
|
+
Program(node) {
|
|
43
|
+
for (const { exp, consumers } of reusedHooks) {
|
|
44
|
+
context.report({
|
|
45
|
+
node,
|
|
46
|
+
loc: { line: exp.line, column: 0 },
|
|
47
|
+
message: `Hook "${exp.name}" has ${String(consumers?.size ?? 0)} consumers; ` +
|
|
48
|
+
"extract it to its own file in a hooks/ folder. " +
|
|
49
|
+
"See docs/code-organization-guide/rules/hook-extraction-rule.md",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { resolveSpecifier } from "../project/index.js";
|
|
3
|
+
import { segmentsOf } from "../project/ccf.js";
|
|
4
|
+
export const importThroughIndexRule = {
|
|
5
|
+
meta: {
|
|
6
|
+
schema: [],
|
|
7
|
+
type: "problem",
|
|
8
|
+
docs: {
|
|
9
|
+
description: "Require imports of support files to use their support-folder index.",
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
const filename = path.resolve(context.filename);
|
|
14
|
+
const sourceRoot = sourceRootOf(filename);
|
|
15
|
+
return {
|
|
16
|
+
Program(node) {
|
|
17
|
+
for (const specifier of importSpecifiers(context.sourceCode.text)) {
|
|
18
|
+
const target = resolveSpecifier(filename, specifier, sourceRoot);
|
|
19
|
+
if (!target)
|
|
20
|
+
continue;
|
|
21
|
+
const targetSegments = segmentsOf(target, sourceRoot);
|
|
22
|
+
const supportFolderIndex = targetSegments.findIndex((segment) => ["constants", "types", "schemas"].includes(segment));
|
|
23
|
+
const supportFolder = supportFolderIndex >= 0 ? targetSegments[supportFolderIndex] : undefined;
|
|
24
|
+
if (!supportFolder || path.basename(target).startsWith("index."))
|
|
25
|
+
continue;
|
|
26
|
+
const folderIndex = targetSegments.slice(0, supportFolderIndex + 1);
|
|
27
|
+
const expected = `@/${folderIndex.join("/")}`;
|
|
28
|
+
context.report({
|
|
29
|
+
node,
|
|
30
|
+
message: `Import support files through ${expected} instead of "${specifier}". ` +
|
|
31
|
+
"See the code-organization-guide exports and imports rules.",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
function importSpecifiers(source) {
|
|
39
|
+
const specifiers = [];
|
|
40
|
+
const pattern = /\bimport\s+(?:(?:type\s+)?[\s\S]*?\sfrom\s+)?["'](?<specifier>[^"']+)["']/g;
|
|
41
|
+
for (const match of source.matchAll(pattern)) {
|
|
42
|
+
const specifier = match.groups?.specifier;
|
|
43
|
+
if (specifier)
|
|
44
|
+
specifiers.push(specifier);
|
|
45
|
+
}
|
|
46
|
+
return specifiers;
|
|
47
|
+
}
|
|
48
|
+
function sourceRootOf(filename) {
|
|
49
|
+
const marker = `${path.sep}src${path.sep}`;
|
|
50
|
+
const srcIndex = filename.lastIndexOf(marker);
|
|
51
|
+
return srcIndex >= 0 ? filename.slice(0, srcIndex + marker.length - 1) : path.resolve("src");
|
|
52
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/interactive-component
|
|
3
|
+
*
|
|
4
|
+
* Interactive elements are component boundaries when they are mixed with other
|
|
5
|
+
* content in the same component. Standalone interactive components are valid.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/code-organization-guide/rules/interactive-component-rule.md
|
|
8
|
+
*/
|
|
9
|
+
import type { Rule } from "eslint";
|
|
10
|
+
export declare const interactiveComponentRule: Rule.RuleModule;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/interactive-component
|
|
3
|
+
*
|
|
4
|
+
* Interactive elements are component boundaries when they are mixed with other
|
|
5
|
+
* content in the same component. Standalone interactive components are valid.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/code-organization-guide/rules/interactive-component-rule.md
|
|
8
|
+
*/
|
|
9
|
+
const INTERACTIVE_TAGS = new Set([
|
|
10
|
+
"a",
|
|
11
|
+
"button",
|
|
12
|
+
"details",
|
|
13
|
+
"embed",
|
|
14
|
+
"iframe",
|
|
15
|
+
"input",
|
|
16
|
+
"label",
|
|
17
|
+
"select",
|
|
18
|
+
"textarea",
|
|
19
|
+
"video",
|
|
20
|
+
]);
|
|
21
|
+
function tagName(node) {
|
|
22
|
+
const name = node.openingElement?.name;
|
|
23
|
+
if (name?.type !== "JSXIdentifier")
|
|
24
|
+
return undefined;
|
|
25
|
+
return name.name;
|
|
26
|
+
}
|
|
27
|
+
function isInteractive(node) {
|
|
28
|
+
const name = tagName(node);
|
|
29
|
+
return name !== undefined && INTERACTIVE_TAGS.has(name);
|
|
30
|
+
}
|
|
31
|
+
function isComponentElement(node) {
|
|
32
|
+
const name = tagName(node);
|
|
33
|
+
return name !== undefined && /^[A-Z]/.test(name);
|
|
34
|
+
}
|
|
35
|
+
function isComponentReturn(node) {
|
|
36
|
+
const parent = node.parent;
|
|
37
|
+
if (parent.type === "ReturnStatement")
|
|
38
|
+
return true;
|
|
39
|
+
return parent.type === "ArrowFunctionExpression" && parent.body === node;
|
|
40
|
+
}
|
|
41
|
+
export const interactiveComponentRule = {
|
|
42
|
+
meta: {
|
|
43
|
+
schema: [],
|
|
44
|
+
type: "problem",
|
|
45
|
+
docs: {
|
|
46
|
+
description: "Require interactive HTML elements mixed with other content to be extracted into components.",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
create(context) {
|
|
50
|
+
if (!context.filename.endsWith(".tsx") && !context.filename.endsWith(".jsx"))
|
|
51
|
+
return {};
|
|
52
|
+
return {
|
|
53
|
+
JSXElement(node) {
|
|
54
|
+
if (!isInteractive(node))
|
|
55
|
+
return;
|
|
56
|
+
if (isComponentElement(node.parent)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (isInteractive(node.parent)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (isComponentReturn(node)) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const name = tagName(node);
|
|
66
|
+
if (name === undefined)
|
|
67
|
+
return;
|
|
68
|
+
context.report({
|
|
69
|
+
node,
|
|
70
|
+
message: `Extract the interactive <${name}> into a descriptive component. ` +
|
|
71
|
+
"See docs/code-organization-guide/rules/interactive-component-rule.md",
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|