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
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Rules must live in rules/, references must live in references/.
|
|
3
|
+
*/
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { getFilename } from "./helpers.js";
|
|
6
|
+
export const supportDocumentPlacementRule = {
|
|
7
|
+
meta: {
|
|
8
|
+
type: "problem",
|
|
9
|
+
docs: {
|
|
10
|
+
description: "Rules must live in rules/, references must live in references/.",
|
|
11
|
+
recommended: true,
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
create(context) {
|
|
15
|
+
return {
|
|
16
|
+
root(node) {
|
|
17
|
+
const filename = getFilename(context);
|
|
18
|
+
if (!filename.endsWith(".md"))
|
|
19
|
+
return;
|
|
20
|
+
const parentFolder = path.basename(path.dirname(filename));
|
|
21
|
+
if (filename.endsWith("-rule.md") && parentFolder !== "rules") {
|
|
22
|
+
context.report({
|
|
23
|
+
node,
|
|
24
|
+
message: `rule lives in "${parentFolder}/" instead of "rules/"`,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (filename.endsWith("-reference.md") && parentFolder !== "references") {
|
|
28
|
+
context.report({
|
|
29
|
+
node,
|
|
30
|
+
message: `reference lives in "${parentFolder}/" instead of "references/"`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Document title must match the file name in kebab-case.
|
|
3
|
+
*/
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { getFilename, getTextContent } from "./helpers.js";
|
|
6
|
+
function toExpectedFileName(title) {
|
|
7
|
+
return `${title
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
10
|
+
.replace(/^-|-$/g, "")}.md`;
|
|
11
|
+
}
|
|
12
|
+
export const titleMatchesFileNameRule = {
|
|
13
|
+
meta: {
|
|
14
|
+
type: "problem",
|
|
15
|
+
docs: {
|
|
16
|
+
description: "Document title must match the file name in kebab-case.",
|
|
17
|
+
recommended: true,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
create(context) {
|
|
21
|
+
return {
|
|
22
|
+
heading(node) {
|
|
23
|
+
if (node.depth !== 1)
|
|
24
|
+
return;
|
|
25
|
+
const filename = getFilename(context);
|
|
26
|
+
if (!filename.endsWith(".md"))
|
|
27
|
+
return;
|
|
28
|
+
const title = getTextContent(node).trim();
|
|
29
|
+
const expectedFileName = toExpectedFileName(title);
|
|
30
|
+
const actualFileName = path.basename(filename);
|
|
31
|
+
if (!title) {
|
|
32
|
+
context.report({
|
|
33
|
+
node,
|
|
34
|
+
message: "document has no `# Title` heading",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
else if (expectedFileName !== actualFileName) {
|
|
38
|
+
context.report({
|
|
39
|
+
node,
|
|
40
|
+
message: `title "${title}" expects file name ${expectedFileName}`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/named-exports
|
|
3
|
+
*
|
|
4
|
+
* Enforces named exports for application files while allowing framework routing
|
|
5
|
+
* files such as Next.js page.tsx and layout.tsx to use their required default
|
|
6
|
+
* export contract.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/code-organization-guide/rules/exports-and-imports-rule.md
|
|
9
|
+
*/
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
const FRAMEWORK_DEFAULT_EXPORT_FILES = new Set([
|
|
12
|
+
"default",
|
|
13
|
+
"error",
|
|
14
|
+
"layout",
|
|
15
|
+
"loading",
|
|
16
|
+
"not-found",
|
|
17
|
+
"page",
|
|
18
|
+
"route",
|
|
19
|
+
"template",
|
|
20
|
+
]);
|
|
21
|
+
function isFrameworkDefaultExportFile(filename) {
|
|
22
|
+
const normalized = filename.replaceAll(path.sep, "/");
|
|
23
|
+
if (!normalized.includes("/src/app/"))
|
|
24
|
+
return false;
|
|
25
|
+
const basename = path.basename(filename, path.extname(filename));
|
|
26
|
+
return FRAMEWORK_DEFAULT_EXPORT_FILES.has(basename);
|
|
27
|
+
}
|
|
28
|
+
export const namedExportsRule = {
|
|
29
|
+
meta: {
|
|
30
|
+
schema: [],
|
|
31
|
+
type: "problem",
|
|
32
|
+
docs: {
|
|
33
|
+
description: "Require named exports except where a framework requires a default export.",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
create(context) {
|
|
37
|
+
if (isFrameworkDefaultExportFile(context.filename))
|
|
38
|
+
return {};
|
|
39
|
+
return {
|
|
40
|
+
ExportDefaultDeclaration(node) {
|
|
41
|
+
context.report({
|
|
42
|
+
node,
|
|
43
|
+
message: "Files that export values must use named exports unless a framework or third-party package requires a different export style for that file. " +
|
|
44
|
+
"See docs/code-organization-guide/rules/exports-and-imports-rule.md",
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/no-eslint-disable
|
|
3
|
+
*
|
|
4
|
+
* Code MUST NOT use eslint-disable directives, and a reported violation MUST
|
|
5
|
+
* be fixed instead.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/agent-policy.md
|
|
8
|
+
*/
|
|
9
|
+
import type { Rule } from "eslint";
|
|
10
|
+
export declare const noEslintDisableRule: Rule.RuleModule;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/no-eslint-disable
|
|
3
|
+
*
|
|
4
|
+
* Code MUST NOT use eslint-disable directives, and a reported violation MUST
|
|
5
|
+
* be fixed instead.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/agent-policy.md
|
|
8
|
+
*/
|
|
9
|
+
export const noEslintDisableRule = {
|
|
10
|
+
meta: {
|
|
11
|
+
schema: [],
|
|
12
|
+
type: "problem",
|
|
13
|
+
docs: {
|
|
14
|
+
description: "Forbid eslint-disable directives.",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
create(context) {
|
|
18
|
+
return {
|
|
19
|
+
Program() {
|
|
20
|
+
for (const comment of context.sourceCode.getAllComments()) {
|
|
21
|
+
if (!comment.value.includes("eslint-disable"))
|
|
22
|
+
continue;
|
|
23
|
+
if (!comment.loc)
|
|
24
|
+
continue;
|
|
25
|
+
context.report({
|
|
26
|
+
loc: comment.loc,
|
|
27
|
+
message: "Do not use eslint-disable directives; fix the reported violation instead.",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -5,32 +5,22 @@
|
|
|
5
5
|
*
|
|
6
6
|
* @see docs/code-organization-guide/rules/no-mixed-concerns-rule.md
|
|
7
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
8
|
function getExportName(declaration) {
|
|
19
|
-
if (declaration
|
|
20
|
-
return declaration.id.name;
|
|
9
|
+
if (declaration?.type === "FunctionDeclaration" && declaration.id) {
|
|
10
|
+
return declaration.id.name ?? null;
|
|
21
11
|
}
|
|
22
|
-
if (declaration
|
|
23
|
-
const declarator = declaration.declarations[0];
|
|
24
|
-
if (declarator?.id
|
|
12
|
+
if (declaration?.type === "VariableDeclaration") {
|
|
13
|
+
const declarator = declaration.declarations?.[0];
|
|
14
|
+
if (declarator?.id?.type === "Identifier" &&
|
|
25
15
|
(declarator.init?.type === "ArrowFunctionExpression" || declarator.init?.type === "FunctionExpression")) {
|
|
26
|
-
return declarator.id.name;
|
|
16
|
+
return declarator.id.name ?? null;
|
|
27
17
|
}
|
|
28
18
|
}
|
|
29
|
-
if (declaration
|
|
19
|
+
if (declaration?.type === "ArrowFunctionExpression") {
|
|
30
20
|
return "default";
|
|
31
21
|
}
|
|
32
|
-
if (declaration
|
|
33
|
-
return declaration.id.name;
|
|
22
|
+
if (declaration?.type === "FunctionExpression" && declaration.id) {
|
|
23
|
+
return declaration.id.name ?? null;
|
|
34
24
|
}
|
|
35
25
|
return null;
|
|
36
26
|
}
|
|
@@ -55,8 +45,6 @@ export const noMixedConcernsRule = {
|
|
|
55
45
|
}
|
|
56
46
|
return {
|
|
57
47
|
ExportNamedDeclaration(node) {
|
|
58
|
-
if (!node.declaration)
|
|
59
|
-
return;
|
|
60
48
|
const name = getExportName(node.declaration);
|
|
61
49
|
if (name)
|
|
62
50
|
registerExport(name);
|
|
@@ -81,4 +69,3 @@ export const noMixedConcernsRule = {
|
|
|
81
69
|
};
|
|
82
70
|
},
|
|
83
71
|
};
|
|
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,48 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { resolveSpecifier } from "../project/index.js";
|
|
3
|
+
export const noUtilBarrelRule = {
|
|
4
|
+
meta: {
|
|
5
|
+
schema: [],
|
|
6
|
+
type: "problem",
|
|
7
|
+
docs: {
|
|
8
|
+
description: "Require utilities to be imported directly without a barrel.",
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
create(context) {
|
|
12
|
+
const filename = path.resolve(context.filename);
|
|
13
|
+
const sourceRoot = sourceRootOf(filename);
|
|
14
|
+
return {
|
|
15
|
+
Program(node) {
|
|
16
|
+
for (const specifier of importSpecifiers(context.sourceCode.text)) {
|
|
17
|
+
const target = resolveSpecifier(filename, specifier, sourceRoot);
|
|
18
|
+
if (!target)
|
|
19
|
+
continue;
|
|
20
|
+
const segments = target.replace(/\\/g, "/").split("/");
|
|
21
|
+
const utilsIndex = segments.lastIndexOf("utils");
|
|
22
|
+
if (utilsIndex < 0 || !path.basename(target).startsWith("index."))
|
|
23
|
+
continue;
|
|
24
|
+
context.report({
|
|
25
|
+
node,
|
|
26
|
+
message: `Import utilities directly instead of through "${specifier}". ` +
|
|
27
|
+
"See docs/code-organization-guide/rules/utilities-rule.md",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
function importSpecifiers(source) {
|
|
35
|
+
const specifiers = [];
|
|
36
|
+
const pattern = /\bimport\s+(?:(?:type\s+)?[\s\S]*?\sfrom\s+)?["'](?<specifier>[^"']+)["']/g;
|
|
37
|
+
for (const match of source.matchAll(pattern)) {
|
|
38
|
+
const specifier = match.groups?.specifier;
|
|
39
|
+
if (specifier)
|
|
40
|
+
specifiers.push(specifier);
|
|
41
|
+
}
|
|
42
|
+
return specifiers;
|
|
43
|
+
}
|
|
44
|
+
function sourceRootOf(filename) {
|
|
45
|
+
const marker = `${path.sep}src${path.sep}`;
|
|
46
|
+
const srcIndex = filename.lastIndexOf(marker);
|
|
47
|
+
return srcIndex >= 0 ? filename.slice(0, srcIndex + marker.length - 1) : path.resolve("src");
|
|
48
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/pure-function-extract
|
|
3
|
+
*
|
|
4
|
+
* A pure function MUST be extracted to utils/, even when it has one consumer.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/code-organization-guide/rules/utilities-rule.md
|
|
7
|
+
*/
|
|
8
|
+
import type { Rule } from "eslint";
|
|
9
|
+
export declare const pureFunctionExtractRule: Rule.RuleModule;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/pure-function-extract
|
|
3
|
+
*
|
|
4
|
+
* A pure function MUST be extracted to utils/, even when it has one consumer.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/code-organization-guide/rules/utilities-rule.md
|
|
7
|
+
*/
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
function isComponentLikeName(name) {
|
|
10
|
+
return /^[A-Z]/.test(name);
|
|
11
|
+
}
|
|
12
|
+
function isHookName(name) {
|
|
13
|
+
return /^use[A-Z]/.test(name);
|
|
14
|
+
}
|
|
15
|
+
function hasHookUsage(body) {
|
|
16
|
+
for (const stmt of body.body) {
|
|
17
|
+
if (stmt.type !== "ExpressionStatement")
|
|
18
|
+
continue;
|
|
19
|
+
const expr = stmt.expression;
|
|
20
|
+
if (expr.type === "CallExpression" && expr.callee.type === "Identifier" && isHookName(expr.callee.name)) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
export const pureFunctionExtractRule = {
|
|
27
|
+
meta: {
|
|
28
|
+
schema: [],
|
|
29
|
+
type: "problem",
|
|
30
|
+
docs: {
|
|
31
|
+
description: "Require pure functions declared in component files to be extracted to utils/.",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
create(context) {
|
|
35
|
+
const filename = context.filename;
|
|
36
|
+
if (!filename.endsWith(".tsx") && !filename.endsWith(".jsx"))
|
|
37
|
+
return {};
|
|
38
|
+
const sourceRoot = path.resolve("src");
|
|
39
|
+
const relative = path.relative(sourceRoot, filename);
|
|
40
|
+
if (relative.startsWith(".."))
|
|
41
|
+
return {};
|
|
42
|
+
const segments = relative.split(path.sep);
|
|
43
|
+
// Already in utils/ or a support folder — fine
|
|
44
|
+
if (segments[0] === "utils")
|
|
45
|
+
return {};
|
|
46
|
+
if (segments[0] === "app")
|
|
47
|
+
return {};
|
|
48
|
+
const supportFolders = new Set(["hooks", "types", "schemas", "constants", "utils"]);
|
|
49
|
+
if (segments.length >= 2 && supportFolders.has(segments[segments.length - 1] ?? ""))
|
|
50
|
+
return {};
|
|
51
|
+
function report(node, name) {
|
|
52
|
+
context.report({
|
|
53
|
+
node,
|
|
54
|
+
message: `Extract pure function "${name}" to utils/. See docs/code-organization-guide/rules/utilities-rule.md`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
FunctionDeclaration(node) {
|
|
59
|
+
const exported = node.parent?.type === "ExportNamedDeclaration";
|
|
60
|
+
if (!exported)
|
|
61
|
+
return;
|
|
62
|
+
const name = node.id?.name;
|
|
63
|
+
if (!name)
|
|
64
|
+
return;
|
|
65
|
+
if (isComponentLikeName(name) || isHookName(name))
|
|
66
|
+
return;
|
|
67
|
+
if (!node.body || hasHookUsage(node.body))
|
|
68
|
+
return;
|
|
69
|
+
report(node, name);
|
|
70
|
+
},
|
|
71
|
+
VariableDeclarator(node) {
|
|
72
|
+
if (node.id.type !== "Identifier")
|
|
73
|
+
return;
|
|
74
|
+
const name = node.id.name;
|
|
75
|
+
if (!name)
|
|
76
|
+
return;
|
|
77
|
+
const exported = node.parent.parent?.type === "ExportNamedDeclaration";
|
|
78
|
+
if (!exported)
|
|
79
|
+
return;
|
|
80
|
+
if (isComponentLikeName(name) || isHookName(name))
|
|
81
|
+
return;
|
|
82
|
+
const init = node.init;
|
|
83
|
+
if (!init || (init.type !== "ArrowFunctionExpression" && init.type !== "FunctionExpression")) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (init.body.type === "BlockStatement" && hasHookUsage(init.body))
|
|
87
|
+
return;
|
|
88
|
+
report(node, name);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/shared-style-dedup
|
|
3
|
+
*
|
|
4
|
+
* A style or group of styles that two or more components use together and
|
|
5
|
+
* should change together MUST become one named custom utility. Styling used by
|
|
6
|
+
* only one component MUST stay in that component file as static Tailwind
|
|
7
|
+
* utility classes. The rule compares className lists across the whole tree,
|
|
8
|
+
* because a single file cannot see what other components use.
|
|
9
|
+
*
|
|
10
|
+
* @see docs/styling-guide/rules/theme-and-utility-definition-rule.md
|
|
11
|
+
*/
|
|
12
|
+
import type { Rule } from "eslint";
|
|
13
|
+
export declare const sharedStyleDedupRule: Rule.RuleModule;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/shared-style-dedup
|
|
3
|
+
*
|
|
4
|
+
* A style or group of styles that two or more components use together and
|
|
5
|
+
* should change together MUST become one named custom utility. Styling used by
|
|
6
|
+
* only one component MUST stay in that component file as static Tailwind
|
|
7
|
+
* utility classes. The rule compares className lists across the whole tree,
|
|
8
|
+
* because a single file cannot see what other components use.
|
|
9
|
+
*
|
|
10
|
+
* @see docs/styling-guide/rules/theme-and-utility-definition-rule.md
|
|
11
|
+
*/
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { readFileSync, statSync } from "node:fs";
|
|
14
|
+
import { getProjectIndex } from "../project/index.js";
|
|
15
|
+
import { segmentsOf } from "../project/ccf.js";
|
|
16
|
+
/** Static className string literals; dynamic class lists are not comparable across files. */
|
|
17
|
+
const CLASS_NAME = /className="(?<classes>[^"]+)"/g;
|
|
18
|
+
let comboCache;
|
|
19
|
+
/** className combinations used by each component file, memoized per tree. */
|
|
20
|
+
function combosFor(index) {
|
|
21
|
+
const files = [...index.modules.keys()]
|
|
22
|
+
.filter((file) => file.endsWith(".tsx") || file.endsWith(".jsx"))
|
|
23
|
+
.sort((left, right) => left.localeCompare(right));
|
|
24
|
+
let fingerprint = "";
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
try {
|
|
27
|
+
fingerprint += String(statSync(file).mtimeMs);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
fingerprint += "0";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (comboCache?.sourceRoot === index.sourceRoot && comboCache.fingerprint === fingerprint) {
|
|
34
|
+
return comboCache.combos;
|
|
35
|
+
}
|
|
36
|
+
const combos = new Map();
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
const text = readFileSync(file, "utf8");
|
|
39
|
+
for (const match of text.matchAll(CLASS_NAME)) {
|
|
40
|
+
const classes = (match.groups?.classes ?? "").split(/\s+/).filter(Boolean);
|
|
41
|
+
if (classes.length < 2)
|
|
42
|
+
continue;
|
|
43
|
+
const key = [...classes].sort().join(" ");
|
|
44
|
+
const users = combos.get(key) ?? new Set();
|
|
45
|
+
users.add(file);
|
|
46
|
+
combos.set(key, users);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
comboCache = { sourceRoot: index.sourceRoot, fingerprint, combos };
|
|
50
|
+
return combos;
|
|
51
|
+
}
|
|
52
|
+
export const sharedStyleDedupRule = {
|
|
53
|
+
meta: {
|
|
54
|
+
schema: [],
|
|
55
|
+
type: "problem",
|
|
56
|
+
docs: {
|
|
57
|
+
description: "Require a className combination used by two or more components to become a named custom utility.",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
create(context) {
|
|
61
|
+
const sourceRoot = path.resolve("src");
|
|
62
|
+
const file = path.resolve(context.filename);
|
|
63
|
+
const segments = segmentsOf(file, sourceRoot);
|
|
64
|
+
if (segments.length === 0)
|
|
65
|
+
return {};
|
|
66
|
+
const index = getProjectIndex(sourceRoot);
|
|
67
|
+
if (!index)
|
|
68
|
+
return {};
|
|
69
|
+
// Report each shared combination once, on the alphabetically first file
|
|
70
|
+
// that uses it, so the whole tree produces one finding per combination.
|
|
71
|
+
const combos = combosFor(index);
|
|
72
|
+
const sharedHere = [...combos.entries()]
|
|
73
|
+
.filter(([, users]) => users.size >= 2 && users.has(file))
|
|
74
|
+
.filter(([, users]) => [...users].sort((left, right) => left.localeCompare(right))[0] === file);
|
|
75
|
+
if (sharedHere.length === 0)
|
|
76
|
+
return {};
|
|
77
|
+
return {
|
|
78
|
+
Program(node) {
|
|
79
|
+
for (const [combo, users] of sharedHere) {
|
|
80
|
+
context.report({
|
|
81
|
+
node,
|
|
82
|
+
loc: { line: 1, column: 0 },
|
|
83
|
+
message: `Class combination "${combo}" is used by ${String(users.size)} components and should change ` +
|
|
84
|
+
"together; create a named custom Tailwind utility for it. " +
|
|
85
|
+
"See docs/styling-guide/rules/theme-and-utility-definition-rule.md",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/stay-flat
|
|
3
|
+
*
|
|
4
|
+
* A component MUST stay flat until it has one or more exclusive child
|
|
5
|
+
* components, then MUST be nested in a folder with the same name. An exclusive
|
|
6
|
+
* child is a sibling component that only this component uses, so a flat
|
|
7
|
+
* component that owns such children must move into a folder named after it.
|
|
8
|
+
*
|
|
9
|
+
* @see docs/code-organization-guide/rules/folder-nesting-rule.md
|
|
10
|
+
*/
|
|
11
|
+
import type { Rule } from "eslint";
|
|
12
|
+
export declare const stayFlatRule: Rule.RuleModule;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/stay-flat
|
|
3
|
+
*
|
|
4
|
+
* A component MUST stay flat until it has one or more exclusive child
|
|
5
|
+
* components, then MUST be nested in a folder with the same name. An exclusive
|
|
6
|
+
* child is a sibling component that only this component uses, so a flat
|
|
7
|
+
* component that owns such children must move into a folder named after it.
|
|
8
|
+
*
|
|
9
|
+
* @see docs/code-organization-guide/rules/folder-nesting-rule.md
|
|
10
|
+
*/
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { getProjectIndex } from "../project/index.js";
|
|
13
|
+
import { segmentsOf } from "../project/ccf.js";
|
|
14
|
+
export const stayFlatRule = {
|
|
15
|
+
meta: {
|
|
16
|
+
schema: [],
|
|
17
|
+
type: "problem",
|
|
18
|
+
docs: {
|
|
19
|
+
description: "Require a component with exclusive children to be nested in a folder with the same name.",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
create(context) {
|
|
23
|
+
const sourceRoot = path.resolve("src");
|
|
24
|
+
const file = path.resolve(context.filename);
|
|
25
|
+
const segments = segmentsOf(file, sourceRoot);
|
|
26
|
+
// A component file directly inside a feature folder: src/features/<feature>/<file>
|
|
27
|
+
if (segments.length !== 3 || segments[0] !== "features")
|
|
28
|
+
return {};
|
|
29
|
+
const index = getProjectIndex(sourceRoot);
|
|
30
|
+
if (!index)
|
|
31
|
+
return {};
|
|
32
|
+
const module = index.modules.get(file);
|
|
33
|
+
if (!module?.exports.some((exp) => exp.kind === "component"))
|
|
34
|
+
return {};
|
|
35
|
+
const featureName = segments[1];
|
|
36
|
+
if (!featureName)
|
|
37
|
+
return {};
|
|
38
|
+
// A sibling component file is an exclusive child when every file that
|
|
39
|
+
// imports it is this component or a support file in the same feature folder.
|
|
40
|
+
const exclusiveChildren = [...index.modules.values()].filter((candidate) => {
|
|
41
|
+
if (candidate.file === file)
|
|
42
|
+
return false;
|
|
43
|
+
const candidateSegments = segmentsOf(candidate.file, sourceRoot);
|
|
44
|
+
if (candidateSegments.length !== 3)
|
|
45
|
+
return false;
|
|
46
|
+
if (candidateSegments[0] !== "features" || candidateSegments[1] !== featureName)
|
|
47
|
+
return false;
|
|
48
|
+
if (candidateSegments[2]?.startsWith("index."))
|
|
49
|
+
return false;
|
|
50
|
+
if (!candidate.exports.some((exp) => exp.kind === "component"))
|
|
51
|
+
return false;
|
|
52
|
+
const consumers = index.consumers.get(candidate.file) ?? new Set();
|
|
53
|
+
if (consumers.size === 0)
|
|
54
|
+
return false;
|
|
55
|
+
return [...consumers].every((consumer) => {
|
|
56
|
+
if (consumer === file)
|
|
57
|
+
return true;
|
|
58
|
+
const consumerSegments = segmentsOf(consumer, sourceRoot);
|
|
59
|
+
if (consumerSegments[0] !== "features" || consumerSegments[1] !== featureName)
|
|
60
|
+
return false;
|
|
61
|
+
return !(index.modules.get(consumer)?.exports.some((exp) => exp.kind === "component") ?? false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
if (exclusiveChildren.length === 0)
|
|
65
|
+
return {};
|
|
66
|
+
const childNames = exclusiveChildren
|
|
67
|
+
.map((child) => child.exports.find((exp) => exp.kind === "component")?.name)
|
|
68
|
+
.filter((name) => name !== undefined);
|
|
69
|
+
return {
|
|
70
|
+
Program(node) {
|
|
71
|
+
context.report({
|
|
72
|
+
node,
|
|
73
|
+
loc: { line: 1, column: 0 },
|
|
74
|
+
message: `This component has exclusive child component(s) ${childNames.join(", ")}; ` +
|
|
75
|
+
`nest it in a folder named after it inside src/features/${featureName}/. ` +
|
|
76
|
+
"See docs/code-organization-guide/rules/folder-nesting-rule.md",
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const SUPPORT_FOLDERS = new Set(["constants", "types", "schemas"]);
|
|
4
|
+
const INDEX_NAMES = new Set(["index.ts", "index.tsx", "index.mts", "index.cts"]);
|
|
5
|
+
export const supportFolderShapeRule = {
|
|
6
|
+
meta: {
|
|
7
|
+
schema: [],
|
|
8
|
+
type: "problem",
|
|
9
|
+
docs: {
|
|
10
|
+
description: "Require support-folder exports to be defined in index.ts or named-re-exported by it.",
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
const filename = path.resolve(context.filename);
|
|
15
|
+
const baseName = path.basename(filename);
|
|
16
|
+
if (!INDEX_NAMES.has(baseName))
|
|
17
|
+
return {};
|
|
18
|
+
const folder = path.basename(path.dirname(filename));
|
|
19
|
+
if (!SUPPORT_FOLDERS.has(folder))
|
|
20
|
+
return {};
|
|
21
|
+
const directory = path.dirname(filename);
|
|
22
|
+
let entries;
|
|
23
|
+
try {
|
|
24
|
+
entries = fs.readdirSync(directory);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
const siblingModules = entries.filter((entry) => entry !== baseName && /\.(?:[cm]?tsx?|jsx?)$/.test(entry));
|
|
30
|
+
if (siblingModules.length === 0)
|
|
31
|
+
return {};
|
|
32
|
+
return {
|
|
33
|
+
Program(node) {
|
|
34
|
+
const source = context.sourceCode.text;
|
|
35
|
+
const hasDirectExport = /export\s+(?:const|let|var|function|class|type|interface|enum)\b/.test(source);
|
|
36
|
+
if (hasDirectExport)
|
|
37
|
+
return;
|
|
38
|
+
const exportedFiles = new Set();
|
|
39
|
+
const exportPattern = /export\s+(?:\{[^}]*\}|\*[^;]*)\s+from\s+["'](?<specifier>\.[^"']+)["']/g;
|
|
40
|
+
for (const match of source.matchAll(exportPattern)) {
|
|
41
|
+
const specifier = match.groups?.specifier;
|
|
42
|
+
if (specifier)
|
|
43
|
+
exportedFiles.add(path.basename(specifier));
|
|
44
|
+
}
|
|
45
|
+
const missing = siblingModules.filter((entry) => {
|
|
46
|
+
const stem = entry.replace(/\.(?:[cm]?tsx?|jsx?)$/, "");
|
|
47
|
+
return !exportedFiles.has(stem);
|
|
48
|
+
});
|
|
49
|
+
if (missing.length === 0)
|
|
50
|
+
return;
|
|
51
|
+
context.report({
|
|
52
|
+
node,
|
|
53
|
+
message: `${folder}/index.ts must named-re-export every support file: ${missing.join(", ")}. ` +
|
|
54
|
+
`See docs/code-organization-guide/rules/${folder === "constants" ? "constants" : "types-and-schemas"}-rule.md`,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|