@starci/eslint-canon-fe 5.1.2 → 6.0.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +17 -18
  3. package/brand-values.mjs +1 -3
  4. package/class-names.mjs +9 -9
  5. package/client-boundary.mjs +125 -37
  6. package/comments.mjs +14 -21
  7. package/docs/client-boundary.md +26 -1
  8. package/docs/grammar-boundary.md +33 -0
  9. package/docs/hygiene.md +15 -4
  10. package/docs/lint-escape-hatch.md +3 -1
  11. package/docs/next-conventions.md +26 -0
  12. package/docs/size-growth.md +29 -0
  13. package/docs/spec-quality.md +15 -7
  14. package/docs/status-colors.md +32 -0
  15. package/docs/translation.md +4 -0
  16. package/docs/transport.md +36 -9
  17. package/e2e-shape.mjs +20 -23
  18. package/env-owner.mjs +1 -1
  19. package/file-layout.mjs +105 -103
  20. package/formatting.mjs +2 -4
  21. package/grammar-boundary.mjs +85 -9
  22. package/hooks-folder.mjs +17 -16
  23. package/hygiene.mjs +370 -92
  24. package/icon.mjs +21 -20
  25. package/index.mjs +27 -85
  26. package/landmark.mjs +0 -11
  27. package/lib/bindings.mjs +144 -0
  28. package/lib/config.mjs +79 -0
  29. package/lib/hfs.mjs +48 -0
  30. package/lib/next.mjs +6 -0
  31. package/lib/platform.mjs +66 -0
  32. package/lib/react.mjs +28 -0
  33. package/lib/scope.mjs +119 -19
  34. package/lib/status-tones.generated.mjs +9 -0
  35. package/lib/types.mjs +63 -0
  36. package/lib/why.mjs +41 -11
  37. package/lint-escape-hatch.mjs +17 -15
  38. package/loading.mjs +9 -16
  39. package/naming.mjs +8 -2
  40. package/next-conventions.mjs +109 -25
  41. package/package.json +9 -4
  42. package/props-and-slots.mjs +19 -13
  43. package/runtime/engine/plain-object.mjs +5 -0
  44. package/runtime/engine/runtime-root.mjs +32 -0
  45. package/runtime/engine/yaml.mjs +161 -0
  46. package/runtime/knowledge/hfs/slots.yaml +1072 -0
  47. package/runtime/scripts/lib/git.mjs +53 -0
  48. package/runtime/scripts/lib/glob.mjs +23 -0
  49. package/runtime/scripts/lib/hfs-allows.mjs +42 -0
  50. package/runtime/scripts/lib/hfs-slots.mjs +722 -0
  51. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  52. package/runtime/scripts/lib/next-contract.mjs +14 -0
  53. package/runtime/scripts/lib/path-key.mjs +17 -0
  54. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  55. package/served-locale.mjs +30 -16
  56. package/shape-slot.mjs +22 -20
  57. package/size-and-state-budget.mjs +41 -23
  58. package/size-growth.mjs +58 -0
  59. package/spec-quality.mjs +95 -16
  60. package/status-colors.mjs +139 -0
  61. package/the-split.mjs +13 -13
  62. package/tokens.mjs +26 -34
  63. package/translation.mjs +52 -19
  64. package/transport.mjs +437 -89
  65. package/type-safety.mjs +8 -17
  66. package/typography.mjs +7 -11
  67. package/vendor-boundary.mjs +5 -27
  68. package/lint-adoption.mjs +0 -79
package/docs/hygiene.md CHANGED
@@ -2,13 +2,24 @@
2
2
 
3
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
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`).
5
+ Work started by a component that nobody owns the end of: a timer, listener, observer, socket or subscription 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. Every rule here decides by resolution: an effect is a call to React's own `useEffect` / `useLayoutEffect` export (the import is followed), the timer or listener is the platform's when its symbol is declared by the default library, "the same handle" is the same scope binding (or member path such as `timerRef.current`), and "a promise" is what the type checker says. There is no folder exemption: a `modules/` file is judged by its structure like any other (the API client starts its timeout timer and clears it in `finally`, in the same function).
6
6
 
7
7
  Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
8
 
9
- ## `starci-fe/timer-needs-effect-cleanup`
9
+ ## `starci-fe/effect-subscription-needs-cleanup`
10
10
 
11
- `setTimeout` and `setInterval` live in an effect whose cleanup clears them.
11
+ Inside a `useEffect` / `useLayoutEffect` callback and inside the subscribe function of `useSyncExternalStore`, everything that keeps running after the callback returns is released by the cleanup the callback returns, against the same handle, target and listener:
12
+
13
+ | Started | Released by the returned cleanup |
14
+ | --- | --- |
15
+ | `setTimeout` / `setInterval` (the handle) | `clearTimeout(id)` / `clearInterval(id)` on the same handle (variable, ref member, module `let`, or a list drained with `forEach(clearTimeout)`) |
16
+ | `requestAnimationFrame` (the id) | `cancelAnimationFrame(id)` |
17
+ | `target.addEventListener(type, listener)` | `target.removeEventListener(type, listener)` with the same target, type and listener binding; an inline listener cannot be removed; or `{ signal }` with `controller.abort()` |
18
+ | `new ResizeObserver` / `IntersectionObserver` / `MutationObserver` that is `.observe`d | `observer.disconnect()` |
19
+ | `new WebSocket` / `EventSource` / `BroadcastChannel` | `connection.close()` |
20
+ | a call returning a subscription object (its type has `unsubscribe`, `off` or `close`) | that method on the stored object |
21
+
22
+ The cleanup may be a returned function, a named function that is returned, or may call a same-file function that releases. Outside an effect, a timer or frame is accepted only when the function that starts it also releases it (the API client's `try { ... } finally { clearTimeout(id) }`, the `subscribe` that returns its own unsubscribe); a listener started outside an effect is not judged. A function-returning unsubscribe (`const off = on(handler)`) is not decided here: the checker cannot tell it from any other callback.
12
23
 
13
24
  **Invalid** (`src/components/blocks/Feed/component.tsx`)
14
25
 
@@ -46,7 +57,7 @@ const subscribe = (listener: () => void) => {
46
57
 
47
58
  ## `starci-fe/no-data-fetch-in-effect`
48
59
 
49
- No `await`, `fetch` or `.then` inside a `useEffect`; read through SWR or a server reader.
60
+ An effect body starts no promise: no `await`, `fetch`, `.then`, `void load()`, `mutate()` or `refresh()`; read through SWR or a server reader. The call is judged when it runs synchronously in the body (an immediately-invoked function counts); a promise started inside a listener or timer callback the effect registers, or in the cleanup, is that callback's work. A call is a load when its type is a promise, when it is `.then`-ed, or when it is `void`-ed and untyped; a platform promise other than `fetch` (`audio.play()`, `clipboard.writeText()`) is not a data load. Data freshness comes from the SWR key: an SWR `mutate()` or `refresh()` called directly in the effect body is a finding too; run it from the event that changed the data (a handler, a socket message).
50
61
 
51
62
  **Invalid** (`src/components/blocks/Feed/component.tsx`)
52
63
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Law module: `lint-escape-hatch.mjs`. Catalogue: R18 HFS_INLINE_SUPPRESSION.
4
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.
5
+ No `eslint-disable` (line, next-line, block), `eslint-enable`, `eslint-env`, inline `eslint rule: ...` config, `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck`, no retired `vn-ok:` pragma, and no `NOSONAR`, `@sonar-ignore`, `istanbul ignore`, `c8 ignore`, `v8 ignore`, `prettier-ignore` or `stylelint-disable` comment. `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
6
 
7
7
  Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
8
 
@@ -15,6 +15,8 @@ Source cannot change its own lint or type-check policy, and the vn-ok pragma is
15
15
  ```ts
16
16
  // eslint-disable-next-line no-console
17
17
  // @ts-ignore
18
+ // NOSONAR
19
+ /* istanbul ignore next */
18
20
  const s = "x" // vn-ok: reason
19
21
  ```
20
22
 
@@ -204,3 +204,29 @@ router.push(routes.newWorkspace())
204
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
205
 
206
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.
207
+
208
+ ## `starci-fe/i18n-stack-in-one-module`
209
+
210
+ next-intl's routing, navigation, request config and middleware factories are called only in the i18n package (or the only app's `modules/i18n`).
211
+
212
+ `defineRouting` (`next-intl/routing`), `createNavigation` (`next-intl/navigation`), `getRequestConfig` (`next-intl/server`) and `createMiddleware` (`next-intl/middleware`), resolved by the import that binds the called name (a renamed import or a namespace member counts), are called only in a file of slot `fe.package.i18n` (`packages/<family>-i18n`, exporting `createAppI18n`), or in a `fe.modules.i18n` file of a repository that declares exactly one app. In a multi-app repository each app's `modules/i18n` calls the package factory. It sits beside `no-second-i18n-stack`, which refuses another i18n library at its import; this rule refuses a second copy of next-intl's own stack.
213
+
214
+ **Invalid** (`apps/web/src/modules/i18n/request.ts`, a repository with two apps)
215
+
216
+ ```ts
217
+ import { getRequestConfig } from "next-intl/server"
218
+ export default getRequestConfig(async () => ({ locale: "vi", messages: {} }))
219
+ ```
220
+
221
+ **Valid** (`apps/web/src/modules/i18n/index.ts`)
222
+
223
+ ```ts
224
+ import { createAppI18n } from "@nivo/i18n"
225
+ export const i18n = createAppI18n({ locales: ["vi"] })
226
+ ```
227
+
228
+ **Finding code:** `FE_I18N_PLACEMENT`
229
+
230
+ **Vì sao (why):** `<file>` tự dựng một tầng của stack next-intl (`defineRouting`, `createNavigation`, `getRequestConfig`, `createMiddleware`). Stack chỉ được viết một lần cho cả repo.
231
+
232
+ **Cách sửa:** Gọi factory `createAppI18n` của gói i18n dùng chung trong `modules/i18n/index.ts` và nhập kết quả; repo một app thì chỉ viết stack trong `modules/i18n` của app đó.
@@ -0,0 +1,29 @@
1
+ # Size growth
2
+
3
+ Law module: `size-growth.mjs`. Catalogue: R20 HFS_SIZE_GROWTH.
4
+
5
+ A source file has a line budget, `ruleParams.fe.fileLines` of `knowledge/hfs/slots.yaml`, read through the slot view `hfsOf(context).ruleParams` (the package ships its own copy of the manifest in `runtime/`). A file over the budget may not be new and may not be longer than it was at the parent commit; a file within the budget is never a finding. The rule takes no option, there is no baseline file and no allowlist: the record is the repository's own history. `*.d.ts` declaration files are not governed.
6
+
7
+ Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-fe/file-size-growth`
10
+
11
+ A source file over the budget is neither new nor longer than its recorded size.
12
+
13
+ **Invalid** (`src/components/Card/index.tsx`)
14
+
15
+ ```ts
16
+ // a new file with more lines than the budget, or an existing file over the budget that gained a line
17
+ ```
18
+
19
+ **Valid** (`src/components/Card/index.tsx`)
20
+
21
+ ```ts
22
+ // a file within the budget, or an oversized file that stayed the same size or shrank
23
+ ```
24
+
25
+ **Finding code:** `HFS_SIZE_GROWTH`
26
+
27
+ **Vì sao (why):** `<file>` vượt ngân sách dòng: file mới đã dài quá mức, hoặc file cũ dài hơn bản ở commit cha. Mỗi file phải nhỏ để một người đọc hết và một bài test phủ hết.
28
+
29
+ **Cách sửa:** Tách phần mới sang file riêng theo trách nhiệm; một file đã vượt ngân sách chỉ được giữ nguyên hoặc ngắn lại.
@@ -52,18 +52,18 @@ import { Chip } from "./Chip"
52
52
 
53
53
  ## `starci-fe/no-barrel-spec`
54
54
 
55
- No `index.test.ts` beside an `index.ts` barrel.
55
+ No `index.spec.ts(x)` beside an `index.ts(x)` that holds only imports and re-exports. The sibling is read and parsed: an `index.ts` that declares a function, class or value (implementation) may have its spec; a missing sibling is no finding.
56
56
 
57
- **Invalid** (`src/hooks/index.test.ts`)
57
+ **Invalid** (`src/hooks/index.spec.ts`, beside `src/hooks/index.ts` = `export * from "./useFeed"`)
58
58
 
59
59
  ```ts
60
- // file: src/hooks/index.test.ts
60
+ // file: src/hooks/index.spec.ts
61
61
  ```
62
62
 
63
- **Valid** (`src/hooks/feed/useFeed.test.ts`)
63
+ **Valid** (`src/modules/browser-storage/index.spec.ts`, beside an `index.ts` that declares `createStore` and exports two stores)
64
64
 
65
65
  ```ts
66
- // file: src/hooks/feed/useFeed.test.ts
66
+ // file: src/modules/browser-storage/index.spec.ts
67
67
  ```
68
68
 
69
69
  **Finding code:** `FE_SPEC_QUALITY`
@@ -118,7 +118,7 @@ it("a11y", async () => { expect(await axe(container)).toHaveNoViolations() })
118
118
 
119
119
  ## `starci-fe/no-mocked-translations`
120
120
 
121
- A spec renders with the real catalogue; it never mocks `next-intl`.
121
+ A spec renders with the real catalogue; it never mocks `next-intl`. A `vi.mock("next-intl/server", factory)` in the spec of a server helper (no provider exists for it) is allowed when the factory - or a `vi.hoisted` block it reads - uses the app's real catalogue (an import that resolves into `modules/i18n/messages/`); a literal or key-echoing server mock, an automock, and any mock of the client `next-intl` stay findings.
122
122
 
123
123
  **Invalid** (`any spec`)
124
124
 
@@ -132,8 +132,16 @@ vi.mock("next-intl", () => ({}))
132
132
  render(<NextIntlClientProvider messages={messages} locale="vi" />)
133
133
  ```
134
134
 
135
+ ```ts
136
+ import messages from "@/modules/i18n/messages/vi.json"
137
+ vi.mock("next-intl/server", async () => {
138
+ const { createTranslator } = await import("next-intl")
139
+ return { getTranslations: async () => createTranslator({ locale: "vi", messages }) }
140
+ })
141
+ ```
142
+
135
143
  **Finding code:** `FE_I18N_CATALOG`
136
144
 
137
145
  **Vì sao (why):** Spec `<file>` giả lập `next-intl`, nên chỉ kiểm khóa chứ không kiểm catalog thật.
138
146
 
139
- **Cách sửa:** Render trong `NextIntlClientProvider` với `messages/<locale>.json` thật.
147
+ **Cách sửa:** Component client: render trong `NextIntlClientProvider` với `messages/<locale>.json` thật. Helper server (`next-intl/server`): factory của mock dựng translator từ catalog thật (`createTranslator` với `modules/i18n/messages/<locale>.json`).
@@ -0,0 +1,32 @@
1
+ # Status colours: text and icons take the soft pair
2
+
3
+ Law module: `status-colors.mjs`. Catalogue: R61 FE_STYLE_TOKEN_ONLY (the class-name half of the soft-pair contrast obligation; the brand layer's own contrast is `starci/status-contrast` of @starci/stylelint-canon).
4
+
5
+ A status tone has two faces. The solid tone (`--success`) is a fill, with `--success-foreground` ink on it. A status shown as text or as an icon takes the soft pair, the way a HeroUI flat Chip does: `--success-soft-foreground` ink, on `--success-soft` when it sits on a tint. The soft pair is what the brand layer is checked to make readable (3:1 on the tint and on the page); the solid tone reads 3.8:1 or worse on white, so as a text colour it fails. The tones are the grammar's status tones (`success`, `warning`, `danger`, `info`), read from `lib/status-tones.generated.mjs`, which is generated from the grammar with the stylelint vocabulary.
6
+
7
+ Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-fe/status-text-uses-soft-foreground`
10
+
11
+ No class that paints text, an icon or a text decoration with a solid status tone: `text-<tone>`, `fill-<tone>`, `stroke-<tone>`, `decoration-<tone>` (with any variant, opacity or `!`), read from `className`/`class`, from `cn()`/`clsx()` arguments, conditionals, arrays, constants and `classes` entries.
12
+
13
+ **Invalid** (`src/components/leaves/StatusText/index.tsx`)
14
+
15
+ ```tsx
16
+ <span className="text-success" />
17
+ <svg className={cn("size-4", ok && "fill-danger")} />
18
+ ```
19
+
20
+ **Valid** (`src/components/leaves/StatusText/index.tsx`)
21
+
22
+ ```tsx
23
+ <span className="bg-success-soft text-success-soft-foreground" />
24
+ <button className="bg-success text-success-foreground" />
25
+ <div className="border-danger ring-2 ring-warning" />
26
+ ```
27
+
28
+ **Finding code:** `FE_STYLE_TOKEN_ONLY`
29
+
30
+ **Vì sao (why):** `<file>` tô chữ hoặc icon bằng tông trạng thái đặc (`<what>`, ví dụ `text-success`). Tông đặc là màu nền; dùng làm màu chữ nó đọc dưới 4.5:1 trên nền trang.
31
+
32
+ **Cách sửa:** Đổi thành cặp mềm: `text-<tông>-soft-foreground` (kèm `bg-<tông>-soft` nếu nằm trên nền nhạt); tông đặc chỉ dùng với `bg-<tông>` và `text-<tông>-foreground`.
@@ -33,6 +33,8 @@ User-facing text comes from a `next-intl` catalogue through `t()`, at every tier
33
33
  <button>Save</button>
34
34
  <input placeholder="Search courses" />
35
35
  const o = { title: "Your courses" }
36
+ const status = { ready: "Your course is ready" }
37
+ <p>{`${count} installed`}</p>
36
38
  ```
37
39
 
38
40
  **Valid** (`src/components/blocks/Feed/index.tsx`)
@@ -43,6 +45,8 @@ const o = { title: "Your courses" }
43
45
  const o = { title: t("courses.title") }
44
46
  ```
45
47
 
48
+ A template literal with substitutions is copy when its static parts contain a word (`${count} installed`, `You have ${c} unread messages`) in a JSX child, a copy attribute or a copy-key value; class names, URLs, keys, ids, units and format tokens (`btn-${tone}`, `/courses/${id}`, `${n}px`, `YYYY-MM-DD HH:mm`) are not copy. An object property under any key is copy when its value is a whole sentence (two or more words, starting like a sentence, ending like one, or in a non-ASCII script): a hook returning `{ ready: "Your course is ready" }` is a finding.
49
+
46
50
  **Finding code:** `FE_I18N_LITERAL`
47
51
 
48
52
  **Vì sao (why):** Chữ `"<text>"` ở `<file>:<line>` không đi qua `t()`.
package/docs/transport.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  Law module: `transport.mjs`. Catalogue: R50 FE_TRANSPORT_OWNER, R51 FE_HTTP_STATUS_COLLAPSE, R52 FE_WIRE_GENERATED.
4
4
 
5
- One app, one client (`modules/api/client.ts`), one result vocabulary (`Outcome<T>`: `ok`, `refused`, `invalid`, `not-found`, `unavailable`). 401 and 403 become `refused`; nothing collapses a status into `null`; wire types are generated from the contract copy in `modules/api/contract/`.
5
+ One repository, one client, one result vocabulary. The client is the file of HFS slot `fe.transport.client` (`apps/<app>/src/modules/api/client.ts`, a one-app repository) or `fe.package.api.client` (`packages/<family>-api/src/client.ts`, the shared client of a multi-app repository); the vocabulary is `Outcome<T>` (`ok`, `refused`, `invalid`, `not-found`, `unavailable`), declared in the `fe.transport.outcome` / `fe.package.api.outcome` file. The rules read the slot of the file being linted, never its path. 401 and 403 become `refused`; nothing collapses a status into `null`; wire types are generated from the contract copy in `modules/api/contract/`.
6
6
 
7
7
  Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
8
 
9
9
  ## `starci-fe/fetch-only-in-api-client`
10
10
 
11
- The app's single `fetch` lives in `modules/api/client.ts`.
11
+ The repository's single `fetch` (and `Request`, `EventSource`, `navigator.sendBeacon`) lives in the api client slot. The global is found by scope resolution: `fetch(...)`, `globalThis.fetch`, `window.fetch`, `self.fetch`, `const f = fetch` and `const { fetch } = globalThis` all count, a local or imported `fetch` does not. A fetch library is refused by module specifier (axios, ky, got, undici, ofetch, graphql-request, urql, `@apollo/*`, ...; `import`, `export ... from`, `import()` and `require`), also inside the client. Another file of the api package (`src/transport.ts`) is not the client.
12
12
 
13
13
  **Invalid** (`src/hooks/course/useCourse.ts`)
14
14
 
@@ -24,9 +24,9 @@ const r = await client.get(url)
24
24
 
25
25
  **Finding code:** `FE_TRANSPORT_OWNER`
26
26
 
27
- **Vì sao (why):** `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.
27
+ **Vì sao (why):** `fetch` (hoặc `Request`, `EventSource`, `sendBeacon`, thư viện HTTP khác) ở `<file>` nằm ngoài client duy nhất (`modules/api/client.ts`, hoặc `src/client.ts` của gói api dùng chung). Mỗi repo chỉ có đúng một đường truyền.
28
28
 
29
- **Cách sửa:** Gọi client của app và nhận `Outcome<T>`; không tự gọi `fetch`.
29
+ **Cách sửa:** Gọi client của repo và nhận `Outcome<T>`; không tự gọi `fetch`.
30
30
 
31
31
  ## `starci-fe/client-fetch-has-signal`
32
32
 
@@ -74,7 +74,7 @@ export const request = (token: string) => token
74
74
 
75
75
  ## `starci-fe/client-maps-auth-to-refused`
76
76
 
77
- `modules/api/client.ts` handles 401 and 403 and produces `refused`.
77
+ The api client branches on the response status: a comparison of the fetch response's status (typed as a `Response`) with 401 and 403 (`===`, `||`, a literal list `.includes`/`.has`, or a `switch` with both cases) whose consequent builds an object with `kind: "refused"`. The three literals lying anywhere in the file do not pass, and neither does a mapping delegated to a function in another file.
78
78
 
79
79
  **Invalid** (`src/modules/api/client.ts`)
80
80
 
@@ -86,14 +86,14 @@ const r = await fetch(u, { signal })
86
86
 
87
87
  ```ts
88
88
  const r = await fetch(u, { signal })
89
- if (r.status === 401 || r.status === 403) return { kind: "refused" }
89
+ if (r.status === 401 || r.status === 403) return { ok: false, kind: "refused" }
90
90
  ```
91
91
 
92
92
  **Finding code:** `FE_HTTP_STATUS_COLLAPSE`
93
93
 
94
- **Vì sao (why):** 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.
94
+ **Vì sao (why):** Client ở `<file>` không có nhánh so sánh `response.status` với 401 và 403 rồi trả `{ kind: "refused" }`, nên trạng thái "cần đăng nhập" không bao giờ đạt được.
95
95
 
96
- **Cách sửa:** Thêm nhánh 401/403 trả `{ kind: "refused" }` trong client.
96
+ **Cách sửa:** Thêm nhánh `response.status === 401 || response.status === 403` trả `{ ok: false, kind: "refused" }` ngay trong client.
97
97
 
98
98
  ## `starci-fe/no-http-status-collapse`
99
99
 
@@ -119,7 +119,7 @@ if (!res.ok) return toOutcome(res.status)
119
119
 
120
120
  ## `starci-fe/no-hand-typed-wire`
121
121
 
122
- Wire types come from the contract copy via codegen; responses are not cast.
122
+ A transport response body is narrowed from `unknown` or typed by a generated wire type (a type imported from a `__generated__/` module), never by a hand-written one: `(await response.json()) as unknown` is the sanctioned entry and does not fire; an assertion or annotation of a `.json()` read (directly, or through a `const` initialised with one) to any other type does. Inline GraphQL documents are also refused. A hand-declared interface is not judged by its name; only a response read typed by hand is.
123
123
 
124
124
  **Invalid** (`src/modules/api/course/read-course.ts`)
125
125
 
@@ -131,6 +131,8 @@ const x = (await res.json()) as Course
131
131
 
132
132
  ```ts
133
133
  import type { CourseQuery } from "../__generated__/graphql"
134
+ const raw = (await res.json()) as unknown
135
+ const x: CourseQuery = parse(raw)
134
136
  ```
135
137
 
136
138
  **Finding code:** `FE_WIRE_GENERATED`
@@ -166,3 +168,28 @@ switch (outcome.kind) {
166
168
  **Vì sao (why):** `switch` trên `kind` ở `<file>` có nhánh `ok` nhưng thiếu một trong refused, invalid, not-found, unavailable.
167
169
 
168
170
  **Cách sửa:** 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`.
171
+
172
+ ## `starci-fe/one-outcome-union`
173
+
174
+ A result union (`ok` / `kind` discriminant) is declared only in the outcome slot; elsewhere the code composes `Outcome<T>`.
175
+
176
+ A union type alias is a result union when its resolved members are all object types that carry the same literal-typed discriminant in the result vocabulary: `ok` (each member pins it to one value, `true` and `false` both present) or `kind` (at least one literal of the Outcome vocabulary: `ok`, `refused`, `forbidden`, `invalid`, `not-found`, `unavailable`). The members are read through the type checker, so `Ok<T> | Failure` and intersections are seen as what they are; no name (`*Outcome`, `*Result`) decides anything. UI state unions (`status`, `state`, `type`, a `kind` of tree nodes or menu items) are not result vocabulary and pass; so does an alias that only composes the one union (`type Read = Outcome<Course>`, `Exclude<Outcome<T>, ...>`). `Outcome<T> | { ok: false; kind: "conflict" }` adds an arm to the one union and is refused.
177
+
178
+ **Invalid** (`src/hooks/invite/useInvite.ts`)
179
+
180
+ ```ts
181
+ export type InviteOutcome = { ok: true } | { ok: false; kind: "refused" }
182
+ ```
183
+
184
+ **Valid** (`src/hooks/invite/useInvite.ts`)
185
+
186
+ ```ts
187
+ import type { Outcome } from "@/modules/api/outcome"
188
+ export type InviteOutcome = Outcome<{ id: string }>
189
+ ```
190
+
191
+ **Finding code:** `FE_HTTP_STATUS_COLLAPSE`
192
+
193
+ **Vì sao (why):** `<file>` khai báo thêm một union kết quả (`ok` hoặc `kind`) ngoài `outcome.ts`. Repo chỉ có một `Outcome<T>`.
194
+
195
+ **Cách sửa:** Dùng `Outcome<T>` của `modules/api/outcome.ts` (hoặc gói api dùng chung); thêm chi tiết nghiệp vụ qua tham số thứ hai thay vì khai báo union mới.
package/e2e-shape.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * The rules that hold `e2e-shape.md` (HFS R66, `FE_E2E_SHAPE`).
3
3
  *
4
4
  * THESE RULES GOVERN THE E2E TREE, NOT `src/`. `starciFeConfig` attaches them to `e2e/**` and
5
- * `playwright.config.*`; every rule here also scopes itself by path, so a config that reaches it by
5
+ * `playwright.config.*`; every rule here also scopes itself by HFS slot (tier e2e, the Playwright config role), so a config that reaches it by
6
6
  * mistake governs nothing it should not.
7
7
  *
8
8
  * WHY THE SHAPE IS FIXED. An e2e suite is the only proof that the whole app works, and it is run by
@@ -16,9 +16,7 @@
16
16
  * architecture machine reads it); that the specs pass is the run itself.
17
17
  */
18
18
 
19
- import { isE2eFile } from "./lib/scope.mjs"
20
- import { baseName } from "./lib/scope.mjs"
21
- import { normalizePath } from "./lib/path.mjs"
19
+ import { baseName, classOf, fileOf, inSlot, isE2eSource, roleOfFile } from "./lib/scope.mjs"
22
20
 
23
21
  /** The three viewports every e2e project set covers, as `width x height`. */
24
22
  export const VIEWPORTS = Object.freeze(["1440x900", "768x1024", "390x844"])
@@ -26,11 +24,11 @@ export const VIEWPORTS = Object.freeze(["1440x900", "768x1024", "390x844"])
26
24
  /** A file that reads like a spec, by any of the common spellings. */
27
25
  const SPEC_LIKE = /(?:\.|-)(?:e2e-)?(?:spec|test)\.[cm]?tsx?$/
28
26
 
29
- /** The one legal spec path: `e2e/<area>/<name>.e2e-spec.ts`. */
30
- const SPEC_PATH = /(?:^|\/)e2e\/[^/]+\/[^/]+\.e2e-spec\.ts$/
27
+ /** A helper file that is named as a spec. */
28
+ const E2E_SPEC_NAME = /\.e2e-spec\./
31
29
 
32
- /** Helper folders: not specs, and never spec-named. */
33
- const HELPER_PATH = /(?:^|\/)e2e\/(?:support|fixtures)\//
30
+ /** The slots of the e2e tree: the specs (`fe.e2e`) and their helpers (`fe.e2e-support`). */
31
+ const E2E_SLOTS = ["fe.e2e", "fe.e2e-support"]
34
32
 
35
33
  /** An absolute filesystem path: a drive, a UNC share, or a well-known root. */
36
34
  const ABSOLUTE_PATH = /^(?:[A-Za-z]:[\\/]|\\\\|\/(?:Users|home|tmp|mnt|var|opt|etc|root|d|c)\/)/
@@ -77,15 +75,14 @@ export const e2eSpecLocation = {
77
75
  },
78
76
  },
79
77
  create(context) {
80
- const file = normalizePath(context.filename || context.getFilename())
81
- if (!/(?:^|\/)e2e\//.test(file)) return {}
82
- const name = baseName(file)
83
- const helper = HELPER_PATH.test(file)
84
- const specLike = SPEC_LIKE.test(name)
85
- const legal = SPEC_PATH.test(file) && !helper
86
- if ((specLike && !legal) || (helper && /\.e2e-spec\./.test(name))) {
87
- return { Program: (node) => context.report({ node, messageId: "location", data: { name } }) }
88
- }
78
+ const name = baseName(fileOf(context))
79
+ const found = classOf(context)
80
+ // A helper slot (`fe.e2e-support`) owns no spec: a helper named like one is a spec in the wrong place.
81
+ const helperNamedSpec = inSlot(context, "fe.e2e-support") && E2E_SPEC_NAME.test(name)
82
+ // A spec-looking file no slot owns but that sits inside the e2e tree (the nearest slot is an e2e slot, matched past the repository root)
83
+ // is a spec off the one place `fe.e2e` gives it. A file outside the e2e tree is not this rule's business.
84
+ const misplacedSpec = found.status === "no-slot" && E2E_SLOTS.includes(found.nearest?.slot) && found.nearest.matchedDepth >= 1 && SPEC_LIKE.test(name)
85
+ if (helperNamedSpec || misplacedSpec) return { Program: (node) => context.report({ node, messageId: "location", data: { name } }) }
89
86
  return {}
90
87
  },
91
88
  }
@@ -104,7 +101,7 @@ export const e2eNoAbsolutePath = {
104
101
  },
105
102
  },
106
103
  create(context) {
107
- if (!isE2eFile(context.filename || context.getFilename())) return {}
104
+ if (!isE2eSource(context)) return {}
108
105
  const check = (node, text) => {
109
106
  if (typeof text === "string" && ABSOLUTE_PATH.test(text)) context.report({ node, messageId: "absolute", data: { path: text } })
110
107
  }
@@ -135,7 +132,7 @@ export const e2eNoDocker = {
135
132
  },
136
133
  },
137
134
  create(context) {
138
- if (!isE2eFile(context.filename || context.getFilename())) return {}
135
+ if (!isE2eSource(context)) return {}
139
136
  return {
140
137
  ImportDeclaration(node) {
141
138
  if (DOCKER_MODULE.test(String(node.source.value))) context.report({ node, messageId: "docker" })
@@ -194,7 +191,7 @@ export const e2eNoCrossRepoWrite = {
194
191
  },
195
192
  },
196
193
  create(context) {
197
- if (!isE2eFile(context.filename || context.getFilename())) return {}
194
+ if (!isE2eSource(context)) return {}
198
195
  const source = context.sourceCode || context.getSourceCode()
199
196
  return {
200
197
  CallExpression(node) {
@@ -230,7 +227,7 @@ export const e2eNoSkip = {
230
227
  },
231
228
  },
232
229
  create(context) {
233
- if (!isE2eFile(context.filename || context.getFilename())) return {}
230
+ if (!isE2eSource(context)) return {}
234
231
  return {
235
232
  CallExpression(node) {
236
233
  const callee = node.callee
@@ -267,7 +264,7 @@ export const e2eTypedHelpers = {
267
264
  },
268
265
  },
269
266
  create(context) {
270
- if (!isE2eFile(context.filename || context.getFilename())) return {}
267
+ if (!isE2eSource(context)) return {}
271
268
  const checkParams = (node) => {
272
269
  if (!isModuleLevel(node)) return
273
270
  for (const parameter of node.params) {
@@ -308,7 +305,7 @@ export const playwrightViewports = {
308
305
  },
309
306
  },
310
307
  create(context) {
311
- if (!/^playwright\.config\.[cm]?[jt]s$/.test(baseName(context.filename || context.getFilename()))) return {}
308
+ if (roleOfFile(context) !== "playwright") return {}
312
309
  const seen = new Map()
313
310
  return {
314
311
  Property(node) {
package/env-owner.mjs CHANGED
@@ -67,7 +67,7 @@ export const noEnvOutsideConfig = {
67
67
  },
68
68
  create(context) {
69
69
  const filename = context.filename || context.getFilename()
70
- if (isConfigModule(filename) || isSpecFile(filename)) return {}
70
+ if (isConfigModule(context) || isSpecFile(filename)) return {}
71
71
  return {
72
72
  MemberExpression(node) {
73
73
  if (isEnvObject(node)) context.report({ node, messageId: "env" })