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.
Files changed (168) hide show
  1. package/README.md +67 -9
  2. package/dist/cli/index.js +34 -40
  3. package/dist/cli/output.d.ts +3 -0
  4. package/dist/cli/output.js +11 -0
  5. package/dist/enforcement/coverage.d.ts +5 -1
  6. package/dist/enforcement/coverage.js +68 -39
  7. package/dist/enforcement/doctor.d.ts +16 -0
  8. package/dist/enforcement/doctor.js +249 -0
  9. package/dist/enforcement/parse-docs.js +5 -1
  10. package/dist/enforcement/types.d.ts +0 -3
  11. package/dist/enforcement/types.js +4 -13
  12. package/dist/eslint/pasika/ast-types.d.ts +122 -0
  13. package/dist/eslint/pasika/ast-types.js +1 -0
  14. package/dist/eslint/pasika/index.d.ts +58 -3
  15. package/dist/eslint/pasika/index.js +61 -1
  16. package/dist/eslint/pasika/rules/application-structure.d.ts +2 -0
  17. package/dist/eslint/pasika/rules/application-structure.js +152 -0
  18. package/dist/eslint/pasika/rules/component-conventions.d.ts +16 -0
  19. package/dist/eslint/pasika/rules/component-conventions.js +145 -0
  20. package/dist/eslint/pasika/rules/component-nesting.d.ts +12 -0
  21. package/dist/eslint/pasika/rules/component-nesting.js +69 -0
  22. package/dist/eslint/pasika/rules/config-extraction.d.ts +14 -0
  23. package/dist/eslint/pasika/rules/config-extraction.js +74 -0
  24. package/dist/eslint/pasika/rules/cross-feature-import.d.ts +2 -0
  25. package/dist/eslint/pasika/rules/cross-feature-import.js +75 -0
  26. package/dist/eslint/pasika/rules/css/apply-usage.d.ts +10 -0
  27. package/dist/eslint/pasika/rules/css/apply-usage.js +45 -0
  28. package/dist/eslint/pasika/rules/css/base-layer-pair.d.ts +10 -0
  29. package/dist/eslint/pasika/rules/css/base-layer-pair.js +49 -0
  30. package/dist/eslint/pasika/rules/css/css-variable-naming.d.ts +10 -0
  31. package/dist/eslint/pasika/rules/css/css-variable-naming.js +39 -0
  32. package/dist/eslint/pasika/rules/css/custom-utility-apply.d.ts +11 -0
  33. package/dist/eslint/pasika/rules/css/custom-utility-apply.js +52 -0
  34. package/dist/eslint/pasika/rules/css/global-css-location.d.ts +10 -0
  35. package/dist/eslint/pasika/rules/css/global-css-location.js +42 -0
  36. package/dist/eslint/pasika/rules/css/helpers.d.ts +29 -0
  37. package/dist/eslint/pasika/rules/css/helpers.js +93 -0
  38. package/dist/eslint/pasika/rules/css/index.d.ts +13 -0
  39. package/dist/eslint/pasika/rules/css/index.js +25 -0
  40. package/dist/eslint/pasika/rules/css/root-variables.d.ts +11 -0
  41. package/dist/eslint/pasika/rules/css/root-variables.js +53 -0
  42. package/dist/eslint/pasika/rules/css/rule-tester.d.ts +11 -0
  43. package/dist/eslint/pasika/rules/css/rule-tester.js +22 -0
  44. package/dist/eslint/pasika/rules/css/stylesheet-ordering.d.ts +11 -0
  45. package/dist/eslint/pasika/rules/css/stylesheet-ordering.js +76 -0
  46. package/dist/eslint/pasika/rules/css/surface-utility.d.ts +10 -0
  47. package/dist/eslint/pasika/rules/css/surface-utility.js +41 -0
  48. package/dist/eslint/pasika/rules/css/theme-reset.d.ts +9 -0
  49. package/dist/eslint/pasika/rules/css/theme-reset.js +36 -0
  50. package/dist/eslint/pasika/rules/css/theme-variable-namespace.d.ts +11 -0
  51. package/dist/eslint/pasika/rules/css/theme-variable-namespace.js +53 -0
  52. package/dist/eslint/pasika/rules/cva-appearance-props.d.ts +10 -0
  53. package/dist/eslint/pasika/rules/cva-appearance-props.js +77 -0
  54. package/dist/eslint/pasika/rules/cva-boolean-variants.d.ts +11 -0
  55. package/dist/eslint/pasika/rules/cva-boolean-variants.js +97 -0
  56. package/dist/eslint/pasika/rules/data-testid-case.d.ts +2 -0
  57. package/dist/eslint/pasika/rules/data-testid-case.js +76 -0
  58. package/dist/eslint/pasika/rules/enforce-cn-merge.d.ts +13 -1
  59. package/dist/eslint/pasika/rules/enforce-cn-merge.js +46 -44
  60. package/dist/eslint/pasika/rules/enforce-cva-variant-props.js +53 -48
  61. package/dist/eslint/pasika/rules/filename-case.js +28 -5
  62. package/dist/eslint/pasika/rules/hook-complexity.d.ts +12 -0
  63. package/dist/eslint/pasika/rules/hook-complexity.js +114 -0
  64. package/dist/eslint/pasika/rules/hook-extraction.d.ts +11 -0
  65. package/dist/eslint/pasika/rules/hook-extraction.js +55 -0
  66. package/dist/eslint/pasika/rules/import-through-index.d.ts +2 -0
  67. package/dist/eslint/pasika/rules/import-through-index.js +52 -0
  68. package/dist/eslint/pasika/rules/interactive-component.d.ts +10 -0
  69. package/dist/eslint/pasika/rules/interactive-component.js +76 -0
  70. package/dist/eslint/pasika/rules/json/index.d.ts +5 -0
  71. package/dist/eslint/pasika/rules/json/index.js +9 -0
  72. package/dist/eslint/pasika/rules/json/no-cache-flag.d.ts +10 -0
  73. package/dist/eslint/pasika/rules/json/no-cache-flag.js +45 -0
  74. package/dist/eslint/pasika/rules/json/no-vulyk-dependency.d.ts +10 -0
  75. package/dist/eslint/pasika/rules/json/no-vulyk-dependency.js +41 -0
  76. package/dist/eslint/pasika/rules/json/rule-tester.d.ts +11 -0
  77. package/dist/eslint/pasika/rules/json/rule-tester.js +21 -0
  78. package/dist/eslint/pasika/rules/jsx-hygiene.d.ts +9 -0
  79. package/dist/eslint/pasika/rules/jsx-hygiene.js +108 -0
  80. package/dist/eslint/pasika/rules/locale-dotted-path.d.ts +10 -0
  81. package/dist/eslint/pasika/rules/locale-dotted-path.js +65 -0
  82. package/dist/eslint/pasika/rules/locale-placement.d.ts +13 -0
  83. package/dist/eslint/pasika/rules/locale-placement.js +163 -0
  84. package/dist/eslint/pasika/rules/locales-location.d.ts +11 -0
  85. package/dist/eslint/pasika/rules/locales-location.js +56 -0
  86. package/dist/eslint/pasika/rules/md/doc-kind-suffix.d.ts +5 -0
  87. package/dist/eslint/pasika/rules/md/doc-kind-suffix.js +29 -0
  88. package/dist/eslint/pasika/rules/md/example-heading-description.d.ts +5 -0
  89. package/dist/eslint/pasika/rules/md/example-heading-description.js +30 -0
  90. package/dist/eslint/pasika/rules/md/glossary-term-linking.d.ts +2 -0
  91. package/dist/eslint/pasika/rules/md/glossary-term-linking.js +99 -0
  92. package/dist/eslint/pasika/rules/md/guide-folder-entry-point.d.ts +2 -0
  93. package/dist/eslint/pasika/rules/md/guide-folder-entry-point.js +46 -0
  94. package/dist/eslint/pasika/rules/md/guide-link-anchors.d.ts +5 -0
  95. package/dist/eslint/pasika/rules/md/guide-link-anchors.js +50 -0
  96. package/dist/eslint/pasika/rules/md/guide-overview-no-links.d.ts +5 -0
  97. package/dist/eslint/pasika/rules/md/guide-overview-no-links.js +51 -0
  98. package/dist/eslint/pasika/rules/md/guide-states-no-requirement.d.ts +5 -0
  99. package/dist/eslint/pasika/rules/md/guide-states-no-requirement.js +26 -0
  100. package/dist/eslint/pasika/rules/md/guide-step-single-link.d.ts +5 -0
  101. package/dist/eslint/pasika/rules/md/guide-step-single-link.js +48 -0
  102. package/dist/eslint/pasika/rules/md/guide-step-single-sentence.d.ts +5 -0
  103. package/dist/eslint/pasika/rules/md/guide-step-single-sentence.js +42 -0
  104. package/dist/eslint/pasika/rules/md/helpers.d.ts +25 -0
  105. package/dist/eslint/pasika/rules/md/helpers.js +37 -0
  106. package/dist/eslint/pasika/rules/md/index.d.ts +27 -0
  107. package/dist/eslint/pasika/rules/md/index.js +53 -0
  108. package/dist/eslint/pasika/rules/md/no-cross-document-link.d.ts +5 -0
  109. package/dist/eslint/pasika/rules/md/no-cross-document-link.js +36 -0
  110. package/dist/eslint/pasika/rules/md/no-nested-how-to.d.ts +5 -0
  111. package/dist/eslint/pasika/rules/md/no-nested-how-to.js +39 -0
  112. package/dist/eslint/pasika/rules/md/no-template-prompt.d.ts +5 -0
  113. package/dist/eslint/pasika/rules/md/no-template-prompt.js +22 -0
  114. package/dist/eslint/pasika/rules/md/overview-length.d.ts +5 -0
  115. package/dist/eslint/pasika/rules/md/overview-length.js +47 -0
  116. package/dist/eslint/pasika/rules/md/overview-present.d.ts +5 -0
  117. package/dist/eslint/pasika/rules/md/overview-present.js +41 -0
  118. package/dist/eslint/pasika/rules/md/policy-no-examples.d.ts +5 -0
  119. package/dist/eslint/pasika/rules/md/policy-no-examples.js +28 -0
  120. package/dist/eslint/pasika/rules/md/policy-single-document.d.ts +5 -0
  121. package/dist/eslint/pasika/rules/md/policy-single-document.js +34 -0
  122. package/dist/eslint/pasika/rules/md/policy-subject-headings.d.ts +5 -0
  123. package/dist/eslint/pasika/rules/md/policy-subject-headings.js +58 -0
  124. package/dist/eslint/pasika/rules/md/project-index.d.ts +17 -0
  125. package/dist/eslint/pasika/rules/md/project-index.js +67 -0
  126. package/dist/eslint/pasika/rules/md/reference-block-headings.d.ts +6 -0
  127. package/dist/eslint/pasika/rules/md/reference-block-headings.js +31 -0
  128. package/dist/eslint/pasika/rules/md/reference-no-rfc-vocabulary.d.ts +6 -0
  129. package/dist/eslint/pasika/rules/md/reference-no-rfc-vocabulary.js +26 -0
  130. package/dist/eslint/pasika/rules/md/requirement-present.d.ts +5 -0
  131. package/dist/eslint/pasika/rules/md/requirement-present.js +41 -0
  132. package/dist/eslint/pasika/rules/md/rfc-only-in-bullets.d.ts +5 -0
  133. package/dist/eslint/pasika/rules/md/rfc-only-in-bullets.js +46 -0
  134. package/dist/eslint/pasika/rules/md/rule-paired-examples.d.ts +5 -0
  135. package/dist/eslint/pasika/rules/md/rule-paired-examples.js +36 -0
  136. package/dist/eslint/pasika/rules/md/rule-tester.d.ts +11 -0
  137. package/dist/eslint/pasika/rules/md/rule-tester.js +21 -0
  138. package/dist/eslint/pasika/rules/md/support-document-placement.d.ts +2 -0
  139. package/dist/eslint/pasika/rules/md/support-document-placement.js +36 -0
  140. package/dist/eslint/pasika/rules/md/title-matches-file-name.d.ts +2 -0
  141. package/dist/eslint/pasika/rules/md/title-matches-file-name.js +46 -0
  142. package/dist/eslint/pasika/rules/named-exports.d.ts +2 -0
  143. package/dist/eslint/pasika/rules/named-exports.js +49 -0
  144. package/dist/eslint/pasika/rules/no-eslint-disable.d.ts +10 -0
  145. package/dist/eslint/pasika/rules/no-eslint-disable.js +33 -0
  146. package/dist/eslint/pasika/rules/no-mixed-concerns.js +9 -22
  147. package/dist/eslint/pasika/rules/no-util-barrel.d.ts +2 -0
  148. package/dist/eslint/pasika/rules/no-util-barrel.js +48 -0
  149. package/dist/eslint/pasika/rules/pure-function-extract.d.ts +9 -0
  150. package/dist/eslint/pasika/rules/pure-function-extract.js +92 -0
  151. package/dist/eslint/pasika/rules/shared-style-dedup.d.ts +13 -0
  152. package/dist/eslint/pasika/rules/shared-style-dedup.js +91 -0
  153. package/dist/eslint/pasika/rules/stay-flat.d.ts +12 -0
  154. package/dist/eslint/pasika/rules/stay-flat.js +81 -0
  155. package/dist/eslint/pasika/rules/support-folder-shape.d.ts +2 -0
  156. package/dist/eslint/pasika/rules/support-folder-shape.js +59 -0
  157. package/dist/eslint/pasika/rules/type-extraction.d.ts +13 -0
  158. package/dist/eslint/pasika/rules/type-extraction.js +85 -0
  159. package/dist/eslint/pasika/rules/ui-state.d.ts +9 -0
  160. package/dist/eslint/pasika/rules/ui-state.js +99 -0
  161. package/dist/eslint/pasika/rules/util-file-name.d.ts +2 -0
  162. package/dist/eslint/pasika/rules/util-file-name.js +48 -0
  163. package/dist/eslint/pasika/rules/value-extraction.d.ts +13 -0
  164. package/dist/eslint/pasika/rules/value-extraction.js +48 -0
  165. package/package.json +14 -7
  166. package/dist/enforcement/docs-check.d.ts +0 -17
  167. package/dist/enforcement/docs-check.js +0 -162
  168. package/enforcement/registry.json +0 -1139
@@ -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,2 @@
1
+ import type { Rule } from "eslint";
2
+ export declare const supportFolderShapeRule: Rule.RuleModule;
@@ -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
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * ESLint rule: pasika/type-extraction
3
+ *
4
+ * A type or schema declared in a component MUST stay in that component file
5
+ * until another file imports it without the component where it is defined, and
6
+ * a type or schema declared outside a component MUST stay in its file until
7
+ * another file needs it without using the code in that file. Importing the
8
+ * type alongside the component that defines it never requires extraction.
9
+ *
10
+ * @see docs/code-organization-guide/rules/types-and-schemas-rule.md
11
+ */
12
+ import type { Rule } from "eslint";
13
+ export declare const typeExtractionRule: Rule.RuleModule;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * ESLint rule: pasika/type-extraction
3
+ *
4
+ * A type or schema declared in a component MUST stay in that component file
5
+ * until another file imports it without the component where it is defined, and
6
+ * a type or schema declared outside a component MUST stay in its file until
7
+ * another file needs it without using the code in that file. Importing the
8
+ * type alongside the component that defines it never requires extraction.
9
+ *
10
+ * @see docs/code-organization-guide/rules/types-and-schemas-rule.md
11
+ */
12
+ import path from "node:path";
13
+ import { getProjectIndex, symbolKey } from "../project/index.js";
14
+ import { describeConsumers, segmentsOf } from "../project/ccf.js";
15
+ export const typeExtractionRule = {
16
+ meta: {
17
+ schema: [],
18
+ type: "problem",
19
+ docs: {
20
+ description: "Require a type or schema imported without its defining code to be extracted.",
21
+ },
22
+ },
23
+ create(context) {
24
+ const sourceRoot = path.resolve("src");
25
+ const file = path.resolve(context.filename);
26
+ const segments = segmentsOf(file, sourceRoot);
27
+ if (segments.length === 0)
28
+ return {};
29
+ const index = getProjectIndex(sourceRoot);
30
+ if (!index)
31
+ return {};
32
+ const module = index.modules.get(file);
33
+ if (!module)
34
+ return {};
35
+ const componentExport = module.exports.find((exp) => exp.kind === "component");
36
+ // The code a consumer can use from this file besides types: hooks,
37
+ // schemas, constants, and functions.
38
+ const codeExports = module.exports.filter((exp) => exp.kind !== "type" && exp.kind !== "other");
39
+ const findings = [];
40
+ for (const exp of module.exports) {
41
+ if (exp.kind !== "type" && exp.kind !== "schema")
42
+ continue;
43
+ const consumers = [...(index.symbolConsumers.get(symbolKey(file, exp.name)) ?? [])];
44
+ if (consumers.length === 0)
45
+ continue;
46
+ if (componentExport) {
47
+ // A consumer that also imports the component uses the code alongside
48
+ // the type, so extraction is not required.
49
+ const componentConsumers = index.symbolConsumers.get(symbolKey(file, componentExport.name)) ?? new Set();
50
+ const independent = consumers.filter((consumer) => !componentConsumers.has(consumer));
51
+ if (independent.length === 0)
52
+ continue;
53
+ findings.push({
54
+ line: exp.line,
55
+ message: `${exp.kind} "${exp.name}" is imported by ${describeConsumers(independent, sourceRoot)} without ` +
56
+ `the component "${componentExport.name}" that defines it. Extract it to a types/ or schemas/ folder. ` +
57
+ "See docs/code-organization-guide/rules/types-and-schemas-rule.md",
58
+ });
59
+ continue;
60
+ }
61
+ if (codeExports.length === 0)
62
+ continue;
63
+ // A consumer that imports any other code from this file uses the file as
64
+ // a whole; one that takes only the type needs it independently.
65
+ const independent = consumers.filter((consumer) => !codeExports.some((other) => index.symbolConsumers.get(symbolKey(file, other.name))?.has(consumer)));
66
+ if (independent.length === 0)
67
+ continue;
68
+ findings.push({
69
+ line: exp.line,
70
+ message: `${exp.kind} "${exp.name}" is imported by ${describeConsumers(independent, sourceRoot)} without using ` +
71
+ "the code in this file. Extract it to a types/ or schemas/ folder. " +
72
+ "See docs/code-organization-guide/rules/types-and-schemas-rule.md",
73
+ });
74
+ }
75
+ if (findings.length === 0)
76
+ return {};
77
+ return {
78
+ Program(node) {
79
+ for (const finding of findings) {
80
+ context.report({ node, loc: { line: finding.line, column: 0 }, message: finding.message });
81
+ }
82
+ },
83
+ };
84
+ },
85
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * ESLint rule: pasika/ui-state
3
+ *
4
+ * Enforces native or ARIA state expression and Tailwind state variants.
5
+ *
6
+ * @see docs/styling-guide/rules/component-ui-state-rule.md
7
+ */
8
+ import type { Rule } from "eslint";
9
+ export declare const uiStateRule: Rule.RuleModule;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * ESLint rule: pasika/ui-state
3
+ *
4
+ * Enforces native or ARIA state expression and Tailwind state variants.
5
+ *
6
+ * @see docs/styling-guide/rules/component-ui-state-rule.md
7
+ */
8
+ const STATE_PROPS = new Set([
9
+ "disabled",
10
+ "loading",
11
+ "selected",
12
+ "expanded",
13
+ "pressed",
14
+ "checked",
15
+ "busy",
16
+ "invalid",
17
+ "open",
18
+ ]);
19
+ const STATE_ATTRIBUTES = new Set([
20
+ "disabled",
21
+ "aria-busy",
22
+ "aria-selected",
23
+ "aria-expanded",
24
+ "aria-pressed",
25
+ "aria-checked",
26
+ "aria-invalid",
27
+ "open",
28
+ ]);
29
+ const STATE_CLASS_RE = /^(?:disabled|aria-(?:busy|selected|expanded|pressed|checked|invalid)|data-\[state)/;
30
+ function isComponentElement(node) {
31
+ const name = node.openingElement?.name;
32
+ return name?.type === "JSXIdentifier" && /^[A-Z]/.test(name.name);
33
+ }
34
+ function expressionName(node) {
35
+ if (node?.type !== "JSXExpressionContainer")
36
+ return undefined;
37
+ if (node.expression?.type === "Identifier")
38
+ return node.expression.name;
39
+ return undefined;
40
+ }
41
+ export const uiStateRule = {
42
+ meta: {
43
+ schema: [],
44
+ type: "problem",
45
+ docs: {
46
+ description: "Require semantic UI states and Tailwind state variants.",
47
+ },
48
+ },
49
+ create(context) {
50
+ return {
51
+ JSXElement(node) {
52
+ if (!isComponentElement(node))
53
+ return;
54
+ const attributes = node.openingElement?.attributes ?? [];
55
+ const names = new Set();
56
+ for (const attribute of attributes) {
57
+ names.add(String(attribute.name?.name));
58
+ }
59
+ for (const attribute of attributes) {
60
+ const name = String(attribute.name?.name);
61
+ const stateName = expressionName(attribute.value);
62
+ if (!stateName || !STATE_PROPS.has(stateName))
63
+ continue;
64
+ const hasNativeState = [...STATE_ATTRIBUTES].some((nativeName) => names.has(nativeName));
65
+ if (!hasNativeState) {
66
+ context.report({
67
+ node: attribute,
68
+ message: `UI state prop "${name}" should be expressed with a native or ARIA state attribute. ` +
69
+ "See docs/styling-guide/rules/component-ui-state-rule.md",
70
+ });
71
+ }
72
+ }
73
+ for (const attribute of attributes) {
74
+ if (String(attribute.name?.name) !== "className")
75
+ continue;
76
+ const value = attribute.value;
77
+ const text = value?.type === "Literal" ? String(value.value) : "";
78
+ if (value?.type === "JSXExpressionContainer" && value.expression.type === "ConditionalExpression") {
79
+ const consequent = value.expression.consequent;
80
+ const alternate = value.expression.alternate;
81
+ const classes = [consequent, alternate]
82
+ .filter((branch) => branch.type === "Literal" && typeof branch.value === "string")
83
+ .flatMap((branch) => String(branch.value).split(/\s+/));
84
+ if (classes.length > 0 && !classes.some((className) => STATE_CLASS_RE.test(className))) {
85
+ context.report({
86
+ node: attribute,
87
+ message: "Use a Tailwind state variant for a UI state instead of conditional replacement classes. " +
88
+ "See docs/styling-guide/rules/component-ui-state-rule.md",
89
+ });
90
+ }
91
+ }
92
+ if (text.split(/\s+/).some((className) => className.includes("state-"))) {
93
+ return;
94
+ }
95
+ }
96
+ },
97
+ };
98
+ },
99
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "eslint";
2
+ export declare const utilFileNameRule: Rule.RuleModule;
@@ -0,0 +1,48 @@
1
+ import path from "node:path";
2
+ import { parseModule } from "../project/parse-module.js";
3
+ function toKebabCase(value) {
4
+ return value
5
+ .replace(/(?<lower>[a-z0-9])(?<upper>[A-Z])/g, "$<lower>-$<upper>")
6
+ .replace(/(?<first>[A-Z])(?<rest>[A-Z][a-z])/g, "$<first>-$<rest>")
7
+ .toLowerCase();
8
+ }
9
+ export const utilFileNameRule = {
10
+ meta: {
11
+ schema: [],
12
+ type: "problem",
13
+ docs: {
14
+ description: "Require single-function utility files to use the function's kebab-case name.",
15
+ },
16
+ },
17
+ create(context) {
18
+ const filename = path.resolve(context.filename);
19
+ const segments = filename.replace(/\\/g, "/").split("/");
20
+ if (!segments.includes("utils"))
21
+ return {};
22
+ let module;
23
+ try {
24
+ module = parseModule(filename);
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ const functions = module.exports.filter((entry) => entry.kind === "function");
30
+ if (functions.length !== 1 || module.exports.length !== 1)
31
+ return {};
32
+ const functionName = functions[0]?.name;
33
+ if (!functionName)
34
+ return {};
35
+ const expected = toKebabCase(functionName);
36
+ const actual = path.basename(filename, path.extname(filename));
37
+ if (!expected || actual === expected)
38
+ return {};
39
+ return {
40
+ Program(node) {
41
+ context.report({
42
+ node,
43
+ message: `A utility file exporting ${functionName} must be named ${expected}.${path.extname(filename).slice(1)}.`,
44
+ });
45
+ },
46
+ };
47
+ },
48
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * ESLint rule: pasika/value-extraction
3
+ *
4
+ * A value MUST remain in its declaring component or file until another file
5
+ * imports it independently; it MUST then be extracted as a constant. A file
6
+ * under src/app/ is supposed to hold only framework-convention files, so a
7
+ * consumer outside src/app/ reaching into one means a value has crossed the
8
+ * independence threshold.
9
+ *
10
+ * @see docs/code-organization-guide/rules/constants-rule.md
11
+ */
12
+ import type { Rule } from "eslint";
13
+ export declare const valueExtractionRule: Rule.RuleModule;