@starci/eslint-canon-fe 3.0.2 → 5.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +41 -16
- package/brand-values.mjs +174 -0
- package/class-names.mjs +1 -1
- package/client-boundary.mjs +199 -0
- package/comments.mjs +144 -190
- package/docs/brand-values.md +30 -0
- package/docs/client-boundary.md +99 -0
- package/docs/e2e-shape.md +161 -0
- package/docs/env-owner.md +52 -0
- package/docs/formatting.md +30 -0
- package/docs/hooks-folder.md +29 -0
- package/docs/hygiene.md +111 -0
- package/docs/lint-escape-hatch.md +32 -0
- package/docs/lists.md +74 -0
- package/docs/native-controls.md +73 -0
- package/docs/next-conventions.md +206 -0
- package/docs/size-and-state-budget.md +73 -0
- package/docs/spec-quality.md +139 -0
- package/docs/translation.md +50 -0
- package/docs/transport.md +168 -0
- package/docs/type-safety.md +95 -0
- package/e2e-shape.mjs +348 -0
- package/env-owner.mjs +134 -0
- package/file-layout.mjs +100 -7
- package/formatting.mjs +106 -0
- package/hooks-folder.mjs +119 -0
- package/hygiene.mjs +235 -0
- package/icon.mjs +210 -211
- package/index.mjs +292 -131
- package/lib/ast.mjs +94 -0
- package/lib/path.mjs +2 -0
- package/lib/scope.mjs +42 -0
- package/lib/why.mjs +304 -0
- package/lint-adoption.mjs +15 -52
- package/lint-escape-hatch.mjs +91 -56
- package/lists.mjs +197 -0
- package/loading.mjs +163 -164
- package/naming.mjs +143 -143
- package/native-controls.mjs +112 -0
- package/next-conventions.mjs +346 -0
- package/package.json +7 -3
- package/props-and-slots.mjs +4 -2
- package/served-locale.mjs +160 -160
- package/shape-slot.mjs +339 -0
- package/size-and-state-budget.mjs +196 -0
- package/spec-quality.mjs +239 -0
- package/the-split.mjs +133 -134
- package/tokens.mjs +267 -268
- package/translation.mjs +262 -122
- package/transport.mjs +404 -0
- package/type-safety.mjs +156 -74
- package/typography.mjs +82 -83
package/lib/why.mjs
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Vietnamese "why" of each rule the HFS catalogue assigns to this canon.
|
|
3
|
+
*
|
|
4
|
+
* WHERE IT IS SURFACED. An ESLint message is written for the developer at the terminal, in English,
|
|
5
|
+
* and says what is wrong in the vocabulary of the rule. The agent that reads a failed land gate needs
|
|
6
|
+
* the catalogue's sentence instead: the finding code (the key in `modules/kernel/failure-codes.yaml`)
|
|
7
|
+
* and a Vietnamese headline and next step it can quote to the owner. This map is the canon's half of
|
|
8
|
+
* that contract: `code` is the finding code, `vi` the headline with `<file>` / `<what>` placeholders
|
|
9
|
+
* the reader fills from the ESLint location, `fixVi` the one sentence "what do I do now".
|
|
10
|
+
*
|
|
11
|
+
* Every rule that carries a catalogue id (R18, R22, R49-R59, R60-R62, R65-R67) has an entry;
|
|
12
|
+
* the twin test refuses a rule of those laws with no entry and an entry for a rule that does not exist.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @typedef {{ code: string, vi: string, fixVi: string }} Why */
|
|
16
|
+
|
|
17
|
+
/** @type {Record<string, Why>} */
|
|
18
|
+
export const why = {
|
|
19
|
+
"no-env-outside-config": {
|
|
20
|
+
code: "FE_ENV_OWNER",
|
|
21
|
+
vi: "`<file>` đọc biến môi trường. Chỉ `modules/config` được đọc env.",
|
|
22
|
+
fixVi: "Đọc giá trị từ export có kiểu của `modules/config` thay vì `process.env`.",
|
|
23
|
+
},
|
|
24
|
+
"no-hardcoded-endpoint-fallback": {
|
|
25
|
+
code: "FE_ENV_OWNER",
|
|
26
|
+
vi: "`<file>` có giá trị dự phòng cứng cho địa chỉ hoặc bí mật (`?? \"http://localhost…\"`). Thiếu biến ở production phải ném lỗi khi nạp cấu hình, không được lặng lẽ trỏ về máy dev.",
|
|
27
|
+
fixVi: "Xóa giá trị dự phòng; để `modules/config` ném lỗi khi thiếu biến ở production.",
|
|
28
|
+
},
|
|
29
|
+
"fetch-only-in-api-client": {
|
|
30
|
+
code: "FE_TRANSPORT_OWNER",
|
|
31
|
+
vi: "`fetch` (hoặc thư viện HTTP khác) ở `<file>` nằm ngoài `modules/api/client.ts`. Mỗi app chỉ có đúng một đường truyền.",
|
|
32
|
+
fixVi: "Gọi client của app và nhận `Outcome<T>`; không tự gọi `fetch`.",
|
|
33
|
+
},
|
|
34
|
+
"client-fetch-has-signal": {
|
|
35
|
+
code: "FE_TRANSPORT_OWNER",
|
|
36
|
+
vi: "`fetch` ở `<file>` không có `signal`. Client bắt buộc có timeout và `AbortSignal`.",
|
|
37
|
+
fixVi: "Truyền `signal` (`AbortSignal.timeout(...)` kết hợp với tín hiệu của người gọi).",
|
|
38
|
+
},
|
|
39
|
+
"no-shared-transport-state": {
|
|
40
|
+
code: "FE_TRANSPORT_OWNER",
|
|
41
|
+
vi: "`<file>` giữ trạng thái dùng chung dạng `let`/`var` trong tầng API. Token và locale không được nằm trong singleton.",
|
|
42
|
+
fixVi: "Truyền credential và locale bằng tham số hoặc context.",
|
|
43
|
+
},
|
|
44
|
+
"client-maps-auth-to-refused": {
|
|
45
|
+
code: "FE_HTTP_STATUS_COLLAPSE",
|
|
46
|
+
vi: "Client ở `<file>` không đổi 401/403 thành `refused`, nên trạng thái \"cần đăng nhập\" không bao giờ đạt được.",
|
|
47
|
+
fixVi: "Thêm nhánh 401/403 trả `{ kind: \"refused\" }` trong client.",
|
|
48
|
+
},
|
|
49
|
+
"no-http-status-collapse": {
|
|
50
|
+
code: "FE_HTTP_STATUS_COLLAPSE",
|
|
51
|
+
vi: "`<file>` gộp mọi mã HTTP thành một nhánh (hoặc trả null khi phản hồi lỗi). 401/403 phải thành `refused`.",
|
|
52
|
+
fixVi: "Trả `Outcome` theo mã: `refused` (401/403), `not-found`, `invalid`, `unavailable`; không trả nguyên văn lỗi của server làm lý do.",
|
|
53
|
+
},
|
|
54
|
+
"no-hand-typed-wire": {
|
|
55
|
+
code: "FE_WIRE_GENERATED",
|
|
56
|
+
vi: "`<file>` tự gõ kiểu wire hoặc ép kiểu phản hồi. Dùng kiểu sinh từ `contract/`.",
|
|
57
|
+
fixVi: "Chạy codegen từ bản sao hợp đồng ở `modules/api/contract/` và nhập kiểu sinh ra; đưa tài liệu GraphQL vào tệp `.graphql`.",
|
|
58
|
+
},
|
|
59
|
+
"use-client-only-at-boundary": {
|
|
60
|
+
code: "FE_CLIENT_BOUNDARY",
|
|
61
|
+
vi: "`\"use client\"` ở `<file>` (`<slot>` không được là client component).",
|
|
62
|
+
fixVi: "Đặt `\"use client\"` ở `index.tsx` của block có tương tác, leaf, hoặc `error.tsx`/`global-error.tsx`; giữ layout và page là server component.",
|
|
63
|
+
},
|
|
64
|
+
"hooks-folder-holds-hooks-only": {
|
|
65
|
+
code: "FE_HOOKS_ARE_HOOKS",
|
|
66
|
+
vi: "`<file>` trong `hooks/` không phải React hook. Reader server về `modules/api`, helper về `<domain>.shared.ts`.",
|
|
67
|
+
fixVi: "Mỗi tệp `use*.ts` một hook; helper dùng chung vào `<domain>.shared.ts`; reader server vào `modules/api/<domain>/read-*.ts`.",
|
|
68
|
+
},
|
|
69
|
+
"no-middleware-file": {
|
|
70
|
+
code: "FE_NEXT_CONVENTIONS",
|
|
71
|
+
vi: "`<file>` dùng tên `middleware`. Từ Next 16 bộ chặn request là `proxy.ts` và xuất `proxy`.",
|
|
72
|
+
fixVi: "Đổi tên tệp thành `proxy.ts` và đổi export thành `proxy`.",
|
|
73
|
+
},
|
|
74
|
+
"locale-segment-is-locale": {
|
|
75
|
+
code: "FE_I18N_PLACEMENT",
|
|
76
|
+
vi: "Tệp `<file>` nằm dưới segment ngôn ngữ không phải `[locale]`. Chỉ có một mẫu: `next-intl`, `[locale]`, mặc định `vi`.",
|
|
77
|
+
fixVi: "Đổi tên thư mục segment thành `[locale]`.",
|
|
78
|
+
},
|
|
79
|
+
"no-second-i18n-stack": {
|
|
80
|
+
code: "FE_I18N_PLACEMENT",
|
|
81
|
+
vi: "`<file>` nhập một thư viện i18n thứ hai. App chỉ dùng `next-intl` qua `modules/i18n`.",
|
|
82
|
+
fixVi: "Chuyển sang `next-intl`; xóa thư viện còn lại.",
|
|
83
|
+
},
|
|
84
|
+
"html-lang-from-locale": {
|
|
85
|
+
code: "FE_I18N_LITERAL",
|
|
86
|
+
vi: "`<html lang>` ở `<file>` là chữ cứng. Thuộc tính `lang` phải lấy từ segment `[locale]`.",
|
|
87
|
+
fixVi: "Dùng `lang={locale}` từ tham số của layout `[locale]`.",
|
|
88
|
+
},
|
|
89
|
+
"no-hardcoded-copy": {
|
|
90
|
+
code: "FE_I18N_LITERAL",
|
|
91
|
+
vi: "Chữ `\"<text>\"` ở `<file>:<line>` không đi qua `t()`.",
|
|
92
|
+
fixVi: "Chuyển câu vào `modules/i18n/messages/<locale>.json` và đọc bằng `t(\"khóa\")`; không có ngoại lệ và không có `vn-ok`.",
|
|
93
|
+
},
|
|
94
|
+
"no-raw-brand-value": {
|
|
95
|
+
code: "FE_STYLE_TOKEN_ONLY",
|
|
96
|
+
vi: "`<file>` dùng giá trị màu/độ dài thô (`<what>`). Màu và khoảng cách chỉ đến từ token grammar; màu thương hiệu chỉ ở `brand.css`.",
|
|
97
|
+
fixVi: "Dùng lớp hoặc biến token của grammar; giá trị màu chỉ khai trong `modules/brand/brand.css`.",
|
|
98
|
+
},
|
|
99
|
+
"no-native-form-control": {
|
|
100
|
+
code: "FE_NATIVE_FORM_CONTROL",
|
|
101
|
+
vi: "`<tag>` thô ở `<file>`. Dùng renderer của grammar.",
|
|
102
|
+
fixVi: "Thay bằng thành phần của grammar; nếu chưa có, thêm vào grammar thay vì vẽ tại chỗ.",
|
|
103
|
+
},
|
|
104
|
+
"component-line-budget": {
|
|
105
|
+
code: "FE_SIZE_AND_STATE_BUDGET",
|
|
106
|
+
vi: "`<file>` dài `<n>` dòng, vượt ngưỡng 300 dòng của một component.",
|
|
107
|
+
fixVi: "Tách phần vẽ khỏi phần dữ liệu và tách từng khu vực thành đơn vị riêng.",
|
|
108
|
+
},
|
|
109
|
+
"unit-hook-budget": {
|
|
110
|
+
code: "FE_SIZE_AND_STATE_BUDGET",
|
|
111
|
+
vi: "`<unit>` có `<n>` hook dữ liệu / `<m>` useState, vượt ngưỡng 6.",
|
|
112
|
+
fixVi: "Gom trạng thái đổi cùng nhau vào reducer hoặc hook riêng; tách mỗi vùng dữ liệu thành block kết nối riêng.",
|
|
113
|
+
},
|
|
114
|
+
"no-hand-rolled-polling": {
|
|
115
|
+
code: "FE_SIZE_AND_STATE_BUDGET",
|
|
116
|
+
vi: "`<unit>` tự viết vòng poll (`setInterval` hoặc `setTimeout` tự gọi lại).",
|
|
117
|
+
fixVi: "Làm tươi qua hook dữ liệu (`refreshInterval`) hoặc socket, mỗi resource đúng một cơ chế.",
|
|
118
|
+
},
|
|
119
|
+
"e2e-spec-location": {
|
|
120
|
+
code: "FE_E2E_SHAPE",
|
|
121
|
+
vi: "Spec e2e `<file>` không nằm ở `e2e/<area>/<name>.e2e-spec.ts`.",
|
|
122
|
+
fixVi: "Đưa spec vào `e2e/<area>/` với hậu tố `.e2e-spec.ts`; helper vào `e2e/support/` hoặc `e2e/fixtures/`.",
|
|
123
|
+
},
|
|
124
|
+
"e2e-no-absolute-path": {
|
|
125
|
+
code: "FE_E2E_SHAPE",
|
|
126
|
+
vi: "Spec e2e `<file>` dùng đường dẫn tuyệt đối.",
|
|
127
|
+
fixVi: "Dựng đường dẫn từ cấu hình hoặc thư mục của chính spec.",
|
|
128
|
+
},
|
|
129
|
+
"e2e-no-docker": {
|
|
130
|
+
code: "FE_E2E_SHAPE",
|
|
131
|
+
vi: "Spec e2e `<file>` phụ thuộc vào docker.",
|
|
132
|
+
fixVi: "Trỏ suite tới một URL từ cấu hình; môi trường tự cung cấp stack.",
|
|
133
|
+
},
|
|
134
|
+
"e2e-no-cross-repo-write": {
|
|
135
|
+
code: "FE_E2E_SHAPE",
|
|
136
|
+
vi: "Spec e2e `<file>` ghi ra ngoài repo của app (đường dẫn tuyệt đối hoặc `..`).",
|
|
137
|
+
fixVi: "Chỉ ghi vào thư mục kết quả của chính repo này.",
|
|
138
|
+
},
|
|
139
|
+
"e2e-no-skip": {
|
|
140
|
+
code: "FE_E2E_SHAPE",
|
|
141
|
+
vi: "Spec e2e `<file>` bỏ qua test (`test.skip`/`fixme`) — thiếu môi trường phải làm suite đỏ, không được xanh.",
|
|
142
|
+
fixVi: "Sửa môi trường hoặc xóa test; không bỏ qua có điều kiện.",
|
|
143
|
+
},
|
|
144
|
+
"e2e-typed-helpers": {
|
|
145
|
+
code: "FE_E2E_SHAPE",
|
|
146
|
+
vi: "Helper e2e ở `<file>` có tham số không kiểu hoặc dùng `any`.",
|
|
147
|
+
fixVi: "Khai kiểu cho mọi tham số của helper; thay `any` bằng kiểu thật hoặc `unknown` kèm kiểm tra.",
|
|
148
|
+
},
|
|
149
|
+
"playwright-viewports": {
|
|
150
|
+
code: "FE_E2E_SHAPE",
|
|
151
|
+
vi: "`playwright.config` không khai đúng ba viewport 1440x900, 768x1024, 390x844.",
|
|
152
|
+
fixVi: "Mỗi project khai một viewport; đủ ba, không thêm cỡ thứ tư.",
|
|
153
|
+
},
|
|
154
|
+
"no-class-string-in-spec": {
|
|
155
|
+
code: "FE_SPEC_QUALITY",
|
|
156
|
+
vi: "Spec `<file>` ghim chuỗi class. Spec kiểm hành vi, không kiểm stylesheet.",
|
|
157
|
+
fixVi: "Assert theo role, tên, trạng thái hoặc chữ hiển thị.",
|
|
158
|
+
},
|
|
159
|
+
"spec-tests-its-neighbour": {
|
|
160
|
+
code: "FE_SPEC_QUALITY",
|
|
161
|
+
vi: "Spec `<file>` không import chủ thể nằm cạnh nó.",
|
|
162
|
+
fixVi: "Đưa spec về cạnh chủ thể, hoặc test đúng đơn vị nằm cạnh.",
|
|
163
|
+
},
|
|
164
|
+
"no-barrel-spec": {
|
|
165
|
+
code: "FE_SPEC_QUALITY",
|
|
166
|
+
vi: "Spec `<file>` đặt cạnh một barrel. Barrel không có hành vi để test.",
|
|
167
|
+
fixVi: "Xóa spec; test từng đơn vị mà barrel xuất lại, cạnh chính đơn vị đó.",
|
|
168
|
+
},
|
|
169
|
+
"no-double-cast-in-spec": {
|
|
170
|
+
code: "FE_SPEC_QUALITY",
|
|
171
|
+
vi: "Spec `<file>` ép kiểu hai lần (`as unknown as`).",
|
|
172
|
+
fixVi: "Dựng giá trị bằng factory có kiểu (`mock<T>()` hoặc fixture builder).",
|
|
173
|
+
},
|
|
174
|
+
"connected-spec-has-axe": {
|
|
175
|
+
code: "FE_SPEC_QUALITY",
|
|
176
|
+
vi: "Spec `<file>` của màn hình kết nối không có assert axe.",
|
|
177
|
+
fixVi: "Thêm `expect(await axe(container)).toHaveNoViolations()`.",
|
|
178
|
+
},
|
|
179
|
+
"no-mocked-translations": {
|
|
180
|
+
code: "FE_I18N_CATALOG",
|
|
181
|
+
vi: "Spec `<file>` giả lập `next-intl`, nên chỉ kiểm khóa chứ không kiểm catalog thật.",
|
|
182
|
+
fixVi: "Render trong `NextIntlClientProvider` với `messages/<locale>.json` thật.",
|
|
183
|
+
},
|
|
184
|
+
"no-inline-lint-config": {
|
|
185
|
+
code: "HFS_INLINE_SUPPRESSION",
|
|
186
|
+
vi: "Có chú thích tắt luật ở `<file>:<line>`. HFS không cho tắt tại chỗ — sửa code, hoặc đề xuất đổi luật.",
|
|
187
|
+
fixVi: "Xóa `eslint-disable`, `@ts-ignore`, `@ts-expect-error` hoặc `vn-ok` và sửa nguyên nhân.",
|
|
188
|
+
},
|
|
189
|
+
"no-double-cast": {
|
|
190
|
+
code: "FE_TYPE_ESCAPE",
|
|
191
|
+
vi: "`<file>` ép kiểu qua `unknown` (`as unknown as T`): trình biên dịch quên mọi thứ nó biết ngay tại chỗ dữ liệu đi vào.",
|
|
192
|
+
fixVi: "Thu hẹp từ `unknown` bằng type guard hoặc parser; không ép kiểu.",
|
|
193
|
+
},
|
|
194
|
+
"no-type-assertion": {
|
|
195
|
+
code: "FE_TYPE_ESCAPE",
|
|
196
|
+
vi: "`<file>` dùng `as T` hoặc `<T>x`: một lời khẳng định trình biên dịch không kiểm được. Sai thì lỗi rơi vào trình duyệt của người dùng.",
|
|
197
|
+
fixVi: "Thu hẹp bằng type guard, `in`, discriminant hoặc parser tại nơi dữ liệu đi vào; dùng `satisfies` nếu chỉ muốn kiểm một literal.",
|
|
198
|
+
},
|
|
199
|
+
"no-non-null-assertion": {
|
|
200
|
+
code: "FE_TYPE_ESCAPE",
|
|
201
|
+
vi: "`<file>` dùng `x!`: khẳng định giá trị có mặt mà không chứng minh.",
|
|
202
|
+
fixVi: "Xử lý nhánh vắng mặt (`if`, `??`, return sớm) hoặc sửa kiểu để giá trị không thể vắng.",
|
|
203
|
+
},
|
|
204
|
+
"no-explicit-any": {
|
|
205
|
+
code: "FE_TYPE_ESCAPE",
|
|
206
|
+
vi: "`<file>` dùng `any`: tắt kiểm kiểu cho giá trị đó và mọi thứ suy ra từ nó.",
|
|
207
|
+
fixVi: "Dùng kiểu thật, generic, hoặc `unknown` rồi thu hẹp tại chỗ dùng.",
|
|
208
|
+
},
|
|
209
|
+
"list-item-has-key": {
|
|
210
|
+
code: "FE_LIST_KEY",
|
|
211
|
+
vi: "Phần tử trả từ `.map` ở `<file>` không có `key`, nên React nhận diện hàng theo vị trí và hàng bị đổi state khi xoá hoặc sắp xếp lại.",
|
|
212
|
+
fixVi: "Thêm `key` lấy từ id của dữ liệu; với fragment dùng `<Fragment key={...}>`.",
|
|
213
|
+
},
|
|
214
|
+
"no-index-key": {
|
|
215
|
+
code: "FE_LIST_KEY",
|
|
216
|
+
vi: "`key` ở `<file>` lấy từ chỉ số của `.map` hoặc giá trị sinh ngẫu nhiên; chỉ số là vị trí, không phải danh tính.",
|
|
217
|
+
fixVi: "Dùng id mà dữ liệu mang (`key={row.id}`); không dùng index, `Math.random`, `Date.now`.",
|
|
218
|
+
},
|
|
219
|
+
"no-inline-literal-prop-in-list": {
|
|
220
|
+
code: "FE_LIST_KEY",
|
|
221
|
+
vi: "Component trong `.map` ở `<file>` nhận object hoặc array literal làm prop: mỗi hàng mỗi lần render một giá trị mới, memo không bao giờ trúng.",
|
|
222
|
+
fixVi: "Nâng hằng số ra ngoài component hoặc dựng một lần trước `.map`.",
|
|
223
|
+
},
|
|
224
|
+
"timer-needs-effect-cleanup": {
|
|
225
|
+
code: "FE_EFFECT_CLEANUP",
|
|
226
|
+
vi: "`<timer>` ở `<file>` chạy ngoài effect hoặc trong effect không có cleanup gọi `clearTimeout`/`clearInterval`.",
|
|
227
|
+
fixVi: "Đặt timer trong `useEffect` và trả `() => clearTimeout(id)`; hoặc chuyển vào một hook làm việc đó.",
|
|
228
|
+
},
|
|
229
|
+
"no-data-fetch-in-effect": {
|
|
230
|
+
code: "FE_EFFECT_FETCH",
|
|
231
|
+
vi: "`useEffect` ở `<file>` tải dữ liệu (`await`, `fetch`, `.then`): không cache, không gộp request, không trạng thái loading/lỗi, không huỷ.",
|
|
232
|
+
fixVi: "Đọc phía client bằng SWR gọi client của app (`hooks/`), phía route bằng server reader `modules/api/<domain>/read-*.ts`.",
|
|
233
|
+
},
|
|
234
|
+
"no-empty-catch": {
|
|
235
|
+
code: "FE_SWALLOWED_ERROR",
|
|
236
|
+
vi: "`catch` hoặc `.catch` ở `<file>` không làm gì: lỗi biến mất, không thông báo, không dấu vết.",
|
|
237
|
+
fixVi: "Trả một outcome có kiểu mang nguyên nhân, hiển thị trạng thái lỗi, hoặc ném lại.",
|
|
238
|
+
},
|
|
239
|
+
"no-console": {
|
|
240
|
+
code: "FE_CONSOLE_CALL",
|
|
241
|
+
vi: "`console.<method>` ở `<file>`: không vào pipeline log nào và lộ chi tiết nội bộ cho người dùng.",
|
|
242
|
+
fixVi: "Trả outcome có kiểu, hiển thị trạng thái lỗi, hoặc để lỗi tới `error.tsx`.",
|
|
243
|
+
},
|
|
244
|
+
"page-exports-metadata": {
|
|
245
|
+
code: "FE_PAGE_METADATA_MISSING",
|
|
246
|
+
vi: "`<file>` là `page.tsx` nhưng không export `metadata` hoặc `generateMetadata`; mọi trang trong nhánh mang cùng một tiêu đề.",
|
|
247
|
+
fixVi: "Export `metadata` (hoặc `generateMetadata` khi tiêu đề lấy từ dữ liệu) với tiêu đề và mô tả lấy từ catalog thông điệp.",
|
|
248
|
+
},
|
|
249
|
+
"no-null-suspense-fallback": {
|
|
250
|
+
code: "FE_SUSPENSE_NULL_FALLBACK",
|
|
251
|
+
vi: "`<Suspense>` ở `<file>` có `fallback` rỗng: người dùng thấy khoảng trống thay vì trạng thái đang tải.",
|
|
252
|
+
fixVi: "Truyền skeleton (trạng thái loading) của đúng thứ đang tải làm `fallback`.",
|
|
253
|
+
},
|
|
254
|
+
"navigation-from-intl": {
|
|
255
|
+
code: "FE_I18N_NAVIGATION",
|
|
256
|
+
vi: "`<file>` nhập `Link`, `useRouter`, `usePathname` hoặc `redirect` từ Next, không biết locale: đường dẫn mất tiền tố `[locale]`.",
|
|
257
|
+
fixVi: "Nhập từ `modules/i18n/navigation`, được next-intl dựng từ `routing.ts`.",
|
|
258
|
+
},
|
|
259
|
+
"no-native-anchor": {
|
|
260
|
+
code: "FE_I18N_NAVIGATION",
|
|
261
|
+
vi: "`<a>` ở `<file>` trỏ route nội bộ (tải lại cả trang, mất locale, mất prefetch) hoặc mở tab mới mà không có `rel`.",
|
|
262
|
+
fixVi: "Dùng `Link` từ `modules/i18n/navigation` với href dựng bởi `modules/routes`; liên kết `_blank` thêm `rel=\"noopener noreferrer\"`.",
|
|
263
|
+
},
|
|
264
|
+
"use-intl-formatter": {
|
|
265
|
+
code: "FE_I18N_FORMATTER",
|
|
266
|
+
vi: "`<file>` định dạng số, tiền hoặc ngày bằng `toLocale*String`, `new Intl.*`, `toFixed`, ký hiệu tiền dán vào template hoặc thư viện ngày, thay vì formatter của next-intl.",
|
|
267
|
+
fixVi: "Dùng `useFormatter()` (hoặc `getFormatter()` ở server): `number(...)`, `dateTime(...)`, `relativeTime(...)`.",
|
|
268
|
+
},
|
|
269
|
+
"no-hardcoded-route": {
|
|
270
|
+
code: "FE_ROUTE_HARDCODED",
|
|
271
|
+
vi: "`<file>` viết thẳng đường dẫn route ở nơi gọi; route đổi chỗ thì bản sao trỏ vào 404.",
|
|
272
|
+
fixVi: "Dựng href bằng hàm của `modules/routes` và dùng đúng hàm đó ở mọi nơi.",
|
|
273
|
+
},
|
|
274
|
+
"client-no-server-import": {
|
|
275
|
+
code: "FE_CLIENT_SERVER_IMPORT",
|
|
276
|
+
vi: "Client component ở `<file>` nhập mã chỉ tồn tại ở server (`server-only`, `next/headers`, module Node, server reader).",
|
|
277
|
+
fixVi: "Đọc dữ liệu ở server component hoặc server reader rồi truyền xuống bằng props; hoặc dùng SWR gọi client của app.",
|
|
278
|
+
},
|
|
279
|
+
"web-storage-only-in-modules": {
|
|
280
|
+
code: "FE_STORAGE_OUTSIDE_MODULES",
|
|
281
|
+
vi: "`<file>` dùng `localStorage`/`sessionStorage` ngoài `modules/`; storage không có ở server và ném lỗi khi đầy hạn mức.",
|
|
282
|
+
fixVi: "Đặt đọc/ghi sau một hook hoặc module trong `modules/` có bảo vệ (`typeof window`, try/catch) rồi gọi từ block.",
|
|
283
|
+
},
|
|
284
|
+
"no-dangerous-html": {
|
|
285
|
+
code: "FE_DANGEROUS_HTML",
|
|
286
|
+
vi: "`dangerouslySetInnerHTML` trên `<tag>` ở `<file>`: chuỗi bất kỳ trở thành HTML chạy được.",
|
|
287
|
+
fixVi: "Render nội dung thành phần tử; chỉ `<script>` mang JSON-LD hoặc mã theme từ hằng số của app mới được dùng.",
|
|
288
|
+
},
|
|
289
|
+
"no-native-img": {
|
|
290
|
+
code: "FE_NATIVE_IMAGE",
|
|
291
|
+
vi: "`<img>` thô ở `<file>`: tải ảnh gốc, không giữ chỗ nên trang nhảy khi ảnh tải xong.",
|
|
292
|
+
fixVi: "Dùng `Image` của `next/image` với `width` và `height` (hoặc `fill` và `sizes`) và `alt` từ catalog.",
|
|
293
|
+
},
|
|
294
|
+
"image-has-size": {
|
|
295
|
+
code: "FE_NATIVE_IMAGE",
|
|
296
|
+
vi: "`Image` ở `<file>` thiếu `width` và `height` (hoặc `fill` mà thiếu `sizes`): trình duyệt không giữ chỗ được.",
|
|
297
|
+
fixVi: "Cho đủ `width` và `height`, hoặc `fill` trong khung có kích thước kèm `sizes`.",
|
|
298
|
+
},
|
|
299
|
+
"outcome-kinds-exhaustive": {
|
|
300
|
+
code: "FE_OUTCOME_KIND_UNHANDLED",
|
|
301
|
+
vi: "`switch` trên `kind` ở `<file>` có nhánh `ok` nhưng thiếu một trong refused, invalid, not-found, unavailable.",
|
|
302
|
+
fixVi: "Viết đủ năm nhánh của `Outcome<T>`, mỗi nhánh một màn; không dựa vào `default`.",
|
|
303
|
+
},
|
|
304
|
+
}
|
package/lint-adoption.mjs
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* An ESLint config can import a plugin, copy a plugin, or call itself StarCi while still leaving
|
|
5
5
|
* canonical rules switched off. The only useful evidence is the effective config ESLint applies
|
|
6
|
-
* to a real production file. This audit compares that printed config with this canon
|
|
6
|
+
* to a real production file. This audit compares that printed config with this canon: pass `sourceRecommended`
|
|
7
|
+
* for a production probe file and `e2eRecommended` for an e2e probe, since each tree has its own rules.
|
|
7
8
|
*/
|
|
8
9
|
/**
|
|
9
|
-
* Where
|
|
10
|
+
* Where each tree sits, per supported repository shape.
|
|
10
11
|
*
|
|
11
12
|
* ONE BRANCH, IN ONE PLACE. The difference between a monorepo and a single app is not a difference
|
|
12
13
|
* of law - the rules, their severities and the inline-config refusal are identical. It is only
|
|
@@ -14,65 +15,27 @@
|
|
|
14
15
|
* lists, and the lists did what hand-kept lists do: one enabled 43 rules, the other 53, and canon
|
|
15
16
|
* had 49. Neither repository was wrong on purpose; nothing told either of them.
|
|
16
17
|
*
|
|
18
|
+
* TWO TREES. `src` is the product source, governed by every law except the e2e one; `e2e` is the
|
|
19
|
+
* Playwright tree and its config, governed by the e2e law and the escape-hatch fence.
|
|
20
|
+
*
|
|
17
21
|
* The candidate path is included deliberately. A Preview candidate is the exact source Apply later
|
|
18
22
|
* ports into production, and leaving it ungoverned means the one file that becomes production was
|
|
19
23
|
* judged by nothing.
|
|
20
24
|
*/
|
|
21
|
-
const LAYOUT_GLOBS = {
|
|
22
|
-
monorepo:
|
|
23
|
-
"packages/
|
|
24
|
-
"apps/*/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
],
|
|
25
|
+
export const LAYOUT_GLOBS = {
|
|
26
|
+
monorepo: {
|
|
27
|
+
src: ["packages/*/src/**/*.{ts,tsx}", "apps/*/src/**/*.{ts,tsx}", "**/candidate/src/**/*.{ts,tsx}"],
|
|
28
|
+
e2e: ["e2e/**/*.{ts,tsx}", "apps/*/e2e/**/*.{ts,tsx}", "packages/*/e2e/**/*.{ts,tsx}", "playwright.config.{ts,mts}", "apps/*/playwright.config.{ts,mts}"],
|
|
29
|
+
},
|
|
30
|
+
"single-app": {
|
|
31
|
+
src: ["src/**/*.{ts,tsx}", "**/candidate/src/**/*.{ts,tsx}"],
|
|
32
|
+
e2e: ["e2e/**/*.{ts,tsx}", "playwright.config.{ts,mts}"],
|
|
33
|
+
},
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
/** The repository shapes this canon knows how to govern. */
|
|
34
37
|
export const LAYOUTS = Object.keys(LAYOUT_GLOBS)
|
|
35
38
|
|
|
36
|
-
/**
|
|
37
|
-
* The one flat-config block that attaches this canon to a repository.
|
|
38
|
-
*
|
|
39
|
-
* A consuming `eslint.config.mjs` spreads the result and owns nothing else about the rules: not
|
|
40
|
-
* which are on, not their severity, not whether an inline comment may switch one off. Its own
|
|
41
|
-
* plugins, its own ignores and its own language options stay its own business - what it may not
|
|
42
|
-
* keep is a second opinion about the law.
|
|
43
|
-
*
|
|
44
|
-
* @param {object} input - Attachment options.
|
|
45
|
-
* @param {"monorepo" | "single-app"} input.layout - Which shape this repository has.
|
|
46
|
-
* @param {object} input.plugin - The plugin object, imported from this canon.
|
|
47
|
-
* @param {object} input.recommended - The recommendation, imported from this canon.
|
|
48
|
-
* @param {object} input.linterOptions - The linter options, imported from this canon.
|
|
49
|
-
* @returns {object} One flat-config block.
|
|
50
|
-
*/
|
|
51
|
-
export const starciFeConfig = ({ layout, plugin, recommended, linterOptions }) => {
|
|
52
|
-
const files = LAYOUT_GLOBS[layout]
|
|
53
|
-
if (!files) {
|
|
54
|
-
throw new Error(`unknown layout "${layout}" - expected one of: ${LAYOUTS.join(", ")}`)
|
|
55
|
-
}
|
|
56
|
-
/*
|
|
57
|
-
* `recommended` is a FLAT rule map here, not a config object with a `rules` key - and reaching
|
|
58
|
-
* for `.rules` on it is the mistake this line exists to stop repeating. Spread that way it
|
|
59
|
-
* yields `undefined`, the block ends up with zero rules, and the repository lints clean while
|
|
60
|
-
* being governed by nothing. That is the worst possible failure for this file: green, silent,
|
|
61
|
-
* and indistinguishable from success. Both spellings are accepted so neither shape can produce
|
|
62
|
-
* an empty block.
|
|
63
|
-
*/
|
|
64
|
-
const rules = recommended?.rules ?? recommended
|
|
65
|
-
if (!rules || Object.keys(rules).length === 0) {
|
|
66
|
-
throw new Error("starciFeConfig received an empty recommendation - a config with no rules is not adoption")
|
|
67
|
-
}
|
|
68
|
-
return {
|
|
69
|
-
files,
|
|
70
|
-
linterOptions,
|
|
71
|
-
plugins: { "starci-fe": plugin },
|
|
72
|
-
rules: { ...rules },
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
39
|
/** Convert every flat-config severity spelling to its numeric meaning. */
|
|
77
40
|
const severityOf = (setting) => {
|
|
78
41
|
const severity = Array.isArray(setting) ? setting[0] : setting
|
package/lint-escape-hatch.mjs
CHANGED
|
@@ -1,56 +1,91 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
export const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The rule and flat-config fence that hold `lint-escape-hatch.md` (HFS R18, `HFS_INLINE_SUPPRESSION`).
|
|
3
|
+
*
|
|
4
|
+
* NO LOCAL EXCEPTION, OF ANY SPELLING. A rule that a comment can switch off is a convention, and a
|
|
5
|
+
* codebase learns which conventions are real by which ones it can suppress. The spellings this law
|
|
6
|
+
* refuses are the ones an author actually reaches for:
|
|
7
|
+
*
|
|
8
|
+
* eslint-disable / -next-line / -line / -enable / -env, and `/* eslint rule: "off" *\/`
|
|
9
|
+
* @ts-ignore, @ts-expect-error, @ts-nocheck
|
|
10
|
+
* vn-ok: <reason> - the retired second-language pragma; it excuses nothing any more
|
|
11
|
+
*
|
|
12
|
+
* TWO LAYERS, BECAUSE ONE IS NOT ENOUGH. The flat-config fence (`noInlineConfig`) makes an ESLint
|
|
13
|
+
* directive INEFFECTIVE; this rule makes it a FINDING, so the attempt is named in the log instead of
|
|
14
|
+
* silently ignored. `reportUnusedDisableDirectives` closes the last gap: a directive that suppresses
|
|
15
|
+
* nothing is dead weight that reads as if it did.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { isE2eFile, isProductFile, isSpecFile } from "./lib/scope.mjs"
|
|
19
|
+
import { normalizePath } from "./lib/path.mjs"
|
|
20
|
+
|
|
21
|
+
/** Product source, its specs and the e2e tree are governed; canon tests deliberately build forbidden directives. */
|
|
22
|
+
const isGoverned = (filename) => {
|
|
23
|
+
const file = normalizePath(filename)
|
|
24
|
+
return isProductFile(file) || isSpecFile(file) || isE2eFile(file)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Any directive that changes ESLint's active rule set inside a source file.
|
|
29
|
+
*
|
|
30
|
+
* ANCHORED AT THE START OF THE COMMENT, because that is the only place ESLint itself honours one:
|
|
31
|
+
* a directive is recognised from the first non-space character of the comment body and nowhere else.
|
|
32
|
+
* An unanchored pattern matched the WORD instead of the directive, so a comment explaining why a
|
|
33
|
+
* file carries no `eslint-disable` was reported as carrying one - and the only way to silence that
|
|
34
|
+
* was to stop writing the explanation, which is the opposite of what this law wants. Under-catching
|
|
35
|
+
* is not the trade: a directive ESLint would obey always sits at the start.
|
|
36
|
+
*/
|
|
37
|
+
const ESLINT_DIRECTIVE = /^\s*eslint-(?:disable(?:-next-line|-line)?|enable|env)\b|^\s*eslint\s+[@\w/-]+\s*:/
|
|
38
|
+
|
|
39
|
+
/** A TypeScript directive that turns the compiler's check off for the next line or the file. */
|
|
40
|
+
const TS_DIRECTIVE = /^\s*(?:\/\s*)?@ts-(?:ignore|expect-error|nocheck)\b/
|
|
41
|
+
|
|
42
|
+
/** The retired second-language pragma. Present anywhere in a comment, it is a finding. */
|
|
43
|
+
const RETIRED_PRAGMA = /\bvn-ok:/
|
|
44
|
+
|
|
45
|
+
/** Inline lint configuration is repository policy, never a file-local choice. */
|
|
46
|
+
export const noInlineLintConfig = {
|
|
47
|
+
meta: {
|
|
48
|
+
type: "problem",
|
|
49
|
+
docs: { description: "Source cannot change its own lint or type-check policy, and the vn-ok pragma is retired." },
|
|
50
|
+
schema: [],
|
|
51
|
+
messages: {
|
|
52
|
+
directive:
|
|
53
|
+
"Inline ESLint configuration makes this file the author of whether repository law applies. Remove the directive and fix the code or the shared rule; there is no local exception path.",
|
|
54
|
+
typescript:
|
|
55
|
+
"A TypeScript suppression directive. It turns the compiler off for code that is failing the check for a reason. Fix the type, or change the shared contract that produced it; there is no local exception path.",
|
|
56
|
+
pragma:
|
|
57
|
+
"`vn-ok:` was the second-language escape hatch and it is retired: user-facing text comes from a `next-intl` catalogue through `t()`, and there is no comment that excuses a literal. Remove the pragma and move the string.",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
create(context) {
|
|
61
|
+
if (!isGoverned(context.filename || context.getFilename())) return {}
|
|
62
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
63
|
+
return {
|
|
64
|
+
Program() {
|
|
65
|
+
for (const comment of source.getAllComments()) {
|
|
66
|
+
if (ESLINT_DIRECTIVE.test(comment.value)) context.report({ node: comment, messageId: "directive" })
|
|
67
|
+
else if (TS_DIRECTIVE.test(comment.value)) context.report({ node: comment, messageId: "typescript" })
|
|
68
|
+
else if (RETIRED_PRAGMA.test(comment.value)) context.report({ node: comment, messageId: "pragma" })
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The rules this law contributes to the plugin. */
|
|
76
|
+
export const rules = {
|
|
77
|
+
"no-inline-lint-config": noInlineLintConfig,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every lint escape is a build error. */
|
|
81
|
+
export const recommended = {
|
|
82
|
+
"starci-fe/no-inline-lint-config": "error",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Consuming flat configs apply this beside the recommended rules.
|
|
87
|
+
*
|
|
88
|
+
* `noInlineConfig` makes an inline directive ineffective; `reportUnusedDisableDirectives` makes one
|
|
89
|
+
* that suppresses nothing an error, so a leftover from before the fence is not mistaken for a live one.
|
|
90
|
+
*/
|
|
91
|
+
export const linterOptions = Object.freeze({ noInlineConfig: true, reportUnusedDisableDirectives: "error" })
|