@starci/eslint-canon-fe 9.2.0 → 9.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +5 -1
- package/dist/brand-values.d.ts +28 -0
- package/dist/brand-values.js +172 -0
- package/dist/button-intent.d.ts +11 -0
- package/dist/button-intent.js +361 -0
- package/dist/callback-shape.d.ts +7 -0
- package/dist/callback-shape.js +396 -0
- package/dist/class-names.d.ts +19 -0
- package/dist/class-names.js +170 -0
- package/dist/client-boundary.d.ts +42 -0
- package/dist/client-boundary.js +404 -0
- package/dist/comments.d.ts +47 -0
- package/dist/comments.js +156 -0
- package/dist/control-height.d.ts +7 -0
- package/dist/control-height.js +83 -0
- package/dist/drawing.d.ts +31 -0
- package/dist/drawing.js +376 -0
- package/dist/env-owner.d.ts +22 -0
- package/dist/env-owner.js +291 -0
- package/dist/fe-stack.d.ts +10 -0
- package/dist/fe-stack.js +73 -0
- package/dist/file-layout.d.ts +71 -0
- package/dist/file-layout.js +465 -0
- package/dist/formatting.d.ts +25 -0
- package/dist/formatting.js +104 -0
- package/dist/function-budget.d.ts +9 -0
- package/dist/function-budget.js +48 -0
- package/dist/grammar-shim.d.ts +5 -0
- package/dist/grammar-shim.js +126 -0
- package/dist/hooks-folder.d.ts +34 -0
- package/dist/hooks-folder.js +320 -0
- package/dist/hygiene.d.ts +39 -0
- package/dist/hygiene.js +540 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +310 -0
- package/{landmark.mjs → dist/landmark.d.ts} +4 -6
- package/dist/landmark.js +6 -0
- package/dist/lib/ast.d.ts +36 -0
- package/dist/lib/ast.js +94 -0
- package/dist/lib/bindings.d.ts +84 -0
- package/dist/lib/bindings.js +168 -0
- package/dist/lib/class-text.d.ts +3 -0
- package/dist/lib/class-text.js +30 -0
- package/dist/lib/config.d.ts +81 -0
- package/dist/lib/config.js +141 -0
- package/dist/lib/drawing-scope.d.ts +5 -0
- package/{lib/drawing-scope.mjs → dist/lib/drawing-scope.js} +6 -11
- package/dist/lib/eslint-boundary.d.ts +23 -0
- package/dist/lib/eslint-boundary.js +6 -0
- package/dist/lib/fe-stack.d.ts +49 -0
- package/dist/lib/fe-stack.js +98 -0
- package/dist/lib/hfs-view.d.ts +74 -0
- package/dist/lib/hfs-view.js +6 -0
- package/dist/lib/hfs.d.ts +34 -0
- package/dist/lib/hfs.js +72 -0
- package/dist/lib/next.d.ts +4 -0
- package/dist/lib/next.js +10 -0
- package/dist/lib/own-hook-origin.d.ts +17 -0
- package/dist/lib/own-hook-origin.js +52 -0
- package/dist/lib/package-root.d.ts +4 -0
- package/dist/lib/package-root.js +10 -0
- package/dist/lib/path.d.ts +2 -0
- package/{lib/path.mjs → dist/lib/path.js} +1 -1
- package/dist/lib/platform.d.ts +26 -0
- package/{lib/platform.mjs → dist/lib/platform.js} +38 -35
- package/dist/lib/program-for.d.ts +5 -0
- package/dist/lib/program-for.js +23 -0
- package/dist/lib/pure-owner-helper.d.ts +10 -0
- package/dist/lib/pure-owner-helper.js +156 -0
- package/dist/lib/react.d.ts +16 -0
- package/dist/lib/react.js +22 -0
- package/dist/lib/redux-hook-origin.d.ts +4 -0
- package/dist/lib/redux-hook-origin.js +50 -0
- package/dist/lib/rule.d.ts +28 -0
- package/dist/lib/rule.js +5 -0
- package/dist/lib/scope.d.ts +88 -0
- package/{lib/scope.mjs → dist/lib/scope.js} +83 -93
- package/dist/lib/typed-class-values.d.ts +13 -0
- package/dist/lib/typed-class-values.js +92 -0
- package/dist/lib/types.d.ts +44 -0
- package/dist/lib/types.js +64 -0
- package/dist/lib/why.d.ts +350 -0
- package/dist/lib/why.js +350 -0
- package/dist/lint-escape-hatch.d.ts +24 -0
- package/{lint-escape-hatch.mjs → dist/lint-escape-hatch.js} +38 -45
- package/dist/lists.d.ts +31 -0
- package/dist/lists.js +202 -0
- package/dist/loading.d.ts +34 -0
- package/dist/loading.js +157 -0
- package/dist/naming.d.ts +33 -0
- package/dist/naming.js +176 -0
- package/dist/native-controls.d.ts +24 -0
- package/dist/native-controls.js +105 -0
- package/dist/next-conventions.d.ts +48 -0
- package/dist/next-conventions.js +452 -0
- package/dist/project-graph.d.ts +5 -0
- package/{project-graph.mjs → dist/project-graph.js} +8 -12
- package/dist/props-and-slots.d.ts +19 -0
- package/dist/props-and-slots.js +320 -0
- package/dist/served-locale.d.ts +38 -0
- package/dist/served-locale.js +263 -0
- package/dist/shape-slot.d.ts +18 -0
- package/dist/shape-slot.js +324 -0
- package/dist/size-and-state-budget.d.ts +46 -0
- package/dist/size-and-state-budget.js +424 -0
- package/dist/size-growth.d.ts +24 -0
- package/dist/size-growth.js +64 -0
- package/dist/sonar-parity.d.ts +5 -0
- package/{sonar-parity.mjs → dist/sonar-parity.js} +11 -7
- package/dist/surface-rules.d.ts +18 -0
- package/dist/surface-rules.js +491 -0
- package/dist/swr-family.d.ts +15 -0
- package/dist/swr-family.js +419 -0
- package/dist/the-split.d.ts +27 -0
- package/dist/the-split.js +157 -0
- package/dist/tokens.d.ts +9 -0
- package/dist/tokens.js +35 -0
- package/dist/translation.d.ts +51 -0
- package/dist/translation.js +403 -0
- package/dist/transport.d.ts +73 -0
- package/dist/transport.js +885 -0
- package/dist/type-safety.d.ts +43 -0
- package/dist/type-safety.js +139 -0
- package/dist/ui-rulings.d.ts +11 -0
- package/dist/ui-rulings.js +121 -0
- package/package.json +31 -9
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
- package/runtime/scripts/lib/package-at.mjs +5 -5
- package/brand-values.mjs +0 -172
- package/button-intent.mjs +0 -320
- package/callback-shape.mjs +0 -332
- package/class-names.mjs +0 -149
- package/client-boundary.mjs +0 -400
- package/comments.mjs +0 -159
- package/control-height.mjs +0 -77
- package/drawing.mjs +0 -315
- package/env-owner.mjs +0 -244
- package/fe-stack.mjs +0 -86
- package/file-layout.mjs +0 -472
- package/formatting.mjs +0 -110
- package/function-budget.mjs +0 -49
- package/grammar-shim.mjs +0 -123
- package/hooks-folder.mjs +0 -300
- package/hygiene.mjs +0 -513
- package/index.mjs +0 -317
- package/lib/ast.mjs +0 -97
- package/lib/bindings.mjs +0 -159
- package/lib/class-text.mjs +0 -29
- package/lib/config.mjs +0 -142
- package/lib/fe-stack.mjs +0 -77
- package/lib/hfs.mjs +0 -49
- package/lib/next.mjs +0 -6
- package/lib/own-hook-origin.mjs +0 -53
- package/lib/program-for.mjs +0 -22
- package/lib/pure-owner-helper.mjs +0 -148
- package/lib/react.mjs +0 -28
- package/lib/redux-hook-origin.mjs +0 -44
- package/lib/typed-class-values.mjs +0 -81
- package/lib/types.mjs +0 -63
- package/lib/why.mjs +0 -352
- package/lists.mjs +0 -193
- package/loading.mjs +0 -156
- package/naming.mjs +0 -185
- package/native-controls.mjs +0 -108
- package/next-conventions.mjs +0 -447
- package/props-and-slots.mjs +0 -276
- package/served-locale.mjs +0 -243
- package/size-and-state-budget.mjs +0 -376
- package/size-growth.mjs +0 -62
- package/surface-rules.mjs +0 -443
- package/swr-family.mjs +0 -326
- package/the-split.mjs +0 -155
- package/tokens.mjs +0 -39
- package/translation.mjs +0 -392
- package/transport.mjs +0 -830
- package/type-safety.mjs +0 -147
- package/ui-rulings.mjs +0 -134
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `translation.md` (HFS R58, `FE_I18N_LITERAL`, and R286 feedback copy).
|
|
3
|
+
*
|
|
4
|
+
* The original pair forbids a translation call below a block - easy and mostly redundant, since
|
|
5
|
+
* the split rule already stops the drawing half reaching for the runtime - and holds the law:
|
|
6
|
+
* NO LITERAL COPY AT ANY TIER, IN ANY LANGUAGE, AND NO PRAGMA.
|
|
7
|
+
*
|
|
8
|
+
* One escape is enough to turn a rule into a comment convention, so there is no tier
|
|
9
|
+
* exemption and no pragma: a word a reader can see or hear comes from `t()` over a `next-intl`
|
|
10
|
+
* catalogue, and the catalogue (`messages/<locale>.json`) is the only place a second language is
|
|
11
|
+
* content.
|
|
12
|
+
*
|
|
13
|
+
* THE ATTRIBUTES MATTER MORE THAN THE MARKUP. Copy hides in `aria-label`, `placeholder`, `title`
|
|
14
|
+
* and `alt` precisely because none of them reads as a sentence when you scan the file - and an
|
|
15
|
+
* `aria-label` is not a small case: a screen reader treats it as the primary text, so an English
|
|
16
|
+
* one on a translated surface is the loudest defect on the page for the reader least able to work
|
|
17
|
+
* around it. It also hides in object copy (`{ title: "..." }`), which is how a page's metadata
|
|
18
|
+
* ends up in one language regardless of the URL.
|
|
19
|
+
*
|
|
20
|
+
* TWO STRENGTHS OF TEST, ON PURPOSE. JSX text and the attributes a reader hears (`label`, `title`,
|
|
21
|
+
* `alt`, `placeholder`, the `aria-*` set) are copy whatever they say, so ANY word reports: `Save` is
|
|
22
|
+
* copy, `logo` is copy. An object property or a generic prop (`description`, `hint`) may carry a
|
|
23
|
+
* token (`{ title: "sm" }`), so those need to look like prose - a space, a non-ASCII letter or a
|
|
24
|
+
* leading capital. A crude test that fires on real copy
|
|
25
|
+
* and spares tokens is worth more than a clever one nobody trusts.
|
|
26
|
+
*/
|
|
27
|
+
import { type Recommended } from "./lib/rule.js";
|
|
28
|
+
/** A component below a block never resolves a word. */
|
|
29
|
+
export declare const noCopyResolutionBelowBlock: import("./lib/rule.js").Rule<"resolves", []>;
|
|
30
|
+
/** No literal copy at any tier, in any language, and no pragma to allow one. */
|
|
31
|
+
export declare const noHardcodedCopy: import("./lib/rule.js").Rule<"text" | "property" | "attribute", []>;
|
|
32
|
+
/** Literal feedback and validation messages are catalogue copy in both editions. */
|
|
33
|
+
export declare const noCopyInCall: import("./lib/rule.js").Rule<"copy", []>;
|
|
34
|
+
/** Browser alerts bypass the application's translated toast feedback. */
|
|
35
|
+
export declare const noAlert: import("./lib/rule.js").Rule<"alert", []>;
|
|
36
|
+
/** The rules this law contributes to the plugin. */
|
|
37
|
+
export declare const rules: {
|
|
38
|
+
"no-copy-resolution-below-block": import("./lib/rule.js").Rule<"resolves", []>;
|
|
39
|
+
"no-hardcoded-copy": import("./lib/rule.js").Rule<"text" | "property" | "attribute", []>;
|
|
40
|
+
"no-copy-in-call": import("./lib/rule.js").Rule<"copy", []>;
|
|
41
|
+
"no-alert": import("./lib/rule.js").Rule<"alert", []>;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
45
|
+
*
|
|
46
|
+
* `no-hardcoded-copy` is the one a repository with history should expect a large count from, and
|
|
47
|
+
* every report is a real move: the string has to be lifted into a catalogue key and read through
|
|
48
|
+
* `t()`, which is work rather than a deletion. That is the reason there is no `warn` rollout and no
|
|
49
|
+
* pragma - a repository burns the count down before it adopts, it does not live beside it.
|
|
50
|
+
*/
|
|
51
|
+
export declare const recommended: Recommended;
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `translation.md` (HFS R58, `FE_I18N_LITERAL`, and R286 feedback copy).
|
|
3
|
+
*
|
|
4
|
+
* The original pair forbids a translation call below a block - easy and mostly redundant, since
|
|
5
|
+
* the split rule already stops the drawing half reaching for the runtime - and holds the law:
|
|
6
|
+
* NO LITERAL COPY AT ANY TIER, IN ANY LANGUAGE, AND NO PRAGMA.
|
|
7
|
+
*
|
|
8
|
+
* One escape is enough to turn a rule into a comment convention, so there is no tier
|
|
9
|
+
* exemption and no pragma: a word a reader can see or hear comes from `t()` over a `next-intl`
|
|
10
|
+
* catalogue, and the catalogue (`messages/<locale>.json`) is the only place a second language is
|
|
11
|
+
* content.
|
|
12
|
+
*
|
|
13
|
+
* THE ATTRIBUTES MATTER MORE THAN THE MARKUP. Copy hides in `aria-label`, `placeholder`, `title`
|
|
14
|
+
* and `alt` precisely because none of them reads as a sentence when you scan the file - and an
|
|
15
|
+
* `aria-label` is not a small case: a screen reader treats it as the primary text, so an English
|
|
16
|
+
* one on a translated surface is the loudest defect on the page for the reader least able to work
|
|
17
|
+
* around it. It also hides in object copy (`{ title: "..." }`), which is how a page's metadata
|
|
18
|
+
* ends up in one language regardless of the URL.
|
|
19
|
+
*
|
|
20
|
+
* TWO STRENGTHS OF TEST, ON PURPOSE. JSX text and the attributes a reader hears (`label`, `title`,
|
|
21
|
+
* `alt`, `placeholder`, the `aria-*` set) are copy whatever they say, so ANY word reports: `Save` is
|
|
22
|
+
* copy, `logo` is copy. An object property or a generic prop (`description`, `hint`) may carry a
|
|
23
|
+
* token (`{ title: "sm" }`), so those need to look like prose - a space, a non-ASCII letter or a
|
|
24
|
+
* leading capital. A crude test that fires on real copy
|
|
25
|
+
* and spares tokens is worth more than a clever one nobody trusts.
|
|
26
|
+
*/
|
|
27
|
+
import { defineRule } from "./lib/rule.js";
|
|
28
|
+
import { isContentFile } from "./comments.js";
|
|
29
|
+
import { variableOf } from "./lib/bindings.js";
|
|
30
|
+
import { globalReferences, isComponentFile, kindOfFile } from "./lib/scope.js";
|
|
31
|
+
import { why } from "./lib/why.js";
|
|
32
|
+
/** True when a file sits in a component layer that must not resolve copy: every layer but `blocks` receives every word it renders, so it can know no domain and no sentence. */
|
|
33
|
+
const isVocabularyFile = (context) => isComponentFile(context) && kindOfFile(context) !== "blocks";
|
|
34
|
+
/** Attributes a reader sees or hears: any word in them is copy. */
|
|
35
|
+
const STRICT_ATTRS = new Set([
|
|
36
|
+
"label",
|
|
37
|
+
"aria-label",
|
|
38
|
+
"aria-description",
|
|
39
|
+
"aria-roledescription",
|
|
40
|
+
"aria-valuetext",
|
|
41
|
+
"aria-placeholder",
|
|
42
|
+
"placeholder",
|
|
43
|
+
"title",
|
|
44
|
+
"alt",
|
|
45
|
+
]);
|
|
46
|
+
/** Props that usually carry a sentence but may carry a token (a variant, an id). */
|
|
47
|
+
const PROSE_ATTRS = new Set([
|
|
48
|
+
"description",
|
|
49
|
+
"helperText",
|
|
50
|
+
"hint",
|
|
51
|
+
"tooltip",
|
|
52
|
+
"caption",
|
|
53
|
+
"heading",
|
|
54
|
+
"subtitle",
|
|
55
|
+
"errorMessage",
|
|
56
|
+
"emptyText",
|
|
57
|
+
"loadingText",
|
|
58
|
+
"confirmLabel",
|
|
59
|
+
"cancelLabel",
|
|
60
|
+
]);
|
|
61
|
+
/** Object keys whose value is shown to a reader. */
|
|
62
|
+
const COPY_KEYS = new Set([
|
|
63
|
+
"title",
|
|
64
|
+
"label",
|
|
65
|
+
"description",
|
|
66
|
+
"placeholder",
|
|
67
|
+
"message",
|
|
68
|
+
"heading",
|
|
69
|
+
"subtitle",
|
|
70
|
+
"caption",
|
|
71
|
+
"tooltip",
|
|
72
|
+
"hint",
|
|
73
|
+
"cta",
|
|
74
|
+
"text",
|
|
75
|
+
"alt",
|
|
76
|
+
"ariaLabel",
|
|
77
|
+
"helperText",
|
|
78
|
+
"emptyText",
|
|
79
|
+
"errorMessage",
|
|
80
|
+
"successMessage",
|
|
81
|
+
]);
|
|
82
|
+
/** Calls that resolve a word at render time. */
|
|
83
|
+
const RESOLVES_COPY = /^(?:useTranslations|useLocale|useFormatter|getTranslations)$/;
|
|
84
|
+
/**
|
|
85
|
+
* True when the text contains a word: two or more letters in any script.
|
|
86
|
+
*
|
|
87
|
+
* TWO LETTERS, NOT ONE, AND THE SAME AS THE REPOSITORY GATE. A lone letter is a unit, a separator or a
|
|
88
|
+
* key cap (`x`, `k`, `A`); a word is where copy starts. The repository's i18n catalog check
|
|
89
|
+
* draws the line at two letters for JSX text and copy attributes, and this rule draws it in the same
|
|
90
|
+
* place so the lint and the gate never disagree about what a literal is. Neither has a suppression
|
|
91
|
+
* marker.
|
|
92
|
+
*/
|
|
93
|
+
const hasLetter = (text) => typeof text === "string" && /\p{L}{2,}/u.test(text);
|
|
94
|
+
/** Prose rather than a token: it has a space, a non-ASCII letter, or it starts like a sentence. */
|
|
95
|
+
const looksLikeProse = (text) => hasLetter(text) && (/\s/.test(text) || /[^\x00-\x7F]/.test(text) || /^[A-Z]/.test(text));
|
|
96
|
+
/** Static string carried by a literal or an expression-free template, else null. */
|
|
97
|
+
const staticString = (node) => {
|
|
98
|
+
if (!node)
|
|
99
|
+
return null;
|
|
100
|
+
if (node.type === "Literal" && typeof node.value === "string")
|
|
101
|
+
return node.value;
|
|
102
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) {
|
|
103
|
+
return node.quasis.map((quasi) => quasi.value.cooked ?? "").join("");
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* The static text of a template literal that has substitutions, each substitution standing as `#`
|
|
109
|
+
* (a template of "the count, then installed" reads "# installed"); null for anything that is not such a template.
|
|
110
|
+
*/
|
|
111
|
+
const templateText = (node) => {
|
|
112
|
+
if (!node || node.type !== "TemplateLiteral" || node.expressions.length === 0)
|
|
113
|
+
return null;
|
|
114
|
+
return node.quasis.map((quasi) => quasi.value.cooked ?? "").join("#");
|
|
115
|
+
};
|
|
116
|
+
/** True when the static parts of a template with substitutions contain a word (a count then "installed", not two ids joined by a dash). */
|
|
117
|
+
const templateHasWord = (node) => {
|
|
118
|
+
const text = templateText(node);
|
|
119
|
+
return text !== null && hasLetter(text.replaceAll("#", " "));
|
|
120
|
+
};
|
|
121
|
+
/** Punctuation at either edge of a word, outside its letters and hyphens. */
|
|
122
|
+
const WORD_START = new Set(['"', "'", "("]);
|
|
123
|
+
const WORD_END = new Set([".", ",", "!", "?", ":", ";", '"', ")"]);
|
|
124
|
+
/** Remove the edge punctuation in a single pass, preserving an apostrophe used as the core. */
|
|
125
|
+
const wordCore = (token) => {
|
|
126
|
+
let end = token.length;
|
|
127
|
+
while (end > 0 && WORD_END.has(token[end - 1]))
|
|
128
|
+
end -= 1;
|
|
129
|
+
let start = 0;
|
|
130
|
+
while (start < end && WORD_START.has(token[start]))
|
|
131
|
+
start += 1;
|
|
132
|
+
if (start > 0 && token[start - 1] === "'")
|
|
133
|
+
start -= 1;
|
|
134
|
+
return token.slice(start, end);
|
|
135
|
+
};
|
|
136
|
+
/** A sentence word allows hyphens between letters; an all-capitals chain (YYYY-MM-DD) is a format. */
|
|
137
|
+
const isSentenceWord = (token) => /^[\p{L}\p{N}#'\u2019]+(?:-[\p{L}\p{N}#]+)*$/u.test(wordCore(token)) && !/^\p{Lu}{2,}(?:-\p{Lu}{2,})+$/u.test(token);
|
|
138
|
+
/**
|
|
139
|
+
* A sentence rather than a token, for a property whose key does not say it is copy: two or more words made of
|
|
140
|
+
* letters and sentence punctuation only (so a class list, a URL, a media type or an id never qualifies), and it starts
|
|
141
|
+
* like a sentence, ends like one, or carries a non-ASCII letter.
|
|
142
|
+
*/
|
|
143
|
+
const looksLikeSentence = (text) => {
|
|
144
|
+
const tokens = text.trim().split(/\s+/);
|
|
145
|
+
if (tokens.length < 2)
|
|
146
|
+
return false;
|
|
147
|
+
if (!tokens.every(isSentenceWord))
|
|
148
|
+
return false;
|
|
149
|
+
if (tokens.filter((token) => /\p{L}{2,}/u.test(token)).length < 2)
|
|
150
|
+
return false;
|
|
151
|
+
return /^\p{Lu}/u.test(text.trim()) || /[^\x00-\x7F]/.test(text) || /[.!?]$/.test(text.trim());
|
|
152
|
+
};
|
|
153
|
+
/** Static string carried by a JSX attribute value, else null. */
|
|
154
|
+
const attributeText = (node) => {
|
|
155
|
+
const value = node && node.value;
|
|
156
|
+
if (!value)
|
|
157
|
+
return null;
|
|
158
|
+
if (value.type === "JSXExpressionContainer")
|
|
159
|
+
return staticString(value.expression);
|
|
160
|
+
return staticString(value);
|
|
161
|
+
};
|
|
162
|
+
/** The value node behind a JSX attribute, for de-duplication against the second-language walk. */
|
|
163
|
+
const attributeValueNode = (node) => node.value && node.value.type === "JSXExpressionContainer" ? node.value.expression : node.value;
|
|
164
|
+
/** The name of an object property key, else null. */
|
|
165
|
+
const keyName = (property) => {
|
|
166
|
+
if (property.computed)
|
|
167
|
+
return null;
|
|
168
|
+
if (property.key.type === "Identifier")
|
|
169
|
+
return property.key.name;
|
|
170
|
+
if (property.key.type === "Literal" && typeof property.key.value === "string")
|
|
171
|
+
return property.key.value;
|
|
172
|
+
return null;
|
|
173
|
+
};
|
|
174
|
+
// -- COPY-1 ----------------------------------------------------------------------------------------
|
|
175
|
+
/** A component below a block never resolves a word. */
|
|
176
|
+
export const noCopyResolutionBelowBlock = defineRule({
|
|
177
|
+
meta: {
|
|
178
|
+
type: "problem",
|
|
179
|
+
docs: { description: "The vocabulary tiers receive resolved strings; they never resolve one." },
|
|
180
|
+
schema: [],
|
|
181
|
+
messages: {
|
|
182
|
+
resolves: "`{{name}}(...)` resolves a word in a tier that receives every word it renders. This component would then need the translation runtime to be rendered from a fixture, and it would have to know which situation the reader is in to pick the right sentence - which is the connected half's job, one file away.",
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
create(context) {
|
|
186
|
+
if (!isVocabularyFile(context))
|
|
187
|
+
return {};
|
|
188
|
+
return {
|
|
189
|
+
CallExpression(node) {
|
|
190
|
+
const callee = node.callee;
|
|
191
|
+
if (!callee || callee.type !== "Identifier" || !RESOLVES_COPY.test(callee.name))
|
|
192
|
+
return;
|
|
193
|
+
context.report({ node, messageId: "resolves", data: { name: callee.name } });
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
// -- COPY-2 ----------------------------------------------------------------------------------------
|
|
199
|
+
/** No literal copy at any tier, in any language, and no pragma to allow one. */
|
|
200
|
+
export const noHardcodedCopy = defineRule({
|
|
201
|
+
meta: {
|
|
202
|
+
type: "problem",
|
|
203
|
+
docs: { description: "User-facing text comes from a `next-intl` catalogue through `t()`, at every tier." },
|
|
204
|
+
schema: [],
|
|
205
|
+
messages: {
|
|
206
|
+
text: "`{{text}}` is copy written into source. A reader in another language sees it exactly as written, and there is no tier or comment that makes that acceptable: take the sentence from the catalogue with `t(\"key\")`.",
|
|
207
|
+
attribute: "`{{attr}}=\"{{text}}\"` is copy, hardcoded. One reader in another language sees it verbatim - and `{{attr}}` is where copy hides, because it does not read as a sentence when scanning the file. Use `t(\"key\")`.",
|
|
208
|
+
property: "`{{key}}: \"{{text}}\"` is copy in an object, so whatever displays this object (a page title, an empty state, a toast) shows it in one language. Use `t(\"key\")`, or build the object where `t` is in scope.",
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
create(context) {
|
|
212
|
+
if (isContentFile(context))
|
|
213
|
+
return {};
|
|
214
|
+
const source = context.sourceCode || context.getSourceCode();
|
|
215
|
+
return {
|
|
216
|
+
JSXText(node) {
|
|
217
|
+
const text = String(node.value || "").trim();
|
|
218
|
+
if (hasLetter(text))
|
|
219
|
+
context.report({ node, messageId: "text", data: { text } });
|
|
220
|
+
},
|
|
221
|
+
JSXExpressionContainer(node) {
|
|
222
|
+
// `<p>{"Hello"}</p>`: a literal in child position is JSX text with braces around it.
|
|
223
|
+
const parent = node.parent;
|
|
224
|
+
if (!parent || (parent.type !== "JSXElement" && parent.type !== "JSXFragment"))
|
|
225
|
+
return;
|
|
226
|
+
if (templateHasWord(node.expression)) {
|
|
227
|
+
return context.report({ node, messageId: "text", data: { text: source.getText(node.expression) } });
|
|
228
|
+
}
|
|
229
|
+
const text = staticString(node.expression);
|
|
230
|
+
if (text === null || !hasLetter(text.trim()))
|
|
231
|
+
return;
|
|
232
|
+
context.report({ node, messageId: "text", data: { text: text.trim() } });
|
|
233
|
+
},
|
|
234
|
+
JSXAttribute(node) {
|
|
235
|
+
const attr = node.name && node.name.type === "JSXIdentifier" ? node.name.name : null;
|
|
236
|
+
if (!attr)
|
|
237
|
+
return;
|
|
238
|
+
const strict = STRICT_ATTRS.has(attr);
|
|
239
|
+
if (!strict && !PROSE_ATTRS.has(attr))
|
|
240
|
+
return;
|
|
241
|
+
const value = attributeValueNode(node);
|
|
242
|
+
const template = templateText(value);
|
|
243
|
+
const text = template ?? attributeText(node);
|
|
244
|
+
if (text === null)
|
|
245
|
+
return;
|
|
246
|
+
if (strict ? !hasLetter(text.replaceAll("#", " ")) : !looksLikeProse(text))
|
|
247
|
+
return;
|
|
248
|
+
context.report({ node, messageId: "attribute", data: { attr, text: template === null ? text : source.getText(value ?? undefined) } });
|
|
249
|
+
},
|
|
250
|
+
Property(node) {
|
|
251
|
+
const key = keyName(node);
|
|
252
|
+
if (!key || node.parent.type !== "ObjectExpression")
|
|
253
|
+
return;
|
|
254
|
+
const template = templateText(node.value);
|
|
255
|
+
const text = template ?? staticString(node.value);
|
|
256
|
+
if (text === null)
|
|
257
|
+
return;
|
|
258
|
+
// a copy key needs prose; any other key needs a whole sentence: { ready: "Your course is ready" } in a hook is copy
|
|
259
|
+
if (!(COPY_KEYS.has(key) ? looksLikeProse(text) : looksLikeSentence(text)))
|
|
260
|
+
return;
|
|
261
|
+
context.report({ node, messageId: "property", data: { key, text: template === null ? text : source.getText(node.value) } });
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
/** The static name of a member, including a bracketed string name. */
|
|
267
|
+
const memberName = (node) => {
|
|
268
|
+
if (node?.type !== "MemberExpression")
|
|
269
|
+
return null;
|
|
270
|
+
return node.computed ? staticString(node.property) : node.property.name ?? null;
|
|
271
|
+
};
|
|
272
|
+
/** The identifier at the root of a fluent call or property chain. */
|
|
273
|
+
const callRoot = (node) => {
|
|
274
|
+
if (!node)
|
|
275
|
+
return null;
|
|
276
|
+
if (node.type === "Identifier")
|
|
277
|
+
return node;
|
|
278
|
+
if (node.type === "CallExpression")
|
|
279
|
+
return callRoot(node.callee);
|
|
280
|
+
if (node.type === "MemberExpression")
|
|
281
|
+
return callRoot(node.object);
|
|
282
|
+
if (node.type === "ChainExpression" || node.type === "TSAsExpression" || node.type === "TSNonNullExpression")
|
|
283
|
+
return callRoot(node.expression);
|
|
284
|
+
return null;
|
|
285
|
+
};
|
|
286
|
+
/** The module an import binding reads from; an `import x = require()` binding carries no `source`, and stops the run as reading it always has. */
|
|
287
|
+
const importSource = (binding) => {
|
|
288
|
+
const declaration = binding.parent;
|
|
289
|
+
if (declaration.type !== "ImportDeclaration")
|
|
290
|
+
throw new TypeError("Cannot read properties of undefined (reading 'value')");
|
|
291
|
+
return declaration.source.value;
|
|
292
|
+
};
|
|
293
|
+
/** The name an import specifier imports, when it is spelled as an identifier. */
|
|
294
|
+
const importedName = (specifier) => specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" ? specifier.imported.name : undefined;
|
|
295
|
+
/** Methods whose first argument is a Zod validation message rather than a value constraint. */
|
|
296
|
+
const ZOD_MESSAGE_FIRST = new Set([
|
|
297
|
+
"email", "url", "uuid", "cuid", "cuid2", "ulid", "emoji", "base64", "base64url", "ip", "ipv4", "ipv6",
|
|
298
|
+
"datetime", "date", "time", "duration", "jwt", "nanoid", "nonempty", "finite", "int", "safe",
|
|
299
|
+
"positive", "negative", "nonnegative", "nonpositive",
|
|
300
|
+
]);
|
|
301
|
+
/** Methods whose constraint or predicate precedes the validation message. */
|
|
302
|
+
const ZOD_MESSAGE_SECOND = new Set([
|
|
303
|
+
"min", "max", "length", "regex", "includes", "startsWith", "endsWith", "gt", "gte", "lt", "lte", "multipleOf", "refine",
|
|
304
|
+
]);
|
|
305
|
+
/** Toast methods that receive visible feedback as their first argument; dismiss receives an id. */
|
|
306
|
+
const TOAST_MESSAGE_METHODS = new Set(["success", "error", "info", "warning", "warn", "loading", "message"]);
|
|
307
|
+
/** Literal feedback and validation messages are catalogue copy in both editions. */
|
|
308
|
+
export const noCopyInCall = defineRule({
|
|
309
|
+
meta: {
|
|
310
|
+
type: "problem",
|
|
311
|
+
docs: { description: "FE_COPY_IN_CALL: toast and Zod validation messages come from next-intl." },
|
|
312
|
+
schema: [],
|
|
313
|
+
messages: {
|
|
314
|
+
copy: "FE_COPY_IN_CALL: `{{text}}` is literal feedback or validation copy. Resolve the message from the catalogue with t().",
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
create(context) {
|
|
318
|
+
const source = context.sourceCode ?? context.getSourceCode();
|
|
319
|
+
return {
|
|
320
|
+
CallExpression(node) {
|
|
321
|
+
const root = callRoot(node.callee);
|
|
322
|
+
if (!root)
|
|
323
|
+
return;
|
|
324
|
+
const binding = variableOf(context, root)?.defs[0] ?? null;
|
|
325
|
+
const importBinding = binding?.type === "ImportBinding" ? binding : null;
|
|
326
|
+
const imported = importBinding ? importSource(importBinding) : null;
|
|
327
|
+
const method = memberName(node.callee);
|
|
328
|
+
const zod = imported === "zod" || imported?.startsWith("zod/") || (!binding && root.name === "z");
|
|
329
|
+
const toast = root.name === "toast" || (imported && /(?:^|\/)toast(?:\/|$)/.test(imported) &&
|
|
330
|
+
importBinding !== null && (importBinding.node.type === "ImportDefaultSpecifier" || importedName(importBinding.node) === "toast"));
|
|
331
|
+
let message;
|
|
332
|
+
if (toast && (node.callee.type === "Identifier" || TOAST_MESSAGE_METHODS.has(method)))
|
|
333
|
+
message = node.arguments[0];
|
|
334
|
+
else if (zod && ZOD_MESSAGE_FIRST.has(method))
|
|
335
|
+
message = node.arguments[0];
|
|
336
|
+
else if (zod && ZOD_MESSAGE_SECOND.has(method))
|
|
337
|
+
message = node.arguments[1];
|
|
338
|
+
if (!message)
|
|
339
|
+
return;
|
|
340
|
+
const text = staticString(message);
|
|
341
|
+
if (text !== null ? hasLetter(text) : templateHasWord(message)) {
|
|
342
|
+
context.report({ node: message, messageId: "copy", data: { text: text ?? source.getText(message) } });
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
/** Browser alerts bypass the application's translated toast feedback. */
|
|
349
|
+
export const noAlert = defineRule({
|
|
350
|
+
meta: {
|
|
351
|
+
type: "problem",
|
|
352
|
+
docs: { description: "FE_NO_ALERT: feedback uses the toast module rather than browser alert()." },
|
|
353
|
+
schema: [],
|
|
354
|
+
messages: {
|
|
355
|
+
alert: "FE_NO_ALERT: browser alert() bypasses the application feedback surface. Use the toast module with a translated message.",
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
create(context) {
|
|
359
|
+
const calls = [];
|
|
360
|
+
return {
|
|
361
|
+
CallExpression(node) { calls.push(node); },
|
|
362
|
+
"Program:exit"() {
|
|
363
|
+
const globals = new Set(globalReferences(context, new Set(["alert", "window", "globalThis", "self"])));
|
|
364
|
+
for (const node of calls) {
|
|
365
|
+
const callee = node.callee;
|
|
366
|
+
const direct = callee.type === "Identifier" && callee.name === "alert" && globals.has(callee);
|
|
367
|
+
const member = callee.type === "MemberExpression" && memberName(callee) === "alert" && callee.object.type === "Identifier" && globals.has(callee.object);
|
|
368
|
+
if (direct || member)
|
|
369
|
+
context.report({ node, messageId: "alert" });
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
// Subcodes remain available to catalog validation when this module is imported independently.
|
|
376
|
+
Object.assign(why, {
|
|
377
|
+
"no-copy-in-call": {
|
|
378
|
+
code: "FE_COPY_IN_CALL",
|
|
379
|
+
en: "`<file>` passes literal copy to a toast or a Zod validation message.",
|
|
380
|
+
fix: "Resolve feedback and validation messages from the next-intl catalogue with t().",
|
|
381
|
+
},
|
|
382
|
+
"no-alert": {
|
|
383
|
+
code: "FE_NO_ALERT",
|
|
384
|
+
en: "`<file>` calls browser alert(), bypassing the application's feedback surface.",
|
|
385
|
+
fix: "Use the toast module with a translated message.",
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
/** The rules this law contributes to the plugin. */
|
|
389
|
+
export const rules = {
|
|
390
|
+
"no-copy-resolution-below-block": noCopyResolutionBelowBlock,
|
|
391
|
+
"no-hardcoded-copy": noHardcodedCopy,
|
|
392
|
+
"no-copy-in-call": noCopyInCall,
|
|
393
|
+
"no-alert": noAlert,
|
|
394
|
+
};
|
|
395
|
+
/**
|
|
396
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
397
|
+
*
|
|
398
|
+
* `no-hardcoded-copy` is the one a repository with history should expect a large count from, and
|
|
399
|
+
* every report is a real move: the string has to be lifted into a catalogue key and read through
|
|
400
|
+
* `t()`, which is work rather than a deletion. That is the reason there is no `warn` rollout and no
|
|
401
|
+
* pragma - a repository burns the count down before it adopts, it does not live beside it.
|
|
402
|
+
*/
|
|
403
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `transport.md` (HFS R50 `FE_TRANSPORT_OWNER`, R51 `FE_HTTP_STATUS_COLLAPSE`,
|
|
3
|
+
* R52 `FE_WIRE_GENERATED`).
|
|
4
|
+
*
|
|
5
|
+
* ONE WIRE, ONE VOCABULARY. A front end talks to its back end only through its API layer, `modules/api` (HFS slot
|
|
6
|
+
* `fe.modules.api`, or the shared api package `fe.package.api`): the auth and no-auth Apollo clients with their link chain are
|
|
7
|
+
* the transport owner (slot `fe.transport.client`: `modules/api/graphql/clients/`, or `fe.package.api.client`), one file per
|
|
8
|
+
* GraphQL operation runs on them, and the few REST calls (uploads, presigned URLs, auth endpoints) are axios calls in
|
|
9
|
+
* `modules/api/rest/`. The layer answers in exactly one vocabulary, `GraphQLResponse` (slot `fe.transport.outcome`:
|
|
10
|
+
* `modules/api/graphql/types.ts`, or `fe.package.api.outcome`). The defects the three halves prevent were measured on real
|
|
11
|
+
* apps: seven transports and six result shapes in one repository, and in another a client that folded every non-2xx into
|
|
12
|
+
* "could not read", so the state a real backend produces for a signed-out reader - 401, which must become `refused` and
|
|
13
|
+
* send the reader to sign in - was unreachable, while a test double hid it by answering 200.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THESE RULES CANNOT SEE. They read one file at a time, so they cannot tell that a client's
|
|
16
|
+
* timeout is long enough or that a `refused` outcome is routed to the sign-in page - the module's
|
|
17
|
+
* spec proves that. They hold the shapes that make it provable: the HTTP libraries and `fetch` only in the API layer, an
|
|
18
|
+
* abort signal on every `fetch`, no shared mutable state, a 401/403 branch where a raw `fetch` is answered, and no null
|
|
19
|
+
* standing in for a status.
|
|
20
|
+
*/
|
|
21
|
+
import { type Recommended, type Rules } from "./lib/rule.js";
|
|
22
|
+
/**
|
|
23
|
+
* `fetch` is reached only in the API layer, each canonical transport library only in its home, and no other HTTP library exists.
|
|
24
|
+
*
|
|
25
|
+
* The API layer is every file of the slots `fe.modules.api`, `fe.transport.client`, `fe.transport.outcome` (an app's
|
|
26
|
+
* `modules/api/`) and `fe.package.api*` (the shared api package): the Apollo clients and their links, the operations and the
|
|
27
|
+
* REST calls. `@apollo/client` is imported there and nowhere else, `axios` only by its REST calls (`modules/api/rest/`),
|
|
28
|
+
* `socket.io-client` only by `hooks/socketio/`. Native `WebSocket` is refused everywhere, including the API layer.
|
|
29
|
+
* Every other file reaches the back end through an SWR hook over an operation.
|
|
30
|
+
*/
|
|
31
|
+
export declare const fetchOnlyInApiClient: import("./lib/rule.js").Rule<"library" | "outside" | "channel" | "websocket" | "xhr" | "placed", []>;
|
|
32
|
+
/** Every `fetch` of the API layer (a custom fetch handed to the Apollo http link, a raw call) carries an abort signal, so a hung backend cannot hang a reader. */
|
|
33
|
+
export declare const clientFetchHasSignal: import("./lib/rule.js").Rule<"signal", []>;
|
|
34
|
+
/** No module-level mutable state in the API layer: a token or locale in a `let` leaks across requests. */
|
|
35
|
+
export declare const noSharedTransportState: import("./lib/rule.js").Rule<"shared", []>;
|
|
36
|
+
/**
|
|
37
|
+
* A raw `fetch` answered in the transport owner maps 401 and 403 to `refused`, so "sign in" is a state a reader can reach.
|
|
38
|
+
*
|
|
39
|
+
* The Apollo clients reach the network through the http link and map an unauthenticated answer in the error link; the rule
|
|
40
|
+
* speaks only when a file of the clients folder (`fe.transport.client`, `fe.package.api.client`) calls `fetch` itself. It judges
|
|
41
|
+
* a real branch in that file: a comparison of the fetch response's status (typed as a `Response`) with 401 and with
|
|
42
|
+
* 403 (`===`, `||`, a literal list, a `switch` with both cases), whose consequent builds an object with `kind: "refused"`. The three
|
|
43
|
+
* literals lying anywhere in the file do not pass. A mapping delegated to a function in another file is not visible here and does
|
|
44
|
+
* not pass either: the client is the one file that owns the status meaning.
|
|
45
|
+
*/
|
|
46
|
+
export declare const clientMapsAuthToRefused: import("./lib/rule.js").Rule<"refused", []>;
|
|
47
|
+
/** A failure (a non-ok HTTP response or a failed `Outcome`) is not one branch, and never `null`: its reason survives to the screen. */
|
|
48
|
+
export declare const noFailureCollapse: import("./lib/rule.js").Rule<"raw" | "empty" | "collapse", []>;
|
|
49
|
+
/**
|
|
50
|
+
* Wire types are generated from the contract; nobody types the wire by hand, and a GraphQL document is written only in the API
|
|
51
|
+
* layer (one file per operation, `modules/api/graphql/{queries,mutations}/`), where codegen reads it.
|
|
52
|
+
*/
|
|
53
|
+
export declare const noHandTypedWire: import("./lib/rule.js").Rule<"document" | "cast", []>;
|
|
54
|
+
/** A switch over an Outcome names every kind, so a failure never falls into the success branch or a blank one. */
|
|
55
|
+
export declare const outcomeKindsExhaustive: import("./lib/rule.js").Rule<"missing", []>;
|
|
56
|
+
/**
|
|
57
|
+
* A result vocabulary is declared once per repository: `GraphQLResponse` in the file of the slot the manifest marks `outcomeHome`
|
|
58
|
+
* (`fe.transport.outcome`: `modules/api/graphql/types.ts`, or `fe.package.api.outcome`: the api package's `src/graphql/types.ts`).
|
|
59
|
+
*
|
|
60
|
+
* A union type alias is a result union when its members are object types that ALL carry the same literal-typed discriminant, in the
|
|
61
|
+
* result vocabulary: `success` or `ok` (both `true` and `false` among the members) or `kind` (a member of kind `"ok"` beside at least one failure
|
|
62
|
+
* kind of the Outcome vocabulary: refused, forbidden, invalid, not-found, unavailable). A view union that a mapper derives from an
|
|
63
|
+
* Outcome (`{ kind: "ready" } | { kind: "not-found" }`, no `ok` arm) is a screen state, not a second result vocabulary. The check reads the resolved members through the checker, so
|
|
64
|
+
* `Ok<T> | Failure` and an intersection are seen as what they are, and no name (`*Outcome`, `*Result`) decides anything.
|
|
65
|
+
* A UI state union (`{ status: "idle" } | { status: "saving" }`, `{ type: ... }`, a `kind` of menu items) is not result
|
|
66
|
+
* vocabulary and passes. An alias that only composes the one union (`type Read = Outcome<Course>`, `Extract<Outcome<T>, ...>`) is not
|
|
67
|
+
* a union type node and passes; `Outcome<T> | { ok: false; kind: "conflict" }` adds an arm to the one union and is refused.
|
|
68
|
+
*/
|
|
69
|
+
export declare const oneOutcomeUnion: import("./lib/rule.js").Rule<"second", []>;
|
|
70
|
+
/** The rules this law contributes to the plugin. */
|
|
71
|
+
export declare const rules: Rules;
|
|
72
|
+
/** Every rule is an error: a second transport or a status collapse is the defect, not a style. */
|
|
73
|
+
export declare const recommended: Recommended;
|