pasika 0.3.1 → 0.3.3
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 +67 -9
- package/dist/cli/index.js +34 -40
- package/dist/cli/output.d.ts +3 -0
- package/dist/cli/output.js +11 -0
- package/dist/enforcement/coverage.d.ts +5 -1
- package/dist/enforcement/coverage.js +68 -39
- package/dist/enforcement/doctor.d.ts +16 -0
- package/dist/enforcement/doctor.js +249 -0
- package/dist/enforcement/parse-docs.js +5 -1
- 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/package.json +14 -7
- package/dist/enforcement/docs-check.d.ts +0 -17
- package/dist/enforcement/docs-check.js +0 -162
- package/enforcement/registry.json +0 -1139
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/application-structure
|
|
3
|
+
*
|
|
4
|
+
* Enforces the path-based parts of the Application Structure, Configuration,
|
|
5
|
+
* and Component Placement rules. Questions about ownership or extraction stay
|
|
6
|
+
* with the cross-file rules because they need the project graph.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/code-organization-guide/rules/application-structure-rule.md
|
|
9
|
+
* @see docs/code-organization-guide/rules/configuration-rule.md
|
|
10
|
+
* @see docs/code-organization-guide/rules/component-placement-rule.md
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { parseModule } from "../project/parse-module.js";
|
|
15
|
+
import { segmentsOf, SUPPORT_FOLDERS } from "../project/ccf.js";
|
|
16
|
+
const MODULE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
17
|
+
const ROUTING_FILES = new Set([
|
|
18
|
+
"default",
|
|
19
|
+
"error",
|
|
20
|
+
"instrumentation",
|
|
21
|
+
"layout",
|
|
22
|
+
"loading",
|
|
23
|
+
"middleware",
|
|
24
|
+
"not-found",
|
|
25
|
+
"page",
|
|
26
|
+
"route",
|
|
27
|
+
"template",
|
|
28
|
+
]);
|
|
29
|
+
const EXPECTED_SUPPORT_FOLDER = {
|
|
30
|
+
hook: "hooks",
|
|
31
|
+
type: "types",
|
|
32
|
+
schema: "schemas",
|
|
33
|
+
constant: "constants",
|
|
34
|
+
function: "utils",
|
|
35
|
+
};
|
|
36
|
+
function report(context, message) {
|
|
37
|
+
return {
|
|
38
|
+
Program(node) {
|
|
39
|
+
context.report({ node, loc: { line: 1, column: 0 }, message });
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function isCodeFile(filename) {
|
|
44
|
+
return MODULE_EXTENSIONS.has(path.extname(filename));
|
|
45
|
+
}
|
|
46
|
+
function isRootSupportFolder(folder) {
|
|
47
|
+
return SUPPORT_FOLDERS.has(folder);
|
|
48
|
+
}
|
|
49
|
+
function exportedKinds(filename) {
|
|
50
|
+
try {
|
|
51
|
+
return new Set(parseModule(filename).exports.map((moduleExport) => moduleExport.kind));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return new Set();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function expectedSupportFolder(kinds) {
|
|
58
|
+
for (const kind of ["component", "hook", "type", "schema", "constant", "function"]) {
|
|
59
|
+
if (kinds.has(kind))
|
|
60
|
+
return EXPECTED_SUPPORT_FOLDER[kind];
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
function configModuleRoot(filename, sourceRoot) {
|
|
65
|
+
const segments = segmentsOf(filename, sourceRoot);
|
|
66
|
+
if (segments[0] !== "config" || segments.length < 3)
|
|
67
|
+
return undefined;
|
|
68
|
+
return path.join(sourceRoot, "config", segments[1] ?? "");
|
|
69
|
+
}
|
|
70
|
+
export const applicationStructureRule = {
|
|
71
|
+
meta: {
|
|
72
|
+
schema: [],
|
|
73
|
+
type: "problem",
|
|
74
|
+
docs: {
|
|
75
|
+
description: "Enforce the repository's application, configuration, and support-folder structure.",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
create(context) {
|
|
79
|
+
const filename = path.resolve(context.filename);
|
|
80
|
+
const sourceRoot = path.resolve("src");
|
|
81
|
+
const segments = segmentsOf(filename, sourceRoot);
|
|
82
|
+
if (segments.length === 0)
|
|
83
|
+
return {};
|
|
84
|
+
const [topLevel, secondLevel] = segments;
|
|
85
|
+
if (topLevel === undefined)
|
|
86
|
+
return {};
|
|
87
|
+
if (segments.length > 1 &&
|
|
88
|
+
topLevel !== "app" &&
|
|
89
|
+
topLevel !== "compositions" &&
|
|
90
|
+
topLevel !== "features" &&
|
|
91
|
+
topLevel !== "shared" &&
|
|
92
|
+
topLevel !== "config" &&
|
|
93
|
+
topLevel !== "locales" &&
|
|
94
|
+
!isRootSupportFolder(topLevel)) {
|
|
95
|
+
return report(context, `Move this source file under an allowed src/ folder; "src/${topLevel}/" is not part of the application structure.`);
|
|
96
|
+
}
|
|
97
|
+
if (topLevel === "features" && segments.length >= 2 && secondLevel !== undefined) {
|
|
98
|
+
if (SUPPORT_FOLDERS.has(secondLevel)) {
|
|
99
|
+
return report(context, `Move this support folder inside a feature folder; "src/features/${secondLevel}/" does not belong to a feature.`);
|
|
100
|
+
}
|
|
101
|
+
if (segments.length === 2) {
|
|
102
|
+
return report(context, "src/features/ must contain only feature folders, not source files.");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (topLevel === "config") {
|
|
106
|
+
if (segments.length === 2) {
|
|
107
|
+
return report(context, "A configuration module must be a src/config/<config-name>/ folder with index.ts as its entry point.");
|
|
108
|
+
}
|
|
109
|
+
const moduleRoot = configModuleRoot(filename, sourceRoot);
|
|
110
|
+
if (moduleRoot && !fs.existsSync(path.join(moduleRoot, "index.ts"))) {
|
|
111
|
+
return report(context, `Add src/config/${path.basename(moduleRoot)}/index.ts as the configuration module entry point.`);
|
|
112
|
+
}
|
|
113
|
+
if (moduleRoot && segments.length === 3 && path.basename(filename) !== "index.ts") {
|
|
114
|
+
const kinds = exportedKinds(filename);
|
|
115
|
+
const expected = expectedSupportFolder(kinds);
|
|
116
|
+
if (expected !== undefined) {
|
|
117
|
+
return report(context, `Move this configuration support file into src/config/${path.basename(moduleRoot)}/${expected}/.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (topLevel === "app" && isCodeFile(filename)) {
|
|
122
|
+
const basename = path.basename(filename, path.extname(filename));
|
|
123
|
+
const currentFolder = path.basename(path.dirname(filename));
|
|
124
|
+
if (SUPPORT_FOLDERS.has(currentFolder)) {
|
|
125
|
+
return report(context, "src/app/ may contain routing files and framework assets, but ordinary components and support files must live outside src/app/.");
|
|
126
|
+
}
|
|
127
|
+
if (!ROUTING_FILES.has(basename) && path.extname(filename) !== ".css") {
|
|
128
|
+
return report(context, "src/app/ may contain routing files and framework assets, but ordinary components and support files must live outside src/app/.");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const currentFolder = path.basename(path.dirname(filename));
|
|
132
|
+
const kinds = exportedKinds(filename);
|
|
133
|
+
const isConfigModuleRoot = topLevel === "config" && segments.length === 3 && path.basename(filename) === "index.ts";
|
|
134
|
+
if (isConfigModuleRoot)
|
|
135
|
+
return {};
|
|
136
|
+
if (!SUPPORT_FOLDERS.has(currentFolder)) {
|
|
137
|
+
const expected = expectedSupportFolder(kinds);
|
|
138
|
+
if (expected !== undefined && segments.length > 2) {
|
|
139
|
+
return report(context, `Move this file to a ${expected}/ folder; ${currentFolder}/ is not a recognized support folder.`);
|
|
140
|
+
}
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
if (kinds.has("component")) {
|
|
144
|
+
return report(context, `A support folder must not contain a component; move ${path.basename(filename)} beside ${currentFolder}/.`);
|
|
145
|
+
}
|
|
146
|
+
const expected = expectedSupportFolder(kinds);
|
|
147
|
+
if (expected !== undefined && expected !== currentFolder) {
|
|
148
|
+
return report(context, `Move this file to a ${expected}/ folder; ${currentFolder}/ is reserved for ${expected === "utils" ? "utilities" : expected}.`);
|
|
149
|
+
}
|
|
150
|
+
return {};
|
|
151
|
+
},
|
|
152
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
export interface ComponentInfo {
|
|
3
|
+
name: string;
|
|
4
|
+
declaration: ts.FunctionDeclaration | ts.VariableDeclaration;
|
|
5
|
+
smart: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function parseComponentInfo(text: string, filename: string): ComponentInfo[];
|
|
8
|
+
export interface SimpleRoot {
|
|
9
|
+
tagName: string;
|
|
10
|
+
attributes: ts.JsxAttributeLike[];
|
|
11
|
+
}
|
|
12
|
+
export declare function findSimpleRoot(component: ComponentInfo, _text: string, _filename: string): SimpleRoot | undefined;
|
|
13
|
+
export declare function getTestId(root: SimpleRoot): {
|
|
14
|
+
value?: string;
|
|
15
|
+
attribute?: ts.JsxAttribute;
|
|
16
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
const isPascalCase = (name) => /^[A-Z][A-Za-z0-9]*$/.test(name);
|
|
4
|
+
const isHookCallName = (name) => /^use[A-Z]/.test(name);
|
|
5
|
+
function isExported(node) {
|
|
6
|
+
if (!ts.canHaveModifiers(node))
|
|
7
|
+
return false;
|
|
8
|
+
return (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
|
9
|
+
}
|
|
10
|
+
function containsJsx(node) {
|
|
11
|
+
let found = false;
|
|
12
|
+
const visit = (child) => {
|
|
13
|
+
if (found)
|
|
14
|
+
return;
|
|
15
|
+
if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child) || ts.isJsxFragment(child)) {
|
|
16
|
+
found = true;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
ts.forEachChild(child, visit);
|
|
20
|
+
};
|
|
21
|
+
ts.forEachChild(node, visit);
|
|
22
|
+
return found;
|
|
23
|
+
}
|
|
24
|
+
function containsSmartCall(node) {
|
|
25
|
+
let found = false;
|
|
26
|
+
const visit = (child) => {
|
|
27
|
+
if (found)
|
|
28
|
+
return;
|
|
29
|
+
if (ts.isCallExpression(child)) {
|
|
30
|
+
const expression = child.expression;
|
|
31
|
+
let name = "";
|
|
32
|
+
if (ts.isIdentifier(expression)) {
|
|
33
|
+
name = expression.text;
|
|
34
|
+
}
|
|
35
|
+
else if (ts.isPropertyAccessExpression(expression)) {
|
|
36
|
+
name = expression.name.text;
|
|
37
|
+
}
|
|
38
|
+
if (isHookCallName(name)) {
|
|
39
|
+
found = true;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
ts.forEachChild(child, visit);
|
|
44
|
+
};
|
|
45
|
+
ts.forEachChild(node, visit);
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
48
|
+
function componentFromFunction(node) {
|
|
49
|
+
const name = node.name?.text;
|
|
50
|
+
if (!name || !isPascalCase(name) || !containsJsx(node))
|
|
51
|
+
return undefined;
|
|
52
|
+
return { name, declaration: node, smart: containsSmartCall(node) };
|
|
53
|
+
}
|
|
54
|
+
function componentFromVariable(node) {
|
|
55
|
+
if (!ts.isIdentifier(node.name) || !isPascalCase(node.name.text))
|
|
56
|
+
return undefined;
|
|
57
|
+
const initializer = node.initializer;
|
|
58
|
+
if (!initializer || (!ts.isArrowFunction(initializer) && !ts.isFunctionExpression(initializer)))
|
|
59
|
+
return undefined;
|
|
60
|
+
if (!containsJsx(initializer))
|
|
61
|
+
return undefined;
|
|
62
|
+
return {
|
|
63
|
+
name: node.name.text,
|
|
64
|
+
declaration: node,
|
|
65
|
+
smart: containsSmartCall(initializer),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function parseComponentInfo(text, filename) {
|
|
69
|
+
const sourceFile = ts.createSourceFile(path.resolve(filename), text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
70
|
+
const components = [];
|
|
71
|
+
for (const statement of sourceFile.statements) {
|
|
72
|
+
if (!isExported(statement))
|
|
73
|
+
continue;
|
|
74
|
+
if (ts.isFunctionDeclaration(statement)) {
|
|
75
|
+
const component = componentFromFunction(statement);
|
|
76
|
+
if (component)
|
|
77
|
+
components.push(component);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (ts.isVariableStatement(statement)) {
|
|
81
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
82
|
+
const component = componentFromVariable(declaration);
|
|
83
|
+
if (component)
|
|
84
|
+
components.push(component);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return components;
|
|
89
|
+
}
|
|
90
|
+
function jsxTagName(element) {
|
|
91
|
+
const tagName = ts.isJsxElement(element) ? element.openingElement.tagName : element.tagName;
|
|
92
|
+
return ts.isIdentifier(tagName) ? tagName.text : undefined;
|
|
93
|
+
}
|
|
94
|
+
function rootFromExpression(expression) {
|
|
95
|
+
if (ts.isJsxSelfClosingElement(expression)) {
|
|
96
|
+
const tagName = jsxTagName(expression);
|
|
97
|
+
if (!tagName?.startsWith(tagName[0]?.toLowerCase() ?? ""))
|
|
98
|
+
return undefined;
|
|
99
|
+
return { tagName, attributes: [...expression.attributes.properties] };
|
|
100
|
+
}
|
|
101
|
+
if (ts.isJsxElement(expression)) {
|
|
102
|
+
const tagName = jsxTagName(expression);
|
|
103
|
+
if (!tagName?.startsWith(tagName[0]?.toLowerCase() ?? ""))
|
|
104
|
+
return undefined;
|
|
105
|
+
return { tagName, attributes: [...expression.openingElement.attributes.properties] };
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
export function findSimpleRoot(component, _text, _filename) {
|
|
110
|
+
const declaration = component.declaration;
|
|
111
|
+
let body;
|
|
112
|
+
if (ts.isFunctionDeclaration(declaration))
|
|
113
|
+
body = declaration.body;
|
|
114
|
+
else if (declaration.initializer && ts.isArrowFunction(declaration.initializer))
|
|
115
|
+
body = declaration.initializer.body;
|
|
116
|
+
else if (declaration.initializer && ts.isFunctionExpression(declaration.initializer)) {
|
|
117
|
+
body = declaration.initializer.body;
|
|
118
|
+
}
|
|
119
|
+
if (!body)
|
|
120
|
+
return undefined;
|
|
121
|
+
if (!ts.isBlock(body))
|
|
122
|
+
return rootFromExpression(body);
|
|
123
|
+
const returns = [];
|
|
124
|
+
const visit = (node) => {
|
|
125
|
+
if (node !== body && (ts.isFunctionLike(node) || ts.isClassLike(node)))
|
|
126
|
+
return;
|
|
127
|
+
if (ts.isReturnStatement(node))
|
|
128
|
+
returns.push(node);
|
|
129
|
+
ts.forEachChild(node, visit);
|
|
130
|
+
};
|
|
131
|
+
visit(body);
|
|
132
|
+
if (returns.length !== 1)
|
|
133
|
+
return undefined;
|
|
134
|
+
const expression = returns[0]?.expression;
|
|
135
|
+
return expression && ts.isExpression(expression) ? rootFromExpression(expression) : undefined;
|
|
136
|
+
}
|
|
137
|
+
export function getTestId(root) {
|
|
138
|
+
const attribute = root.attributes.find((candidate) => ts.isJsxAttribute(candidate) && ts.isIdentifier(candidate.name) && candidate.name.text === "data-testid");
|
|
139
|
+
if (!attribute)
|
|
140
|
+
return {};
|
|
141
|
+
const value = attribute.initializer;
|
|
142
|
+
if (!value || !ts.isStringLiteral(value))
|
|
143
|
+
return { attribute };
|
|
144
|
+
return { value: value.text, attribute };
|
|
145
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/component-nesting
|
|
3
|
+
*
|
|
4
|
+
* A component MUST NOT be nested only because it has support files. Nesting is
|
|
5
|
+
* justified by exclusive child components, so a nested component folder whose
|
|
6
|
+
* only contents are the component, its index, and support folders must be
|
|
7
|
+
* flattened.
|
|
8
|
+
*
|
|
9
|
+
* @see docs/code-organization-guide/rules/folder-nesting-rule.md
|
|
10
|
+
*/
|
|
11
|
+
import type { Rule } from "eslint";
|
|
12
|
+
export declare const componentNestingRule: Rule.RuleModule;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/component-nesting
|
|
3
|
+
*
|
|
4
|
+
* A component MUST NOT be nested only because it has support files. Nesting is
|
|
5
|
+
* justified by exclusive child components, so a nested component folder whose
|
|
6
|
+
* only contents are the component, its index, and support folders must be
|
|
7
|
+
* flattened.
|
|
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 componentNestingRule = {
|
|
15
|
+
meta: {
|
|
16
|
+
schema: [],
|
|
17
|
+
type: "problem",
|
|
18
|
+
docs: {
|
|
19
|
+
description: "Require a nested component to have exclusive child components, not only support files.",
|
|
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 subfolder:
|
|
27
|
+
// src/features/<feature>/<folder>/<file>
|
|
28
|
+
if (segments.length !== 4 || segments[0] !== "features")
|
|
29
|
+
return {};
|
|
30
|
+
const index = getProjectIndex(sourceRoot);
|
|
31
|
+
if (!index)
|
|
32
|
+
return {};
|
|
33
|
+
const module = index.modules.get(file);
|
|
34
|
+
if (!module?.exports.some((exp) => exp.kind === "component"))
|
|
35
|
+
return {};
|
|
36
|
+
const folder = segments.slice(0, 3);
|
|
37
|
+
// Another component file in the same folder is an exclusive child and makes
|
|
38
|
+
// the nesting real; an index barrel does not count.
|
|
39
|
+
const hasChildComponent = [...index.modules.values()].some((candidate) => {
|
|
40
|
+
if (candidate.file === file)
|
|
41
|
+
return false;
|
|
42
|
+
const candidateSegments = segmentsOf(candidate.file, sourceRoot);
|
|
43
|
+
if (candidateSegments.length !== 4)
|
|
44
|
+
return false;
|
|
45
|
+
if (candidateSegments[0] !== folder[0] ||
|
|
46
|
+
candidateSegments[1] !== folder[1] ||
|
|
47
|
+
candidateSegments[2] !== folder[2]) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (candidateSegments[3]?.startsWith("index."))
|
|
51
|
+
return false;
|
|
52
|
+
return candidate.exports.some((exp) => exp.kind === "component");
|
|
53
|
+
});
|
|
54
|
+
if (hasChildComponent)
|
|
55
|
+
return {};
|
|
56
|
+
const flatFolder = segments.slice(0, 2).join("/");
|
|
57
|
+
return {
|
|
58
|
+
Program(node) {
|
|
59
|
+
context.report({
|
|
60
|
+
node,
|
|
61
|
+
loc: { line: 1, column: 0 },
|
|
62
|
+
message: `This component is nested in src/${folder.join("/")}/ but its folder has no exclusive ` +
|
|
63
|
+
`child components — only support files. Flatten it back into src/${flatFolder}/. ` +
|
|
64
|
+
"See docs/code-organization-guide/rules/folder-nesting-rule.md",
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/config-extraction
|
|
3
|
+
*
|
|
4
|
+
* A type, schema, or utility used only to implement one configuration module
|
|
5
|
+
* MUST be extracted even with one consumer. A configuration module is a
|
|
6
|
+
* src/config/<name>/ folder with index.ts as its entry point, so any type or
|
|
7
|
+
* schema still declared in a module file that is not a support file belongs in
|
|
8
|
+
* the module's types/ or schemas/ folder — or, once a consumer outside the
|
|
9
|
+
* module imports it, in the matching root support folder.
|
|
10
|
+
*
|
|
11
|
+
* @see docs/code-organization-guide/rules/configuration-rule.md
|
|
12
|
+
*/
|
|
13
|
+
import type { Rule } from "eslint";
|
|
14
|
+
export declare const configExtractionRule: Rule.RuleModule;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/config-extraction
|
|
3
|
+
*
|
|
4
|
+
* A type, schema, or utility used only to implement one configuration module
|
|
5
|
+
* MUST be extracted even with one consumer. A configuration module is a
|
|
6
|
+
* src/config/<name>/ folder with index.ts as its entry point, so any type or
|
|
7
|
+
* schema still declared in a module file that is not a support file belongs in
|
|
8
|
+
* the module's types/ or schemas/ folder — or, once a consumer outside the
|
|
9
|
+
* module imports it, in the matching root support folder.
|
|
10
|
+
*
|
|
11
|
+
* @see docs/code-organization-guide/rules/configuration-rule.md
|
|
12
|
+
*/
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { getProjectIndex, symbolKey } from "../project/index.js";
|
|
15
|
+
import { describeConsumers, segmentsOf, SUPPORT_FOLDERS } from "../project/ccf.js";
|
|
16
|
+
export const configExtractionRule = {
|
|
17
|
+
meta: {
|
|
18
|
+
schema: [],
|
|
19
|
+
type: "problem",
|
|
20
|
+
docs: {
|
|
21
|
+
description: "Require config-only types and schemas to be extracted into the module's support folder.",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
create(context) {
|
|
25
|
+
const sourceRoot = path.resolve("src");
|
|
26
|
+
const file = path.resolve(context.filename);
|
|
27
|
+
const segments = segmentsOf(file, sourceRoot);
|
|
28
|
+
// A configuration module file: src/config/<name>/<file>. Support folders are
|
|
29
|
+
// already the extraction destination, so they are not candidates.
|
|
30
|
+
if (segments.length < 3 || segments[0] !== "config")
|
|
31
|
+
return {};
|
|
32
|
+
if (SUPPORT_FOLDERS.has(segments[2] ?? ""))
|
|
33
|
+
return {};
|
|
34
|
+
const index = getProjectIndex(sourceRoot);
|
|
35
|
+
if (!index)
|
|
36
|
+
return {};
|
|
37
|
+
const module = index.modules.get(file);
|
|
38
|
+
if (!module)
|
|
39
|
+
return {};
|
|
40
|
+
const suspects = module.exports.filter((exp) => exp.kind === "type" || exp.kind === "schema");
|
|
41
|
+
if (suspects.length === 0)
|
|
42
|
+
return {};
|
|
43
|
+
const findings = [];
|
|
44
|
+
for (const exp of suspects) {
|
|
45
|
+
const consumers = [...(index.symbolConsumers.get(symbolKey(file, exp.name)) ?? [])];
|
|
46
|
+
const outsideConfig = consumers.filter((consumer) => segmentsOf(consumer, sourceRoot)[0] !== "config");
|
|
47
|
+
if (outsideConfig.length > 0) {
|
|
48
|
+
findings.push({
|
|
49
|
+
line: exp.line,
|
|
50
|
+
message: `${exp.kind} "${exp.name}" in a configuration module is imported by ` +
|
|
51
|
+
`${describeConsumers(outsideConfig, sourceRoot)} outside src/config/. Move it to the ` +
|
|
52
|
+
"matching root support folder. See docs/code-organization-guide/rules/configuration-rule.md",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
else if (consumers.length > 0) {
|
|
56
|
+
findings.push({
|
|
57
|
+
line: exp.line,
|
|
58
|
+
message: `${exp.kind} "${exp.name}" is used only to implement this configuration module; ` +
|
|
59
|
+
`extract it to the module's ${exp.kind === "schema" ? "schemas/" : "types/"} folder even with one consumer. ` +
|
|
60
|
+
"See docs/code-organization-guide/rules/configuration-rule.md",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (findings.length === 0)
|
|
65
|
+
return {};
|
|
66
|
+
return {
|
|
67
|
+
Program(node) {
|
|
68
|
+
for (const finding of findings) {
|
|
69
|
+
context.report({ node, loc: { line: finding.line, column: 0 }, message: finding.message });
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/cross-feature-import
|
|
3
|
+
*
|
|
4
|
+
* A component that imports from two or more feature folders MUST live in
|
|
5
|
+
* src/compositions/.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/code-organization-guide/rules/component-placement-rule.md
|
|
8
|
+
*/
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
const FEATURES_SEGMENT = "features";
|
|
11
|
+
function featureNameOf(resolvedPath, sourceRoot) {
|
|
12
|
+
const relative = path.relative(sourceRoot, resolvedPath);
|
|
13
|
+
if (relative.startsWith(".."))
|
|
14
|
+
return undefined;
|
|
15
|
+
const segments = relative.split(path.sep);
|
|
16
|
+
if (segments[0] !== FEATURES_SEGMENT || segments.length < 2)
|
|
17
|
+
return undefined;
|
|
18
|
+
return segments[1];
|
|
19
|
+
}
|
|
20
|
+
export const crossFeatureImportRule = {
|
|
21
|
+
meta: {
|
|
22
|
+
schema: [],
|
|
23
|
+
type: "problem",
|
|
24
|
+
docs: {
|
|
25
|
+
description: "Require non-composition files that import from two or more feature folders to live in src/compositions/.",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
create(context) {
|
|
29
|
+
const filename = context.filename;
|
|
30
|
+
if (!filename.endsWith(".tsx") && !filename.endsWith(".jsx"))
|
|
31
|
+
return {};
|
|
32
|
+
const sourceRoot = path.resolve("src");
|
|
33
|
+
const fileRelative = path.relative(sourceRoot, filename);
|
|
34
|
+
if (fileRelative.startsWith(".."))
|
|
35
|
+
return {};
|
|
36
|
+
const fileSegments = fileRelative.split(path.sep);
|
|
37
|
+
const isInCompositions = fileSegments[0] === "compositions";
|
|
38
|
+
const isInApp = fileSegments[0] === "app";
|
|
39
|
+
const isConfig = fileSegments[0] === "config";
|
|
40
|
+
if (isInCompositions || isInApp || isConfig)
|
|
41
|
+
return {};
|
|
42
|
+
const importedFeatures = new Set();
|
|
43
|
+
let alreadyReported = false;
|
|
44
|
+
return {
|
|
45
|
+
ImportDeclaration(node) {
|
|
46
|
+
if (alreadyReported)
|
|
47
|
+
return;
|
|
48
|
+
const source = node.source;
|
|
49
|
+
if (typeof source.value !== "string")
|
|
50
|
+
return;
|
|
51
|
+
let resolved;
|
|
52
|
+
if (source.value.startsWith("@/")) {
|
|
53
|
+
resolved = path.resolve(sourceRoot, source.value.slice(2));
|
|
54
|
+
}
|
|
55
|
+
else if (source.value.startsWith(".")) {
|
|
56
|
+
resolved = path.resolve(path.dirname(filename), source.value);
|
|
57
|
+
}
|
|
58
|
+
if (!resolved)
|
|
59
|
+
return;
|
|
60
|
+
const feature = featureNameOf(resolved, sourceRoot);
|
|
61
|
+
if (feature)
|
|
62
|
+
importedFeatures.add(feature);
|
|
63
|
+
if (importedFeatures.size >= 2) {
|
|
64
|
+
alreadyReported = true;
|
|
65
|
+
const names = [...importedFeatures].sort().join(", ");
|
|
66
|
+
context.report({
|
|
67
|
+
node,
|
|
68
|
+
message: `This component imports from two or more feature folders (${names}) and must live in src/compositions/. ` +
|
|
69
|
+
"See docs/code-organization-guide/rules/component-placement-rule.md",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/apply-usage
|
|
3
|
+
*
|
|
4
|
+
* Style declarations added by the project inside global selectors MUST use
|
|
5
|
+
* @apply.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/styling-guide/rules/global-stylesheet-rule.md
|
|
8
|
+
*/
|
|
9
|
+
import type { CSSRuleDefinition } from "@eslint/css";
|
|
10
|
+
export declare const applyUsageRule: CSSRuleDefinition;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/apply-usage
|
|
3
|
+
*
|
|
4
|
+
* Style declarations added by the project inside global selectors MUST use
|
|
5
|
+
* @apply.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/styling-guide/rules/global-stylesheet-rule.md
|
|
8
|
+
*/
|
|
9
|
+
import { blockChildren } from "./helpers.js";
|
|
10
|
+
export const applyUsageRule = {
|
|
11
|
+
meta: {
|
|
12
|
+
schema: [],
|
|
13
|
+
type: "problem",
|
|
14
|
+
docs: {
|
|
15
|
+
description: "Require @apply for project style declarations inside global selectors.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
create(context) {
|
|
19
|
+
return {
|
|
20
|
+
"StyleSheet:exit"(node) {
|
|
21
|
+
// Project style declarations inside global selectors are the ones in
|
|
22
|
+
// @layer base rules. Each rule's declarations must go through @apply,
|
|
23
|
+
// not raw CSS properties.
|
|
24
|
+
const layers = node.children.filter((child) => child.type === "Atrule" && child.name === "layer");
|
|
25
|
+
for (const layer of layers) {
|
|
26
|
+
for (const child of blockChildren(layer)) {
|
|
27
|
+
if (child.type !== "Rule")
|
|
28
|
+
continue;
|
|
29
|
+
for (const declaration of blockChildren(child)) {
|
|
30
|
+
if (declaration.type !== "Declaration")
|
|
31
|
+
continue;
|
|
32
|
+
// @apply itself arrives as an Atrule, so a raw Declaration means
|
|
33
|
+
// the project wrote a property by hand instead of applying a
|
|
34
|
+
// utility.
|
|
35
|
+
context.report({
|
|
36
|
+
node: declaration,
|
|
37
|
+
message: `Style declaration "${declaration.property}" inside a global selector must use @apply.`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/base-layer-pair
|
|
3
|
+
*
|
|
4
|
+
* The global base layer MUST apply base-canvas and base-ink to the document
|
|
5
|
+
* body as the default page pair.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/styling-guide/rules/global-stylesheet-rule.md
|
|
8
|
+
*/
|
|
9
|
+
import type { CSSRuleDefinition } from "@eslint/css";
|
|
10
|
+
export declare const baseLayerPairRule: CSSRuleDefinition;
|