@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.
- package/CHANGELOG.md +78 -2
- package/README.md +17 -18
- package/brand-values.mjs +1 -3
- package/class-names.mjs +9 -9
- package/client-boundary.mjs +125 -37
- package/comments.mjs +14 -21
- package/docs/client-boundary.md +26 -1
- package/docs/grammar-boundary.md +33 -0
- package/docs/hygiene.md +15 -4
- package/docs/lint-escape-hatch.md +3 -1
- package/docs/next-conventions.md +26 -0
- package/docs/size-growth.md +29 -0
- package/docs/spec-quality.md +15 -7
- package/docs/status-colors.md +32 -0
- package/docs/translation.md +4 -0
- package/docs/transport.md +36 -9
- package/e2e-shape.mjs +20 -23
- package/env-owner.mjs +1 -1
- package/file-layout.mjs +105 -103
- package/formatting.mjs +2 -4
- package/grammar-boundary.mjs +85 -9
- package/hooks-folder.mjs +17 -16
- package/hygiene.mjs +370 -92
- package/icon.mjs +21 -20
- package/index.mjs +27 -85
- package/landmark.mjs +0 -11
- package/lib/bindings.mjs +144 -0
- package/lib/config.mjs +79 -0
- package/lib/hfs.mjs +48 -0
- package/lib/next.mjs +6 -0
- package/lib/platform.mjs +66 -0
- package/lib/react.mjs +28 -0
- package/lib/scope.mjs +119 -19
- package/lib/status-tones.generated.mjs +9 -0
- package/lib/types.mjs +63 -0
- package/lib/why.mjs +41 -11
- package/lint-escape-hatch.mjs +17 -15
- package/loading.mjs +9 -16
- package/naming.mjs +8 -2
- package/next-conventions.mjs +109 -25
- package/package.json +9 -4
- package/props-and-slots.mjs +19 -13
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1072 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +42 -0
- package/runtime/scripts/lib/hfs-slots.mjs +722 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/next-contract.mjs +14 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/served-locale.mjs +30 -16
- package/shape-slot.mjs +22 -20
- package/size-and-state-budget.mjs +41 -23
- package/size-growth.mjs +58 -0
- package/spec-quality.mjs +95 -16
- package/status-colors.mjs +139 -0
- package/the-split.mjs +13 -13
- package/tokens.mjs +26 -34
- package/translation.mjs +52 -19
- package/transport.mjs +437 -89
- package/type-safety.mjs +8 -17
- package/typography.mjs +7 -11
- package/vendor-boundary.mjs +5 -27
- 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.
|
|
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/
|
|
9
|
+
## `starci-fe/effect-subscription-needs-cleanup`
|
|
10
10
|
|
|
11
|
-
`
|
|
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
|
-
|
|
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`,
|
|
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
|
|
package/docs/next-conventions.md
CHANGED
|
@@ -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.
|
package/docs/spec-quality.md
CHANGED
|
@@ -52,18 +52,18 @@ import { Chip } from "./Chip"
|
|
|
52
52
|
|
|
53
53
|
## `starci-fe/no-barrel-spec`
|
|
54
54
|
|
|
55
|
-
No `index.
|
|
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.
|
|
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.
|
|
60
|
+
// file: src/hooks/index.spec.ts
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
**Valid** (`src/
|
|
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/
|
|
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:**
|
|
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`.
|
package/docs/translation.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
30
|
-
const
|
|
27
|
+
/** A helper file that is named as a spec. */
|
|
28
|
+
const E2E_SPEC_NAME = /\.e2e-spec\./
|
|
31
29
|
|
|
32
|
-
/**
|
|
33
|
-
const
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (
|
|
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(
|
|
70
|
+
if (isConfigModule(context) || isSpecFile(filename)) return {}
|
|
71
71
|
return {
|
|
72
72
|
MemberExpression(node) {
|
|
73
73
|
if (isEnvObject(node)) context.report({ node, messageId: "env" })
|