@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,18 @@
|
|
|
1
|
+
import { type Recommended } from "./lib/rule.js";
|
|
2
|
+
/** A pure half that branches on its shape covers every member of its `XShape` union, with no default that hides one. */
|
|
3
|
+
export declare const exhaustiveShapeSwitch: import("./lib/rule.js").Rule<"missing" | "hidden", []>;
|
|
4
|
+
/** A unit below the status view takes no data-status prop. */
|
|
5
|
+
export declare const noDataStatusProp: import("./lib/rule.js").Rule<"status" | "leaf", []>;
|
|
6
|
+
/** A region that reads data hands its Base a DataSlot, and the Base renders it through SlotView. */
|
|
7
|
+
export declare const regionReadsThroughSlot: import("./lib/rule.js").Rule<"slot" | "view", []>;
|
|
8
|
+
/** A branch keeps only interaction state; a leaf holds no hook. */
|
|
9
|
+
export declare const branchIntrinsicState: import("./lib/rule.js").Rule<"hooks" | "data" | "fetch" | "leaf", []>;
|
|
10
|
+
/** The rules this law contributes to the plugin. */
|
|
11
|
+
export declare const rules: {
|
|
12
|
+
"exhaustive-shape-switch": import("./lib/rule.js").Rule<"missing" | "hidden", []>;
|
|
13
|
+
"no-data-status-prop": import("./lib/rule.js").Rule<"status" | "leaf", []>;
|
|
14
|
+
"region-reads-through-slot": import("./lib/rule.js").Rule<"slot" | "view", []>;
|
|
15
|
+
"branch-intrinsic-state": import("./lib/rule.js").Rule<"hooks" | "data" | "fetch" | "leaf", []>;
|
|
16
|
+
};
|
|
17
|
+
/** Every FE canon rule is an error; existing debt is fixed before adoption. */
|
|
18
|
+
export declare const recommended: Recommended;
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { inSplitTier, pureHalf } from "./lib/drawing-scope.js";
|
|
3
|
+
import { hfsOf } from "./lib/hfs.js";
|
|
4
|
+
import { defineRule } from "./lib/rule.js";
|
|
5
|
+
import { fileOf, inSlot, kindOfFile, roleOfFile } from "./lib/scope.js";
|
|
6
|
+
/** The folder name of the unit the linted file belongs to (`HandoffBlock` for `.../HandoffBlock/index.tsx`). */
|
|
7
|
+
const unitName = (context) => hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0] ?? "";
|
|
8
|
+
/** The name of an identifier or a string-literal key; undefined for anything else. */
|
|
9
|
+
const nameOfKey = (key) => key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : undefined;
|
|
10
|
+
/** The callee name of a call: `useX` for `useX()` and for `React.useX()`. */
|
|
11
|
+
const calleeNameOf = (call) => {
|
|
12
|
+
const callee = call.callee;
|
|
13
|
+
if (callee.type === "Identifier")
|
|
14
|
+
return callee.name;
|
|
15
|
+
if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier")
|
|
16
|
+
return callee.property.name;
|
|
17
|
+
return undefined;
|
|
18
|
+
};
|
|
19
|
+
// -- SHAPE-1 ----------------------------------------------------------------------------------------
|
|
20
|
+
/** The name of a pure half's shape union. */
|
|
21
|
+
const SHAPE_NAME = /^[A-Z][A-Za-z0-9]*Shape$/;
|
|
22
|
+
/** The string members of a union of string literals, or null when the type is not one. */
|
|
23
|
+
const literalMembers = (node) => {
|
|
24
|
+
const parts = node.type === "TSUnionType" ? node.types : [node];
|
|
25
|
+
const members = [];
|
|
26
|
+
for (const part of parts) {
|
|
27
|
+
if (part.type !== "TSLiteralType" || !("value" in part.literal) || typeof part.literal.value !== "string")
|
|
28
|
+
return null;
|
|
29
|
+
members.push(part.literal.value);
|
|
30
|
+
}
|
|
31
|
+
return members;
|
|
32
|
+
};
|
|
33
|
+
/** The string literal members of a checker type, or null when it is not a union of string literals only. */
|
|
34
|
+
const typedMembers = (type) => {
|
|
35
|
+
const parts = type.isUnion() ? type.types : [type];
|
|
36
|
+
const members = [];
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
if ((part.flags & ts.TypeFlags.Undefined) !== 0)
|
|
39
|
+
continue;
|
|
40
|
+
if (!part.isStringLiteral())
|
|
41
|
+
return null;
|
|
42
|
+
members.push(part.value);
|
|
43
|
+
}
|
|
44
|
+
return members.length > 0 ? members : null;
|
|
45
|
+
};
|
|
46
|
+
/** True for `props.shape` (any receiver named `props`) or a bare `shape`. */
|
|
47
|
+
const isShapeRead = (node) => (node.type === "Identifier" && node.name === "shape") ||
|
|
48
|
+
(node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier" && node.property.name === "shape" &&
|
|
49
|
+
node.object.type === "Identifier" && node.object.name === "props");
|
|
50
|
+
/** The literal a `shape === "x"` test compares against, or undefined. */
|
|
51
|
+
const testedMember = (test) => {
|
|
52
|
+
if (test.type !== "BinaryExpression" || (test.operator !== "===" && test.operator !== "=="))
|
|
53
|
+
return undefined;
|
|
54
|
+
const [read, literal] = isShapeRead(test.left) ? [test.left, test.right] : [test.right, test.left];
|
|
55
|
+
if (!isShapeRead(read) || literal.type !== "Literal" || typeof literal.value !== "string")
|
|
56
|
+
return undefined;
|
|
57
|
+
return literal.value;
|
|
58
|
+
};
|
|
59
|
+
/** A pure half that branches on its shape covers every member of its `XShape` union, with no default that hides one. */
|
|
60
|
+
export const exhaustiveShapeSwitch = defineRule({
|
|
61
|
+
meta: {
|
|
62
|
+
type: "problem",
|
|
63
|
+
docs: { description: "A pure half that branches on `props.shape` / `shape` covers every member of its `XShape` union; a default branch never stands in for a missing member." },
|
|
64
|
+
schema: [],
|
|
65
|
+
messages: {
|
|
66
|
+
missing: "The branch on `{{read}}` never draws {{members}}. Every member of the shape union is a drawing the owner approved: give each its own case.",
|
|
67
|
+
hidden: "The branch on `{{read}}` lets a default stand in for {{members}}. A default hides the member the next shape adds: name every member as its own case and drop the default.",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
create(context) {
|
|
71
|
+
if (!pureHalf(context))
|
|
72
|
+
return {};
|
|
73
|
+
const local = new Map();
|
|
74
|
+
const services = context.sourceCode.parserServices;
|
|
75
|
+
const program = services?.program ?? null;
|
|
76
|
+
const map = services?.esTreeNodeToTSNodeMap;
|
|
77
|
+
/** The members of the shape a read names: its checker type, else the one local `XShape` union. */
|
|
78
|
+
const membersOf = (read) => {
|
|
79
|
+
if (program && map) {
|
|
80
|
+
const tsNode = map.get(read);
|
|
81
|
+
const members = tsNode ? typedMembers(program.getTypeChecker().getTypeAtLocation(tsNode)) : null;
|
|
82
|
+
if (members)
|
|
83
|
+
return members;
|
|
84
|
+
}
|
|
85
|
+
return local.size === 1 ? [...local.values()][0] ?? null : null;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Report the members a branch leaves undrawn. `fallback` is how many members a trailing default may stand for: none
|
|
89
|
+
* for a switch with no default, one for the last alternate of a conditional chain, any for a switch default.
|
|
90
|
+
*/
|
|
91
|
+
const judge = (node, read, covered, fallback) => {
|
|
92
|
+
const members = membersOf(read);
|
|
93
|
+
if (!members)
|
|
94
|
+
return;
|
|
95
|
+
const missing = members.filter((member) => !covered.has(member));
|
|
96
|
+
const hidden = fallback === "default" ? missing.length > 0 : fallback === "one" && missing.length > 1;
|
|
97
|
+
if (!hidden && (fallback !== "none" || missing.length === 0))
|
|
98
|
+
return;
|
|
99
|
+
const data = { read: context.sourceCode.getText(read), members: missing.map((member) => `"${member}"`).join(", ") };
|
|
100
|
+
context.report({ node, messageId: hidden ? "hidden" : "missing", data });
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
Program(node) {
|
|
104
|
+
for (const statement of node.body) {
|
|
105
|
+
const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
|
|
106
|
+
if (declaration?.type !== "TSTypeAliasDeclaration" || !SHAPE_NAME.test(declaration.id.name))
|
|
107
|
+
continue;
|
|
108
|
+
const members = literalMembers(declaration.typeAnnotation);
|
|
109
|
+
if (members)
|
|
110
|
+
local.set(declaration.id.name, members);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
SwitchStatement(node) {
|
|
114
|
+
if (!isShapeRead(node.discriminant))
|
|
115
|
+
return;
|
|
116
|
+
const covered = new Set();
|
|
117
|
+
let hasDefault = false;
|
|
118
|
+
for (const branch of node.cases) {
|
|
119
|
+
if (branch.test === null)
|
|
120
|
+
hasDefault = true;
|
|
121
|
+
else if (branch.test.type === "Literal" && typeof branch.test.value === "string")
|
|
122
|
+
covered.add(branch.test.value);
|
|
123
|
+
}
|
|
124
|
+
judge(node, node.discriminant, covered, hasDefault ? "default" : "none");
|
|
125
|
+
},
|
|
126
|
+
ConditionalExpression(node) {
|
|
127
|
+
// Judge a chain once, from its head: `a ? X : b ? Y : Z`, whose last alternate stands for one member.
|
|
128
|
+
if (node.parent.type === "ConditionalExpression" && node.parent.alternate === node)
|
|
129
|
+
return;
|
|
130
|
+
const covered = new Set();
|
|
131
|
+
let read;
|
|
132
|
+
let current = node;
|
|
133
|
+
while (current.type === "ConditionalExpression") {
|
|
134
|
+
const member = testedMember(current.test);
|
|
135
|
+
if (member === undefined)
|
|
136
|
+
return;
|
|
137
|
+
if (current.test.type === "BinaryExpression")
|
|
138
|
+
read ??= isShapeRead(current.test.left) ? current.test.left : current.test.right;
|
|
139
|
+
covered.add(member);
|
|
140
|
+
current = current.alternate;
|
|
141
|
+
}
|
|
142
|
+
if (read !== undefined)
|
|
143
|
+
judge(node, read, covered, "one");
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
// -- SLOT-1 -----------------------------------------------------------------------------------------
|
|
149
|
+
/** The data-status props only SlotView takes; a lower unit gets `isSkeleton` and nothing else. */
|
|
150
|
+
const STATUS_PROPS = new Set(["isLoading", "isError", "isForbidden", "isFailed", "failure"]);
|
|
151
|
+
/** The units that own the status recipe: the status view itself and a route's loading page. */
|
|
152
|
+
const isStatusOwner = (name) => name === "SlotView" || /LoadingPage$/.test(name);
|
|
153
|
+
/** A unit below the status view takes no data-status prop. */
|
|
154
|
+
export const noDataStatusProp = defineRule({
|
|
155
|
+
meta: {
|
|
156
|
+
type: "problem",
|
|
157
|
+
docs: { description: "A unit other than SlotView (and a route LoadingPage) takes no `isLoading`, `isError`, `isForbidden`, `isFailed` or `failure` prop; a leaf takes only `isSkeleton`." },
|
|
158
|
+
schema: [],
|
|
159
|
+
messages: {
|
|
160
|
+
status: "`{{key}}` is a data status handed to `{{unit}}`. A read's status is a DataSlot rendered through SlotView in the region's place; the parts below it take `isSkeleton` and rest as themselves.",
|
|
161
|
+
leaf: "`{{key}}` is a data status handed to the leaf `{{unit}}`. A leaf takes only `isSkeleton`: SlotView passes it while the region waits.",
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
create(context) {
|
|
165
|
+
const unit = inSlot(context, "fe.components", "fe.package.ui", "fe.feature") && kindOfFile(context) !== null ? unitName(context) : null;
|
|
166
|
+
if (unit === null || isStatusOwner(unit))
|
|
167
|
+
return {};
|
|
168
|
+
const leaf = kindOfFile(context) === "leaves";
|
|
169
|
+
return {
|
|
170
|
+
TSPropertySignature(node) {
|
|
171
|
+
const key = nameOfKey(node.key);
|
|
172
|
+
if (key === undefined || !STATUS_PROPS.has(key))
|
|
173
|
+
return;
|
|
174
|
+
context.report({ node, messageId: leaf ? "leaf" : "status", data: { key, unit } });
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
// -- SLOT-2 -----------------------------------------------------------------------------------------
|
|
180
|
+
/** A data hook of `hooks/swr`: `useQueryOrdersSwr`, `useGetHandoffSwr`. */
|
|
181
|
+
const SWR_HOOK = /^use[A-Za-z0-9]*Swr$/;
|
|
182
|
+
/** The builders that fold a read into a DataSlot (`hooks/slot/slot.shared.ts`). */
|
|
183
|
+
const SLOT_BUILDER = /^(?:slotOfSwr|mapSlot|loadingSlot|toSlot)$/;
|
|
184
|
+
/** A region that reads data hands its Base a DataSlot, and the Base renders it through SlotView. */
|
|
185
|
+
export const regionReadsThroughSlot = defineRule({
|
|
186
|
+
meta: {
|
|
187
|
+
type: "problem",
|
|
188
|
+
docs: { description: "A connected half that calls an `*Swr` hook folds the read into a DataSlot for its Base; a pure half that takes a DataSlot renders it through SlotView." },
|
|
189
|
+
schema: [],
|
|
190
|
+
messages: {
|
|
191
|
+
slot: "`{{hook}}(...)` is read here but never folded into a DataSlot. Build the slot with `slotOfSwr` (or `mapSlot`) and hand it to the Base, which renders it through SlotView.",
|
|
192
|
+
view: "This pure half takes a DataSlot but never renders `<SlotView>`. Render the slot through `components/composites/SlotView` in the region's place, with a same-shape placeholder.",
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
create(context) {
|
|
196
|
+
if (!inSplitTier(context))
|
|
197
|
+
return {};
|
|
198
|
+
const role = roleOfFile(context);
|
|
199
|
+
if (role === "entry") {
|
|
200
|
+
const reads = [];
|
|
201
|
+
let folds = false;
|
|
202
|
+
return {
|
|
203
|
+
CallExpression(node) {
|
|
204
|
+
const name = calleeNameOf(node);
|
|
205
|
+
if (name === undefined)
|
|
206
|
+
return;
|
|
207
|
+
if (SWR_HOOK.test(name))
|
|
208
|
+
reads.push(node);
|
|
209
|
+
else if (SLOT_BUILDER.test(name))
|
|
210
|
+
folds = true;
|
|
211
|
+
},
|
|
212
|
+
"Program:exit"() {
|
|
213
|
+
const [first] = reads;
|
|
214
|
+
if (folds || first === undefined)
|
|
215
|
+
return;
|
|
216
|
+
context.report({ node: first, messageId: "slot", data: { hook: calleeNameOf(first) ?? "" } });
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (role !== "drawing")
|
|
221
|
+
return {};
|
|
222
|
+
let slotType;
|
|
223
|
+
let rendersView = false;
|
|
224
|
+
return {
|
|
225
|
+
TSTypeReference(node) {
|
|
226
|
+
if (slotType === undefined && node.typeName.type === "Identifier" && node.typeName.name === "DataSlot")
|
|
227
|
+
slotType = node;
|
|
228
|
+
},
|
|
229
|
+
JSXOpeningElement(node) {
|
|
230
|
+
if (node.name.type === "JSXIdentifier" && node.name.name === "SlotView")
|
|
231
|
+
rendersView = true;
|
|
232
|
+
},
|
|
233
|
+
"Program:exit"() {
|
|
234
|
+
if (slotType !== undefined && !rendersView)
|
|
235
|
+
context.report({ node: slotType, messageId: "view" });
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
// -- SLOT-3 -----------------------------------------------------------------------------------------
|
|
241
|
+
/** The effect hooks whose body must not fetch in a branch. */
|
|
242
|
+
const EFFECT_HOOKS = new Set(["useEffect", "useLayoutEffect"]);
|
|
243
|
+
/** True when a function body fetches: a `fetch(...)` call, an `await`, or a `.then(...)`. */
|
|
244
|
+
const fetchesIn = (root, sourceCode) => {
|
|
245
|
+
let found = false;
|
|
246
|
+
const visit = (node) => {
|
|
247
|
+
if (found)
|
|
248
|
+
return;
|
|
249
|
+
if (node.type === "AwaitExpression")
|
|
250
|
+
found = true;
|
|
251
|
+
if (node.type === "CallExpression") {
|
|
252
|
+
const name = calleeNameOf(node);
|
|
253
|
+
if (name === "fetch" || (name === "then" && node.callee.type === "MemberExpression"))
|
|
254
|
+
found = true;
|
|
255
|
+
}
|
|
256
|
+
for (const key of sourceCode.visitorKeys[node.type] ?? []) {
|
|
257
|
+
const child = Reflect.get(node, key);
|
|
258
|
+
for (const item of Array.isArray(child) ? child : [child]) {
|
|
259
|
+
if (typeof item === "object" && item !== null && "type" in item && typeof item.type === "string")
|
|
260
|
+
visit(item);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
visit(root);
|
|
265
|
+
return found;
|
|
266
|
+
};
|
|
267
|
+
/** A branch keeps only interaction state; a leaf holds no hook. */
|
|
268
|
+
export const branchIntrinsicState = defineRule({
|
|
269
|
+
meta: {
|
|
270
|
+
type: "problem",
|
|
271
|
+
docs: { description: "A branch keeps only interaction state (no data hook, no `@/hooks` import, no fetching effect); a leaf calls no hook." },
|
|
272
|
+
schema: [],
|
|
273
|
+
messages: {
|
|
274
|
+
hooks: "A branch imports `{{source}}`. A branch keeps only its own interaction state; product state and reads live in the connected block above it, which passes them down as atoms.",
|
|
275
|
+
data: "A branch reads data through `{{name}}(...)`. Reads live in the connected block: fold them into a DataSlot there and pass the branch atoms.",
|
|
276
|
+
fetch: "A branch effect fetches. A branch keeps only interaction state; the read belongs to the connected block, through a hook of `hooks/swr`.",
|
|
277
|
+
leaf: "A leaf calls `{{name}}(...)`. A leaf is drawn from its props alone: move the state to the branch or block that owns the interaction.",
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
create(context) {
|
|
281
|
+
if (!inSlot(context, "fe.components", "fe.package.ui"))
|
|
282
|
+
return {};
|
|
283
|
+
const kind = kindOfFile(context);
|
|
284
|
+
if (kind === "leaves") {
|
|
285
|
+
return {
|
|
286
|
+
CallExpression(node) {
|
|
287
|
+
const name = calleeNameOf(node);
|
|
288
|
+
if (name !== undefined && /^use[A-Z]/.test(name))
|
|
289
|
+
context.report({ node, messageId: "leaf", data: { name } });
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (kind !== "branches")
|
|
294
|
+
return {};
|
|
295
|
+
return {
|
|
296
|
+
ImportDeclaration(node) {
|
|
297
|
+
const source = node.source.value;
|
|
298
|
+
if (source === "@/hooks" || source.startsWith("@/hooks/"))
|
|
299
|
+
context.report({ node, messageId: "hooks", data: { source } });
|
|
300
|
+
},
|
|
301
|
+
CallExpression(node) {
|
|
302
|
+
const name = calleeNameOf(node);
|
|
303
|
+
if (name === undefined)
|
|
304
|
+
return;
|
|
305
|
+
if (SWR_HOOK.test(name)) {
|
|
306
|
+
context.report({ node, messageId: "data", data: { name } });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const [effect] = node.arguments;
|
|
310
|
+
if (EFFECT_HOOKS.has(name) && effect !== undefined && fetchesIn(effect, context.sourceCode))
|
|
311
|
+
context.report({ node, messageId: "fetch" });
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
/** The rules this law contributes to the plugin. */
|
|
317
|
+
export const rules = {
|
|
318
|
+
"exhaustive-shape-switch": exhaustiveShapeSwitch,
|
|
319
|
+
"no-data-status-prop": noDataStatusProp,
|
|
320
|
+
"region-reads-through-slot": regionReadsThroughSlot,
|
|
321
|
+
"branch-intrinsic-state": branchIntrinsicState,
|
|
322
|
+
};
|
|
323
|
+
/** Every FE canon rule is an error; existing debt is fixed before adoption. */
|
|
324
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `size-and-state-budget.md` (HFS R65, `FE_SIZE_AND_STATE_BUDGET`).
|
|
3
|
+
*
|
|
4
|
+
* A BUDGET IS A SMELL DETECTOR, NOT A STYLE. A component past 300 lines, or one that holds more than
|
|
5
|
+
* six pieces of state or reads six data hooks, is doing the work of several units: one repository's
|
|
6
|
+
* status-flow component held 20 `useState`s and three data sources in one 900-line file, and no
|
|
7
|
+
* reviewer could say what it did. The numbers are the same for every app so that "too big" is a
|
|
8
|
+
* measurement, not an opinion.
|
|
9
|
+
*
|
|
10
|
+
* WHAT THE LINT HOLDS, AND WHAT IT LEAVES. Absolute limits per file and per component are file-local
|
|
11
|
+
* facts and are held here. "Size may not GROW against the parent commit" (`HFS_SIZE_GROWTH`) is a
|
|
12
|
+
* repository fact needing the previous revision, so it belongs to the architecture machine; the two
|
|
13
|
+
* are complementary, not duplicates - this catches the new giant, that catches the old one growing.
|
|
14
|
+
*
|
|
15
|
+
* POLLING. A hand-rolled `setInterval` or self-scheduling `setTimeout` is a second refresh mechanism
|
|
16
|
+
* beside the data hook's own, and two mechanisms per resource means double requests and a spinner
|
|
17
|
+
* that never settles. Each resource refreshes by exactly one mechanism (a SWR `refreshInterval`, or a
|
|
18
|
+
* socket), so the loop in a component is always the wrong one.
|
|
19
|
+
*
|
|
20
|
+
* LIVE TIME. A recurring timer that reads the wall clock to refresh a time label is a second clock beside Grammar's shared
|
|
21
|
+
* ticker; product code passes the target instant to `RelativeTime` instead.
|
|
22
|
+
*/
|
|
23
|
+
import { type Recommended } from "./lib/rule.js";
|
|
24
|
+
/** A file is within the line budget its slot states for its role, pure re-export barrel (`barrel`) or every file (`file`). */
|
|
25
|
+
export declare const componentLineBudget: import("./lib/rule.js").Rule<"lines", []>;
|
|
26
|
+
/** A connected unit holds at most the slot's `useState` budget of state hooks and reads at most its `dataHooks` budget of data hooks. */
|
|
27
|
+
export declare const unitHookBudget: import("./lib/rule.js").Rule<"data" | "state", []>;
|
|
28
|
+
/**
|
|
29
|
+
* No hand-written polling loop in a component or hook.
|
|
30
|
+
*
|
|
31
|
+
* A timer is polling when what it runs READS DATA (see `readsData`, decided by the type of each call, not by a name): a
|
|
32
|
+
* `setInterval`, or a `setTimeout` whose callback schedules the enclosing function again. A timer that only ticks time or state
|
|
33
|
+
* is not a data-refresh mechanism. Grammar's live-time ownership is independently enforced by `no-app-relative-clock` below.
|
|
34
|
+
*/
|
|
35
|
+
export declare const noHandRolledPolling: import("./lib/rule.js").Rule<"interval" | "timeout", []>;
|
|
36
|
+
/** Grammar owns the shared clock for live time labels; product components and hooks only pass instants. */
|
|
37
|
+
export declare const noAppRelativeClock: import("./lib/rule.js").Rule<"clock", []>;
|
|
38
|
+
/** The rules this law contributes to the plugin. */
|
|
39
|
+
export declare const rules: {
|
|
40
|
+
"component-line-budget": import("./lib/rule.js").Rule<"lines", []>;
|
|
41
|
+
"unit-hook-budget": import("./lib/rule.js").Rule<"data" | "state", []>;
|
|
42
|
+
"no-hand-rolled-polling": import("./lib/rule.js").Rule<"interval" | "timeout", []>;
|
|
43
|
+
"no-app-relative-clock": import("./lib/rule.js").Rule<"clock", []>;
|
|
44
|
+
};
|
|
45
|
+
/** Every rule is an error: the budget is the same for every app, and there is no baseline. */
|
|
46
|
+
export declare const recommended: Recommended;
|