@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,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds `client-boundary.md` (HFS R55, `FE_CLIENT_BOUNDARY`).
|
|
3
|
+
*
|
|
4
|
+
* SERVER FIRST. A route file is a server component; `"use client"` is a cost the app pays where a
|
|
5
|
+
* reader interacts, and it is paid once, at the top of the interactive subtree, not sprinkled
|
|
6
|
+
* wherever a hook happened to be needed. The measured alternative: 155 client files in one app,
|
|
7
|
+
* a client layout that shipped a 132 KB message catalog to every page, against 3 client files in a
|
|
8
|
+
* sibling app that did the same job server-first.
|
|
9
|
+
*
|
|
10
|
+
* The directive is allowed in exactly the places the framework or the interaction requires:
|
|
11
|
+
* - the `index.tsx` of an interactive block (or overlay), which is the connected half;
|
|
12
|
+
* - a leaf, whose interactivity is intrinsic (a menu, a tooltip trigger);
|
|
13
|
+
* - `error.tsx` and `global-error.tsx`, which Next requires to be client components;
|
|
14
|
+
* - the client state of the front-end stack, which only ever runs in the browser: a provider of
|
|
15
|
+
* `components/providers/<name>/index.tsx`, every file of `hooks/` (the SWR, form, store, socket and effect hooks and the
|
|
16
|
+
* side-effect components), the redux store (`redux/`) and the toast module (`modules/toast/`). A directive there states
|
|
17
|
+
* what the file already is; it ships nothing a server component would have kept.
|
|
18
|
+
* Everything below a client block is already on the client and needs no directive; everything
|
|
19
|
+
* above it is a server component and must not have one.
|
|
20
|
+
*/
|
|
21
|
+
import ts from "typescript";
|
|
22
|
+
import { defineRule } from "./lib/rule.js";
|
|
23
|
+
import { isProductSource, kindOfFile, roleOfFile, slotOfFile, tierOfFile } from "./lib/scope.js";
|
|
24
|
+
import { typed } from "./lib/types.js";
|
|
25
|
+
/** Route-tree roles that are client components by construction: the error boundaries, and the providers wrapper a layout renders. */
|
|
26
|
+
const FRAMEWORK_CLIENT_ROLES = new Set(["error", "global-error", "providers"]);
|
|
27
|
+
/** The slots of client state outside the hooks tier: the redux store and the toast module (fe-stack state-global, toast). */
|
|
28
|
+
const CLIENT_STATE_SLOTS = new Set(["fe.redux", "fe.modules.toast"]);
|
|
29
|
+
/**
|
|
30
|
+
* The client state of the front-end stack, which runs only in the browser, so the directive states what the file already is: a
|
|
31
|
+
* provider entry (`fe.components.providers`), every file of the hooks tier, the redux store and the toast module.
|
|
32
|
+
*/
|
|
33
|
+
const isClientState = (context) => (slotOfFile(context) === "fe.components.providers" && roleOfFile(context) === "entry") ||
|
|
34
|
+
tierOfFile(context) === "hooks" ||
|
|
35
|
+
CLIENT_STATE_SLOTS.has(slotOfFile(context));
|
|
36
|
+
/** True when the program opens with the `"use client"` directive. */
|
|
37
|
+
const clientDirective = (program) => program.body.find((statement) => statement.type === "ExpressionStatement" && statement.directive === "use client") ?? null;
|
|
38
|
+
/** The layers whose files are legitimately client: a block's entry, a leaf (its interaction is intrinsic) and, in a shared package, the interactive layers (branches and leaves; a composite is presentation). */
|
|
39
|
+
const isInteractionBoundary = (context) => {
|
|
40
|
+
const slot = slotOfFile(context);
|
|
41
|
+
const kind = kindOfFile(context);
|
|
42
|
+
const role = roleOfFile(context);
|
|
43
|
+
if (slot === "fe.components")
|
|
44
|
+
return kind === "leaves" || (kind === "blocks" && role === "entry");
|
|
45
|
+
if (slot === "fe.feature")
|
|
46
|
+
return kind === "overlays" && role === "entry";
|
|
47
|
+
if (slot === "fe.package.ui")
|
|
48
|
+
return kind === "branches" || kind === "leaves";
|
|
49
|
+
return false;
|
|
50
|
+
};
|
|
51
|
+
/** Where the directive is a legitimate boundary. */
|
|
52
|
+
const isBoundary = (context) => (slotOfFile(context) === "fe.route" && FRAMEWORK_CLIENT_ROLES.has(roleOfFile(context))) || isInteractionBoundary(context) || isClientState(context);
|
|
53
|
+
/** The slot a file fills in the route tree (its role in `fe.route`, or the page or layout owner of a feature), when it fills one. */
|
|
54
|
+
const routeFill = (context) => {
|
|
55
|
+
if (slotOfFile(context) === "fe.route" && roleOfFile(context) !== null)
|
|
56
|
+
return roleOfFile(context);
|
|
57
|
+
if (slotOfFile(context) === "fe.feature" && kindOfFile(context) === "pages")
|
|
58
|
+
return "page owner";
|
|
59
|
+
if (slotOfFile(context) === "fe.feature" && kindOfFile(context) === "layouts")
|
|
60
|
+
return "layout owner";
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
/** `"use client"` only at an interaction boundary; pages and layouts stay server components. */
|
|
64
|
+
export const useClientOnlyAtBoundary = defineRule({
|
|
65
|
+
meta: {
|
|
66
|
+
type: "problem",
|
|
67
|
+
docs: { description: "`\"use client\"` appears only at a block index, a leaf, a Next error boundary, or the client state of the stack (a provider, `hooks/`, `redux/`, `modules/toast/`)." },
|
|
68
|
+
schema: [],
|
|
69
|
+
messages: {
|
|
70
|
+
slot: "`\"use client\"` on a {{slot}}. Routes, pages and layouts are server components: making one a client component ships its whole subtree, including the message catalog, to every reader and stops it reading data on the server. Keep the {{slot}} a server component that renders a client component: move the interaction into a block (or overlay) below it, put the directive on that block's `index.tsx`, and import that entry from the {{slot}}; context providers go in the route tree's `providers.tsx`.",
|
|
71
|
+
elsewhere: "`\"use client\"` here is not at an interaction boundary. The directive belongs on the `index.tsx` of the interactive block (or overlay), on a leaf whose interaction is intrinsic, on `error.tsx`/`global-error.tsx`, on the route tree's `providers.tsx` (the context providers a layout renders around its children), or on the client state of the stack (`components/providers/<name>/index.tsx`, `hooks/`, `redux/`, `modules/toast/`). Below a client block the file is already client; above one it must stay a server component.",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
create(context) {
|
|
75
|
+
if (isBoundary(context))
|
|
76
|
+
return {};
|
|
77
|
+
const slot = routeFill(context);
|
|
78
|
+
return {
|
|
79
|
+
Program(program) {
|
|
80
|
+
const directive = clientDirective(program);
|
|
81
|
+
if (!directive)
|
|
82
|
+
return;
|
|
83
|
+
if (slot)
|
|
84
|
+
context.report({ node: directive, messageId: "slot", data: { slot } });
|
|
85
|
+
else
|
|
86
|
+
context.report({ node: directive, messageId: "elsewhere" });
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
// -- FE-CLIENT-2 -----------------------------------------------------------------------------------
|
|
92
|
+
/** Node built-ins a browser bundle cannot carry. */
|
|
93
|
+
const NODE_BUILTINS = /^(?:node:.+|fs|fs\/promises|path|os|net|tls|http|https|http2|child_process|cluster|crypto|stream|zlib|dns|worker_threads)$/;
|
|
94
|
+
/** A server API: the request-scoped Next APIs, the server half of next-intl, a Node built-in; null for anything else. */
|
|
95
|
+
const serverApiReason = (source) => {
|
|
96
|
+
if (source === "next/headers")
|
|
97
|
+
return "`next/headers`";
|
|
98
|
+
if (source === "next/server")
|
|
99
|
+
return "`next/server`";
|
|
100
|
+
if (source === "next-intl/server")
|
|
101
|
+
return "`next-intl/server`";
|
|
102
|
+
if (NODE_BUILTINS.test(source))
|
|
103
|
+
return `\`${source}\``;
|
|
104
|
+
return null;
|
|
105
|
+
};
|
|
106
|
+
/** A server-only source: the marker package or a server API. */
|
|
107
|
+
const serverOnlyReason = (source) => (source === "server-only" ? "`server-only`" : serverApiReason(source));
|
|
108
|
+
/** A client component imports nothing that only exists on the server. */
|
|
109
|
+
export const clientNoServerImport = defineRule({
|
|
110
|
+
meta: {
|
|
111
|
+
type: "problem",
|
|
112
|
+
docs: { description: "A `\"use client\"` file does not import `server-only`, `next/headers`, `next/server`, `next-intl/server` or a Node built-in." },
|
|
113
|
+
schema: [],
|
|
114
|
+
messages: {
|
|
115
|
+
server: "This client component imports {{what}}, which exists only on the server. The build either fails or, worse, pulls server code and its secrets toward the browser bundle. Read the data through an SWR hook of `hooks/swr` over an operation of `modules/api`, and keep request-scoped server APIs in server components.",
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
create(context) {
|
|
119
|
+
let client = false;
|
|
120
|
+
return {
|
|
121
|
+
Program(program) {
|
|
122
|
+
client = clientDirective(program) !== null;
|
|
123
|
+
},
|
|
124
|
+
ImportDeclaration(node) {
|
|
125
|
+
if (!client || node.importKind === "type")
|
|
126
|
+
return;
|
|
127
|
+
const what = serverOnlyReason(String(node.source.value));
|
|
128
|
+
if (what)
|
|
129
|
+
context.report({ node, messageId: "server", data: { what } });
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
// -- FE-CLIENT-2b ----------------------------------------------------------------------------------
|
|
135
|
+
/** The slots whose files are Next route files: server components by construction, so the framework decides, not a marker. */
|
|
136
|
+
const ROUTE_FILE_SLOTS = new Set(["fe.route", "fe.source-root-pinned"]);
|
|
137
|
+
/** True when a statement is `import "server-only"`: an import with no bindings from the marker package. */
|
|
138
|
+
const isMarkerStatement = (statement) => statement?.type === "ImportDeclaration" && statement.specifiers.length === 0 && statement.source.value === "server-only";
|
|
139
|
+
/** True when a TypeScript source file opens with `import "server-only"`. */
|
|
140
|
+
const tsFileIsMarked = (file) => {
|
|
141
|
+
// a file-level directive ("use strict") is the prologue: the marker is the first statement after it
|
|
142
|
+
const first = file.statements.find((statement) => !(ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)));
|
|
143
|
+
return first !== undefined && ts.isImportDeclaration(first) && !first.importClause && ts.isStringLiteral(first.moduleSpecifier) && first.moduleSpecifier.text === "server-only";
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* The source file an import specifier resolves to: the checker's module symbol, so path aliases, extensions and index
|
|
147
|
+
* files resolve as the compiler resolves them. Null for a declaration file, a package or an unresolved specifier.
|
|
148
|
+
*/
|
|
149
|
+
const resolvedSourceFile = (context, specifier) => {
|
|
150
|
+
const { checker, toTs } = typed(context);
|
|
151
|
+
const tsNode = toTs(specifier);
|
|
152
|
+
if (!tsNode)
|
|
153
|
+
return null;
|
|
154
|
+
const symbol = checker.getSymbolAtLocation(tsNode);
|
|
155
|
+
const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0];
|
|
156
|
+
return declaration && ts.isSourceFile(declaration) && !declaration.isDeclarationFile ? declaration : null;
|
|
157
|
+
};
|
|
158
|
+
/** A module that uses a server-only API, or imports a module that is itself server-only, opens with `import "server-only"`. */
|
|
159
|
+
/** Component tiers: a component is judged by where client code reaches it, not by a marker. */
|
|
160
|
+
const COMPONENT_TIERS = new Set(["components", "feature"]);
|
|
161
|
+
/** True when the whole statement is type-only (`import type`, `export type`). */
|
|
162
|
+
const statementIsType = (node) => node.type === "ImportDeclaration" ? node.importKind === "type" : node.exportKind === "type";
|
|
163
|
+
/** True when one specifier is type-only (`import { type A }`, `export { type A }`). */
|
|
164
|
+
const specifierIsType = (specifier) => (specifier.type === "ImportSpecifier" && specifier.importKind === "type") || (specifier.type === "ExportSpecifier" && specifier.exportKind === "type");
|
|
165
|
+
/** The specifiers of a statement; none for `export * from`. */
|
|
166
|
+
const specifiersOf = (node) => node.type === "ExportAllDeclaration" ? undefined : node.specifiers;
|
|
167
|
+
export const serverModuleMarksServerOnly = defineRule({
|
|
168
|
+
meta: {
|
|
169
|
+
type: "problem",
|
|
170
|
+
docs: { description: "A module importing `next/headers`, `next/server`, `next-intl/server`, a Node built-in or a server-only module starts with `import \"server-only\"`." },
|
|
171
|
+
schema: [],
|
|
172
|
+
messages: {
|
|
173
|
+
mark: "This module imports {{what}}, which exists only on the server, but it does not start with `import \"server-only\"`. Without the marker nothing stops a client component from importing this module (directly or through a hook): the build then fails far from the cause, or ships server code toward the browser. Make `import \"server-only\"` the first statement of the file. Route files (`page`, `layout`, `route`, `proxy`) are server components by construction and need no marker.",
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
create(context) {
|
|
177
|
+
const slot = slotOfFile(context);
|
|
178
|
+
if (!slot || ROUTE_FILE_SLOTS.has(slot))
|
|
179
|
+
return {};
|
|
180
|
+
// A block or a page is a server or client COMPONENT, not a server module: whether client code reaches the server through it is
|
|
181
|
+
// the machine's multi-hop question (FE_CLIENT_REACHES_SERVER). The marker belongs to the modules that hold server work.
|
|
182
|
+
if (COMPONENT_TIERS.has(tierOfFile(context)))
|
|
183
|
+
return {};
|
|
184
|
+
let client = false;
|
|
185
|
+
let marked = false;
|
|
186
|
+
const uses = [];
|
|
187
|
+
return {
|
|
188
|
+
Program(program) {
|
|
189
|
+
client = clientDirective(program) !== null;
|
|
190
|
+
// a file-level directive ("use strict") is the prologue, not a statement: the marker is the first statement after it
|
|
191
|
+
marked = isMarkerStatement(program.body.find((statement) => !(statement.type === "ExpressionStatement" && typeof statement.directive === "string")));
|
|
192
|
+
},
|
|
193
|
+
"ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration"(node) {
|
|
194
|
+
if (!node.source || statementIsType(node))
|
|
195
|
+
return;
|
|
196
|
+
const specifiers = specifiersOf(node);
|
|
197
|
+
const onlyTypes = specifiers !== undefined && specifiers.length > 0 && specifiers.every(specifierIsType);
|
|
198
|
+
if (onlyTypes)
|
|
199
|
+
return;
|
|
200
|
+
const specifier = String(node.source.value);
|
|
201
|
+
if (specifier === "server-only")
|
|
202
|
+
return;
|
|
203
|
+
const api = serverApiReason(specifier);
|
|
204
|
+
if (api) {
|
|
205
|
+
uses.push({ node, what: api });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const target = resolvedSourceFile(context, node.source);
|
|
209
|
+
if (target && tsFileIsMarked(target))
|
|
210
|
+
uses.push({ node, what: `\`${specifier}\` (a module that is itself server-only)` });
|
|
211
|
+
},
|
|
212
|
+
"Program:exit"() {
|
|
213
|
+
// a "use client" file that imports the server is client-no-server-import's finding, not a missing marker
|
|
214
|
+
if (client || marked)
|
|
215
|
+
return;
|
|
216
|
+
for (const use of uses)
|
|
217
|
+
context.report({ node: use.node, messageId: "mark", data: { what: use.what } });
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
// -- FE-CLIENT-2c (R243 FE_SERVER_ACTION_FORBIDDEN) ------------------------------------------------
|
|
223
|
+
/**
|
|
224
|
+
* The front end serves no API: no `"use server"` directive, at the top of a module or as the first statement of a function.
|
|
225
|
+
* Reads and writes reach the back end through the operations of `modules/api` and the SWR hooks; a write that must run on a
|
|
226
|
+
* server is a GraphQL mutation of the back end.
|
|
227
|
+
*/
|
|
228
|
+
export const noServerAction = defineRule({
|
|
229
|
+
meta: {
|
|
230
|
+
type: "problem",
|
|
231
|
+
docs: { description: "No `\"use server\"` directive (module or function level): the front end holds no Server Action." },
|
|
232
|
+
schema: [],
|
|
233
|
+
messages: {
|
|
234
|
+
action: "`\"use server\"` makes this a Server Action: an HTTP endpoint the front end serves, with its own input parsing, credential and error mapping beside the back end's. The front end serves no API. Write it as a GraphQL mutation of the back end, call it through its operation in `modules/api/graphql/mutations/` and its SWR hook (`hooks/swr/`), and report the outcome through `modules/toast`.",
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
create(context) {
|
|
238
|
+
const check = (statements) => {
|
|
239
|
+
for (const statement of statements ?? []) {
|
|
240
|
+
if (statement.type !== "ExpressionStatement" || typeof statement.directive !== "string")
|
|
241
|
+
return;
|
|
242
|
+
if (statement.directive === "use server")
|
|
243
|
+
context.report({ node: statement, messageId: "action" });
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
const body = (node) => {
|
|
247
|
+
if (node.body?.type === "BlockStatement")
|
|
248
|
+
check(node.body.body);
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
Program: (program) => check(program.body),
|
|
252
|
+
FunctionDeclaration: body,
|
|
253
|
+
FunctionExpression: body,
|
|
254
|
+
ArrowFunctionExpression: body,
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
// -- FE-CLIENT-3 -----------------------------------------------------------------------------------
|
|
259
|
+
/** Web storage exists only in the browser and only under `modules/`, which guards it. */
|
|
260
|
+
const STORAGE = new Set(["localStorage", "sessionStorage"]);
|
|
261
|
+
/** The tiers where storage is guarded. */
|
|
262
|
+
const MODULE_TIERS = ["foundation", "modules", "transport"];
|
|
263
|
+
/** Web storage is read and written only inside `modules/`. */
|
|
264
|
+
export const webStorageOnlyInModules = defineRule({
|
|
265
|
+
meta: {
|
|
266
|
+
type: "problem",
|
|
267
|
+
docs: { description: "`localStorage` and `sessionStorage` are touched only inside `modules/`." },
|
|
268
|
+
schema: [],
|
|
269
|
+
messages: {
|
|
270
|
+
storage: "`{{name}}` here, outside `modules/`. Storage does not exist on the server and throws in private windows and when the quota is full, so a block that reads it during render breaks server rendering and hydrates differently from the HTML it received. Put the read and write behind a hook or a module that guards both (`typeof window`, try/catch) and let the block call that.",
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
create(context) {
|
|
274
|
+
// Product source outside the module tiers (foundation modules, the other modules, the api transport), which are where storage is guarded.
|
|
275
|
+
if (!isProductSource(context) || MODULE_TIERS.includes(tierOfFile(context)))
|
|
276
|
+
return {};
|
|
277
|
+
return {
|
|
278
|
+
MemberExpression(node) {
|
|
279
|
+
if (node.computed || node.property.type !== "Identifier" || !STORAGE.has(node.property.name))
|
|
280
|
+
return;
|
|
281
|
+
if (node.object.type !== "Identifier" || (node.object.name !== "window" && node.object.name !== "globalThis"))
|
|
282
|
+
return;
|
|
283
|
+
context.report({ node, messageId: "storage", data: { name: node.property.name } });
|
|
284
|
+
},
|
|
285
|
+
Identifier(node) {
|
|
286
|
+
if (!STORAGE.has(node.name))
|
|
287
|
+
return;
|
|
288
|
+
const parent = node.parent;
|
|
289
|
+
if (parent.type === "MemberExpression" && parent.property === node)
|
|
290
|
+
return;
|
|
291
|
+
if (parent.type === "Property" && parent.key === node && !parent.shorthand)
|
|
292
|
+
return;
|
|
293
|
+
if (parent.type === "TSPropertySignature" || parent.type === "TSTypeAnnotation")
|
|
294
|
+
return;
|
|
295
|
+
context.report({ node, messageId: "storage", data: { name: node.name } });
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
// -- FE-CLIENT-4 -----------------------------------------------------------------------------------
|
|
301
|
+
/** Raw HTML is set only on a `<script>`, where the framework has no other way to inline JSON-LD or the theme bootstrap. */
|
|
302
|
+
export const noDangerousHtml = defineRule({
|
|
303
|
+
meta: {
|
|
304
|
+
type: "problem",
|
|
305
|
+
docs: { description: "`dangerouslySetInnerHTML` only on `<script>`; never on a visible element." },
|
|
306
|
+
schema: [],
|
|
307
|
+
messages: {
|
|
308
|
+
html: "`dangerouslySetInnerHTML` on `<{{tag}}>` writes a string into the page as markup, so any text from a user, a provider or a message catalogue becomes script. Render the content as elements. The one legitimate use is a `<script>` carrying JSON-LD or the theme bootstrap, from a constant this app wrote.",
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
create(context) {
|
|
312
|
+
return {
|
|
313
|
+
JSXAttribute(node) {
|
|
314
|
+
if (node.name.type !== "JSXIdentifier" || node.name.name !== "dangerouslySetInnerHTML")
|
|
315
|
+
return;
|
|
316
|
+
const opening = node.parent;
|
|
317
|
+
const tag = opening.name.type === "JSXIdentifier" ? opening.name.name : "element";
|
|
318
|
+
if (tag !== "script")
|
|
319
|
+
context.report({ node, messageId: "html", data: { tag } });
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
// -- FE-CLIENT-5 (R107 FE_COOKIE_ATTRIBUTES) -------------------------------------------------------
|
|
325
|
+
/** Forward-slash file name of a declaration, and the package a `node_modules/<pkg>/` (or `@scope/<pkg>`) file belongs to. */
|
|
326
|
+
const packageOfDeclaration = (declaration) => {
|
|
327
|
+
const match = /\/node_modules\/((?:@[^/]+\/)?[^/]+)\//.exec(String(declaration.getSourceFile().fileName).split("\\").join("/"));
|
|
328
|
+
return match ? match[1] ?? null : null;
|
|
329
|
+
};
|
|
330
|
+
/** Whether a call is `set` of Next's `ResponseCookies` (a `Set-Cookie` write), whatever the receiver is called: `response.cookies.set`, `(await cookies()).set`. */
|
|
331
|
+
const isResponseCookieWrite = (checker, toTs, call) => {
|
|
332
|
+
const tsCall = toTs(call);
|
|
333
|
+
const declaration = tsCall && ts.isCallLikeExpression(tsCall) ? checker.getResolvedSignature(tsCall)?.declaration : undefined;
|
|
334
|
+
if (!declaration || !ts.isMethodDeclaration(declaration) || declaration.name.getText() !== "set")
|
|
335
|
+
return false;
|
|
336
|
+
const owner = declaration.parent;
|
|
337
|
+
return ts.isClassDeclaration(owner) && owner.name?.text === "ResponseCookies" && packageOfDeclaration(declaration) === "next";
|
|
338
|
+
};
|
|
339
|
+
/** The present (non-nullish) members of a property's type on an options type, or [] when the property is absent or only nullish. */
|
|
340
|
+
const presentPropertyParts = (checker, type, name, at) => {
|
|
341
|
+
const symbol = type.getProperty(name);
|
|
342
|
+
if (!symbol)
|
|
343
|
+
return [];
|
|
344
|
+
const propertyType = checker.getTypeOfSymbolAtLocation(symbol, at);
|
|
345
|
+
const parts = propertyType.isUnion() ? propertyType.types : [propertyType];
|
|
346
|
+
return parts.filter((part) => !(part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Void)));
|
|
347
|
+
};
|
|
348
|
+
/** True for the boolean literal type `value`, read from the checker's intrinsic name. */
|
|
349
|
+
const isBooleanLiteral = (part, value) => Boolean(part.flags & ts.TypeFlags.BooleanLiteral) && "intrinsicName" in part && part.intrinsicName === String(value);
|
|
350
|
+
const SAME_SITE = new Set(["lax", "strict"]);
|
|
351
|
+
/** The attributes a written cookie is missing, judged on the TYPE of its options (a spread `as const` constant keeps its literals). */
|
|
352
|
+
const missingAttributes = (checker, type, at) => {
|
|
353
|
+
const missing = [];
|
|
354
|
+
// httpOnly is a stated decision: `true` for a session, `false` only for a preference page script reads; a bare `boolean` decides nothing
|
|
355
|
+
const httpOnly = presentPropertyParts(checker, type, "httpOnly", at);
|
|
356
|
+
const stated = httpOnly.length > 0 && (httpOnly.every((part) => isBooleanLiteral(part, true)) || httpOnly.every((part) => isBooleanLiteral(part, false)));
|
|
357
|
+
if (!stated)
|
|
358
|
+
missing.push("`httpOnly` stated as `true` or `false`");
|
|
359
|
+
const secure = presentPropertyParts(checker, type, "secure", at);
|
|
360
|
+
if (secure.length === 0 || secure.every((part) => isBooleanLiteral(part, false)))
|
|
361
|
+
missing.push("`secure`");
|
|
362
|
+
const sameSite = presentPropertyParts(checker, type, "sameSite", at);
|
|
363
|
+
if (sameSite.length === 0 || !sameSite.every((part) => part.isStringLiteral() && SAME_SITE.has(part.value)))
|
|
364
|
+
missing.push('`sameSite: "lax"` or `"strict"`');
|
|
365
|
+
return missing;
|
|
366
|
+
};
|
|
367
|
+
/** A cookie the app writes states its script reach, is sent only over TLS and is kept off cross-site requests. */
|
|
368
|
+
export const responseCookieAttributes = defineRule({
|
|
369
|
+
meta: {
|
|
370
|
+
type: "problem",
|
|
371
|
+
docs: { description: "A cookie written through Next's response cookies states `httpOnly` as a literal, carries `secure` and `sameSite` `lax` or `strict`." },
|
|
372
|
+
schema: [],
|
|
373
|
+
messages: {
|
|
374
|
+
attributes: "This cookie is written without {{missing}}. A cookie left to the defaults is readable by page script and sent on cross-site requests: state `httpOnly: true` for a session (`false` only for a preference page script must read), `secure` (from `modules/config`) to keep it off plain HTTP, and `sameSite` `lax` or `strict`. Pass one options constant that states all three, declared `as const` by the module that owns the cookie.",
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
create(context) {
|
|
378
|
+
const { checker, toTs } = typed(context);
|
|
379
|
+
return {
|
|
380
|
+
CallExpression(node) {
|
|
381
|
+
if (node.callee.type !== "MemberExpression" || !isResponseCookieWrite(checker, toTs, node))
|
|
382
|
+
return;
|
|
383
|
+
// `set(name, value, options)` or `set({ name, value, ...options })`; `set(name, value)` states nothing
|
|
384
|
+
const options = node.arguments.length === 1 ? node.arguments[0] : node.arguments[2];
|
|
385
|
+
const tsOptions = options ? toTs(options) : undefined;
|
|
386
|
+
const missing = tsOptions ? missingAttributes(checker, checker.getTypeAtLocation(tsOptions), tsOptions) : ["`httpOnly` stated as `true` or `false`", "`secure`", '`sameSite: "lax"` or `"strict"`'];
|
|
387
|
+
if (missing.length > 0)
|
|
388
|
+
context.report({ node, messageId: "attributes", data: { missing: missing.join(", ") } });
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
/** The rules this law contributes to the plugin. */
|
|
394
|
+
export const rules = {
|
|
395
|
+
"use-client-only-at-boundary": useClientOnlyAtBoundary,
|
|
396
|
+
"client-no-server-import": clientNoServerImport,
|
|
397
|
+
"server-module-marks-server-only": serverModuleMarksServerOnly,
|
|
398
|
+
"no-server-action": noServerAction,
|
|
399
|
+
"web-storage-only-in-modules": webStorageOnlyInModules,
|
|
400
|
+
"no-dangerous-html": noDangerousHtml,
|
|
401
|
+
"response-cookie-attributes": responseCookieAttributes,
|
|
402
|
+
};
|
|
403
|
+
/** Every rule is an error. */
|
|
404
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `comments.md`.
|
|
3
|
+
*
|
|
4
|
+
* Both reach further than their names suggest, and deliberately. An emoji rule that read only JSX
|
|
5
|
+
* would miss the one in a log message, so it walks comments, identifiers, string literals, template
|
|
6
|
+
* chunks and JSX text alike - everywhere prose can hide in a source file.
|
|
7
|
+
*
|
|
8
|
+
* `no-vietnamese-in-source` holds the English-only law: identifiers, string literals, template text, JSX text,
|
|
9
|
+
* comments and test titles carry no Vietnamese letter. There is no exemption marker. Detection is
|
|
10
|
+
* structural on characters (`scripts/lib/language.mjs`, folded to NFC), never a word list.
|
|
11
|
+
*
|
|
12
|
+
* The exceptions that remain are placements, not judgements, and that is on purpose: a locale dictionary
|
|
13
|
+
* IS the other language, and a fixture reproducing a real string has to reproduce it exactly. A
|
|
14
|
+
* judgement-based exception would be argued per file forever. The Vietnamese rule has no placement exemption at all.
|
|
15
|
+
*/
|
|
16
|
+
import { type AnyContext, type Recommended } from "./lib/rule.js";
|
|
17
|
+
/**
|
|
18
|
+
* Files whose second-language text or emoji is CONTENT rather than authoring, decided by what the file IS.
|
|
19
|
+
*
|
|
20
|
+
* One kind only: the locale dictionaries (a `.json` file of an i18n slot, `fe.modules.i18n` or `fe.package.i18n`),
|
|
21
|
+
* which are the product's other language. There is no "copy module": a `resources/` folder of strings is copy that
|
|
22
|
+
* skipped the catalogue.
|
|
23
|
+
*
|
|
24
|
+
* @param context - The ESLint rule context.
|
|
25
|
+
* @returns True when the linted file holds content rather than authoring.
|
|
26
|
+
*/
|
|
27
|
+
export declare const isContentFile: (context: AnyContext) => boolean;
|
|
28
|
+
/** Every export opens with a documentation block. */
|
|
29
|
+
export declare const requireExportJsdoc: import("./lib/rule.js").Rule<"jsdoc", []>;
|
|
30
|
+
/** No Unicode emoji in source: stable product reactions use attributed checked-in SVG artwork. */
|
|
31
|
+
export declare const noEmojiInSource: import("./lib/rule.js").Rule<"emoji", []>;
|
|
32
|
+
/** Source prose is English: no Vietnamese letter in an identifier, string, comment, JSX text or test title. */
|
|
33
|
+
export declare const noVietnameseInSource: import("./lib/rule.js").Rule<"vietnamese", []>;
|
|
34
|
+
/** The rules this law contributes to the plugin. */
|
|
35
|
+
export declare const rules: {
|
|
36
|
+
"require-export-jsdoc": import("./lib/rule.js").Rule<"jsdoc", []>;
|
|
37
|
+
"no-emoji-in-source": import("./lib/rule.js").Rule<"emoji", []>;
|
|
38
|
+
"no-vietnamese-in-source": import("./lib/rule.js").Rule<"vietnamese", []>;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
42
|
+
*
|
|
43
|
+
* `require-export-jsdoc` is the one a repository adopting this with history should expect a count
|
|
44
|
+
* from - every undocumented export in the tree reports at once. The other two are usually already
|
|
45
|
+
* at zero, because a second language in source is noticed by readers long before a rule arrives.
|
|
46
|
+
*/
|
|
47
|
+
export declare const recommended: Recommended;
|
package/dist/comments.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `comments.md`.
|
|
3
|
+
*
|
|
4
|
+
* Both reach further than their names suggest, and deliberately. An emoji rule that read only JSX
|
|
5
|
+
* would miss the one in a log message, so it walks comments, identifiers, string literals, template
|
|
6
|
+
* chunks and JSX text alike - everywhere prose can hide in a source file.
|
|
7
|
+
*
|
|
8
|
+
* `no-vietnamese-in-source` holds the English-only law: identifiers, string literals, template text, JSX text,
|
|
9
|
+
* comments and test titles carry no Vietnamese letter. There is no exemption marker. Detection is
|
|
10
|
+
* structural on characters (`scripts/lib/language.mjs`, folded to NFC), never a word list.
|
|
11
|
+
*
|
|
12
|
+
* The exceptions that remain are placements, not judgements, and that is on purpose: a locale dictionary
|
|
13
|
+
* IS the other language, and a fixture reproducing a real string has to reproduce it exactly. A
|
|
14
|
+
* judgement-based exception would be argued per file forever. The Vietnamese rule has no placement exemption at all.
|
|
15
|
+
*/
|
|
16
|
+
import { hasSecondLanguage } from "#runtime/scripts/lib/language.mjs";
|
|
17
|
+
import { jsdocBefore } from "#runtime/scripts/lib/jsdoc.mjs";
|
|
18
|
+
import { defineRule } from "./lib/rule.js";
|
|
19
|
+
import { fileOf, inSlot } from "./lib/scope.js";
|
|
20
|
+
/**
|
|
21
|
+
* Extended pictographs, or a regional-indicator pair.
|
|
22
|
+
*
|
|
23
|
+
* Two tests rather than one character class, so the class stays free of joiners and combining marks
|
|
24
|
+
* - a single class covering both trips the misleading-character-class rule, and a rule that has to
|
|
25
|
+
* be silenced to exist is one nobody trusts.
|
|
26
|
+
*/
|
|
27
|
+
const hasEmoji = (text) => typeof text === "string" &&
|
|
28
|
+
(/\p{Extended_Pictographic}/u.test(text) || /[\u{1F1E6}-\u{1F1FF}]{2}/u.test(text));
|
|
29
|
+
/**
|
|
30
|
+
* Files whose second-language text or emoji is CONTENT rather than authoring, decided by what the file IS.
|
|
31
|
+
*
|
|
32
|
+
* One kind only: the locale dictionaries (a `.json` file of an i18n slot, `fe.modules.i18n` or `fe.package.i18n`),
|
|
33
|
+
* which are the product's other language. There is no "copy module": a `resources/` folder of strings is copy that
|
|
34
|
+
* skipped the catalogue.
|
|
35
|
+
*
|
|
36
|
+
* @param context - The ESLint rule context.
|
|
37
|
+
* @returns True when the linted file holds content rather than authoring.
|
|
38
|
+
*/
|
|
39
|
+
export const isContentFile = (context) => {
|
|
40
|
+
const file = fileOf(context);
|
|
41
|
+
return inSlot(context, "fe.modules.i18n", "fe.package.i18n") && file.endsWith(".json");
|
|
42
|
+
};
|
|
43
|
+
/** Walk every place prose can hide, and hand each to one check. */
|
|
44
|
+
const proseVisitors = (context, report) => {
|
|
45
|
+
const source = context.sourceCode || context.getSourceCode();
|
|
46
|
+
return {
|
|
47
|
+
Program() {
|
|
48
|
+
for (const comment of source.getAllComments())
|
|
49
|
+
report(comment, comment.value);
|
|
50
|
+
},
|
|
51
|
+
Identifier(node) {
|
|
52
|
+
report(node, node.name);
|
|
53
|
+
},
|
|
54
|
+
JSXIdentifier(node) {
|
|
55
|
+
report(node, node.name);
|
|
56
|
+
},
|
|
57
|
+
PrivateIdentifier(node) {
|
|
58
|
+
report(node, node.name);
|
|
59
|
+
},
|
|
60
|
+
Literal(node) {
|
|
61
|
+
if (typeof node.value === "string")
|
|
62
|
+
report(node, node.value);
|
|
63
|
+
},
|
|
64
|
+
TemplateElement(node) {
|
|
65
|
+
report(node, node.value && node.value.cooked);
|
|
66
|
+
},
|
|
67
|
+
JSXText(node) {
|
|
68
|
+
report(node, node.value);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
/** The name an exported declaration introduces: its own `id`, or the first declarator's of a variable declaration. */
|
|
73
|
+
const declaredId = (declaration) => {
|
|
74
|
+
if ("id" in declaration && declaration.id)
|
|
75
|
+
return declaration.id;
|
|
76
|
+
return declaration.type === "VariableDeclaration" ? declaration.declarations[0]?.id : undefined;
|
|
77
|
+
};
|
|
78
|
+
// -- COMMENTS-1 ------------------------------------------------------------------------------------
|
|
79
|
+
/** Every export opens with a documentation block. */
|
|
80
|
+
export const requireExportJsdoc = defineRule({
|
|
81
|
+
meta: {
|
|
82
|
+
type: "suggestion",
|
|
83
|
+
docs: { description: "Exported declarations open with a documentation block." },
|
|
84
|
+
schema: [],
|
|
85
|
+
messages: {
|
|
86
|
+
jsdoc: "`{{name}}` is exported without an adjacent JSDoc description. An export is read far more often than it is written, and by people who never open the body - name the ROLE it plays, not the signature, which the signature already states.",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
create(context) {
|
|
90
|
+
const source = context.sourceCode || context.getSourceCode();
|
|
91
|
+
const hasBlock = (node) => jsdocBefore(source, node) !== null;
|
|
92
|
+
const check = (node) => {
|
|
93
|
+
const declaration = node.declaration;
|
|
94
|
+
if (!declaration)
|
|
95
|
+
return;
|
|
96
|
+
const kinds = ["VariableDeclaration", "TSInterfaceDeclaration", "FunctionDeclaration", "TSTypeAliasDeclaration"];
|
|
97
|
+
if (!kinds.includes(declaration.type) || hasBlock(node))
|
|
98
|
+
return;
|
|
99
|
+
const id = declaredId(declaration);
|
|
100
|
+
context.report({ node: id || declaration, messageId: "jsdoc", data: { name: (id && "name" in id && id.name) || "this export" } });
|
|
101
|
+
};
|
|
102
|
+
return { ExportNamedDeclaration: check, ExportDefaultDeclaration: check };
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
// -- COMMENTS-4 ------------------------------------------------------------------------------------
|
|
106
|
+
/** No Unicode emoji in source: stable product reactions use attributed checked-in SVG artwork. */
|
|
107
|
+
export const noEmojiInSource = defineRule({
|
|
108
|
+
meta: {
|
|
109
|
+
type: "problem",
|
|
110
|
+
docs: { description: "No emoji in source authoring." },
|
|
111
|
+
schema: [],
|
|
112
|
+
messages: {
|
|
113
|
+
emoji: "Unicode emoji in source authoring. It renders differently on every platform, sorts unpredictably, breaks a terminal that is not expecting it, and does not mean the same thing in two countries. Generic marks belong to the icon vocabulary; product reactions use the attributed checked-in SVG artwork through the reaction leaf.",
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
create(context) {
|
|
117
|
+
if (isContentFile(context))
|
|
118
|
+
return {};
|
|
119
|
+
return proseVisitors(context, (node, text) => {
|
|
120
|
+
if (hasEmoji(text))
|
|
121
|
+
context.report({ node, messageId: "emoji" });
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
// -- COMMENTS-5 ------------------------------------------------------------------------------------
|
|
126
|
+
/** Source prose is English: no Vietnamese letter in an identifier, string, comment, JSX text or test title. */
|
|
127
|
+
export const noVietnameseInSource = defineRule({
|
|
128
|
+
meta: {
|
|
129
|
+
type: "problem",
|
|
130
|
+
docs: { description: "No Vietnamese in source authoring, specs included." },
|
|
131
|
+
schema: [],
|
|
132
|
+
messages: {
|
|
133
|
+
vietnamese: "A Vietnamese letter in source. Every reader has to be able to read all of the code, its comments and its test titles, and half of a two-language file is unavailable to somebody. Write it in English; product copy lives in the `messages/<locale>.json` catalogue behind `t()`; there is no pragma.",
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
create(context) {
|
|
137
|
+
return proseVisitors(context, (node, text) => {
|
|
138
|
+
if (hasSecondLanguage(text))
|
|
139
|
+
context.report({ node, messageId: "vietnamese" });
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
/** The rules this law contributes to the plugin. */
|
|
144
|
+
export const rules = {
|
|
145
|
+
"require-export-jsdoc": requireExportJsdoc,
|
|
146
|
+
"no-emoji-in-source": noEmojiInSource,
|
|
147
|
+
"no-vietnamese-in-source": noVietnameseInSource,
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
151
|
+
*
|
|
152
|
+
* `require-export-jsdoc` is the one a repository adopting this with history should expect a count
|
|
153
|
+
* from - every undocumented export in the tree reports at once. The other two are usually already
|
|
154
|
+
* at zero, because a second language in source is noticed by readers long before a rule arrives.
|
|
155
|
+
*/
|
|
156
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Recommended } from "./lib/rule.js";
|
|
2
|
+
/** The one rule of the law. */
|
|
3
|
+
export declare const rules: {
|
|
4
|
+
"control-height": import("./lib/rule.js").Rule<"FE_CONTROL_HEIGHT", []>;
|
|
5
|
+
};
|
|
6
|
+
/** The rule reaches consumers at error. */
|
|
7
|
+
export declare const recommended: Recommended;
|