@starci/eslint-canon-fe 4.0.0 → 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +34 -16
  3. package/brand-values.mjs +174 -0
  4. package/class-names.mjs +1 -1
  5. package/client-boundary.mjs +199 -0
  6. package/comments.mjs +144 -190
  7. package/docs/brand-values.md +30 -0
  8. package/docs/client-boundary.md +99 -0
  9. package/docs/e2e-shape.md +161 -0
  10. package/docs/env-owner.md +52 -0
  11. package/docs/formatting.md +30 -0
  12. package/docs/hooks-folder.md +29 -0
  13. package/docs/hygiene.md +111 -0
  14. package/docs/lint-escape-hatch.md +32 -0
  15. package/docs/lists.md +74 -0
  16. package/docs/native-controls.md +73 -0
  17. package/docs/next-conventions.md +206 -0
  18. package/docs/size-and-state-budget.md +73 -0
  19. package/docs/spec-quality.md +139 -0
  20. package/docs/translation.md +50 -0
  21. package/docs/transport.md +168 -0
  22. package/docs/type-safety.md +95 -0
  23. package/e2e-shape.mjs +348 -0
  24. package/env-owner.mjs +134 -0
  25. package/file-layout.mjs +1 -2
  26. package/formatting.mjs +106 -0
  27. package/hooks-folder.mjs +119 -0
  28. package/hygiene.mjs +235 -0
  29. package/icon.mjs +210 -211
  30. package/index.mjs +290 -131
  31. package/lib/ast.mjs +94 -0
  32. package/lib/path.mjs +2 -0
  33. package/lib/scope.mjs +42 -0
  34. package/lib/why.mjs +304 -0
  35. package/lint-adoption.mjs +15 -52
  36. package/lint-escape-hatch.mjs +91 -56
  37. package/lists.mjs +197 -0
  38. package/loading.mjs +163 -164
  39. package/naming.mjs +143 -143
  40. package/native-controls.mjs +112 -0
  41. package/next-conventions.mjs +346 -0
  42. package/package.json +8 -4
  43. package/served-locale.mjs +160 -160
  44. package/shape-slot.mjs +1 -2
  45. package/size-and-state-budget.mjs +196 -0
  46. package/spec-quality.mjs +239 -0
  47. package/the-split.mjs +133 -134
  48. package/tokens.mjs +267 -268
  49. package/translation.mjs +262 -122
  50. package/transport.mjs +404 -0
  51. package/type-safety.mjs +156 -74
  52. package/typography.mjs +82 -83
@@ -0,0 +1,52 @@
1
+ # Environment owner
2
+
3
+ Law module: `env-owner.mjs`. Catalogue: R49 FE_ENV_OWNER.
4
+
5
+ The environment is read in exactly one place, `modules/config`, and nothing there has a literal default that stands in for a real deployment value. A missing production variable must fail when the config module loads, not point production at a developer's machine.
6
+
7
+ Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-fe/no-env-outside-config`
10
+
11
+ `process.env` and `import.meta.env` are read only in `modules/config`.
12
+
13
+ **Invalid** (`src/hooks/session/useSession.ts`)
14
+
15
+ ```ts
16
+ const url = process.env.API_URL
17
+ ```
18
+
19
+ **Valid** (`src/hooks/session/useSession.ts`)
20
+
21
+ ```ts
22
+ import { config } from "@/modules/config"
23
+ const url = config.apiUrl
24
+ ```
25
+
26
+ **Finding code:** `FE_ENV_OWNER`
27
+
28
+ **Vì sao (why):** `<file>` đọc biến môi trường. Chỉ `modules/config` được đọc env.
29
+
30
+ **Cách sửa:** Đọc giá trị từ export có kiểu của `modules/config` thay vì `process.env`.
31
+
32
+ ## `starci-fe/no-hardcoded-endpoint-fallback`
33
+
34
+ No URL literal as a fallback, and no literal default for an address or secret variable.
35
+
36
+ **Invalid** (`src/modules/config/env.ts`)
37
+
38
+ ```ts
39
+ const url = process.env.API_URL ?? "http://localhost:3068"
40
+ ```
41
+
42
+ **Valid** (`src/modules/config/env.ts`)
43
+
44
+ ```ts
45
+ const url = process.env.API_URL
46
+ ```
47
+
48
+ **Finding code:** `FE_ENV_OWNER`
49
+
50
+ **Vì sao (why):** `<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.
51
+
52
+ **Cách sửa:** Xóa giá trị dự phòng; để `modules/config` ném lỗi khi thiếu biến ở production.
@@ -0,0 +1,30 @@
1
+ # Formatting numbers, money and dates
2
+
3
+ Law module: `formatting.mjs`. Catalogue: R59 FE_I18N_PLACEMENT, sub-check `FE_I18N_FORMATTER`.
4
+
5
+ `next-intl` owns the reader's locale and time zone and hands them to `useFormatter()` / `getFormatter()`. `toLocale*String`, `new Intl.*`, displayed `toFixed`, a currency symbol glued to a template substitution and the date libraries either guess the locale (server and browser disagree, so a price flickers on hydration) or hard-code one. `modules/i18n/**` is exempt: it is where formats are configured.
6
+
7
+ Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-fe/use-intl-formatter`
10
+
11
+ Format numbers, money and dates with `useFormatter` / `getFormatter` from `next-intl`.
12
+
13
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
14
+
15
+ ```tsx
16
+ <p>{new Date(at).toLocaleString()}</p>
17
+ ```
18
+
19
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
20
+
21
+ ```tsx
22
+ const format = useFormatter()
23
+ <p>{format.dateTime(new Date(at), { dateStyle: "medium" })}</p>
24
+ ```
25
+
26
+ **Finding code:** `FE_I18N_FORMATTER`
27
+
28
+ **Vì sao (why):** `<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.
29
+
30
+ **Cách sửa:** Dùng `useFormatter()` (hoặc `getFormatter()` ở server): `number(...)`, `dateTime(...)`, `relativeTime(...)`.
@@ -0,0 +1,29 @@
1
+ # hooks/ holds hooks
2
+
3
+ Law module: `hooks-folder.mjs`. Catalogue: R56 FE_HOOKS_ARE_HOOKS (lint half).
4
+
5
+ `hooks/<domain>/use<Name>.ts` holds one hook per file; `hooks/<domain>/<domain>.shared.ts` holds the domain's non-hook helpers; server readers live in `modules/api/<domain>/read-*.ts`. Whether a folder has exactly one shared file and whether a helper name repeats across files belongs to the architecture machine.
6
+
7
+ Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-fe/hooks-folder-holds-hooks-only`
10
+
11
+ `hooks/<domain>/` holds `use*.ts` hooks and one `<domain>.shared.ts`; nothing else.
12
+
13
+ **Invalid** (`src/hooks/course/readCourse.ts`)
14
+
15
+ ```ts
16
+ export const readCourse = () => 1
17
+ ```
18
+
19
+ **Valid** (`src/hooks/course/useCourse.ts`)
20
+
21
+ ```ts
22
+ export const useCourse = () => 1
23
+ ```
24
+
25
+ **Finding code:** `FE_HOOKS_ARE_HOOKS`
26
+
27
+ **Vì sao (why):** `<file>` trong `hooks/` không phải React hook. Reader server về `modules/api`, helper về `<domain>.shared.ts`.
28
+
29
+ **Cách sửa:** 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`.
@@ -0,0 +1,111 @@
1
+ # Runtime hygiene
2
+
3
+ Law module: `hygiene.mjs`. Catalogue: R65 FE_SIZE_AND_STATE_BUDGET (sub-checks `FE_EFFECT_CLEANUP`, `FE_SWALLOWED_ERROR`, `FE_CONSOLE_CALL`) and R50 FE_TRANSPORT_OWNER (sub-check `FE_EFFECT_FETCH`).
4
+
5
+ Work started by a component that nobody owns the end of: a timer that outlives the component, a fetch in an effect with no cache or cancel, an empty `catch` that hides the failure, a `console` call no pipeline reads. `modules/**` is exempt from the timer rule only (the API client owns its timeout timer and clears it in `finally`).
6
+
7
+ Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-fe/timer-needs-effect-cleanup`
10
+
11
+ `setTimeout` and `setInterval` live in an effect whose cleanup clears them.
12
+
13
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
14
+
15
+ ```tsx
16
+ const onClick = () => { setTimeout(() => setOpen(false), 300) }
17
+ ```
18
+
19
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
20
+
21
+ ```tsx
22
+ useEffect(() => {
23
+ const id = setTimeout(() => setOpen(false), 300)
24
+ return () => clearTimeout(id)
25
+ }, [])
26
+ ```
27
+
28
+ **Valid** also: a function that starts the timer and directly returns a cleanup clearing the same handle (the `subscribe` of `useSyncExternalStore`)
29
+
30
+ ```tsx
31
+ const subscribe = (listener: () => void) => {
32
+ listeners.add(listener)
33
+ if (timer === undefined) timer = setInterval(tick, 60_000)
34
+ return () => {
35
+ listeners.delete(listener)
36
+ if (listeners.size === 0) { clearInterval(timer); timer = undefined }
37
+ }
38
+ }
39
+ ```
40
+
41
+ **Finding code:** `FE_EFFECT_CLEANUP`
42
+
43
+ **Vì sao (why):** `<timer>` ở `<file>` chạy ngoài effect hoặc trong effect không có cleanup gọi `clearTimeout`/`clearInterval`.
44
+
45
+ **Cách sửa:** Đặt timer trong `useEffect` và trả `() => clearTimeout(id)`; hoặc chuyển vào một hook làm việc đó.
46
+
47
+ ## `starci-fe/no-data-fetch-in-effect`
48
+
49
+ No `await`, `fetch` or `.then` inside a `useEffect`; read through SWR or a server reader.
50
+
51
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
52
+
53
+ ```tsx
54
+ useEffect(() => { fetch(url).then(setData) }, [url])
55
+ ```
56
+
57
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
58
+
59
+ ```tsx
60
+ const { data } = useSWR(key, fetcher)
61
+ ```
62
+
63
+ **Finding code:** `FE_EFFECT_FETCH`
64
+
65
+ **Vì sao (why):** `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ỷ.
66
+
67
+ **Cách sửa:** Đọ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`.
68
+
69
+ ## `starci-fe/no-empty-catch`
70
+
71
+ A `catch` that does nothing hides the failure; handle it or return a typed outcome.
72
+
73
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
74
+
75
+ ```tsx
76
+ try { await save() } catch {}
77
+ ```
78
+
79
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
80
+
81
+ ```tsx
82
+ try { await save() } catch (error) { return { kind: "unavailable", cause: error } }
83
+ ```
84
+
85
+ **Finding code:** `FE_SWALLOWED_ERROR`
86
+
87
+ **Vì sao (why):** `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.
88
+
89
+ **Cách sửa:** 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.
90
+
91
+ ## `starci-fe/no-console`
92
+
93
+ No `console.*` call in product source.
94
+
95
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
96
+
97
+ ```tsx
98
+ console.error(error)
99
+ ```
100
+
101
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
102
+
103
+ ```tsx
104
+ return { kind: "unavailable", cause: error }
105
+ ```
106
+
107
+ **Finding code:** `FE_CONSOLE_CALL`
108
+
109
+ **Vì sao (why):** `console.<method>` ở `<file>`: không vào pipeline log nào và lộ chi tiết nội bộ cho người dùng.
110
+
111
+ **Cách sửa:** Trả outcome có kiểu, hiển thị trạng thái lỗi, hoặc để lỗi tới `error.tsx`.
@@ -0,0 +1,32 @@
1
+ # No inline suppression of any spelling
2
+
3
+ Law module: `lint-escape-hatch.mjs`. Catalogue: R18 HFS_INLINE_SUPPRESSION.
4
+
5
+ No `eslint-disable` (line, next-line, block), `eslint-enable`, `eslint-env`, inline `eslint rule: ...` config, `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck`, and no retired `vn-ok:` pragma. `noInlineConfig` makes a directive ineffective, this rule makes it a finding, and `reportUnusedDisableDirectives` reports a leftover that suppresses nothing. The rule covers `src/**`, specs and the e2e tree.
6
+
7
+ Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-fe/no-inline-lint-config`
10
+
11
+ Source cannot change its own lint or type-check policy, and the vn-ok pragma is retired.
12
+
13
+ **Invalid** (`src/components/blocks/Feed/index.tsx`)
14
+
15
+ ```ts
16
+ // eslint-disable-next-line no-console
17
+ // @ts-ignore
18
+ const s = "x" // vn-ok: reason
19
+ ```
20
+
21
+ **Valid** (`src/components/blocks/Feed/index.tsx`)
22
+
23
+ ```ts
24
+ // the rule is fixed centrally; fix the code instead
25
+ const s = 1
26
+ ```
27
+
28
+ **Finding code:** `HFS_INLINE_SUPPRESSION`
29
+
30
+ **Vì sao (why):** 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.
31
+
32
+ **Cách sửa:** Xóa `eslint-disable`, `@ts-ignore`, `@ts-expect-error` hoặc `vn-ok` và sửa nguyên nhân.
package/docs/lists.md ADDED
@@ -0,0 +1,74 @@
1
+ # Lists
2
+
3
+ Law module: `lists.mjs`. Catalogue: R65 FE_SIZE_AND_STATE_BUDGET, sub-check `FE_LIST_KEY`.
4
+
5
+ A list is items with identity. React reconciles a rendered list by `key`; a missing, positional or random key hands a deleted row's state to its neighbour or remounts every row. A component prop written as an object or array literal inside `.map` is a new value per row per render, so a memoised row never hits.
6
+
7
+ Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-fe/list-item-has-key`
10
+
11
+ Every element a `.map` callback returns carries a `key`.
12
+
13
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
14
+
15
+ ```tsx
16
+ rows.map((row) => <Row row={row} />)
17
+ ```
18
+
19
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
20
+
21
+ ```tsx
22
+ rows.map((row) => <Row key={row.id} row={row} />)
23
+ ```
24
+
25
+ **Finding code:** `FE_LIST_KEY`
26
+
27
+ **Vì sao (why):** 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.
28
+
29
+ **Cách sửa:** Thêm `key` lấy từ id của dữ liệu; với fragment dùng `<Fragment key={...}>`.
30
+
31
+ ## `starci-fe/no-index-key`
32
+
33
+ A list key is stable data identity, never the map index or a random value. A static array written in place, or a callback that ignores its item (`(_, index) =>`), is exempt.
34
+
35
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
36
+
37
+ ```tsx
38
+ rows.map((row, index) => <Row key={index} row={row} />)
39
+ ```
40
+
41
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
42
+
43
+ ```tsx
44
+ rows.map((row) => <Row key={row.id} row={row} />)
45
+ ```
46
+
47
+ **Finding code:** `FE_LIST_KEY`
48
+
49
+ **Vì sao (why):** `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.
50
+
51
+ **Cách sửa:** Dùng id mà dữ liệu mang (`key={row.id}`); không dùng index, `Math.random`, `Date.now`.
52
+
53
+ ## `starci-fe/no-inline-literal-prop-in-list`
54
+
55
+ No object or array literal as a prop of a component rendered inside `.map`.
56
+
57
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
58
+
59
+ ```tsx
60
+ rows.map((row) => <Avatar key={row.id} props={{ size: "sm" }} />)
61
+ ```
62
+
63
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
64
+
65
+ ```tsx
66
+ const AVATAR_PROPS = { size: "sm" } as const
67
+ rows.map((row) => <Avatar key={row.id} props={AVATAR_PROPS} />)
68
+ ```
69
+
70
+ **Finding code:** `FE_LIST_KEY`
71
+
72
+ **Vì sao (why):** 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.
73
+
74
+ **Cách sửa:** Nâng hằng số ra ngoài component hoặc dựng một lần trước `.map`.
@@ -0,0 +1,73 @@
1
+ # No native form controls
2
+
3
+ Law module: `native-controls.mjs`. Catalogue: R62 FE_NATIVE_FORM_CONTROL.
4
+
5
+ `<select>`, `<input>`, `<textarea>` and `<button>` are rendered by the grammar in product source, at every tier. If the grammar lacks the control, add it to the grammar.
6
+
7
+ Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-fe/no-native-form-control`
10
+
11
+ Use the grammar's renderers; never a bare select, input, textarea or button.
12
+
13
+ **Invalid** (`src/components/blocks/Feed/index.tsx`)
14
+
15
+ ```ts
16
+ <button onClick={go} />
17
+ ```
18
+
19
+ **Valid** (`src/components/blocks/Feed/index.tsx`)
20
+
21
+ ```ts
22
+ <Button onPress={go}>{t("go")}</Button>
23
+ ```
24
+
25
+ **Finding code:** `FE_NATIVE_FORM_CONTROL`
26
+
27
+ **Vì sao (why):** `<tag>` thô ở `<file>`. Dùng renderer của grammar.
28
+
29
+ **Cách sửa:** 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ỗ.
30
+
31
+ ## `starci-fe/no-native-img`
32
+
33
+ Use `next/image`, never a bare `<img>` (catalogue R62, sub-check `FE_NATIVE_IMAGE`).
34
+
35
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
36
+
37
+ ```tsx
38
+ <img src={src} alt={t("cover")} />
39
+ ```
40
+
41
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
42
+
43
+ ```tsx
44
+ <Image src={src} alt={t("cover")} width={40} height={40} />
45
+ ```
46
+
47
+ **Finding code:** `FE_NATIVE_IMAGE`
48
+
49
+ **Vì sao (why):** `<img>` thô ở `<file>`: tải ảnh gốc, không giữ chỗ nên trang nhảy khi ảnh tải xong.
50
+
51
+ **Cách sửa:** Dùng `Image` của `next/image` với `width` và `height` (hoặc `fill` và `sizes`) và `alt` từ catalog.
52
+
53
+ ## `starci-fe/image-has-size`
54
+
55
+ `next/image` carries `width` and `height`, or `fill` with `sizes` (sub-check `FE_NATIVE_IMAGE`).
56
+
57
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
58
+
59
+ ```tsx
60
+ <Image src={src} alt={t("cover")} />
61
+ ```
62
+
63
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
64
+
65
+ ```tsx
66
+ <Image src={src} alt={t("cover")} width={40} height={40} />
67
+ ```
68
+
69
+ **Finding code:** `FE_NATIVE_IMAGE`
70
+
71
+ **Vì sao (why):** `Image` ở `<file>` thiếu `width` và `height` (hoặc `fill` mà thiếu `sizes`): trình duyệt không giữ chỗ được.
72
+
73
+ **Cách sửa:** Cho đủ `width` và `height`, hoặc `fill` trong khung có kích thước kèm `sizes`.
@@ -0,0 +1,206 @@
1
+ # Next conventions and the one locale pattern
2
+
3
+ Law module: `next-conventions.mjs`. Catalogue: FE_NEXT_CONVENTIONS, R58 (html lang), R59 (i18n placement, single-file half).
4
+
5
+ One locale pattern everywhere: `next-intl`, a `[locale]` segment, default `vi`, `localePrefix: "as-needed"`, and `proxy.ts` (Next 16) instead of `middleware.ts`.
6
+
7
+ Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-fe/no-middleware-file`
10
+
11
+ `middleware.ts` is forbidden: the request interceptor is `proxy.ts` and exports `proxy`.
12
+
13
+ **Invalid** (`src/middleware.ts`)
14
+
15
+ ```ts
16
+ export function middleware() {}
17
+ ```
18
+
19
+ **Valid** (`src/proxy.ts`)
20
+
21
+ ```ts
22
+ export function proxy(request) { return request }
23
+ ```
24
+
25
+ **Finding code:** `FE_NEXT_CONVENTIONS`
26
+
27
+ **Vì sao (why):** `<file>` dùng tên `middleware`. Từ Next 16 bộ chặn request là `proxy.ts` và xuất `proxy`.
28
+
29
+ **Cách sửa:** Đổi tên tệp thành `proxy.ts` và đổi export thành `proxy`.
30
+
31
+ ## `starci-fe/locale-segment-is-locale`
32
+
33
+ The locale route segment is `[locale]`, never `[lang]`.
34
+
35
+ **Invalid** (`src/app/[lang]/page.tsx`)
36
+
37
+ ```ts
38
+ export default function Page() {}
39
+ ```
40
+
41
+ **Valid** (`src/app/[locale]/page.tsx`)
42
+
43
+ ```ts
44
+ export default function Page() {}
45
+ ```
46
+
47
+ **Finding code:** `FE_I18N_PLACEMENT`
48
+
49
+ **Vì sao (why):** 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`.
50
+
51
+ **Cách sửa:** Đổi tên thư mục segment thành `[locale]`.
52
+
53
+ ## `starci-fe/no-second-i18n-stack`
54
+
55
+ `next-intl` is the only i18n library.
56
+
57
+ **Invalid** (`any file`)
58
+
59
+ ```ts
60
+ import { useTranslation } from "react-i18next"
61
+ ```
62
+
63
+ **Valid** (`any file`)
64
+
65
+ ```ts
66
+ import { useTranslations } from "next-intl"
67
+ ```
68
+
69
+ **Finding code:** `FE_I18N_PLACEMENT`
70
+
71
+ **Vì sao (why):** `<file>` nhập một thư viện i18n thứ hai. App chỉ dùng `next-intl` qua `modules/i18n`.
72
+
73
+ **Cách sửa:** Chuyển sang `next-intl`; xóa thư viện còn lại.
74
+
75
+ ## `starci-fe/html-lang-from-locale`
76
+
77
+ `<html lang>` is taken from the `[locale]` segment, never a literal.
78
+
79
+ **Invalid** (`src/app/[locale]/layout.tsx`)
80
+
81
+ ```ts
82
+ <html lang="en">
83
+ ```
84
+
85
+ **Valid** (`src/app/[locale]/layout.tsx`)
86
+
87
+ ```ts
88
+ <html lang={locale}>
89
+ ```
90
+
91
+ **Finding code:** `FE_I18N_LITERAL`
92
+
93
+ **Vì sao (why):** `<html lang>` ở `<file>` là chữ cứng. Thuộc tính `lang` phải lấy từ segment `[locale]`.
94
+
95
+ **Cách sửa:** Dùng `lang={locale}` từ tham số của layout `[locale]`.
96
+
97
+ ## `starci-fe/page-exports-metadata`
98
+
99
+ Every `page.tsx` exports `metadata` or `generateMetadata` (catalogue R54, sub-check `FE_PAGE_METADATA_MISSING`).
100
+
101
+ **Invalid** (`src/app/[locale]/courses/page.tsx`)
102
+
103
+ ```tsx
104
+ export default function Page() { return <CoursesPage /> }
105
+ ```
106
+
107
+ **Valid** (`src/app/[locale]/courses/page.tsx`)
108
+
109
+ ```tsx
110
+ export async function generateMetadata() { return { title: t("courses.title") } }
111
+ export default function Page() { return <CoursesPage /> }
112
+ ```
113
+
114
+ **Finding code:** `FE_PAGE_METADATA_MISSING`
115
+
116
+ **Vì sao (why):** `<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 đề.
117
+
118
+ **Cách sửa:** 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.
119
+
120
+ ## `starci-fe/no-null-suspense-fallback`
121
+
122
+ `<Suspense>` has a fallback that renders something; `fallback={null}` is forbidden (catalogue R53, sub-check `FE_SUSPENSE_NULL_FALLBACK`).
123
+
124
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
125
+
126
+ ```tsx
127
+ <Suspense fallback={null}><Feed /></Suspense>
128
+ ```
129
+
130
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
131
+
132
+ ```tsx
133
+ <Suspense fallback={<FeedSkeleton />}><Feed /></Suspense>
134
+ ```
135
+
136
+ **Finding code:** `FE_SUSPENSE_NULL_FALLBACK`
137
+
138
+ **Vì sao (why):** `<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.
139
+
140
+ **Cách sửa:** Truyền skeleton (trạng thái loading) của đúng thứ đang tải làm `fallback`.
141
+
142
+ ## `starci-fe/navigation-from-intl`
143
+
144
+ `Link`, `useRouter`, `usePathname` and `redirect` come from `modules/i18n/navigation`, not from Next (catalogue R59, sub-check `FE_I18N_NAVIGATION`).
145
+
146
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
147
+
148
+ ```tsx
149
+ import { useRouter } from "next/navigation"
150
+ ```
151
+
152
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
153
+
154
+ ```tsx
155
+ import { useRouter } from "@/modules/i18n/navigation"
156
+ ```
157
+
158
+ **Finding code:** `FE_I18N_NAVIGATION`
159
+
160
+ **Vì sao (why):** `<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]`.
161
+
162
+ **Cách sửa:** Nhập từ `modules/i18n/navigation`, được next-intl dựng từ `routing.ts`.
163
+
164
+ ## `starci-fe/no-native-anchor`
165
+
166
+ An internal link is `Link` from `modules/i18n/navigation`; an external `_blank` link carries `rel` (catalogue R59, sub-check `FE_I18N_NAVIGATION`).
167
+
168
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
169
+
170
+ ```tsx
171
+ <a href="/courses">{t("courses")}</a>
172
+ ```
173
+
174
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
175
+
176
+ ```tsx
177
+ <Link href={routes.courses()}>{t("courses")}</Link>
178
+ ```
179
+
180
+ **Finding code:** `FE_I18N_NAVIGATION`
181
+
182
+ **Vì sao (why):** `<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`.
183
+
184
+ **Cách sửa:** 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"`.
185
+
186
+ ## `starci-fe/no-hardcoded-route`
187
+
188
+ A route path is built by `modules/routes`, never written as a literal at a link or a navigation call (catalogue R57, sub-check `FE_ROUTE_HARDCODED`).
189
+
190
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
191
+
192
+ ```tsx
193
+ router.push("/agentos/workspaces/new")
194
+ ```
195
+
196
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
197
+
198
+ ```tsx
199
+ router.push(routes.newWorkspace())
200
+ ```
201
+
202
+ **Finding code:** `FE_ROUTE_HARDCODED`
203
+
204
+ **Vì sao (why):** `<file>` viết thẳng đường dẫn route ở nơi gọi; route đổi chỗ thì bản sao trỏ vào 404.
205
+
206
+ **Cách sửa:** Dựng href bằng hàm của `modules/routes` và dùng đúng hàm đó ở mọi nơi.
@@ -0,0 +1,73 @@
1
+ # Size and state budget
2
+
3
+ Law module: `size-and-state-budget.mjs`. Catalogue: R65 FE_SIZE_AND_STATE_BUDGET.
4
+
5
+ A component file is at most 300 lines; a connected unit holds at most 6 `useState` and reads at most 6 data hooks; there is no hand-written polling loop. `HFS_SIZE_GROWTH` (no growth against the parent commit) is the architecture machine's; these are the absolute file-local limits.
6
+
7
+ Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-fe/component-line-budget`
10
+
11
+ A component file has at most 300 lines.
12
+
13
+ **Invalid** (`src/components/blocks/Feed/index.tsx`)
14
+
15
+ ```ts
16
+ // a 301-line component file
17
+ ```
18
+
19
+ **Valid** (`src/components/blocks/Feed/index.tsx`)
20
+
21
+ ```ts
22
+ // 300 lines or fewer
23
+ ```
24
+
25
+ **Finding code:** `FE_SIZE_AND_STATE_BUDGET`
26
+
27
+ **Vì sao (why):** `<file>` dài `<n>` dòng, vượt ngưỡng 300 dòng của một component.
28
+
29
+ **Cách sửa:** 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.
30
+
31
+ ## `starci-fe/unit-hook-budget`
32
+
33
+ A unit has at most 6 state hooks and 6 data hooks.
34
+
35
+ **Invalid** (`src/components/blocks/Feed/index.tsx`)
36
+
37
+ ```ts
38
+ const Feed = () => { /* 7 x useState */ }
39
+ ```
40
+
41
+ **Valid** (`src/components/blocks/Feed/index.tsx`)
42
+
43
+ ```ts
44
+ const Feed = () => { /* up to 6 x useState and 6 data hooks */ }
45
+ ```
46
+
47
+ **Finding code:** `FE_SIZE_AND_STATE_BUDGET`
48
+
49
+ **Vì sao (why):** `<unit>` có `<n>` hook dữ liệu / `<m>` useState, vượt ngưỡng 6.
50
+
51
+ **Cách sửa:** 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.
52
+
53
+ ## `starci-fe/no-hand-rolled-polling`
54
+
55
+ No `setInterval` and no self-scheduling `setTimeout`: one refresh mechanism per resource.
56
+
57
+ **Invalid** (`src/components/blocks/Feed/index.tsx`)
58
+
59
+ ```ts
60
+ useEffect(() => { const t = setInterval(load, 5000) }, [])
61
+ ```
62
+
63
+ **Valid** (`src/components/blocks/Feed/index.tsx`)
64
+
65
+ ```ts
66
+ const d = useQueryFeedSwr({ refreshInterval: 5000 })
67
+ ```
68
+
69
+ **Finding code:** `FE_SIZE_AND_STATE_BUDGET`
70
+
71
+ **Vì sao (why):** `<unit>` tự viết vòng poll (`setInterval` hoặc `setTimeout` tự gọi lại).
72
+
73
+ **Cách sửa:** Làm tươi qua hook dữ liệu (`refreshInterval`) hoặc socket, mỗi resource đúng một cơ chế.