pasika 0.1.6 → 0.3.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 +99 -78
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +132 -0
- package/dist/enforcement/coverage.d.ts +51 -0
- package/dist/enforcement/coverage.js +210 -0
- package/dist/enforcement/docs-check.d.ts +17 -0
- package/dist/enforcement/docs-check.js +159 -0
- package/dist/enforcement/normalize.d.ts +11 -0
- package/dist/enforcement/normalize.js +21 -0
- package/dist/enforcement/parse-docs.d.ts +58 -0
- package/dist/enforcement/parse-docs.js +94 -0
- package/dist/enforcement/types.d.ts +57 -0
- package/dist/enforcement/types.js +59 -0
- package/dist/eslint/pasika/index.d.ts +13 -1
- package/dist/eslint/pasika/index.js +24 -11
- package/dist/eslint/pasika/project/ccf.d.ts +48 -0
- package/dist/eslint/pasika/project/ccf.js +119 -0
- package/dist/eslint/pasika/project/index.d.ts +21 -0
- package/dist/eslint/pasika/project/index.js +139 -0
- package/dist/eslint/pasika/project/parse-module.d.ts +27 -0
- package/dist/eslint/pasika/project/parse-module.js +128 -0
- package/dist/eslint/pasika/rules/component-placement.d.ts +11 -0
- package/dist/eslint/pasika/rules/component-placement.js +75 -0
- package/dist/eslint/pasika/rules/enforce-barrel-exports.d.ts +9 -0
- package/dist/eslint/pasika/rules/enforce-barrel-exports.js +78 -0
- package/dist/eslint/pasika/rules/enforce-cn-merge.d.ts +9 -0
- package/dist/eslint/pasika/rules/enforce-cn-merge.js +84 -0
- package/dist/eslint/pasika/rules/enforce-cva-variant-props.d.ts +9 -0
- package/dist/eslint/pasika/rules/enforce-cva-variant-props.js +81 -0
- package/dist/eslint/pasika/rules/filename-case.js +10 -0
- package/dist/eslint/pasika/rules/import-boundaries.d.ts +2 -0
- package/dist/eslint/pasika/rules/{organization-imports.js → import-boundaries.js} +62 -19
- package/dist/eslint/pasika/rules/no-arbitrary-tailwind.d.ts +9 -0
- package/dist/eslint/pasika/rules/no-arbitrary-tailwind.js +97 -0
- package/dist/eslint/pasika/rules/no-mixed-concerns.d.ts +9 -0
- package/dist/eslint/pasika/rules/no-mixed-concerns.js +84 -0
- package/dist/eslint/pasika/rules/support-file-placement.d.ts +15 -0
- package/dist/eslint/pasika/rules/support-file-placement.js +70 -0
- package/enforcement/registry.json +1139 -0
- package/package.json +21 -15
- package/claude/hooks/.vulyk +0 -3
- package/claude/hooks/AGENTS.md +0 -3
- package/claude/hooks/CLAUDE.md +0 -1
- package/claude/hooks/claude-hooks.md +0 -30
- package/claude/hooks/notification.sh +0 -38
- package/claude/hooks/protect-files.sh +0 -21
- package/claude/hooks/status-line/index.js +0 -57
- package/claude/scripts/render-settings.ts +0 -223
- package/claude/settings.base.json +0 -38
- package/dist/claude/scripts/render-settings.js +0 -145
- package/dist/eslint/pasika/rules/organization-imports.d.ts +0 -2
- package/dist/eslint.config.js +0 -7
- package/dist/scripts/pasika.js +0 -59
- package/docs/agent-conventions.md +0 -27
- package/docs/claude/hooks.md +0 -30
- package/docs/code-organization-guide/code-organization-guide.md +0 -69
- package/docs/code-organization-guide/references/application-architecture-reference.md +0 -149
- package/docs/code-organization-guide/rules/component-placement-rule.md +0 -119
- package/docs/code-organization-guide/rules/configuration-rule.md +0 -42
- package/docs/code-organization-guide/rules/constants-rule.md +0 -74
- package/docs/code-organization-guide/rules/exports-and-imports-rule.md +0 -84
- package/docs/code-organization-guide/rules/folder-nesting-rule.md +0 -82
- package/docs/code-organization-guide/rules/hook-extraction-rule.md +0 -141
- package/docs/code-organization-guide/rules/interactive-component-rule.md +0 -97
- package/docs/code-organization-guide/rules/jsx-hygiene-rule.md +0 -67
- package/docs/code-organization-guide/rules/locales-rule.md +0 -53
- package/docs/code-organization-guide/rules/nameable-visual-concept-rule.md +0 -66
- package/docs/code-organization-guide/rules/no-mixed-concerns-rule.md +0 -63
- package/docs/code-organization-guide/rules/repeated-structure-rule.md +0 -93
- package/docs/code-organization-guide/rules/smart-vs-dumb-component-rule.md +0 -112
- package/docs/code-organization-guide/rules/sole-state-owner-rule.md +0 -101
- package/docs/code-organization-guide/rules/types-and-schemas-rule.md +0 -139
- package/docs/code-organization-guide/rules/utilities-rule.md +0 -86
- package/docs/documentation-guide/_templates/grouped-reference.md +0 -11
- package/docs/documentation-guide/_templates/guide.md +0 -19
- package/docs/documentation-guide/_templates/rule.md +0 -21
- package/docs/documentation-guide/_templates/single-lookup-reference.md +0 -5
- package/docs/documentation-guide/documentation-guide.md +0 -13
- package/docs/documentation-guide/references/documentation-types-reference.md +0 -9
- package/docs/documentation-guide/rules/guide-creation-rule.md +0 -113
- package/docs/documentation-guide/rules/reference-creation-rule.md +0 -132
- package/docs/documentation-guide/rules/rule-creation-rule.md +0 -81
- package/docs/documentation-guide/rules/template-usage-rule.md +0 -49
- package/docs/shadcn-theme.md +0 -121
- package/docs/styling-guide/rules/arbitrary-value-rule.md +0 -31
- package/docs/styling-guide/rules/class-composition-rule.md +0 -52
- package/docs/styling-guide/rules/component-ui-state-rule.md +0 -53
- package/docs/styling-guide/rules/component-variant-rule.md +0 -125
- package/docs/styling-guide/rules/global-stylesheet-rule.md +0 -67
- package/docs/styling-guide/rules/theme-and-utility-definition-rule.md +0 -86
- package/docs/styling-guide/styling-guide.md +0 -14
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/enforce-cn-merge
|
|
3
|
+
*
|
|
4
|
+
* Enforces the "Class Composition Rule".
|
|
5
|
+
*
|
|
6
|
+
* @see docs/styling-guide/rules/class-composition-rule.md
|
|
7
|
+
*/
|
|
8
|
+
function classCount(str) {
|
|
9
|
+
return str.split(/\s+/).filter(Boolean).length;
|
|
10
|
+
}
|
|
11
|
+
export const enforceCnMergeRule = {
|
|
12
|
+
meta: {
|
|
13
|
+
schema: [],
|
|
14
|
+
type: "problem",
|
|
15
|
+
docs: {
|
|
16
|
+
description: "Enforce cn() for conditional class merging.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
create(context) {
|
|
20
|
+
return {
|
|
21
|
+
JSXAttribute(node) {
|
|
22
|
+
if (node.name.name !== "className" && node.name.name !== "class")
|
|
23
|
+
return;
|
|
24
|
+
const valueNode = node.value;
|
|
25
|
+
if (!valueNode)
|
|
26
|
+
return;
|
|
27
|
+
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
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (valueNode.type !== "JSXExpressionContainer" || !valueNode.expression)
|
|
38
|
+
return;
|
|
39
|
+
const expr = valueNode.expression;
|
|
40
|
+
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
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
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
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (expr.type === "Literal" && typeof expr.value === "string") {
|
|
57
|
+
if (classCount(expr.value) > 5) {
|
|
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
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (expr.type === "CallExpression" && expr.callee.type === "Identifier" && expr.callee.name === "cn") {
|
|
67
|
+
for (const arg of expr.arguments) {
|
|
68
|
+
if (arg.type === "Literal" && typeof arg.value === "string") {
|
|
69
|
+
if (classCount(arg.value) > 5) {
|
|
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
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
};
|
|
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 */
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/enforce-cva-variant-props
|
|
3
|
+
*
|
|
4
|
+
* Enforces the "Component Variant Rule" — VariantProps from CVA.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/styling-guide/rules/component-variant-rule.md
|
|
7
|
+
*/
|
|
8
|
+
import type { Rule } from "eslint";
|
|
9
|
+
export declare const enforceCvaVariantPropsRule: Rule.RuleModule;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/enforce-cva-variant-props
|
|
3
|
+
*
|
|
4
|
+
* Enforces the "Component Variant Rule" — VariantProps from CVA.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/styling-guide/rules/component-variant-rule.md
|
|
7
|
+
*/
|
|
8
|
+
export const enforceCvaVariantPropsRule = {
|
|
9
|
+
meta: {
|
|
10
|
+
schema: [],
|
|
11
|
+
type: "problem",
|
|
12
|
+
docs: {
|
|
13
|
+
description: "Enforce VariantProps from CVA instead of manual variant union types.",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
create(context) {
|
|
17
|
+
const cvaDefinitions = new Map();
|
|
18
|
+
return {
|
|
19
|
+
VariableDeclarator(node) {
|
|
20
|
+
if (node.init?.type === "CallExpression" &&
|
|
21
|
+
node.init.callee.type === "Identifier" &&
|
|
22
|
+
node.init.callee.name === "cva" &&
|
|
23
|
+
node.id.type === "Identifier") {
|
|
24
|
+
const args = node.init.arguments;
|
|
25
|
+
if (args.length < 2)
|
|
26
|
+
return;
|
|
27
|
+
const options = args[1];
|
|
28
|
+
if (options?.type === "ObjectExpression") {
|
|
29
|
+
for (const prop of options.properties) {
|
|
30
|
+
if (prop.type === "Property" &&
|
|
31
|
+
prop.key.type === "Identifier" &&
|
|
32
|
+
prop.key.name === "variants" &&
|
|
33
|
+
prop.value.type === "ObjectExpression") {
|
|
34
|
+
const variantNames = prop.value.properties
|
|
35
|
+
.filter((p) => p.type === "Property" && p.key.type === "Identifier")
|
|
36
|
+
.map((p) => p.key.name);
|
|
37
|
+
if (variantNames.length > 0) {
|
|
38
|
+
cvaDefinitions.set(node.id.name, variantNames);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
TSTypeAliasDeclaration(node) {
|
|
46
|
+
if (!node.id.name.endsWith("Props"))
|
|
47
|
+
return;
|
|
48
|
+
if (node.typeAnnotation.type !== "TSTypeLiteral")
|
|
49
|
+
return;
|
|
50
|
+
for (const member of node.typeAnnotation.members) {
|
|
51
|
+
if (member.type === "TSPropertySignature" &&
|
|
52
|
+
member.key.type === "Identifier" &&
|
|
53
|
+
member.typeAnnotation?.typeAnnotation?.type === "TSUnionType") {
|
|
54
|
+
const keyName = String(member.key.name);
|
|
55
|
+
const typeAnn = member.typeAnnotation.typeAnnotation;
|
|
56
|
+
// typescript-eslint emits ESTree `Literal` nodes; other TypeScript
|
|
57
|
+
// parsers emit `StringLiteral`. Accept both so the rule works
|
|
58
|
+
// whichever parser the consuming config installs.
|
|
59
|
+
const allStringLiterals = typeAnn.types.every((t) => t.type === "TSLiteralType" &&
|
|
60
|
+
(t.literal?.type === "StringLiteral" ||
|
|
61
|
+
(t.literal?.type === "Literal" && typeof t.literal.value === "string")));
|
|
62
|
+
if (allStringLiterals && typeAnn.types.length >= 2) {
|
|
63
|
+
for (const [, variantNames] of cvaDefinitions) {
|
|
64
|
+
if (variantNames.includes(keyName)) {
|
|
65
|
+
context.report({
|
|
66
|
+
node: member,
|
|
67
|
+
message: `Variant prop "${keyName}" duplicates CVA variant values. ` +
|
|
68
|
+
"Use VariantProps<typeof variants> instead of manually writing union types. " +
|
|
69
|
+
"See docs/styling-guide/rules/component-variant-rule.md",
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
};
|
|
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 */
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/filename-case
|
|
3
|
+
*
|
|
4
|
+
* Enforces the naming conventions from the "Smart vs Dumb Component Rule":
|
|
5
|
+
* - Smart components: PascalCase.tsx
|
|
6
|
+
* - Dumb components: kebab-case.tsx
|
|
7
|
+
* - Non-component files: kebab-case
|
|
8
|
+
*
|
|
9
|
+
* @see docs/code-organization-guide/rules/smart-vs-dumb-component-rule.md
|
|
10
|
+
*/
|
|
1
11
|
import path from "node:path";
|
|
2
12
|
/**
|
|
3
13
|
* Next.js App Router routing files that are exempt from filename-case checks.
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/import-boundaries
|
|
3
|
+
*
|
|
4
|
+
* Enforces the import conventions from the "Exports and Imports Rule":
|
|
5
|
+
* - Whichever of the relative path and the @/* alias has fewer segments, with
|
|
6
|
+
* a tie going to the relative path.
|
|
7
|
+
* - Layer boundary enforcement (app → compositions → features → shared → root).
|
|
8
|
+
*
|
|
9
|
+
* @see docs/code-organization-guide/rules/exports-and-imports-rule.md
|
|
10
|
+
*/
|
|
1
11
|
import path from "node:path";
|
|
2
12
|
const sourceRoot = path.resolve("src");
|
|
3
13
|
const rootSupportFolders = new Set(["config", "constants", "hooks", "locales", "schemas", "types", "utils"]);
|
|
@@ -19,14 +29,35 @@ function sourceSegments(absolutePath) {
|
|
|
19
29
|
}
|
|
20
30
|
return relativePath.split(path.sep);
|
|
21
31
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
const nonParentSegments = segments.length - parentTraversals;
|
|
27
|
-
return (parentTraversals === 0 && nonParentSegments <= 2) || (parentTraversals === 1 && nonParentSegments <= 1);
|
|
32
|
+
/** The relative form of an import, always prefixed so it reads as a path. */
|
|
33
|
+
function relativeSpecifier(filename, resolvedPath) {
|
|
34
|
+
const relativePath = path.relative(path.dirname(filename), resolvedPath).split(path.sep).join("/");
|
|
35
|
+
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
28
36
|
}
|
|
29
|
-
|
|
37
|
+
/** The `@/*` form of an import. */
|
|
38
|
+
function aliasSpecifier(resolvedPath) {
|
|
39
|
+
return `@/${(sourceSegments(resolvedPath) ?? []).join("/")}`;
|
|
40
|
+
}
|
|
41
|
+
/** Segments in a specifier: one per `../` step and one per name, ignoring a leading `./`. */
|
|
42
|
+
function segmentCount(specifier) {
|
|
43
|
+
return specifier
|
|
44
|
+
.replace(/^@\//, "")
|
|
45
|
+
.split("/")
|
|
46
|
+
.filter((segment) => segment !== "." && segment !== "").length;
|
|
47
|
+
}
|
|
48
|
+
function describeSegments(count) {
|
|
49
|
+
return `${String(count)} segment${count === 1 ? "" : "s"}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether the relative form is the one to use. Shorter wins, and a tie goes to
|
|
53
|
+
* the relative form. Because crossing a layer always costs at least one `../`
|
|
54
|
+
* while the alias spells the same tail, the alias always wins for a
|
|
55
|
+
* cross-layer import without this needing to know what a layer is.
|
|
56
|
+
*/
|
|
57
|
+
function prefersRelative(filename, resolvedPath) {
|
|
58
|
+
return segmentCount(relativeSpecifier(filename, resolvedPath)) <= segmentCount(aliasSpecifier(resolvedPath));
|
|
59
|
+
}
|
|
60
|
+
export const importBoundariesRule = {
|
|
30
61
|
meta: {
|
|
31
62
|
schema: [],
|
|
32
63
|
type: "problem",
|
|
@@ -50,18 +81,6 @@ export const organizationImportsRule = {
|
|
|
50
81
|
if (!importer || !imported || importer.length === 0 || imported.length === 0) {
|
|
51
82
|
return;
|
|
52
83
|
}
|
|
53
|
-
if (isNearbyImport(filename, resolvedPath) && importPath.startsWith("@/")) {
|
|
54
|
-
context.report({
|
|
55
|
-
node: source,
|
|
56
|
-
message: "Use a relative path for imports in the same folder, a descendant, or one folder up.",
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
if (!isNearbyImport(filename, resolvedPath) && importPath.startsWith(".")) {
|
|
60
|
-
context.report({
|
|
61
|
-
node: source,
|
|
62
|
-
message: "Use the @/* alias for imports beyond one folder up.",
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
84
|
const [importerLayer = "", importerFeature] = importer;
|
|
66
85
|
const [importedLayer = "", importedFeature] = imported;
|
|
67
86
|
const extension = path.extname(importPath);
|
|
@@ -97,6 +116,30 @@ export const organizationImportsRule = {
|
|
|
97
116
|
node: source,
|
|
98
117
|
message: "This import violates the src layer boundary.",
|
|
99
118
|
});
|
|
119
|
+
// The fix is to move the file, so how the specifier is spelled does not matter yet.
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const relativeForm = relativeSpecifier(filename, resolvedPath);
|
|
123
|
+
const aliasForm = aliasSpecifier(resolvedPath);
|
|
124
|
+
const relativeSegments = segmentCount(relativeForm);
|
|
125
|
+
const aliasSegments = segmentCount(aliasForm);
|
|
126
|
+
function describeChoice(preferred, preferredSegments, other, otherSegments) {
|
|
127
|
+
const tie = preferredSegments === otherSegments ? ", and a tie goes to the relative path" : "";
|
|
128
|
+
return (`Use "${preferred}" (${describeSegments(preferredSegments)}) ` +
|
|
129
|
+
`instead of "${other}" (${describeSegments(otherSegments)})${tie}.`);
|
|
130
|
+
}
|
|
131
|
+
if (prefersRelative(filename, resolvedPath) && importPath.startsWith("@/")) {
|
|
132
|
+
context.report({
|
|
133
|
+
node: source,
|
|
134
|
+
message: describeChoice(relativeForm, relativeSegments, aliasForm, aliasSegments),
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!prefersRelative(filename, resolvedPath) && importPath.startsWith(".")) {
|
|
139
|
+
context.report({
|
|
140
|
+
node: source,
|
|
141
|
+
message: describeChoice(aliasForm, aliasSegments, relativeForm, relativeSegments),
|
|
142
|
+
});
|
|
100
143
|
}
|
|
101
144
|
}
|
|
102
145
|
return {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/no-arbitrary-tailwind
|
|
3
|
+
*
|
|
4
|
+
* Enforces the "Arbitrary Value Rule" — no arbitrary-value Tailwind classes.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/styling-guide/rules/arbitrary-value-rule.md
|
|
7
|
+
*/
|
|
8
|
+
import type { Rule } from "eslint";
|
|
9
|
+
export declare const noArbitraryTailwindRule: Rule.RuleModule;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/no-arbitrary-tailwind
|
|
3
|
+
*
|
|
4
|
+
* Enforces the "Arbitrary Value Rule" — no arbitrary-value Tailwind classes.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/styling-guide/rules/arbitrary-value-rule.md
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Matches Tailwind arbitrary-value utility classes like rounded-[13px], text-[#fff].
|
|
10
|
+
*
|
|
11
|
+
* Does NOT match variant prefixes like min-[400px]:flex-row or md:flex.
|
|
12
|
+
* The negative lookahead (?![-\w]*:) excludes anything followed by a colon + more.
|
|
13
|
+
*
|
|
14
|
+
* Note: Tailwind breakpoint variants (sm:, md:, lg:, etc.) don't use brackets,
|
|
15
|
+
* so they're never matched. Only bracket-based variants like min-[400px]: need
|
|
16
|
+
* the lookahead exclusion.
|
|
17
|
+
*/
|
|
18
|
+
const ARBITRARY_VALUE_RE = /(?:^|(?<=\s))(?:[a-z]+(?:-[a-z]+)*)-\[[^\]]+\](?![-\w]*:)/g;
|
|
19
|
+
const CLASS_HELPERS = new Set(["cn", "clsx", "twMerge", "twJoin"]);
|
|
20
|
+
export const noArbitraryTailwindRule = {
|
|
21
|
+
meta: {
|
|
22
|
+
schema: [],
|
|
23
|
+
type: "problem",
|
|
24
|
+
docs: {
|
|
25
|
+
description: "Disallow Tailwind arbitrary-value classes like rounded-[13px].",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
create(context) {
|
|
29
|
+
function reportClassString(node, value) {
|
|
30
|
+
ARBITRARY_VALUE_RE.lastIndex = 0;
|
|
31
|
+
const match = ARBITRARY_VALUE_RE.exec(value);
|
|
32
|
+
if (!match)
|
|
33
|
+
return;
|
|
34
|
+
context.report({
|
|
35
|
+
node,
|
|
36
|
+
message: `Tailwind arbitrary-value class "${match[0]}" is not allowed. ` +
|
|
37
|
+
"Use a named token or custom utility. " +
|
|
38
|
+
"See docs/styling-guide/rules/arbitrary-value-rule.md",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Walks every expression a class name can hide in: conditionals, arrays, and object keys. */
|
|
42
|
+
function checkExpression(node, expression) {
|
|
43
|
+
if (!expression)
|
|
44
|
+
return;
|
|
45
|
+
if (expression.type === "Literal") {
|
|
46
|
+
if (typeof expression.value === "string")
|
|
47
|
+
reportClassString(node, expression.value);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (expression.type === "TemplateLiteral") {
|
|
51
|
+
for (const quasi of expression.quasis)
|
|
52
|
+
reportClassString(node, quasi.value.raw);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (expression.type === "LogicalExpression") {
|
|
56
|
+
checkExpression(node, expression.left);
|
|
57
|
+
checkExpression(node, expression.right);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (expression.type === "ConditionalExpression") {
|
|
61
|
+
checkExpression(node, expression.consequent);
|
|
62
|
+
checkExpression(node, expression.alternate);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (expression.type === "ArrayExpression") {
|
|
66
|
+
for (const element of expression.elements)
|
|
67
|
+
checkExpression(node, element);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (expression.type === "ObjectExpression") {
|
|
71
|
+
// clsx and cn accept `{ "px-[3px]": isActive }`, so the keys carry classes.
|
|
72
|
+
for (const property of expression.properties) {
|
|
73
|
+
if (property.type === "Property")
|
|
74
|
+
checkExpression(node, property.key);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
JSXAttribute(node) {
|
|
80
|
+
const attributeName = node.name?.name;
|
|
81
|
+
if (attributeName !== "className" && attributeName !== "class")
|
|
82
|
+
return;
|
|
83
|
+
const value = node.value;
|
|
84
|
+
if (!value)
|
|
85
|
+
return;
|
|
86
|
+
checkExpression(node, value.type === "JSXExpressionContainer" ? value.expression : value);
|
|
87
|
+
},
|
|
88
|
+
CallExpression(node) {
|
|
89
|
+
if (node.callee.type !== "Identifier" || !CLASS_HELPERS.has(node.callee.name))
|
|
90
|
+
return;
|
|
91
|
+
for (const argument of node.arguments) {
|
|
92
|
+
checkExpression(node, argument);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/no-mixed-concerns
|
|
3
|
+
*
|
|
4
|
+
* Enforces the "No Mixed Concerns Rule" — one React component per .tsx file.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/code-organization-guide/rules/no-mixed-concerns-rule.md
|
|
7
|
+
*/
|
|
8
|
+
import type { Rule } from "eslint";
|
|
9
|
+
export declare const noMixedConcernsRule: Rule.RuleModule;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/no-mixed-concerns
|
|
3
|
+
*
|
|
4
|
+
* Enforces the "No Mixed Concerns Rule" — one React component per .tsx file.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/code-organization-guide/rules/no-mixed-concerns-rule.md
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Return the name of an export node so we can report it clearly.
|
|
10
|
+
* Handles:
|
|
11
|
+
* export function Foo() { }
|
|
12
|
+
* export const Foo = () => { }
|
|
13
|
+
* export const Foo = function () { }
|
|
14
|
+
* export default function Foo() { }
|
|
15
|
+
* export default () => { }
|
|
16
|
+
*/
|
|
17
|
+
// biome-ignore lint/suspicious/noExplicitAny: AST node type from ESLint with dynamic property access
|
|
18
|
+
function getExportName(declaration) {
|
|
19
|
+
if (declaration.type === "FunctionDeclaration" && declaration.id) {
|
|
20
|
+
return declaration.id.name;
|
|
21
|
+
}
|
|
22
|
+
if (declaration.type === "VariableDeclaration") {
|
|
23
|
+
const declarator = declaration.declarations[0];
|
|
24
|
+
if (declarator?.id.type === "Identifier" &&
|
|
25
|
+
(declarator.init?.type === "ArrowFunctionExpression" || declarator.init?.type === "FunctionExpression")) {
|
|
26
|
+
return declarator.id.name;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (declaration.type === "ArrowFunctionExpression") {
|
|
30
|
+
return "default";
|
|
31
|
+
}
|
|
32
|
+
if (declaration.type === "FunctionExpression" && declaration.id) {
|
|
33
|
+
return declaration.id.name;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
export const noMixedConcernsRule = {
|
|
38
|
+
meta: {
|
|
39
|
+
schema: [],
|
|
40
|
+
type: "problem",
|
|
41
|
+
docs: {
|
|
42
|
+
description: "Enforce one exported React component per .tsx file.",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
create(context) {
|
|
46
|
+
if (!context.filename.endsWith(".tsx"))
|
|
47
|
+
return {};
|
|
48
|
+
let exportedComponentCount = 0;
|
|
49
|
+
const extraExports = [];
|
|
50
|
+
function registerExport(name) {
|
|
51
|
+
exportedComponentCount++;
|
|
52
|
+
if (exportedComponentCount > 1) {
|
|
53
|
+
extraExports.push({ name });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
ExportNamedDeclaration(node) {
|
|
58
|
+
if (!node.declaration)
|
|
59
|
+
return;
|
|
60
|
+
const name = getExportName(node.declaration);
|
|
61
|
+
if (name)
|
|
62
|
+
registerExport(name);
|
|
63
|
+
},
|
|
64
|
+
ExportDefaultDeclaration(node) {
|
|
65
|
+
const name = getExportName(node.declaration);
|
|
66
|
+
if (name)
|
|
67
|
+
registerExport(name);
|
|
68
|
+
},
|
|
69
|
+
"Program:exit"() {
|
|
70
|
+
if (exportedComponentCount > 1) {
|
|
71
|
+
for (const extra of extraExports) {
|
|
72
|
+
context.report({
|
|
73
|
+
loc: { line: 1, column: 0 },
|
|
74
|
+
message: `File exports multiple components. "${extra.name}" is an extra component export. ` +
|
|
75
|
+
"Move it to its own file. Each .tsx file MUST contain exactly one component. " +
|
|
76
|
+
"See docs/code-organization-guide/rules/no-mixed-concerns-rule.md",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
/* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any -- re-enable after AST node access block */
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/support-file-placement
|
|
3
|
+
*
|
|
4
|
+
* Enforces where a hook, type, schema, constant, or utility lives, which depends
|
|
5
|
+
* on the files that import it and so cannot be decided from one file.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/code-organization-guide/rules/types-and-schemas-rule.md
|
|
8
|
+
* @see docs/code-organization-guide/rules/constants-rule.md
|
|
9
|
+
* @see docs/code-organization-guide/rules/utilities-rule.md
|
|
10
|
+
* @see docs/code-organization-guide/rules/hook-extraction-rule.md
|
|
11
|
+
* @see docs/code-organization-guide/rules/configuration-rule.md
|
|
12
|
+
* @see docs/code-organization-guide/rules/folder-nesting-rule.md
|
|
13
|
+
*/
|
|
14
|
+
import type { Rule } from "eslint";
|
|
15
|
+
export declare const supportFilePlacementRule: Rule.RuleModule;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/support-file-placement
|
|
3
|
+
*
|
|
4
|
+
* Enforces where a hook, type, schema, constant, or utility lives, which depends
|
|
5
|
+
* on the files that import it and so cannot be decided from one file.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/code-organization-guide/rules/types-and-schemas-rule.md
|
|
8
|
+
* @see docs/code-organization-guide/rules/constants-rule.md
|
|
9
|
+
* @see docs/code-organization-guide/rules/utilities-rule.md
|
|
10
|
+
* @see docs/code-organization-guide/rules/hook-extraction-rule.md
|
|
11
|
+
* @see docs/code-organization-guide/rules/configuration-rule.md
|
|
12
|
+
* @see docs/code-organization-guide/rules/folder-nesting-rule.md
|
|
13
|
+
*/
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { getProjectIndex } from "../project/index.js";
|
|
16
|
+
import { describeConsumers, folderSegmentsOf, formatFolder, isConfigModule, resolveSupportPlacement, segmentsOf, SUPPORT_FOLDERS, } from "../project/ccf.js";
|
|
17
|
+
/**
|
|
18
|
+
* Support folders a configuration module keeps regardless of who imports them: a
|
|
19
|
+
* constant or type whose meaning is derived from the configuration stays beside
|
|
20
|
+
* it even when consumers exist outside the module.
|
|
21
|
+
*/
|
|
22
|
+
const CONFIG_OWNED_FOLDERS = new Set(["types", "constants"]);
|
|
23
|
+
const REASON_TEXT = {
|
|
24
|
+
"app-consumer": "a file under src/app/ imports it, so it belongs to the app-wide support folder",
|
|
25
|
+
"config-module": "every file that imports it belongs to that configuration module",
|
|
26
|
+
ccf: "that is the closest folder its consumers share",
|
|
27
|
+
"across-features": "its consumers span more than one feature, so no feature can own it",
|
|
28
|
+
"across-layers": "its consumers span more than one layer, so no layer can own it",
|
|
29
|
+
};
|
|
30
|
+
const sameFolder = (left, right) => left.length === right.length && left.every((segment, depth) => segment === right[depth]);
|
|
31
|
+
export const supportFilePlacementRule = {
|
|
32
|
+
meta: {
|
|
33
|
+
schema: [],
|
|
34
|
+
type: "problem",
|
|
35
|
+
docs: {
|
|
36
|
+
description: "Enforce that a support file lives in the folder its consumers imply.",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
create(context) {
|
|
40
|
+
const sourceRoot = path.resolve("src");
|
|
41
|
+
const supportFile = path.resolve(context.filename);
|
|
42
|
+
const currentFolder = folderSegmentsOf(supportFile, sourceRoot);
|
|
43
|
+
const supportFolder = currentFolder[currentFolder.length - 1];
|
|
44
|
+
// Only a file already inside a support folder is placed by this rule; a
|
|
45
|
+
// declaration still sitting beside its consumer is an extraction question.
|
|
46
|
+
if (supportFolder === undefined || !SUPPORT_FOLDERS.has(supportFolder))
|
|
47
|
+
return {};
|
|
48
|
+
// A type or constant whose meaning comes from a configuration may stay with
|
|
49
|
+
// it however widely it is used, so its consumers cannot place it.
|
|
50
|
+
if (isConfigModule(segmentsOf(supportFile, sourceRoot)) && CONFIG_OWNED_FOLDERS.has(supportFolder))
|
|
51
|
+
return {};
|
|
52
|
+
const index = getProjectIndex(sourceRoot);
|
|
53
|
+
if (!index)
|
|
54
|
+
return {};
|
|
55
|
+
const placement = resolveSupportPlacement(supportFile, supportFolder, index);
|
|
56
|
+
if (!placement || sameFolder(currentFolder, placement.expectedFolder))
|
|
57
|
+
return {};
|
|
58
|
+
return {
|
|
59
|
+
Program(node) {
|
|
60
|
+
context.report({
|
|
61
|
+
node,
|
|
62
|
+
loc: { line: 1, column: 0 },
|
|
63
|
+
message: `Move this file to ${formatFolder(placement.expectedFolder)} — ` +
|
|
64
|
+
`${REASON_TEXT[placement.reason] ?? "that is where its consumers place it"}. ` +
|
|
65
|
+
`Imported by ${describeConsumers(placement.countedConsumers, sourceRoot)}.`,
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
};
|