@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,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `file-layout.md`.
|
|
3
|
+
*
|
|
4
|
+
* Four rules locking the same habit: stuffing a cluster into the folder of one screen. It always
|
|
5
|
+
* starts harmlessly - "only this page uses it" - and ends as a page folder holding four components,
|
|
6
|
+
* a constants folder, a utils folder and three hand-copied resting shapes, at which point the
|
|
7
|
+
* screen is a second codebase with its own private vocabulary that nobody else can reuse.
|
|
8
|
+
*
|
|
9
|
+
* Every rule here asks the HFS slot view where the file sits (slot, layer kind, role, tier) rather than
|
|
10
|
+
* reading the contents, which is what makes them cheap and exact. The cost of that choice is stated where it
|
|
11
|
+
* bites: a slot cannot tell a component from a helper, only a folder from a folder, so each rule below names the
|
|
12
|
+
* destination it sends things to rather than merely refusing.
|
|
13
|
+
*/
|
|
14
|
+
import { defineRule } from "./lib/rule.js";
|
|
15
|
+
import { hfsOf, requiredRoot, requiredSlot } from "./lib/hfs.js";
|
|
16
|
+
import { classOf, fileOf, inSlot, isComponentFile, kindOfFile, roleOfFile } from "./lib/scope.js";
|
|
17
|
+
/** The path segments of the linted file below the folder of its owner (the slot's `root`), directories only. */
|
|
18
|
+
const dirsBelowRoot = (context) => {
|
|
19
|
+
const root = requiredRoot(classOf(context));
|
|
20
|
+
const relative = hfsOf(context).relative(fileOf(context));
|
|
21
|
+
return relative.slice(root.length + 1).split("/").slice(0, -1);
|
|
22
|
+
};
|
|
23
|
+
/** The folder segment that sits where a component layer folder is expected but is not one, or null: `components/shells/`, `src/shells/`. */
|
|
24
|
+
const unknownLayerFolder = (context) => {
|
|
25
|
+
const hfs = hfsOf(context);
|
|
26
|
+
const file = fileOf(context);
|
|
27
|
+
const found = hfs.classify(file);
|
|
28
|
+
const relative = hfs.relative(file);
|
|
29
|
+
if (found.status === "no-slot" && found.nearest?.slot === "fe.components") {
|
|
30
|
+
const prefix = found.nearest.matchedPrefix;
|
|
31
|
+
return relative.startsWith(`${prefix}/`) ? (relative.slice(prefix.length + 1).split("/")[0] ?? null) : null;
|
|
32
|
+
}
|
|
33
|
+
if (found.slot === "fe.package.ui" && !found.kind) {
|
|
34
|
+
const [folder, layer, ...rest] = relative.slice(requiredRoot(found).length + 1).split("/");
|
|
35
|
+
return folder === "src" && rest.length > 0 ? layer ?? null : null;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
};
|
|
39
|
+
/** Folders whose contents are not component code, whatever they are nested inside. */
|
|
40
|
+
const NON_COMPONENT_FOLDERS = new Set(["constants", "utils", "types", "hooks"]);
|
|
41
|
+
const surfaceFolder = (context) => {
|
|
42
|
+
if (!inSlot(context, "fe.feature"))
|
|
43
|
+
return null;
|
|
44
|
+
const found = classOf(context);
|
|
45
|
+
const { kind, bindings } = found;
|
|
46
|
+
const below = hfsOf(context).relative(fileOf(context)).slice(requiredRoot(found).length + 1).split("/");
|
|
47
|
+
if (kind === "overlays")
|
|
48
|
+
return below.length > 1 ? { tier: kind, name: below[0], rest: below.slice(1).join("/") } : null;
|
|
49
|
+
if (bindings === undefined)
|
|
50
|
+
throw new TypeError("Cannot read properties of undefined (reading 'name')");
|
|
51
|
+
return { tier: kind, name: bindings.name, rest: below.join("/") };
|
|
52
|
+
};
|
|
53
|
+
/** A file a surface folder may hold: one of the slot's roles (entry, drawing, styles), directly in the folder. */
|
|
54
|
+
const belongsInSurfaceFolder = (context, folder) => !folder.rest.includes("/") && roleOfFile(context) !== null;
|
|
55
|
+
// -- FILE-2 --------------------------------------------------------------------------------------
|
|
56
|
+
/** A page, layout or overlay folder holds its two halves and its colocated class-name module. */
|
|
57
|
+
export const surfaceFolderTwoFilesOnly = defineRule({
|
|
58
|
+
meta: {
|
|
59
|
+
type: "problem",
|
|
60
|
+
docs: { description: "A page/layout/overlay folder holds `component.tsx` + `index.tsx` and nothing else." },
|
|
61
|
+
schema: [],
|
|
62
|
+
messages: {
|
|
63
|
+
extra: "`{{tier}}/{{name}}/` contains `{{rest}}` - a surface folder holds `component.tsx`, `index.tsx`, and an optional `classNames.ts`. Whatever else this is has a real home: a component of its own goes to `blocks/<category>/`, a closed arrangement to `composites/`, a fetch to `hooks/`, a pure helper to `modules/utils/`, a shape to `modules/types/`, copy or a config map to `resources/`.",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
create(context) {
|
|
67
|
+
const folder = surfaceFolder(context);
|
|
68
|
+
if (!folder || belongsInSurfaceFolder(context, folder))
|
|
69
|
+
return {};
|
|
70
|
+
return {
|
|
71
|
+
Program(node) {
|
|
72
|
+
context.report({ node, messageId: "extra", data: { ...folder } });
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
// -- FILE-3 --------------------------------------------------------------------------------------
|
|
78
|
+
/** What is not component code does not live in the component tree. */
|
|
79
|
+
export const noHelperFolderInComponents = defineRule({
|
|
80
|
+
meta: {
|
|
81
|
+
type: "problem",
|
|
82
|
+
docs: { description: "`constants/` `utils/` `types/` `hooks/` are not component folders." },
|
|
83
|
+
schema: [],
|
|
84
|
+
messages: {
|
|
85
|
+
helper: "`{{kind}}/` under a component owner - this is not component code, so it does not live in the component tree. A fetch is a `hooks/`, a pure function is a `modules/utils/`, a shape is a `modules/types/`, copy or a config map is a `resources/`. Left here it stays invisible to everyone who would have reused it, so the second author writes it again and the two drift.",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
create(context) {
|
|
89
|
+
if (!isComponentFile(context))
|
|
90
|
+
return {};
|
|
91
|
+
const dirs = dirsBelowRoot(context);
|
|
92
|
+
// a package owner sits at the package root, so its `src/<layer>/<name>` prefix is not part of the owner
|
|
93
|
+
const inside = inSlot(context, "fe.package.ui") ? dirs.slice(3) : dirs;
|
|
94
|
+
const folder = inside.find((name) => NON_COMPONENT_FOLDERS.has(name));
|
|
95
|
+
if (!folder)
|
|
96
|
+
return {};
|
|
97
|
+
return {
|
|
98
|
+
Program(node) {
|
|
99
|
+
context.report({ node, messageId: "helper", data: { kind: folder } });
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
// -- FILE-1 --------------------------------------------------------------------------------------
|
|
105
|
+
/** A PascalCase folder exports a family whose name belongs to the folder. */
|
|
106
|
+
export const exportMatchesFolder = defineRule({
|
|
107
|
+
meta: {
|
|
108
|
+
type: "suggestion",
|
|
109
|
+
docs: { description: "A PascalCase folder exports a direct named-export family matching the folder." },
|
|
110
|
+
schema: [],
|
|
111
|
+
messages: {
|
|
112
|
+
mismatch: "`index.tsx` in folder `{{folder}}` has no direct named export belonging to the folder's family (exports: {{names}}). The path is supposed to predict the name, so a reader who knows one knows the other - export `{{folder}}`, or a member like `{{folder}}Root`.",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
create(context) {
|
|
116
|
+
if (roleOfFile(context) !== "entry" || kindOfFile(context) === null)
|
|
117
|
+
return {};
|
|
118
|
+
const folder = hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0];
|
|
119
|
+
if (folder === undefined || !/^[A-Z][A-Za-z0-9]*$/.test(folder))
|
|
120
|
+
return {};
|
|
121
|
+
const names = new Set();
|
|
122
|
+
const belongsToFamily = (name) => name === folder || (name.startsWith(folder) && name.length > folder.length && /^[A-Z]/.test(name.slice(folder.length)));
|
|
123
|
+
return {
|
|
124
|
+
ExportNamedDeclaration(node) {
|
|
125
|
+
const declaration = node.declaration;
|
|
126
|
+
if (declaration && declaration.type === "VariableDeclaration") {
|
|
127
|
+
declaration.declarations.forEach((one) => one.id.type === "Identifier" && one.id.name && names.add(one.id.name));
|
|
128
|
+
}
|
|
129
|
+
if (declaration && declaration.type === "FunctionDeclaration" && declaration.id)
|
|
130
|
+
names.add(declaration.id.name);
|
|
131
|
+
for (const specifier of node.specifiers || []) {
|
|
132
|
+
if (specifier.exported.type === "Identifier" && specifier.exported.name)
|
|
133
|
+
names.add(specifier.exported.name);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
"Program:exit"(node) {
|
|
137
|
+
if (names.size === 0 || [...names].some(belongsToFamily))
|
|
138
|
+
return;
|
|
139
|
+
context.report({ node, messageId: "mismatch", data: { folder, names: [...names].join(", ") } });
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
// -- FILE-4 --------------------------------------------------------------------------------------
|
|
145
|
+
/** Members that make an object literal look like a component family rather than data. */
|
|
146
|
+
const looksLikeComponentMember = (name) => /^[A-Z]/.test(String(name));
|
|
147
|
+
/** A folder exports a family, never one runtime object standing in for a namespace. */
|
|
148
|
+
export const noRuntimeNamespace = defineRule({
|
|
149
|
+
meta: {
|
|
150
|
+
type: "problem",
|
|
151
|
+
docs: { description: "A component family is exported as members, not as one runtime object." },
|
|
152
|
+
schema: [],
|
|
153
|
+
messages: {
|
|
154
|
+
namespace: "`{{name}}` is a runtime namespace: one object holding {{members}}. It bundles as a single unit, so a call site importing one member links them all and nothing can be dropped from the build. Export the members directly - the dotted call site is a convenience the bundler pays for.",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
create(context) {
|
|
158
|
+
return {
|
|
159
|
+
ExportNamedDeclaration(node) {
|
|
160
|
+
const declaration = node.declaration;
|
|
161
|
+
if (!declaration || declaration.type !== "VariableDeclaration")
|
|
162
|
+
return;
|
|
163
|
+
for (const one of declaration.declarations) {
|
|
164
|
+
const name = one.id.type === "Identifier" ? one.id.name : undefined;
|
|
165
|
+
const init = one.init && one.init.type === "TSAsExpression" ? one.init.expression : one.init;
|
|
166
|
+
if (!name || !/^[A-Z]/.test(name) || !init || init.type !== "ObjectExpression")
|
|
167
|
+
continue;
|
|
168
|
+
const members = init.properties
|
|
169
|
+
.filter((property) => property.type === "Property" && !property.computed)
|
|
170
|
+
.map((property) => (property.key.type === "Identifier" ? property.key.name : null))
|
|
171
|
+
.filter((member) => Boolean(member));
|
|
172
|
+
if (members.length < 2 || !members.every(looksLikeComponentMember))
|
|
173
|
+
continue;
|
|
174
|
+
context.report({ node: one.id, messageId: "namespace", data: { name, members: members.join(", ") } });
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
// -- FILE-5 --------------------------------------------------------------------------------------
|
|
181
|
+
/**
|
|
182
|
+
* Whether the repository declares the shared UI package (slot `fe.package.ui` is opt-in): the classification of a file
|
|
183
|
+
* of the package's own root is `owned` only then.
|
|
184
|
+
*/
|
|
185
|
+
/** A list field of a slot the manifest always declares; a missing one is a broken manifest and stops the run. */
|
|
186
|
+
const slotList = (hfs, id, field) => {
|
|
187
|
+
const list = requiredSlot(hfs.slot(id), id)[field];
|
|
188
|
+
if (list === undefined)
|
|
189
|
+
throw new TypeError(`the slot ${id} declares no ${field}`);
|
|
190
|
+
return list;
|
|
191
|
+
};
|
|
192
|
+
const uiPackageDeclared = (hfs) => {
|
|
193
|
+
const root = requiredSlot(hfs.slot("fe.package.ui"), "fe.package.ui").path.replace(/<[^>]+>/g, "probe").replace(/\/$/, "");
|
|
194
|
+
return hfs.classify(`${root}/package.json`).status === "owned";
|
|
195
|
+
};
|
|
196
|
+
/** The layer folders that know a feature: those a component owner or a feature owner has and the shared package does not. */
|
|
197
|
+
const featureTiers = (hfs) => {
|
|
198
|
+
const shared = new Set(requiredSlot(hfs.slot("fe.package.ui"), "fe.package.ui").kinds);
|
|
199
|
+
return new Set([...slotList(hfs, "fe.components", "layers"), ...slotList(hfs, "fe.feature", "kinds")].filter((name) => !shared.has(name)));
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* In a front end with two or more apps, the shared package stops below the block.
|
|
203
|
+
*
|
|
204
|
+
* WHY THE SLOT IS ENOUGH HERE. The feature line is already drawn by the layer: a leaf knows
|
|
205
|
+
* no domain and a block is a domain sentence, so which side of the workspace each belongs on
|
|
206
|
+
* follows from the layer the slot reports. The layers a shared package may hold are the `kinds` of
|
|
207
|
+
* `fe.package.ui`; an app holds the shared ones only when the repository declares that package and another app can
|
|
208
|
+
* consume them. A one-app front end has nothing to share, so every tier may stay with that app.
|
|
209
|
+
*
|
|
210
|
+
* WHAT IT COST TO LEARN. A shared package carried one `blocks/WorkerRow/` while its own header
|
|
211
|
+
* insisted that "a block carries feature meaning and therefore belongs to the app that owns the
|
|
212
|
+
* feature". The document was right, the tree disagreed, and nothing turned red for either of them -
|
|
213
|
+
* which is the whole argument for this rule rather than the paragraph.
|
|
214
|
+
*/
|
|
215
|
+
export const monorepoTierBelongsToItsSide = defineRule({
|
|
216
|
+
meta: {
|
|
217
|
+
type: "problem",
|
|
218
|
+
docs: {
|
|
219
|
+
description: "With two or more apps, the shared package holds leaves, composites and branches; blocks, overlays, layouts and pages belong to the app that owns the feature.",
|
|
220
|
+
},
|
|
221
|
+
schema: [],
|
|
222
|
+
messages: {
|
|
223
|
+
featureInPackage: "`{{tier}}/` knows a feature, so it belongs to the app that owns that feature, not to the shared package - every app that never wanted this domain now ships it. Move it to `apps/<app>/src/components/{{tier}}/`.",
|
|
224
|
+
vocabularyInApp: "`{{tier}}/` knows no feature, and this repository has multiple apps, so one copy belongs to every app - left here, another app writes it again and the two drift with nothing to notice. Move it to `packages/ui/src/{{tier}}/`.",
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
create(context) {
|
|
228
|
+
const hfs = hfsOf(context);
|
|
229
|
+
if (hfs.apps.length < 2)
|
|
230
|
+
return {};
|
|
231
|
+
const folder = unknownLayerFolder(context);
|
|
232
|
+
const inPackage = inSlot(context, "fe.package.ui") && folder !== null && featureTiers(hfs).has(folder) ? folder : null;
|
|
233
|
+
const tier = kindOfFile(context);
|
|
234
|
+
const inApp = inSlot(context, "fe.components") && tier !== null && slotList(hfs, "fe.package.ui", "kinds").includes(tier) && uiPackageDeclared(hfs) ? tier : null;
|
|
235
|
+
return {
|
|
236
|
+
Program(node) {
|
|
237
|
+
if (inPackage) {
|
|
238
|
+
context.report({ node, messageId: "featureInPackage", data: { tier: inPackage } });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (inApp)
|
|
242
|
+
context.report({ node, messageId: "vocabularyInApp", data: { tier: inApp } });
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
// -- FILE-6 --------------------------------------------------------------------------------------
|
|
248
|
+
/**
|
|
249
|
+
* A file under `app/` names which page renders at which URL, and does nothing else.
|
|
250
|
+
*
|
|
251
|
+
* WHY THIS RULE EXISTS RATHER THAN THE PARAGRAPH ALONE. The law said "the route file mounts and
|
|
252
|
+
* nothing else" and had said so for as long as the tier list existed. Nothing checked it. A page
|
|
253
|
+
* owner was written to `app/<segment>/workers-page.tsx`, carried through a build, a lint run, a
|
|
254
|
+
* typecheck, four sealed screenshots and an approval, and arrived at the edge of a production write
|
|
255
|
+
* with every gate green - because every gate was reading rules, and this one was only prose.
|
|
256
|
+
*
|
|
257
|
+
* WHY IT READS THE SLOT AND NOT THE CONTENTS. "Drawing" is not a property this can measure: a
|
|
258
|
+
* route that mounts one component and a route that arranges six both return JSX. What CAN be
|
|
259
|
+
* measured exactly is whether a file in the routing tree (slot `fe.route`) is one of the framework's own slots. A page
|
|
260
|
+
* owner is a component, a component has a name, and a named component belongs in the tier that
|
|
261
|
+
* groups it with its siblings - `components/pages/<Name>/`, where the next author looks for it.
|
|
262
|
+
*
|
|
263
|
+
* The cost is stated where it bites: a route file that draws inside `page.tsx` still passes. That is
|
|
264
|
+
* FILE-2's `component.tsx` split to catch, and no path rule can see it.
|
|
265
|
+
*/
|
|
266
|
+
export const routeTreeHoldsRoutesOnly = defineRule({
|
|
267
|
+
meta: {
|
|
268
|
+
type: "problem",
|
|
269
|
+
docs: { description: "`src/app/**` holds framework route files only; a page owner lives in `features/pages/<Name>/`." },
|
|
270
|
+
schema: [],
|
|
271
|
+
messages: {
|
|
272
|
+
stray: "`app/{{rest}}` is not a route file. A file under `app/` names which page renders at which URL - `page`, `layout`, `loading`, `error` and their siblings are the framework's slots, and `{{basename}}` is not one of them. This is a component, so it belongs where its siblings are: `features/pages/<Name>/` for a screen, `components/blocks/<category>/<Name>/` for a domain sentence. Left here it is the one place nobody looks, and the route has quietly become a second page.",
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
create(context) {
|
|
276
|
+
if (!inSlot(context, "fe.route"))
|
|
277
|
+
return {};
|
|
278
|
+
const verdict = hfsOf(context).allows(fileOf(context));
|
|
279
|
+
if (!verdict || verdict.allowed)
|
|
280
|
+
return {};
|
|
281
|
+
const rest = verdict.relative;
|
|
282
|
+
const basename = rest.slice(rest.lastIndexOf("/") + 1);
|
|
283
|
+
return {
|
|
284
|
+
Program(node) {
|
|
285
|
+
context.report({ node, messageId: "stray", data: { rest, basename } });
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
function propertyName(node) {
|
|
291
|
+
if (node.key.type === "Identifier")
|
|
292
|
+
return node.key.name;
|
|
293
|
+
if (node.key.type === "Literal")
|
|
294
|
+
return node.key.value;
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
/** `meta.shape` must tell the same truth as the source path. */
|
|
298
|
+
export const sourceTierMarkerMatchesFolder = defineRule({
|
|
299
|
+
meta: {
|
|
300
|
+
type: "problem",
|
|
301
|
+
docs: { description: "A source tier marker matches the component folder that owns it." },
|
|
302
|
+
schema: [],
|
|
303
|
+
messages: {
|
|
304
|
+
mismatch: "This file sits in `{{tier}}/` but declares `meta.shape = {{shape}}`. A source marker is evidence, not a second classification; change the owner or the marker so the path and source agree.",
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
create(context) {
|
|
308
|
+
const tier = kindOfFile(context);
|
|
309
|
+
if (tier === null)
|
|
310
|
+
return {};
|
|
311
|
+
const markers = {
|
|
312
|
+
blocks: "block",
|
|
313
|
+
branches: "branch",
|
|
314
|
+
composites: "composite",
|
|
315
|
+
layouts: "layout",
|
|
316
|
+
leaves: "leaf",
|
|
317
|
+
overlays: "overlay",
|
|
318
|
+
pages: "page",
|
|
319
|
+
};
|
|
320
|
+
const expected = markers[tier];
|
|
321
|
+
return {
|
|
322
|
+
ExportNamedDeclaration(node) {
|
|
323
|
+
const declaration = node.declaration;
|
|
324
|
+
if (!declaration || declaration.type !== "VariableDeclaration")
|
|
325
|
+
return;
|
|
326
|
+
for (const item of declaration.declarations || []) {
|
|
327
|
+
if (item.id?.type !== "Identifier" || item.id.name !== "meta")
|
|
328
|
+
continue;
|
|
329
|
+
let init = item.init;
|
|
330
|
+
while (init && (init.type === "TSAsExpression" || init.type === "TSSatisfiesExpression"))
|
|
331
|
+
init = init.expression;
|
|
332
|
+
if (!init || init.type !== "ObjectExpression")
|
|
333
|
+
continue;
|
|
334
|
+
const shapeProperty = init.properties.find((property) => property.type === "Property" && !property.computed && propertyName(property) === "shape");
|
|
335
|
+
const shapeValue = shapeProperty?.value;
|
|
336
|
+
const shape = shapeValue?.type === "Literal" ? shapeValue.value : null;
|
|
337
|
+
if (shapeValue !== undefined && typeof shape === "string" && shape !== expected) {
|
|
338
|
+
context.report({ node: shapeValue, messageId: "mismatch", data: { tier, shape } });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
/** `shells/` is not a tier; vendor mechanics are closed named branches. */
|
|
346
|
+
export const noShellTier = defineRule({
|
|
347
|
+
meta: {
|
|
348
|
+
type: "problem",
|
|
349
|
+
docs: { description: "The component vocabulary has no shell tier." },
|
|
350
|
+
schema: [],
|
|
351
|
+
messages: {
|
|
352
|
+
shell: "`shells/` is an untyped branch exemption. Move the owner to a named branch and keep vendor mechanics closed there.",
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
create(context) {
|
|
356
|
+
if (unknownLayerFolder(context) !== "shells")
|
|
357
|
+
return {};
|
|
358
|
+
return { Program(node) { context.report({ node, messageId: "shell" }); } };
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
/**
|
|
362
|
+
* The component a route slot mounts takes the slot's own name.
|
|
363
|
+
*
|
|
364
|
+
* `page.tsx` default-exports `Page`, `layout.tsx` default-exports `Layout`, and so on through
|
|
365
|
+
* the component slots. The filename already tells the framework what the file is; the export
|
|
366
|
+
* name is what the reader and the search results get, and a slot that calls its component
|
|
367
|
+
* `LandingPage` or `AuthenticationRoute` invents a second name for a file that already has a
|
|
368
|
+
* fixed one. The page OWNER - the component that draws the screen - keeps its own name and
|
|
369
|
+
* lives in `components/pages/`; the slot is a shell that mounts it, and a shell named `Page`
|
|
370
|
+
* says exactly that.
|
|
371
|
+
*
|
|
372
|
+
* `default` is deliberately absent: a parallel-route default mirrors whichever page it stands
|
|
373
|
+
* in for, so its name is a decision rather than a constant. `route`, `providers`, `globals`
|
|
374
|
+
* and the asset slots export no component and are not here.
|
|
375
|
+
*/
|
|
376
|
+
const ROUTE_SLOT_NAME = {
|
|
377
|
+
page: "Page",
|
|
378
|
+
layout: "Layout",
|
|
379
|
+
template: "Template",
|
|
380
|
+
loading: "Loading",
|
|
381
|
+
// Not `Error`: that name shadows the global Error constructor (Sonar typescript:S2137, a bug), and React calls this
|
|
382
|
+
// component an error boundary.
|
|
383
|
+
error: "ErrorBoundary",
|
|
384
|
+
"global-error": "GlobalError",
|
|
385
|
+
"not-found": "NotFound",
|
|
386
|
+
};
|
|
387
|
+
export const routeSlotFixedName = defineRule({
|
|
388
|
+
meta: {
|
|
389
|
+
type: "problem",
|
|
390
|
+
docs: { description: "A route slot file default-exports a component named after the slot." },
|
|
391
|
+
schema: [],
|
|
392
|
+
messages: {
|
|
393
|
+
wrong: "`{{basename}}` default-exports `{{actual}}`. A route slot takes the slot's own name - rename it `{{expected}}` and keep the drawing in the page owner under `features/pages/` (the slot is a shell that mounts it).",
|
|
394
|
+
anonymous: "`{{basename}}` default-exports an anonymous component. A route slot takes the slot's own name - `const {{expected}} = () => <Owner />` then `export default {{expected}}`.",
|
|
395
|
+
missing: "`{{basename}}` has no default export. A route slot's job is to mount its component - default-export `{{expected}}`.",
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
create(context) {
|
|
399
|
+
if (!inSlot(context, "fe.route"))
|
|
400
|
+
return {};
|
|
401
|
+
const expected = ROUTE_SLOT_NAME[String(roleOfFile(context))];
|
|
402
|
+
if (!expected)
|
|
403
|
+
return {};
|
|
404
|
+
const file = hfsOf(context).relative(fileOf(context));
|
|
405
|
+
const basename = file.slice(file.lastIndexOf("/") + 1);
|
|
406
|
+
let seen = false;
|
|
407
|
+
const report = (node, actual) => context.report({
|
|
408
|
+
node,
|
|
409
|
+
messageId: actual === null ? "anonymous" : "wrong",
|
|
410
|
+
data: { basename, actual: actual ?? "(anonymous)", expected },
|
|
411
|
+
});
|
|
412
|
+
return {
|
|
413
|
+
ExportDefaultDeclaration(node) {
|
|
414
|
+
seen = true;
|
|
415
|
+
const declaration = node.declaration;
|
|
416
|
+
if (declaration.type === "Identifier") {
|
|
417
|
+
if (declaration.name !== expected)
|
|
418
|
+
report(node, declaration.name);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if ((declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration") &&
|
|
422
|
+
declaration.id) {
|
|
423
|
+
if (declaration.id.name !== expected)
|
|
424
|
+
report(node, declaration.id.name);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
report(node, null);
|
|
428
|
+
},
|
|
429
|
+
ExportNamedDeclaration(node) {
|
|
430
|
+
for (const specifier of node.specifiers || []) {
|
|
431
|
+
if ((specifier.exported.type === "Identifier" ? specifier.exported.name : undefined) !== "default")
|
|
432
|
+
continue;
|
|
433
|
+
seen = true;
|
|
434
|
+
const local = specifier.local.type === "Identifier" ? specifier.local.name : null;
|
|
435
|
+
if (local !== expected)
|
|
436
|
+
report(node, local);
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
"Program:exit"(node) {
|
|
440
|
+
if (!seen)
|
|
441
|
+
context.report({ node, messageId: "missing", data: { basename, expected } });
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
export const rules = {
|
|
447
|
+
"no-shell-tier": noShellTier,
|
|
448
|
+
"source-tier-marker-matches-folder": sourceTierMarkerMatchesFolder,
|
|
449
|
+
"surface-folder-two-files-only": surfaceFolderTwoFilesOnly,
|
|
450
|
+
"route-tree-holds-routes-only": routeTreeHoldsRoutesOnly,
|
|
451
|
+
"route-slot-fixed-name": routeSlotFixedName,
|
|
452
|
+
"no-helper-folder-in-components": noHelperFolderInComponents,
|
|
453
|
+
"export-matches-folder": exportMatchesFolder,
|
|
454
|
+
"no-runtime-namespace": noRuntimeNamespace,
|
|
455
|
+
"monorepo-tier-belongs-to-its-side": monorepoTierBelongsToItsSide,
|
|
456
|
+
};
|
|
457
|
+
/**
|
|
458
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
459
|
+
*
|
|
460
|
+
* A consuming repository's `eslint.config.mjs` stays the authority on what is actually switched on.
|
|
461
|
+
* `export-matches-folder` is the one worth adopting at `warn` first in an existing tree: it fires
|
|
462
|
+
* on every folder whose convention predates the rule, and that count is a migration rather than a
|
|
463
|
+
* defect.
|
|
464
|
+
*/
|
|
465
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds `formatting.md` (HFS R59 `FE_I18N_PLACEMENT`, sub-check `FE_I18N_FORMATTER`).
|
|
3
|
+
*
|
|
4
|
+
* THE LOCALE HAS ONE OWNER. `next-intl` knows the reader's locale and time zone and hands them to
|
|
5
|
+
* `useFormatter()` / `getFormatter()`. Every other way of turning a number or a date into text either
|
|
6
|
+
* guesses the locale (`toLocaleString()` with no argument uses the SERVER's on the server and the
|
|
7
|
+
* browser's on the client, which is the hydration mismatch that makes a price flicker), hard-codes
|
|
8
|
+
* one (`"en-US"` in a Vietnamese product), or builds a private formatter that ignores the app's
|
|
9
|
+
* currency and date styles. Money and dates are the two places a wrong format is a wrong fact.
|
|
10
|
+
*
|
|
11
|
+
* WHAT IS REFUSED. `toLocaleString`, `toLocaleDateString`, `toLocaleTimeString`; `new Intl.*Format`;
|
|
12
|
+
* `toFixed` when the result is shown (inside JSX or a template); a currency symbol glued to a
|
|
13
|
+
* substitution in a template; and `dayjs` outside `modules/dayjs` (the front-end stack's date-math home; its display
|
|
14
|
+
* goes through the formatter). The other date libraries (`moment`, `date-fns`, `luxon`) are forbidden alternatives of the
|
|
15
|
+
* stack and refused by `fe-stack-import`. `modules/i18n/**` is exempt: it is where the formatters are configured.
|
|
16
|
+
*/
|
|
17
|
+
import { type Recommended } from "./lib/rule.js";
|
|
18
|
+
/** Numbers, money and dates are formatted by the `next-intl` formatter. */
|
|
19
|
+
export declare const useIntlFormatter: import("./lib/rule.js").Rule<"method" | "fixed" | "intl" | "currency" | "library", []>;
|
|
20
|
+
/** The rules this law contributes to the plugin. */
|
|
21
|
+
export declare const rules: {
|
|
22
|
+
"use-intl-formatter": import("./lib/rule.js").Rule<"method" | "fixed" | "intl" | "currency" | "library", []>;
|
|
23
|
+
};
|
|
24
|
+
/** Every rule is an error. */
|
|
25
|
+
export declare const recommended: Recommended;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds `formatting.md` (HFS R59 `FE_I18N_PLACEMENT`, sub-check `FE_I18N_FORMATTER`).
|
|
3
|
+
*
|
|
4
|
+
* THE LOCALE HAS ONE OWNER. `next-intl` knows the reader's locale and time zone and hands them to
|
|
5
|
+
* `useFormatter()` / `getFormatter()`. Every other way of turning a number or a date into text either
|
|
6
|
+
* guesses the locale (`toLocaleString()` with no argument uses the SERVER's on the server and the
|
|
7
|
+
* browser's on the client, which is the hydration mismatch that makes a price flicker), hard-codes
|
|
8
|
+
* one (`"en-US"` in a Vietnamese product), or builds a private formatter that ignores the app's
|
|
9
|
+
* currency and date styles. Money and dates are the two places a wrong format is a wrong fact.
|
|
10
|
+
*
|
|
11
|
+
* WHAT IS REFUSED. `toLocaleString`, `toLocaleDateString`, `toLocaleTimeString`; `new Intl.*Format`;
|
|
12
|
+
* `toFixed` when the result is shown (inside JSX or a template); a currency symbol glued to a
|
|
13
|
+
* substitution in a template; and `dayjs` outside `modules/dayjs` (the front-end stack's date-math home; its display
|
|
14
|
+
* goes through the formatter). The other date libraries (`moment`, `date-fns`, `luxon`) are forbidden alternatives of the
|
|
15
|
+
* stack and refused by `fe-stack-import`. `modules/i18n/**` is exempt: it is where the formatters are configured.
|
|
16
|
+
*/
|
|
17
|
+
import { defineRule } from "./lib/rule.js";
|
|
18
|
+
import { forbiddingConcern } from "./lib/fe-stack.js";
|
|
19
|
+
import { classOf, inSlot } from "./lib/scope.js";
|
|
20
|
+
/** The locale-sensitive `Date` and `Number` methods. */
|
|
21
|
+
const LOCALE_METHODS = new Set(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"]);
|
|
22
|
+
/** The `Intl` formatters a product must not build itself. */
|
|
23
|
+
const INTL_FORMATTERS = new Set(["NumberFormat", "DateTimeFormat", "RelativeTimeFormat", "ListFormat", "PluralRules"]);
|
|
24
|
+
/** Date libraries that format on their own locale. */
|
|
25
|
+
const DATE_LIBRARIES = /^(?:moment(?:-timezone)?|dayjs(?:\/.+)?|date-fns(?:\/.+)?|luxon)$/;
|
|
26
|
+
/** The date-math home of the front-end stack (fe-stack `dates`): the capability `dayjs` of slot `fe.modules` (`modules/dayjs/`). */
|
|
27
|
+
const isDayjsHome = (context) => inSlot(context, "fe.modules") && classOf(context).bindings?.capability === "dayjs";
|
|
28
|
+
/** A currency mark or code beside a substitution. */
|
|
29
|
+
const CURRENCY = /[₫€£¥]|\b(?:VND|USD|EUR)\b/;
|
|
30
|
+
/** True when `node` is displayed: it sits in a JSX expression or a template literal. */
|
|
31
|
+
const isDisplayed = (node) => {
|
|
32
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
33
|
+
if (current.type === "JSXExpressionContainer" || current.type === "TemplateLiteral")
|
|
34
|
+
return true;
|
|
35
|
+
if (current.type === "ArrowFunctionExpression" || current.type === "FunctionExpression" || current.type === "FunctionDeclaration")
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
};
|
|
40
|
+
/** Numbers, money and dates are formatted by the `next-intl` formatter. */
|
|
41
|
+
export const useIntlFormatter = defineRule({
|
|
42
|
+
meta: {
|
|
43
|
+
type: "problem",
|
|
44
|
+
docs: { description: "Format numbers, money and dates with `useFormatter` / `getFormatter` from `next-intl`." },
|
|
45
|
+
schema: [],
|
|
46
|
+
messages: {
|
|
47
|
+
method: "`{{name}}` formats with a locale this file does not own: with no argument it is the server's on the server and the browser's in the browser (a price that flickers on hydration), with a string it is one language for every reader. Use `useFormatter().number(...)` / `.dateTime(...)` (or `getFormatter()` on the server).",
|
|
48
|
+
intl: "`new Intl.{{name}}` builds a private formatter that ignores the app's locale, time zone and named formats. Use the `next-intl` formatter, and put a shared format in `modules/i18n` once.",
|
|
49
|
+
fixed: "`.toFixed()` in displayed text writes a number with a dot decimal separator and no grouping to every reader. Use `useFormatter().number(value, { maximumFractionDigits })`.",
|
|
50
|
+
currency: "A currency mark glued to a substitution in a template writes the symbol on the wrong side for half of the locales and skips grouping. Use `useFormatter().number(value, { style: \"currency\", currency })`.",
|
|
51
|
+
library: "`{{name}}` is imported outside `modules/dayjs`. It formats dates on its own locale data, next to `next-intl`'s, and one library decides the language of a date: do date math through `modules/dayjs` and display with the `next-intl` formatter (`dateTime`, `relativeTime`).",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
create(context) {
|
|
55
|
+
if (inSlot(context, "fe.modules.i18n"))
|
|
56
|
+
return {};
|
|
57
|
+
return {
|
|
58
|
+
ImportDeclaration(node) {
|
|
59
|
+
const source = String(node.source.value);
|
|
60
|
+
// a date library the stack forbids is fe-stack-import's finding; dayjs is legal in its home
|
|
61
|
+
if (DATE_LIBRARIES.test(source) && !forbiddingConcern(source) && !isDayjsHome(context))
|
|
62
|
+
context.report({ node, messageId: "library", data: { name: source } });
|
|
63
|
+
},
|
|
64
|
+
CallExpression(node) {
|
|
65
|
+
const callee = node.callee;
|
|
66
|
+
if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier")
|
|
67
|
+
return;
|
|
68
|
+
const name = callee.property.name;
|
|
69
|
+
if (LOCALE_METHODS.has(name))
|
|
70
|
+
context.report({ node, messageId: "method", data: { name } });
|
|
71
|
+
else if (name === "toFixed" && isDisplayed(node))
|
|
72
|
+
context.report({ node, messageId: "fixed" });
|
|
73
|
+
},
|
|
74
|
+
NewExpression(node) {
|
|
75
|
+
const callee = node.callee;
|
|
76
|
+
if (callee.type === "MemberExpression" &&
|
|
77
|
+
callee.object.type === "Identifier" &&
|
|
78
|
+
callee.object.name === "Intl" &&
|
|
79
|
+
callee.property.type === "Identifier" &&
|
|
80
|
+
INTL_FORMATTERS.has(callee.property.name)) {
|
|
81
|
+
context.report({ node, messageId: "intl", data: { name: callee.property.name } });
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
TemplateLiteral(node) {
|
|
85
|
+
if (node.expressions.length === 0)
|
|
86
|
+
return;
|
|
87
|
+
const glued = node.quasis.some((quasi, index) => {
|
|
88
|
+
const text = quasi.value.cooked ?? "";
|
|
89
|
+
if (index < node.expressions.length && text.endsWith("$"))
|
|
90
|
+
return true;
|
|
91
|
+
return CURRENCY.test(text);
|
|
92
|
+
});
|
|
93
|
+
if (glued)
|
|
94
|
+
context.report({ node, messageId: "currency" });
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
/** The rules this law contributes to the plugin. */
|
|
100
|
+
export const rules = {
|
|
101
|
+
"use-intl-formatter": useIntlFormatter,
|
|
102
|
+
};
|
|
103
|
+
/** Every rule is an error. */
|
|
104
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Recommended } from "./lib/rule.js";
|
|
2
|
+
/** Functions, methods, constructors, accessors and callbacks all receive the same body budget. */
|
|
3
|
+
export declare const functionLineBudget: import("./lib/rule.js").Rule<"lines", []>;
|
|
4
|
+
/** The rule this law contributes to the plugin. */
|
|
5
|
+
export declare const rules: {
|
|
6
|
+
"function-line-budget": import("./lib/rule.js").Rule<"lines", []>;
|
|
7
|
+
};
|
|
8
|
+
/** The function budget is an error in both editions. */
|
|
9
|
+
export declare const recommended: Recommended;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { defineRule } from "./lib/rule.js";
|
|
2
|
+
import { hfsOf } from "./lib/hfs.js";
|
|
3
|
+
/** Functions, methods, constructors, accessors and callbacks all receive the same body budget. */
|
|
4
|
+
export const functionLineBudget = defineRule({
|
|
5
|
+
meta: {
|
|
6
|
+
type: "problem",
|
|
7
|
+
docs: { description: "A function body stays within ruleParams.functionLines, counting comments and blank lines." },
|
|
8
|
+
schema: [],
|
|
9
|
+
messages: {
|
|
10
|
+
lines: "HFS_FUNCTION_BUDGET: this function body has {{lines}} lines; the budget is {{max}}. Split the work into functions with one responsibility each.",
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
// C08 adds this common parameter to the shipped manifest; keep the owner-decided cap while it lands.
|
|
15
|
+
const max = hfsOf(context).ruleParams.functionLines ?? 80;
|
|
16
|
+
if (!Number.isInteger(max) || max < 1)
|
|
17
|
+
throw new RangeError("ruleParams.functionLines must be a positive integer");
|
|
18
|
+
const source = context.sourceCode || context.getSourceCode();
|
|
19
|
+
const check = (node) => {
|
|
20
|
+
if (!node.body)
|
|
21
|
+
return;
|
|
22
|
+
let start = node.body.loc.start.line;
|
|
23
|
+
let end = node.body.loc.end.line;
|
|
24
|
+
if (node.type === "ArrowFunctionExpression" && node.body.type !== "BlockStatement") {
|
|
25
|
+
const arrow = source.getTokenBefore(node.body, { filter: (token) => token.value === "=>" });
|
|
26
|
+
if (arrow === null)
|
|
27
|
+
throw new TypeError("an expression arrow has no => token");
|
|
28
|
+
const after = source.getTokenAfter(arrow, { includeComments: true });
|
|
29
|
+
if (after === null)
|
|
30
|
+
throw new TypeError("an arrow token has nothing after it");
|
|
31
|
+
start = after.loc.start.line;
|
|
32
|
+
end = node.loc.end.line;
|
|
33
|
+
}
|
|
34
|
+
const lines = end - start + 1;
|
|
35
|
+
if (lines > max)
|
|
36
|
+
context.report({ node: node.body, messageId: "lines", data: { lines, max } });
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
FunctionDeclaration: check,
|
|
40
|
+
FunctionExpression: check,
|
|
41
|
+
ArrowFunctionExpression: check,
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
/** The rule this law contributes to the plugin. */
|
|
46
|
+
export const rules = { "function-line-budget": functionLineBudget };
|
|
47
|
+
/** The function budget is an error in both editions. */
|
|
48
|
+
export const recommended = { "starci-fe/function-line-budget": "error" };
|