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,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All JSON rules for the package.json checks.
|
|
3
|
+
*/
|
|
4
|
+
import { noCacheFlagRule } from "./no-cache-flag.js";
|
|
5
|
+
import { noVulykDependencyRule } from "./no-vulyk-dependency.js";
|
|
6
|
+
export const jsonRules = {
|
|
7
|
+
"no-cache-flag": noCacheFlagRule,
|
|
8
|
+
"no-vulyk-dependency": noVulykDependencyRule,
|
|
9
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/no-cache-flag
|
|
3
|
+
*
|
|
4
|
+
* A lint command MUST NOT pass ESLint's --cache flag, because rules that
|
|
5
|
+
* compare a file against the rest of the tree need every file in the run.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/agent-policy.md
|
|
8
|
+
*/
|
|
9
|
+
import type { JSONRuleDefinition } from "@eslint/json";
|
|
10
|
+
export declare const noCacheFlagRule: JSONRuleDefinition;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/no-cache-flag
|
|
3
|
+
*
|
|
4
|
+
* A lint command MUST NOT pass ESLint's --cache flag, because rules that
|
|
5
|
+
* compare a file against the rest of the tree need every file in the run.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/agent-policy.md
|
|
8
|
+
*/
|
|
9
|
+
function memberName(member) {
|
|
10
|
+
return member.name.type === "String" ? member.name.value : member.name.name;
|
|
11
|
+
}
|
|
12
|
+
export const noCacheFlagRule = {
|
|
13
|
+
meta: {
|
|
14
|
+
schema: [],
|
|
15
|
+
type: "problem",
|
|
16
|
+
docs: {
|
|
17
|
+
description: "Require lint scripts to not pass ESLint's --cache flag.",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
create(context) {
|
|
21
|
+
return {
|
|
22
|
+
Document(node) {
|
|
23
|
+
const root = node.body;
|
|
24
|
+
if (root.type !== "Object")
|
|
25
|
+
return;
|
|
26
|
+
const scripts = root.members.find((member) => memberName(member) === "scripts");
|
|
27
|
+
if (scripts?.value.type !== "Object")
|
|
28
|
+
return;
|
|
29
|
+
for (const member of scripts.value.members) {
|
|
30
|
+
const name = memberName(member);
|
|
31
|
+
if (name !== "lint" && name !== "fix")
|
|
32
|
+
continue;
|
|
33
|
+
if (member.value.type !== "String")
|
|
34
|
+
continue;
|
|
35
|
+
if (member.value.value.includes("--cache")) {
|
|
36
|
+
context.report({
|
|
37
|
+
node: member,
|
|
38
|
+
message: "Lint scripts must not pass ESLint's --cache flag because cross-file rules require every file in the run.",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/no-vulyk-dependency
|
|
3
|
+
*
|
|
4
|
+
* Vulyk MUST NOT be added to package.json in order to run its CLI; it MUST run
|
|
5
|
+
* as an ephemeral command such as npx vulyk@latest.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/agent-policy.md
|
|
8
|
+
*/
|
|
9
|
+
import type { JSONRuleDefinition } from "@eslint/json";
|
|
10
|
+
export declare const noVulykDependencyRule: JSONRuleDefinition;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/no-vulyk-dependency
|
|
3
|
+
*
|
|
4
|
+
* Vulyk MUST NOT be added to package.json in order to run its CLI; it MUST run
|
|
5
|
+
* as an ephemeral command such as npx vulyk@latest.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/agent-policy.md
|
|
8
|
+
*/
|
|
9
|
+
function memberName(member) {
|
|
10
|
+
return member.name.type === "String" ? member.name.value : member.name.name;
|
|
11
|
+
}
|
|
12
|
+
export const noVulykDependencyRule = {
|
|
13
|
+
meta: {
|
|
14
|
+
schema: [],
|
|
15
|
+
type: "problem",
|
|
16
|
+
docs: {
|
|
17
|
+
description: "Require vulyk to run as an ephemeral command, not a package.json dependency.",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
create(context) {
|
|
21
|
+
return {
|
|
22
|
+
Document(node) {
|
|
23
|
+
const root = node.body;
|
|
24
|
+
if (root.type !== "Object")
|
|
25
|
+
return;
|
|
26
|
+
for (const key of ["dependencies", "devDependencies"]) {
|
|
27
|
+
const section = root.members.find((member) => memberName(member) === key);
|
|
28
|
+
if (section?.value.type !== "Object")
|
|
29
|
+
continue;
|
|
30
|
+
const vulyk = section.value.members.find((member) => memberName(member) === "vulyk");
|
|
31
|
+
if (vulyk) {
|
|
32
|
+
context.report({
|
|
33
|
+
node: vulyk,
|
|
34
|
+
message: 'Vulyk must not be a package.json dependency. Run it as "npx vulyk@latest" instead.',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared RuleTester wiring for the package.json rule tests.
|
|
3
|
+
*
|
|
4
|
+
* Each `describe` title in a rule test is the exact text of the requirement the
|
|
5
|
+
* case pins, which is how `pasika coverage` verifies that a requirement recorded
|
|
6
|
+
* as lint-enforced has a test behind it.
|
|
7
|
+
*/
|
|
8
|
+
import { describe } from "node:test";
|
|
9
|
+
import { RuleTester } from "eslint";
|
|
10
|
+
export declare const jsonRuleTester: RuleTester;
|
|
11
|
+
export { describe };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared RuleTester wiring for the package.json rule tests.
|
|
3
|
+
*
|
|
4
|
+
* Each `describe` title in a rule test is the exact text of the requirement the
|
|
5
|
+
* case pins, which is how `pasika coverage` verifies that a requirement recorded
|
|
6
|
+
* as lint-enforced has a test behind it.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
import { RuleTester } from "eslint";
|
|
10
|
+
import jsonPlugin from "@eslint/json";
|
|
11
|
+
import { jsonRules } from "../../index.js";
|
|
12
|
+
RuleTester.describe = describe;
|
|
13
|
+
RuleTester.it = it;
|
|
14
|
+
export const jsonRuleTester = new RuleTester({
|
|
15
|
+
language: "json/json",
|
|
16
|
+
plugins: {
|
|
17
|
+
json: { languages: { json: jsonPlugin.languages.json } },
|
|
18
|
+
pasika: { rules: jsonRules },
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
export { describe };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/jsx-hygiene
|
|
3
|
+
*
|
|
4
|
+
* Keeps calculations and complex conditions out of JSX children and attributes.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/code-organization-guide/rules/jsx-hygiene-rule.md
|
|
7
|
+
*/
|
|
8
|
+
import type { Rule } from "eslint";
|
|
9
|
+
export declare const jsxHygieneRule: Rule.RuleModule;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/jsx-hygiene
|
|
3
|
+
*
|
|
4
|
+
* Keeps calculations and complex conditions out of JSX children and attributes.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/code-organization-guide/rules/jsx-hygiene-rule.md
|
|
7
|
+
*/
|
|
8
|
+
import ts from "typescript";
|
|
9
|
+
const ARITHMETIC_OPERATORS = new Set(["+", "-", "*", "/", "%"]);
|
|
10
|
+
function isCallExpression(node) {
|
|
11
|
+
return node.type === "CallExpression";
|
|
12
|
+
}
|
|
13
|
+
function isMemberExpression(node) {
|
|
14
|
+
return ["MemberExpression", "OptionalMemberExpression"].includes(node.type);
|
|
15
|
+
}
|
|
16
|
+
function logicalOperatorCount(node) {
|
|
17
|
+
if (node.type !== "LogicalExpression")
|
|
18
|
+
return 0;
|
|
19
|
+
return 1 + logicalOperatorCount(node.left) + logicalOperatorCount(node.right);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether the parsed text of a node's subtree contains a conditional expression
|
|
23
|
+
* anywhere. Re-parses just the node's source slice with the TypeScript compiler
|
|
24
|
+
* so the walk stays fully typed, including inside JSX children.
|
|
25
|
+
*/
|
|
26
|
+
function containsConditionalIn(node, sourceText) {
|
|
27
|
+
const start = node.range?.[0] ?? 0;
|
|
28
|
+
const end = node.range?.[1] ?? sourceText.length;
|
|
29
|
+
const sourceFile = ts.createSourceFile("fragment.tsx", sourceText.slice(start, end), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
30
|
+
let found = false;
|
|
31
|
+
const visit = (child) => {
|
|
32
|
+
if (found)
|
|
33
|
+
return;
|
|
34
|
+
if (ts.isConditionalExpression(child)) {
|
|
35
|
+
found = true;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
ts.forEachChild(child, visit);
|
|
39
|
+
};
|
|
40
|
+
visit(sourceFile);
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
43
|
+
function hasNestedTernary(node, sourceText) {
|
|
44
|
+
if (node.type !== "ConditionalExpression")
|
|
45
|
+
return false;
|
|
46
|
+
return containsConditionalIn(node.consequent, sourceText) || containsConditionalIn(node.alternate, sourceText);
|
|
47
|
+
}
|
|
48
|
+
function hasChainedCall(node) {
|
|
49
|
+
if (!isCallExpression(node) || !isMemberExpression(node.callee))
|
|
50
|
+
return false;
|
|
51
|
+
return isCallExpression(node.callee.object) || isMemberExpression(node.callee.object);
|
|
52
|
+
}
|
|
53
|
+
function hasExternalCall(node) {
|
|
54
|
+
if (!isCallExpression(node))
|
|
55
|
+
return false;
|
|
56
|
+
const callee = node.callee;
|
|
57
|
+
if (callee.type === "Identifier" && callee.name === "cn")
|
|
58
|
+
return false;
|
|
59
|
+
if (isMemberExpression(callee))
|
|
60
|
+
return false;
|
|
61
|
+
return callee.type === "Identifier";
|
|
62
|
+
}
|
|
63
|
+
function findViolation(node, sourceText) {
|
|
64
|
+
if (node.type === "BinaryExpression" && ARITHMETIC_OPERATORS.has(node.operator)) {
|
|
65
|
+
return "arithmetic";
|
|
66
|
+
}
|
|
67
|
+
if (hasChainedCall(node))
|
|
68
|
+
return "chained built-in method calls";
|
|
69
|
+
if (hasExternalCall(node))
|
|
70
|
+
return "calls to functions declared outside the component";
|
|
71
|
+
if (hasNestedTernary(node, sourceText))
|
|
72
|
+
return "nested ternaries";
|
|
73
|
+
if (logicalOperatorCount(node) >= 2)
|
|
74
|
+
return "conditions containing two or more logical operators";
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
export const jsxHygieneRule = {
|
|
78
|
+
meta: {
|
|
79
|
+
schema: [],
|
|
80
|
+
type: "problem",
|
|
81
|
+
docs: {
|
|
82
|
+
description: "Require complex JSX expressions to be extracted before return.",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
create(context) {
|
|
86
|
+
if (!context.filename.endsWith(".tsx") && !context.filename.endsWith(".jsx"))
|
|
87
|
+
return {};
|
|
88
|
+
const sourceText = context.sourceCode.text;
|
|
89
|
+
function checkExpression(node) {
|
|
90
|
+
const violation = findViolation(node, sourceText);
|
|
91
|
+
if (!violation)
|
|
92
|
+
return;
|
|
93
|
+
context.report({
|
|
94
|
+
node,
|
|
95
|
+
message: `Extract ${violation} from JSX before return. ` +
|
|
96
|
+
"See docs/code-organization-guide/rules/jsx-hygiene-rule.md",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
JSXExpressionContainer(node) {
|
|
101
|
+
const expression = node.expression;
|
|
102
|
+
if (!expression || expression.type === "JSXEmptyExpression")
|
|
103
|
+
return;
|
|
104
|
+
checkExpression(expression);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/locale-dotted-path
|
|
3
|
+
*
|
|
4
|
+
* A namespaced locale MUST be read through its full dotted path
|
|
5
|
+
* (locales.stream.watchLiveStream) rather than destructured.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/code-organization-guide/rules/locales-rule.md
|
|
8
|
+
*/
|
|
9
|
+
import type { Rule } from "eslint";
|
|
10
|
+
export declare const localeDottedPathRule: Rule.RuleModule;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/locale-dotted-path
|
|
3
|
+
*
|
|
4
|
+
* A namespaced locale MUST be read through its full dotted path
|
|
5
|
+
* (locales.stream.watchLiveStream) rather than destructured.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/code-organization-guide/rules/locales-rule.md
|
|
8
|
+
*/
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
function isInLocalesDir(filename) {
|
|
11
|
+
const segments = path.resolve(filename).split(path.sep);
|
|
12
|
+
const srcIdx = segments.lastIndexOf("src");
|
|
13
|
+
return srcIdx !== -1 && segments[srcIdx + 1] === "locales";
|
|
14
|
+
}
|
|
15
|
+
function isLocalesAccess(node) {
|
|
16
|
+
if (node.type === "Identifier" && node.name === "locales") {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
if (node.type === "MemberExpression" &&
|
|
20
|
+
node.object.type === "Identifier" &&
|
|
21
|
+
node.object.name === "locales" &&
|
|
22
|
+
node.property.type === "Identifier") {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
export const localeDottedPathRule = {
|
|
28
|
+
meta: {
|
|
29
|
+
schema: [],
|
|
30
|
+
type: "problem",
|
|
31
|
+
docs: {
|
|
32
|
+
description: "Require reading namespaced locales through their full dotted path.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
create(context) {
|
|
36
|
+
if (isInLocalesDir(context.filename))
|
|
37
|
+
return {};
|
|
38
|
+
return {
|
|
39
|
+
VariableDeclarator(node) {
|
|
40
|
+
if (node.id.type !== "ObjectPattern")
|
|
41
|
+
return;
|
|
42
|
+
const init = node.init;
|
|
43
|
+
if (!init || !isLocalesAccess(init))
|
|
44
|
+
return;
|
|
45
|
+
for (const prop of node.id.properties) {
|
|
46
|
+
if (prop.type !== "Property")
|
|
47
|
+
continue;
|
|
48
|
+
let valueName;
|
|
49
|
+
if (prop.value.type === "Identifier") {
|
|
50
|
+
valueName = prop.value.name;
|
|
51
|
+
}
|
|
52
|
+
else if (prop.key.type === "Identifier") {
|
|
53
|
+
valueName = prop.key.name;
|
|
54
|
+
}
|
|
55
|
+
if (valueName) {
|
|
56
|
+
context.report({
|
|
57
|
+
node,
|
|
58
|
+
message: `Destructuring locales.${valueName} loses the namespace; read through the full dotted path instead.`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/locale-placement
|
|
3
|
+
*
|
|
4
|
+
* Locales read by files in more than one feature folder or by src/shared/,
|
|
5
|
+
* src/compositions/, src/app/, or root support folders MUST live at the top
|
|
6
|
+
* level of locales. Locales read only by files in one feature folder MUST live
|
|
7
|
+
* in an object with the camelCase form of its feature folder name. The rule
|
|
8
|
+
* reads which keys the tree uses, because a single file cannot see them.
|
|
9
|
+
*
|
|
10
|
+
* @see docs/code-organization-guide/rules/locales-rule.md
|
|
11
|
+
*/
|
|
12
|
+
import type { Rule } from "eslint";
|
|
13
|
+
export declare const localePlacementRule: Rule.RuleModule;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/locale-placement
|
|
3
|
+
*
|
|
4
|
+
* Locales read by files in more than one feature folder or by src/shared/,
|
|
5
|
+
* src/compositions/, src/app/, or root support folders MUST live at the top
|
|
6
|
+
* level of locales. Locales read only by files in one feature folder MUST live
|
|
7
|
+
* in an object with the camelCase form of its feature folder name. The rule
|
|
8
|
+
* reads which keys the tree uses, because a single file cannot see them.
|
|
9
|
+
*
|
|
10
|
+
* @see docs/code-organization-guide/rules/locales-rule.md
|
|
11
|
+
*/
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import ts from "typescript";
|
|
15
|
+
import { getProjectIndex, resolveSpecifier } from "../project/index.js";
|
|
16
|
+
import { describeConsumers, segmentsOf, SUPPORT_FOLDERS } from "../project/ccf.js";
|
|
17
|
+
/** The first segment of a `locales.<key>` read. */
|
|
18
|
+
const LOCALE_ACCESS = /\blocales\.(?<key>[A-Za-z_$][\w$]*)/g;
|
|
19
|
+
const camelCase = (name) => name.replace(/-[a-z]/g, (match) => match.slice(1).toUpperCase());
|
|
20
|
+
const FORCED_TOP_LEVEL = new Set(["app", "shared", "compositions", "config"]);
|
|
21
|
+
/** A file whose own folder decides the locale scope: app/shared/compositions/config or a root support folder. */
|
|
22
|
+
function forcesTopLevel(segments) {
|
|
23
|
+
return FORCED_TOP_LEVEL.has(segments[0] ?? "") || SUPPORT_FOLDERS.has(segments[0] ?? "");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Where each top-level key of the exported `locales` object lives: directly on
|
|
27
|
+
* the object ("top") or as a namespaced object ("nested").
|
|
28
|
+
*/
|
|
29
|
+
function localePlacement(text) {
|
|
30
|
+
const sourceFile = ts.createSourceFile("locales.ts", text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
31
|
+
for (const statement of sourceFile.statements) {
|
|
32
|
+
if (!ts.isVariableStatement(statement))
|
|
33
|
+
continue;
|
|
34
|
+
const isExported = (ts.getModifiers(statement) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
|
35
|
+
if (!isExported)
|
|
36
|
+
continue;
|
|
37
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
38
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "locales")
|
|
39
|
+
continue;
|
|
40
|
+
if (!declaration.initializer || !ts.isObjectLiteralExpression(declaration.initializer))
|
|
41
|
+
continue;
|
|
42
|
+
const placement = new Map();
|
|
43
|
+
for (const property of declaration.initializer.properties) {
|
|
44
|
+
if (!ts.isPropertyAssignment(property))
|
|
45
|
+
continue;
|
|
46
|
+
let name;
|
|
47
|
+
if (ts.isIdentifier(property.name))
|
|
48
|
+
name = property.name.text;
|
|
49
|
+
else if (ts.isStringLiteral(property.name))
|
|
50
|
+
name = property.name.text;
|
|
51
|
+
if (name === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
const line = sourceFile.getLineAndCharacterOfPosition(property.getStart(sourceFile)).line + 1;
|
|
54
|
+
placement.set(name, {
|
|
55
|
+
kind: ts.isObjectLiteralExpression(property.initializer) ? "nested" : "top",
|
|
56
|
+
line,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return placement;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
export const localePlacementRule = {
|
|
65
|
+
meta: {
|
|
66
|
+
schema: [],
|
|
67
|
+
type: "problem",
|
|
68
|
+
docs: {
|
|
69
|
+
description: "Require shared locales at the top level and single-feature locales in a namespaced object.",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
create(context) {
|
|
73
|
+
const sourceRoot = path.resolve("src");
|
|
74
|
+
const file = path.resolve(context.filename);
|
|
75
|
+
const segments = segmentsOf(file, sourceRoot);
|
|
76
|
+
if (segments.length === 0)
|
|
77
|
+
return {};
|
|
78
|
+
const index = getProjectIndex(sourceRoot);
|
|
79
|
+
if (!index)
|
|
80
|
+
return {};
|
|
81
|
+
// The named locales object lives in src/locales/index.ts; the rule reports
|
|
82
|
+
// on that file, because the fix is in the definition.
|
|
83
|
+
const localesFile = [...index.modules.keys()].find((candidate) => {
|
|
84
|
+
const candidateSegments = segmentsOf(candidate, sourceRoot);
|
|
85
|
+
return candidateSegments.length === 2 && candidateSegments[0] === "locales" && candidateSegments[1]?.startsWith("index.");
|
|
86
|
+
});
|
|
87
|
+
if (!localesFile || file !== localesFile)
|
|
88
|
+
return {};
|
|
89
|
+
const placement = localePlacement(readFileSync(localesFile, "utf8"));
|
|
90
|
+
if (!placement)
|
|
91
|
+
return {};
|
|
92
|
+
const keyReaders = new Map();
|
|
93
|
+
const keyFeatures = new Map();
|
|
94
|
+
const keyForcedTopLevel = new Set();
|
|
95
|
+
for (const [candidateFile, module] of index.modules) {
|
|
96
|
+
if (candidateFile === localesFile)
|
|
97
|
+
continue;
|
|
98
|
+
const importsLocales = module.imports.some((moduleImport) => resolveSpecifier(candidateFile, moduleImport.specifier, sourceRoot) === localesFile);
|
|
99
|
+
if (!importsLocales)
|
|
100
|
+
continue;
|
|
101
|
+
const candidateSegments = segmentsOf(candidateFile, sourceRoot);
|
|
102
|
+
for (const match of readFileSync(candidateFile, "utf8").matchAll(LOCALE_ACCESS)) {
|
|
103
|
+
const key = match.groups?.key;
|
|
104
|
+
if (key === undefined)
|
|
105
|
+
continue;
|
|
106
|
+
const readers = keyReaders.get(key) ?? new Set();
|
|
107
|
+
readers.add(candidateFile);
|
|
108
|
+
keyReaders.set(key, readers);
|
|
109
|
+
if (candidateSegments[0] === "features" && candidateSegments.length >= 2) {
|
|
110
|
+
const features = keyFeatures.get(key) ?? new Set();
|
|
111
|
+
features.add(candidateSegments[1] ?? "");
|
|
112
|
+
keyFeatures.set(key, features);
|
|
113
|
+
}
|
|
114
|
+
else if (forcesTopLevel(candidateSegments)) {
|
|
115
|
+
keyForcedTopLevel.add(key);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const findings = [];
|
|
120
|
+
for (const [key, readers] of keyReaders) {
|
|
121
|
+
const features = keyFeatures.get(key) ?? new Set();
|
|
122
|
+
const shared = features.size > 1 || keyForcedTopLevel.has(key);
|
|
123
|
+
const current = placement.get(key);
|
|
124
|
+
if (shared) {
|
|
125
|
+
if (current?.kind === "nested") {
|
|
126
|
+
findings.push({
|
|
127
|
+
line: current.line,
|
|
128
|
+
message: `Locale "${key}" is read by ${describeConsumers([...readers], sourceRoot)} and must live ` +
|
|
129
|
+
"at the top level of locales. See docs/code-organization-guide/rules/locales-rule.md",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (features.size !== 1)
|
|
135
|
+
continue;
|
|
136
|
+
const featureName = [...features][0] ?? "";
|
|
137
|
+
const expected = camelCase(featureName);
|
|
138
|
+
if (current?.kind === "top") {
|
|
139
|
+
findings.push({
|
|
140
|
+
line: current.line,
|
|
141
|
+
message: `Locale "${key}" is read only by the ${featureName} feature; it must live in an object ` +
|
|
142
|
+
`named "${expected}". See docs/code-organization-guide/rules/locales-rule.md`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
else if (current?.kind === "nested" && key !== expected) {
|
|
146
|
+
findings.push({
|
|
147
|
+
line: current.line,
|
|
148
|
+
message: `Locale "${key}" is read only by the ${featureName} feature; it must live in an object ` +
|
|
149
|
+
`named "${expected}", not "${key}". See docs/code-organization-guide/rules/locales-rule.md`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (findings.length === 0)
|
|
154
|
+
return {};
|
|
155
|
+
return {
|
|
156
|
+
Program(node) {
|
|
157
|
+
for (const finding of findings) {
|
|
158
|
+
context.report({ node, loc: { line: finding.line, column: 0 }, message: finding.message });
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/locales-location
|
|
3
|
+
*
|
|
4
|
+
* All locales MUST live in the named locales object exported from
|
|
5
|
+
* src/locales/index.ts. User-facing strings defined outside the locales
|
|
6
|
+
* directory are reported.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/code-organization-guide/rules/locales-rule.md
|
|
9
|
+
*/
|
|
10
|
+
import type { Rule } from "eslint";
|
|
11
|
+
export declare const localesLocationRule: Rule.RuleModule;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/locales-location
|
|
3
|
+
*
|
|
4
|
+
* All locales MUST live in the named locales object exported from
|
|
5
|
+
* src/locales/index.ts. User-facing strings defined outside the locales
|
|
6
|
+
* directory are reported.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/code-organization-guide/rules/locales-rule.md
|
|
9
|
+
*/
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
function isLocalesFile(filename) {
|
|
12
|
+
const segments = path.resolve(filename).split(path.sep);
|
|
13
|
+
const srcIdx = segments.lastIndexOf("src");
|
|
14
|
+
return srcIdx !== -1 && segments[srcIdx + 1] === "locales";
|
|
15
|
+
}
|
|
16
|
+
const LOCALE_NAME_RE = /^[a-z][a-zA-Z0-9]*$/;
|
|
17
|
+
function looksLikeLocaleKey(name) {
|
|
18
|
+
return LOCALE_NAME_RE.test(name);
|
|
19
|
+
}
|
|
20
|
+
export const localesLocationRule = {
|
|
21
|
+
meta: {
|
|
22
|
+
schema: [],
|
|
23
|
+
type: "problem",
|
|
24
|
+
docs: {
|
|
25
|
+
description: "Require all locales to live in the named locales object.",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
create(context) {
|
|
29
|
+
if (isLocalesFile(context.filename))
|
|
30
|
+
return {};
|
|
31
|
+
const filename = context.filename;
|
|
32
|
+
const segments = path.resolve(filename).split(path.sep);
|
|
33
|
+
const srcIdx = segments.lastIndexOf("src");
|
|
34
|
+
if (srcIdx === -1)
|
|
35
|
+
return {};
|
|
36
|
+
const folder = segments[srcIdx + 1];
|
|
37
|
+
if (folder === "app" || folder === "config")
|
|
38
|
+
return {};
|
|
39
|
+
return {
|
|
40
|
+
VariableDeclarator(node) {
|
|
41
|
+
if (node.id.type === "Identifier" &&
|
|
42
|
+
node.init?.type === "ObjectExpression" &&
|
|
43
|
+
node.init.properties.length > 0 &&
|
|
44
|
+
looksLikeLocaleKey(node.id.name)) {
|
|
45
|
+
const hasStringValues = node.init.properties.some((p) => p.type === "Property" && p.value.type === "Literal");
|
|
46
|
+
if (hasStringValues) {
|
|
47
|
+
context.report({
|
|
48
|
+
node,
|
|
49
|
+
message: "User-facing strings must live in src/locales/, not inline in component files.",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getFilename } from "./helpers.js";
|
|
2
|
+
export const docKindSuffixRule = {
|
|
3
|
+
meta: {
|
|
4
|
+
type: "problem",
|
|
5
|
+
docs: {
|
|
6
|
+
description: "Documentation files must have a -guide, -rule, -reference, or -policy suffix.",
|
|
7
|
+
recommended: true,
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
create(context) {
|
|
11
|
+
return {
|
|
12
|
+
root(node) {
|
|
13
|
+
const filename = getFilename(context);
|
|
14
|
+
if (!filename.endsWith(".md"))
|
|
15
|
+
return;
|
|
16
|
+
if (filename.endsWith("-guide.md") ||
|
|
17
|
+
filename.endsWith("-rule.md") ||
|
|
18
|
+
filename.endsWith("-reference.md") ||
|
|
19
|
+
filename.endsWith("-policy.md")) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
context.report({
|
|
23
|
+
node,
|
|
24
|
+
message: "file name carries no -guide, -rule, -reference, or -policy suffix",
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
};
|