@starci/eslint-canon-fe 5.1.2 → 6.0.1
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 +86 -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 +104 -36
- 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/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,92 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.0.1 - 2026-09-30
|
|
4
|
+
|
|
5
|
+
- **Fix: `no-hand-rolled-polling` refused a shared clock.** A timer is polling only when what it runs reads data - a call whose
|
|
6
|
+
value is a promise (`fetch`, the transport client, a reader, SWR's `mutate()`), an `await`, or a same-file function that does -
|
|
7
|
+
decided by type, not by name. nivo-fe `useNow` (a one-minute tick behind `useSyncExternalStore`) and a state countdown pass; a
|
|
8
|
+
timer that fetches or revalidates is still refused. The self-scheduling `setTimeout` check follows the enclosing function's
|
|
9
|
+
binding, not its spelling. The FE convention names no shared clock owner, so no clock rule is added.
|
|
10
|
+
|
|
11
|
+
## 6.0.0 - 2026-09-30
|
|
12
|
+
|
|
13
|
+
- **Census fixes (starci-next-fe main, lane F0):** `one-outcome-union` treats a `kind` union as a result union only with an `ok`
|
|
14
|
+
arm beside a failure kind (a mapper's view union - `LessonViewerView`, `StudyStepView` - is a screen state);
|
|
15
|
+
`client-maps-auth-to-refused` accepts the Outcome constructor named by its kind (`failed("refused", ...)`) and a status branch
|
|
16
|
+
inside a helper whose parameter every call fills with the response status;
|
|
17
|
+
`i18n-stack-in-one-module` lets the proxy of a one-app repository build the locale middleware; `server-module-marks-server-only`
|
|
18
|
+
requires the marker of server modules and of every server reader (role `reader`), not of components (reachability is
|
|
19
|
+
FE_CLIENT_REACHES_SERVER's); `route-tree-holds-routes-only` admits next-intl's root `not-found.tsx` and its root `layout.tsx`.
|
|
20
|
+
|
|
21
|
+
- **Every law scopes by slot, never by folder regex (lane F0-H).** The path predicates of `class-names`, `props-and-slots`,
|
|
22
|
+
`vendor-boundary`, `loading`, `type-safety`, `typography`, `brand-values`, `formatting`, `comments`, `hygiene`, `translation`,
|
|
23
|
+
`lint-escape-hatch`, `client-boundary`, `file-layout`, `shape-slot`, `the-split`, `landmark`, `icon`, `served-locale`, `e2e-shape`,
|
|
24
|
+
`hooks-folder`, `naming`, `next-conventions`, `tokens`, `size-and-state-budget`, `spec-quality` and `transport` ask the slot view
|
|
25
|
+
(`kind`, `role`, tier, owner, `hfs.allows(file)`, `classifyImport`) instead of testing a path. Shared-package layers (`fe.package.ui`)
|
|
26
|
+
are governed like app components; a file no slot owns is judged by `hfs check` (`HFS_SLOT_UNDECLARED`), not by a folder-name rule.
|
|
27
|
+
Line and hook budgets (`component.tsx`, `index.tsx`, `file`, `useState`, `dataHooks`) are read from the slot manifest.
|
|
28
|
+
`navigation-from-intl` exempts only the `proxy` and `global-error` roles; `no-middleware-file` reads slot `fe.source-root-retired`;
|
|
29
|
+
`route-tree-holds-routes-only` reads the `allows` of `fe.route`; `no-unresolved-token-class` reads the app's route and brand stylesheets.
|
|
30
|
+
|
|
31
|
+
- New law `status-colors` (R61 `FE_STYLE_TOKEN_ONLY`): `status-text-uses-soft-foreground` refuses a class that paints text, an icon or a
|
|
32
|
+
text decoration with a solid status tone (`text-success`, `fill-danger`, `stroke-warning`, `decoration-info`, with any variant, opacity or
|
|
33
|
+
`!`), read from `className`/`class`, `cn()`/`clsx()` arguments, conditionals, arrays, constants and `classes` entries. The soft pair
|
|
34
|
+
(`text-<tone>-soft-foreground`, `bg-<tone>-soft`), a solid fill with its own ink (`bg-<tone> text-<tone>-foreground`) and solid borders,
|
|
35
|
+
rings and outlines pass. The tones come from `lib/status-tones.generated.mjs`, generated from the grammar by
|
|
36
|
+
`packages/stylelint` (`npm run vocabulary`), never listed in the rule.
|
|
37
|
+
|
|
38
|
+
- **Breaking: `starciFeConfig({ hfs: loadHfs(import.meta.url) })` replaces `starciFeConfig({ layout })`.** The factory reads the
|
|
39
|
+
repository's `hfs.json` and the shipped slot manifest (`runtime/`, refreshed by `packages/hfs/scripts/sync-runtime.mjs`), lints
|
|
40
|
+
every app's and every workspace package's `src/` with typed linting (`parserOptions.projectService`) and the e2e tree
|
|
41
|
+
syntax-only, ignores build output, `__generated__/` and `.starci/`, and puts the slot view in `settings.starci.hfs`
|
|
42
|
+
(`lib/hfs.mjs` `hfsOf(context)`); `lib/types.mjs` holds the type-aware helpers. `LAYOUTS`/`LAYOUT_GLOBS` are deleted.
|
|
43
|
+
- **Breaking: the `lint-adoption` law and its `effective-config` audit are deleted**, with `audits`/`auditOwners`: the
|
|
44
|
+
managed `eslint.config.mjs` one-liner that `hfs check` compares with its render (R17) is the one proof that no rule is off.
|
|
45
|
+
- `linterOptions` lives in `lib/config.mjs` only (the copy in `lint-escape-hatch.mjs` is gone).
|
|
46
|
+
- New peer dependency `typescript >=5.9`; `@typescript-eslint/parser` is a dependency. Tests build typed cases with
|
|
47
|
+
`fixtures/typed/tester.mjs` (`typedTester`, `slotTester`, `at`).
|
|
48
|
+
|
|
49
|
+
- **Breaking: `no-direct-heroicon-import` is deleted.** It was a second name for a second copy of
|
|
50
|
+
`no-vendor-icon-outside-icon-leaf` (icon law); one obligation has one rule (redundancy RED20). The copy in
|
|
51
|
+
`vendor-boundary.mjs` is gone; `no-vendor-icon-outside-icon-leaf` is unchanged.
|
|
52
|
+
- New law `size-growth` (R20 `HFS_SIZE_GROWTH`): `file-size-growth` refuses a new source file over the line budget and an existing file over it that is longer than at the parent commit. The budget is `ruleParams.fe.fileLines` of the slot manifest, read through the slot view (`hfsOf(context).ruleParams`, the package's own `runtime/` copy) (`packages/hfs/scripts/sync-runtime.mjs` keeps it equal to the runtime's); the rule takes no option.
|
|
53
|
+
- **Breaking: `no-internal-starci-href` is deleted.** It was a second, weaker copy of `no-hardcoded-route` (which already refuses a
|
|
54
|
+
route literal in any JSX `href` and any navigation call, specs excluded); one obligation has one rule. `no-hardcoded-route` gained a
|
|
55
|
+
passing case for `mailto:` on `<a>` and a violating case for `<a href="/tasks">`. R93 no longer lists it.
|
|
56
|
+
- **Fix: `no-direct-const-alias` fired on `export const generateMetadata = x` in a Next route segment file.** The names Next reserves
|
|
57
|
+
(`generateMetadata`, `metadata`, `viewport`, `dynamic`, ... in `lib/next.mjs`, the list of the architecture machine's
|
|
58
|
+
`NEXT_RESERVED_EXPORTS`) are exempt when the file is a route segment file (slot `fe.route`, stem `page`/`layout`/`template`/...);
|
|
59
|
+
a non-reserved alias in a route file and a reserved name elsewhere still fire. The rule now needs the slot view (`hfsOf`).
|
|
60
|
+
- **Fix: `no-barrel-spec` fired on every `index.spec.ts`.** It now reads and parses the sibling `index.ts(x)` and fires only when the
|
|
61
|
+
sibling holds nothing but import and export declarations (a barrel); an `index.ts` with a function, class or value (starci-next-fe
|
|
62
|
+
`modules/browser-storage`) may have its spec, and a missing sibling is no finding. It also covers `.tsx`.
|
|
63
|
+
- **Fix: `no-mocked-translations` refused a `next-intl/server` mock that serves the real catalogue.** A server helper has no provider to
|
|
64
|
+
render inside; the mock is allowed when its factory (or a `vi.hoisted` block it reads) uses an import that resolves into the app's
|
|
65
|
+
`modules/i18n/messages/`. Client `next-intl` mocks, literal/key-echoing server mocks and automocks still fire, now with a
|
|
66
|
+
server-specific message (`mockedServer`) naming `createTranslator` over the real messages. The rule needs the slot view (`hfsOf`).
|
|
67
|
+
- **Fix: `no-hand-typed-wire` fired on `(await response.json()) as unknown`, the narrowing entry `no-type-assertion` allows.** It now
|
|
68
|
+
fires only when a value read from `.json()` (directly or through a `const`) is asserted or annotated to a type that is not `unknown`
|
|
69
|
+
and not imported from a `__generated__/` module (decided from the file's import declarations). **Breaking:** the `declared` branch
|
|
70
|
+
(a name suffix `Wire|Dto|DTO|Response|Payload` on an interface or alias in `modules/api`) and the `graphqlType` branch (a name
|
|
71
|
+
pattern `GraphQl*`) are deleted; detection by name is not a detection.
|
|
72
|
+
- Every rule is catalogued under an HFS rule id (R91 FE_SOURCE_FORM, R92 FE_COMPONENT_API, R93 FE_VENDOR_BOUNDARY added).
|
|
73
|
+
- **One client, one Outcome, one i18n stack, grammar components (lane F0-D).**
|
|
74
|
+
- `lib/scope.mjs`: `isConfigModule`, `isApiClient` and the new `isOutcomeModule` are slot questions (`fe.modules.config`, `fe.transport.client` / `fe.package.api.client`, `fe.transport.outcome` / `fe.package.api.outcome`) taking the rule `context`, not path regexes; `PACKAGE_TIERS` is deleted (`use-client-only-at-boundary` asks slot `fe.package.ui` and reads the tier below the package root); `slotOfFile`, `inSlot` and `globalReferences` (scope-resolved globals) are new. Their rule tests run under `slotTester()`/`typedTester()` with `at("apps/web/src/...")` filenames.
|
|
75
|
+
- `fetch-only-in-api-client` resolves the global `fetch` (and `globalThis.fetch`, `window.fetch`, `self.fetch`, aliases, destructuring), `new Request`, `new EventSource`, `navigator.sendBeacon` by scope, and fetch libraries (axios, ky, got, undici, ofetch, graphql-request, urql, `@apollo/*`, `@urql/*`) by module specifier; the one file allowed is slot `fe.transport.client` or `fe.package.api.client`. `client-fetch-has-signal` and `no-shared-transport-state` ask the same slots (the shared api package is covered).
|
|
76
|
+
- `client-maps-auth-to-refused` judges a real branch: the fetch response's status (typed `Response`) compared with 401 and 403 whose consequent builds `kind: "refused"`; three literals anywhere no longer pass.
|
|
77
|
+
- New `one-outcome-union` (R51): a union alias of object types sharing a literal `ok` or `kind` discriminant is declared only in the outcome slot.
|
|
78
|
+
- New `i18n-stack-in-one-module` (R59): `defineRouting`, `createNavigation`, `getRequestConfig`, `createMiddleware` (by import specifier) are called only in slot `fe.package.i18n`, or `fe.modules.i18n` of a one-app repository. `no-second-i18n-stack` stays: it refuses another i18n library, a different obligation.
|
|
79
|
+
- New `no-raw-structural-element` (`grammar-boundary`, R62): raw `h1-h6 p span hr label form fieldset dl dt dd table..th ul ol li header footer nav section main figure figcaption` in app slots and non-`ui` packages; `div br aside article` have no grammar component and stay allowed.
|
|
80
|
+
|
|
81
|
+
- **Breaking: the timer rule is now `effect-subscription-needs-cleanup`** (`FE_EFFECT_CLEANUP`, no alias). Inside a React `useEffect` / `useLayoutEffect` callback (resolved through the import) and inside a `useSyncExternalStore` subscribe (also when passed by name), every `setTimeout` / `setInterval` handle, `requestAnimationFrame` id, `addEventListener` (target, type, listener), `.observe`d `ResizeObserver` / `IntersectionObserver` / `MutationObserver`, `WebSocket` / `EventSource` / `BroadcastChannel` and subscription object (`unsubscribe` / `off` / `close` in its type) must be released by the returned cleanup against the SAME handle (scope binding or member path), not by any `clearTimeout` in it. The `/modules/` exemption is gone: outside an effect a timer or frame is accepted only when the function that starts it releases it (the 5.1.2 returned-closure acceptance and the API client's `finally` both hold structurally). Typed linting is required.
|
|
82
|
+
- **`no-data-fetch-in-effect` sees indirect loads.** A call executed synchronously in the effect body whose value is a promise (`void load()` with `load` async anywhere, an SWR `mutate()` / `refresh()`), is `.then`-ed, or is `void`-ed and untyped is a finding; a promise started inside a listener or timer callback the effect registers is not. The effect is React's export, resolved through the import.
|
|
83
|
+
- **New rule `server-module-marks-server-only`** (R55, new code `FE_SERVER_ONLY_MARK`): a module that imports `next/headers`, `next/server`, `next-intl/server`, a Node built-in, or a module that resolves to a file starting with `import "server-only"` must itself start with `import "server-only"`. Route files (slots `fe.route`, `fe.source-root-pinned`) are exempt. `client-no-server-import` also refuses `next-intl/server` and `next/server`.
|
|
84
|
+
- **`no-hardcoded-copy` (R58)** reads template literals with substitutions whose static parts hold a word (`${count} installed`) in JSX children and copy attributes, and any object property whose value is a whole sentence under any key (`{ ready: "Your course is ready" }`); class names, URLs, ids, keys, units and format tokens still pass.
|
|
85
|
+
- **`no-inline-lint-config` (R18)** also refuses `NOSONAR`, `@sonar-ignore`, `istanbul ignore`, `c8 ignore`, `v8 ignore`, `prettier-ignore` and `stylelint-disable` comments.
|
|
86
|
+
- New `lib/bindings.mjs` (scope bindings, imports, stable keys), `lib/platform.mjs` (platform symbols) and `lib/react.mjs` (React exports by import).
|
|
87
|
+
|
|
3
88
|
## 5.1.2 - 2026-09-30
|
|
4
89
|
|
|
5
|
-
- **Fix: `timer-needs-effect-cleanup` refused a correctly cleaned-up timer outside an effect.** nivo-fe `useNow` starts its `setInterval` inside the `subscribe` given to `useSyncExternalStore` and clears it in the unsubscribe that `subscribe` returns; the rule knew only `useEffect` cleanups and reported it. A timer is now also accepted when a function enclosing it directly returns a function (arrow expression body, or the last top-level `return`) that calls `clearTimeout`/`clearInterval` (bare, `window.` or `globalThis.`) on the same identifier the timer handle was stored in (`const id = ...` or `id = ...`). Detection is structural, not by name. A timer with no stored handle, a returned cleanup that clears another handle, or no returned cleanup at all is still refused.
|
|
6
90
|
|
|
7
91
|
## 5.1.1 - 2026-09-30
|
|
8
92
|
|
|
@@ -15,7 +99,7 @@ Round 2 of the FE enforcers, measured on nivo-fe, starci-next-fe and miamia-fe.
|
|
|
15
99
|
|
|
16
100
|
- **Type safety (R22, `FE_TYPE_ESCAPE`)**: `no-type-assertion` (`as T`, `<T>x`; `as const` and `as unknown` stay), `no-non-null-assertion`, `no-explicit-any`. `no-double-cast` now has a why entry.
|
|
17
101
|
- **Lists (R65, `FE_LIST_KEY`)**, new law `lists`: `list-item-has-key`, `no-index-key`, `no-inline-literal-prop-in-list`.
|
|
18
|
-
- **Runtime hygiene (R65, R50)**, new law `hygiene`: `
|
|
102
|
+
- **Runtime hygiene (R65, R50)**, new law `hygiene`: `effect-subscription-needs-cleanup` (`FE_EFFECT_CLEANUP`), `no-data-fetch-in-effect` (`FE_EFFECT_FETCH`), `no-empty-catch` (`FE_SWALLOWED_ERROR`), `no-console` (`FE_CONSOLE_CALL`).
|
|
19
103
|
- **Formatting (R59, `FE_I18N_FORMATTER`)**, new law `formatting`: `use-intl-formatter` (`toLocale*String`, `new Intl.*Format`, displayed `toFixed`, currency glued to a template, date libraries).
|
|
20
104
|
- **Next conventions**: `page-exports-metadata` (R54, `FE_PAGE_METADATA_MISSING`), `no-null-suspense-fallback` (R53, `FE_SUSPENSE_NULL_FALLBACK`), `navigation-from-intl` and `no-native-anchor` (R59, `FE_I18N_NAVIGATION`), `no-hardcoded-route` (R57, `FE_ROUTE_HARDCODED`).
|
|
21
105
|
- **Client boundary (R55)**: `client-no-server-import` (`FE_CLIENT_SERVER_IMPORT`), `web-storage-only-in-modules` (`FE_STORAGE_OUTSIDE_MODULES`), `no-dangerous-html` (`FE_DANGEROUS_HTML`).
|
package/README.md
CHANGED
|
@@ -5,30 +5,29 @@ component ownership, accessibility, loading behavior, vendor boundaries, transla
|
|
|
5
5
|
and colocated class-name composition consistent without prescribing a custom rendering protocol.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm i -D @starci/eslint-canon-fe eslint-plugin-react-hooks
|
|
8
|
+
npm i -D @starci/eslint-canon-fe eslint-plugin-react-hooks typescript
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
import { starciFeConfig } from "@starci/eslint-canon-fe"
|
|
11
|
+
A repository's `eslint.config.mjs` is the managed one-liner `hfs sync` renders (and `hfs check` compares, R05/R17):
|
|
13
12
|
|
|
14
|
-
|
|
13
|
+
```js
|
|
14
|
+
import { loadHfs, starciFeConfig } from "@starci/eslint-canon-fe"
|
|
15
|
+
export default starciFeConfig({ hfs: loadHfs(import.meta.url) })
|
|
15
16
|
```
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
(`eslint-plugin-react-hooks` 7, its
|
|
20
|
-
|
|
21
|
-
`reportUnusedDisableDirectives`).
|
|
18
|
+
`loadHfs` reads the repository's `hfs.json` and the slot manifest shipped in `runtime/`. The factory returns three
|
|
19
|
+
flat-config blocks and owns everything about the law: which files are linted (from the HFS profile), which rules are on
|
|
20
|
+
(every one, at `error` - nothing is off and nothing is a warning), the React Hooks rules (`eslint-plugin-react-hooks` 7, its
|
|
21
|
+
recommended set lifted to `error`), typed linting, and the inline-directive fence (`noInlineConfig` plus
|
|
22
|
+
`reportUnusedDisableDirectives`). The repository states nothing.
|
|
22
23
|
|
|
23
|
-
- The **
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
- The **ignore** block: `node_modules`, `.next`, `dist`, `coverage`, `__generated__` and `.starci/`.
|
|
25
|
+
- The **source** block governs `apps/*/src/**` and `packages/*/src/**` with types (`parserOptions.projectService`) and
|
|
26
|
+
`settings.starci.hfs`, the slot view path-scoped rules ask (`lib/hfs.mjs` `hfsOf`), never a path pattern.
|
|
27
|
+
- The **e2e** block governs `e2e/**` and `playwright.config.ts` with the e2e rules and the escape-hatch fence.
|
|
27
28
|
|
|
28
|
-
`recommended`, `sourceRecommended`, `e2eRecommended`, `why`, `
|
|
29
|
-
exported
|
|
30
|
-
`audits["effective-config"]`; `why[rule]` is `{ code, vi, fixVi }`, the finding code and the
|
|
31
|
-
Vietnamese why the harness quotes.
|
|
29
|
+
`recommended`, `sourceRecommended`, `e2eRecommended`, `why`, `loadHfs`, `linterOptions` and the plugin itself are also
|
|
30
|
+
exported; `why[rule]` is `{ code, vi, fixVi }`, the finding code and the Vietnamese why the harness quotes.
|
|
32
31
|
|
|
33
32
|
## Rules
|
|
34
33
|
|
|
@@ -38,7 +37,7 @@ its Vietnamese why. New in 5.0: [env-owner](docs/env-owner.md), [transport](docs
|
|
|
38
37
|
[next-conventions](docs/next-conventions.md), [translation](docs/translation.md) (no literal copy at any
|
|
39
38
|
tier, no `vn-ok`), [brand-values](docs/brand-values.md), [native-controls](docs/native-controls.md),
|
|
40
39
|
[size-and-state-budget](docs/size-and-state-budget.md), [e2e-shape](docs/e2e-shape.md),
|
|
41
|
-
[spec-quality](docs/spec-quality.md) and [lint-escape-hatch](docs/lint-escape-hatch.md).
|
|
40
|
+
[spec-quality](docs/spec-quality.md) and [lint-escape-hatch](docs/lint-escape-hatch.md). New in 5.1: [lists](docs/lists.md), [hygiene](docs/hygiene.md), [formatting](docs/formatting.md) and [type-safety](docs/type-safety.md), plus rules added to next-conventions, client-boundary, native-controls and transport. New in 6.0: `effect-subscription-needs-cleanup` (timers, frames, listeners, observers, sockets and subscriptions are released against the same handle), `server-module-marks-server-only` ([client-boundary](docs/client-boundary.md)), `no-data-fetch-in-effect` sees indirect loads, `no-hardcoded-copy` reads templates and sentence values, `no-inline-lint-config` refuses `NOSONAR` and the coverage/formatter switches. Also new in 6.0: [grammar-boundary](docs/grammar-boundary.md) (`no-raw-structural-element`), `one-outcome-union` ([transport](docs/transport.md)), `i18n-stack-in-one-module` ([next-conventions](docs/next-conventions.md)), [size-growth](docs/size-growth.md) (`file-size-growth`: a file over the manifest's line budget is not born large and does not grow; the budget is `ruleParams.fe.fileLines`, read from the manifest copy in `runtime/`).
|
|
42
41
|
|
|
43
42
|
Older laws cover comments, file layout, icons and vendor ownership, landmarks, loading states,
|
|
44
43
|
naming, props, served locales, component splits, design tokens, type safety, typography, and
|
package/brand-values.mjs
CHANGED
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
* for one span would be two findings for one fix.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { isSpecFile } from "./lib/scope.mjs"
|
|
22
21
|
import { isContentFile } from "./comments.mjs"
|
|
23
22
|
|
|
24
23
|
/** A hex colour: 3, 4, 6 or 8 digits, not a longer word and not part of an identifier. */
|
|
@@ -105,8 +104,7 @@ export const noRawBrandValue = {
|
|
|
105
104
|
},
|
|
106
105
|
},
|
|
107
106
|
create(context) {
|
|
108
|
-
|
|
109
|
-
if (isSpecFile(filename) || isContentFile(filename)) return {}
|
|
107
|
+
if (isContentFile(context)) return {}
|
|
110
108
|
const source = context.sourceCode || context.getSourceCode()
|
|
111
109
|
/** Values already reported through an owner, so a string reports once. */
|
|
112
110
|
const claimed = new WeakSet()
|
package/class-names.mjs
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* classNames files own the utility-token composition.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import { isComponentFile, roleOfFile } from "./lib/scope.mjs"
|
|
7
|
+
|
|
8
|
+
/** The styling declarations of a component owner: the file its slot gives the role `styles`. */
|
|
9
|
+
const isClassNamesFile = (context) => isComponentFile(context) && roleOfFile(context) === "styles"
|
|
9
10
|
const isClassAttribute = (node) => node.type === "JSXAttribute" && ["className", "class"].includes(node.name?.name)
|
|
10
11
|
const isCn = (node) => node?.type === "Identifier" && node.name === "cn"
|
|
11
12
|
|
|
@@ -81,7 +82,7 @@ export const noInlineClassName = {
|
|
|
81
82
|
messages: { inline: "Move this className value to an imported colocated classNames.ts export." },
|
|
82
83
|
},
|
|
83
84
|
create(context) {
|
|
84
|
-
if (!
|
|
85
|
+
if (!isComponentFile(context)) return {}
|
|
85
86
|
const imported = importedClassNames(context)
|
|
86
87
|
return {
|
|
87
88
|
JSXAttribute(node) {
|
|
@@ -102,11 +103,11 @@ export const classNamesInColocatedFile = {
|
|
|
102
103
|
messages: { misplaced: "Put reusable className declarations in a colocated classNames.ts file." },
|
|
103
104
|
},
|
|
104
105
|
create(context) {
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
if (!isComponentFile(context)) return {}
|
|
107
|
+
const styles = isClassNamesFile(context)
|
|
107
108
|
return {
|
|
108
109
|
CallExpression(node) {
|
|
109
|
-
if (!isCn(node.callee) ||
|
|
110
|
+
if (!isCn(node.callee) || styles) return
|
|
110
111
|
context.report({ node, messageId: "misplaced" })
|
|
111
112
|
},
|
|
112
113
|
}
|
|
@@ -126,8 +127,7 @@ export const cnArgumentsAreSingleTokens = {
|
|
|
126
127
|
},
|
|
127
128
|
},
|
|
128
129
|
create(context) {
|
|
129
|
-
|
|
130
|
-
if (!isClassNamesFile(filename)) return {}
|
|
130
|
+
if (!isClassNamesFile(context)) return {}
|
|
131
131
|
return {
|
|
132
132
|
CallExpression(node) {
|
|
133
133
|
if (!isCn(node.callee)) return
|
package/client-boundary.mjs
CHANGED
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
* above it is a server component and must not have one.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import
|
|
19
|
-
import {
|
|
18
|
+
import ts from "typescript"
|
|
19
|
+
import { classifyImport, fileOf, isProductSource, isSpecFile, kindOfFile, roleOfFile, slotOfFile, tierOfFile } from "./lib/scope.mjs"
|
|
20
|
+
import { typed } from "./lib/types.mjs"
|
|
20
21
|
|
|
21
|
-
/** The framework's own
|
|
22
|
-
const
|
|
22
|
+
/** The route roles that are client-only by the framework's own rule: `error` and `global-error`. */
|
|
23
|
+
const FRAMEWORK_CLIENT_ROLES = new Set(["error", "global-error"])
|
|
23
24
|
|
|
24
25
|
/** True when the program opens with the `"use client"` directive. */
|
|
25
26
|
const clientDirective = (program) =>
|
|
@@ -27,29 +28,25 @@ const clientDirective = (program) =>
|
|
|
27
28
|
(statement) => statement.type === "ExpressionStatement" && statement.directive === "use client",
|
|
28
29
|
) ?? null
|
|
29
30
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
/** The layers whose files are legitimately client: a block's entry, a leaf (its interaction is intrinsic) and, in a shared package, the interactive layers (branches and leaves; a composite is presentation). */
|
|
32
|
+
const isInteractionBoundary = (context) => {
|
|
33
|
+
const slot = slotOfFile(context)
|
|
34
|
+
const kind = kindOfFile(context)
|
|
35
|
+
const role = roleOfFile(context)
|
|
36
|
+
if (slot === "fe.components") return kind === "leaves" || (kind === "blocks" && role === "entry")
|
|
37
|
+
if (slot === "fe.feature") return kind === "overlays" && role === "entry"
|
|
38
|
+
if (slot === "fe.package.ui") return kind === "branches" || kind === "leaves"
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
38
41
|
|
|
39
42
|
/** Where the directive is a legitimate boundary. */
|
|
40
|
-
const isBoundary = (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
/** The slot a file fills in the route tree, when it fills one. */
|
|
48
|
-
const slotOf = (file) => {
|
|
49
|
-
const name = stem(file)
|
|
50
|
-
if (/\/app\//.test(file) && ROUTE_SLOTS.includes(name)) return name
|
|
51
|
-
if (/\/features\/pages\//.test(file)) return "page owner"
|
|
52
|
-
if (/\/features\/layouts\//.test(file)) return "layout owner"
|
|
43
|
+
const isBoundary = (context) => (slotOfFile(context) === "fe.route" && FRAMEWORK_CLIENT_ROLES.has(roleOfFile(context))) || isInteractionBoundary(context)
|
|
44
|
+
|
|
45
|
+
/** The slot a file fills in the route tree (its role in `fe.route`, or the page or layout owner of a feature), when it fills one. */
|
|
46
|
+
const routeFill = (context) => {
|
|
47
|
+
if (slotOfFile(context) === "fe.route" && roleOfFile(context) !== null) return roleOfFile(context)
|
|
48
|
+
if (slotOfFile(context) === "fe.feature" && kindOfFile(context) === "pages") return "page owner"
|
|
49
|
+
if (slotOfFile(context) === "fe.feature" && kindOfFile(context) === "layouts") return "layout owner"
|
|
53
50
|
return null
|
|
54
51
|
}
|
|
55
52
|
|
|
@@ -67,9 +64,8 @@ export const useClientOnlyAtBoundary = {
|
|
|
67
64
|
},
|
|
68
65
|
},
|
|
69
66
|
create(context) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const slot = slotOf(filename)
|
|
67
|
+
if (isSpecFile(fileOf(context)) || isBoundary(context)) return {}
|
|
68
|
+
const slot = routeFill(context)
|
|
73
69
|
return {
|
|
74
70
|
Program(program) {
|
|
75
71
|
const directive = clientDirective(program)
|
|
@@ -86,13 +82,22 @@ export const useClientOnlyAtBoundary = {
|
|
|
86
82
|
/** Node built-ins a browser bundle cannot carry. */
|
|
87
83
|
const NODE_BUILTINS = /^(?:node:.+|fs|fs\/promises|path|os|net|tls|http|https|http2|child_process|cluster|crypto|stream|zlib|dns|worker_threads)$/
|
|
88
84
|
|
|
89
|
-
/** A server
|
|
90
|
-
const
|
|
91
|
-
if (source === "server-only") return "`server-only`"
|
|
85
|
+
/** A server API: the request-scoped Next APIs, the server half of next-intl, a Node built-in; null for anything else. */
|
|
86
|
+
const serverApiReason = (source) => {
|
|
92
87
|
if (source === "next/headers") return "`next/headers`"
|
|
93
88
|
if (source === "next/server") return "`next/server`"
|
|
89
|
+
if (source === "next-intl/server") return "`next-intl/server`"
|
|
94
90
|
if (NODE_BUILTINS.test(source)) return `\`${source}\``
|
|
95
|
-
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A server-only source: the marker package, a server API, a server reader (role `reader` of slot `fe.modules.api`). */
|
|
95
|
+
const serverOnlyReason = (context, source) => {
|
|
96
|
+
if (source === "server-only") return "`server-only`"
|
|
97
|
+
const api = serverApiReason(source)
|
|
98
|
+
if (api) return api
|
|
99
|
+
const target = classifyImport(context, source)
|
|
100
|
+
if (target?.slot === "fe.modules.api" && target.role === "reader") return `the server reader \`${source}\``
|
|
96
101
|
return null
|
|
97
102
|
}
|
|
98
103
|
|
|
@@ -108,8 +113,7 @@ export const clientNoServerImport = {
|
|
|
108
113
|
},
|
|
109
114
|
},
|
|
110
115
|
create(context) {
|
|
111
|
-
|
|
112
|
-
if (isSpecFile(file)) return {}
|
|
116
|
+
if (isSpecFile(fileOf(context))) return {}
|
|
113
117
|
let client = false
|
|
114
118
|
return {
|
|
115
119
|
Program(program) {
|
|
@@ -117,13 +121,96 @@ export const clientNoServerImport = {
|
|
|
117
121
|
},
|
|
118
122
|
ImportDeclaration(node) {
|
|
119
123
|
if (!client || node.importKind === "type") return
|
|
120
|
-
const what = serverOnlyReason(String(node.source.value))
|
|
124
|
+
const what = serverOnlyReason(context, String(node.source.value))
|
|
121
125
|
if (what) context.report({ node, messageId: "server", data: { what } })
|
|
122
126
|
},
|
|
123
127
|
}
|
|
124
128
|
},
|
|
125
129
|
}
|
|
126
130
|
|
|
131
|
+
// -- FE-CLIENT-2b ----------------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/** The slots whose files are Next route files: server components by construction, so the framework decides, not a marker. */
|
|
134
|
+
const ROUTE_FILE_SLOTS = new Set(["fe.route", "fe.source-root-pinned"])
|
|
135
|
+
|
|
136
|
+
/** True when a statement is `import "server-only"`: an import with no bindings from the marker package. */
|
|
137
|
+
const isMarkerStatement = (statement) =>
|
|
138
|
+
statement?.type === "ImportDeclaration" && statement.specifiers.length === 0 && statement.source.value === "server-only"
|
|
139
|
+
|
|
140
|
+
/** True when a TypeScript source file opens with `import "server-only"`. */
|
|
141
|
+
const tsFileIsMarked = (file) => {
|
|
142
|
+
const first = file.statements[0]
|
|
143
|
+
return Boolean(first) && ts.isImportDeclaration(first) && !first.importClause && ts.isStringLiteral(first.moduleSpecifier) && first.moduleSpecifier.text === "server-only"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The source file an import specifier resolves to: the checker's module symbol, so path aliases, extensions and index
|
|
148
|
+
* files resolve as the compiler resolves them. Null for a declaration file, a package or an unresolved specifier.
|
|
149
|
+
*/
|
|
150
|
+
const resolvedSourceFile = (context, specifier) => {
|
|
151
|
+
const { checker, toTs } = typed(context)
|
|
152
|
+
const tsNode = toTs(specifier)
|
|
153
|
+
if (!tsNode) return null
|
|
154
|
+
const symbol = checker.getSymbolAtLocation(tsNode)
|
|
155
|
+
const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]
|
|
156
|
+
return declaration && ts.isSourceFile(declaration) && !declaration.isDeclarationFile ? declaration : null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A module that uses a server-only API, or imports a module that is itself server-only, opens with `import "server-only"`. */
|
|
160
|
+
/** Component tiers: a component is judged by where client code reaches it, not by a marker. */
|
|
161
|
+
const COMPONENT_TIERS = new Set(["components", "feature"])
|
|
162
|
+
|
|
163
|
+
export const serverModuleMarksServerOnly = {
|
|
164
|
+
meta: {
|
|
165
|
+
type: "problem",
|
|
166
|
+
docs: { description: "A module importing `next/headers`, `next/server`, `next-intl/server`, a Node built-in or a server-only module starts with `import \"server-only\"`." },
|
|
167
|
+
schema: [],
|
|
168
|
+
messages: {
|
|
169
|
+
mark:
|
|
170
|
+
"This module imports {{what}}, which exists only on the server, but it does not start with `import \"server-only\"`. Without the marker nothing stops a client component from importing this module (directly or through a hook): the build then fails far from the cause, or ships server code toward the browser. Make `import \"server-only\"` the first statement of the file. Route files (`page`, `layout`, `route`, `proxy`) are server components by construction and need no marker.",
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
create(context) {
|
|
174
|
+
if (isSpecFile(fileOf(context))) return {}
|
|
175
|
+
const slot = slotOfFile(context)
|
|
176
|
+
if (!slot || ROUTE_FILE_SLOTS.has(slot)) return {}
|
|
177
|
+
// A block or a page is a server or client COMPONENT, not a server module: whether client code reaches the server through it is
|
|
178
|
+
// the machine's multi-hop question (FE_CLIENT_REACHES_SERVER). The marker belongs to the modules that hold server work.
|
|
179
|
+
if (COMPONENT_TIERS.has(tierOfFile(context))) return {}
|
|
180
|
+
const reader = roleOfFile(context) === "reader"
|
|
181
|
+
let client = false
|
|
182
|
+
let marked = false
|
|
183
|
+
const uses = []
|
|
184
|
+
return {
|
|
185
|
+
Program(program) {
|
|
186
|
+
client = clientDirective(program) !== null
|
|
187
|
+
marked = isMarkerStatement(program.body[0])
|
|
188
|
+
},
|
|
189
|
+
"ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration"(node) {
|
|
190
|
+
if (!node.source || node.importKind === "type" || node.exportKind === "type") return
|
|
191
|
+
const onlyTypes = node.specifiers?.length > 0 && node.specifiers.every((specifier) => specifier.importKind === "type" || specifier.exportKind === "type")
|
|
192
|
+
if (onlyTypes) return
|
|
193
|
+
const specifier = String(node.source.value)
|
|
194
|
+
if (specifier === "server-only") return
|
|
195
|
+
const api = serverApiReason(specifier)
|
|
196
|
+
if (api) {
|
|
197
|
+
uses.push({ node, what: api })
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
const target = resolvedSourceFile(context, node.source)
|
|
201
|
+
if (target && tsFileIsMarked(target)) uses.push({ node, what: `\`${specifier}\` (a module that is itself server-only)` })
|
|
202
|
+
},
|
|
203
|
+
"Program:exit"() {
|
|
204
|
+
// a "use client" file that imports the server is client-no-server-import's finding, not a missing marker
|
|
205
|
+
if (client || marked) return
|
|
206
|
+
// A server reader (`read-*.ts`, role `reader` of the api slot) is server-only by its role, whatever it imports.
|
|
207
|
+
if (reader && uses.length === 0) context.report({ node: context.sourceCode.ast, messageId: "mark", data: { what: "the request (it is a server reader)" } })
|
|
208
|
+
for (const use of uses) context.report({ node: use.node, messageId: "mark", data: { what: use.what } })
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
127
214
|
// -- FE-CLIENT-3 -----------------------------------------------------------------------------------
|
|
128
215
|
|
|
129
216
|
/** Web storage exists only in the browser and only under `modules/`, which guards it. */
|
|
@@ -141,8 +228,8 @@ export const webStorageOnlyInModules = {
|
|
|
141
228
|
},
|
|
142
229
|
},
|
|
143
230
|
create(context) {
|
|
144
|
-
|
|
145
|
-
if (
|
|
231
|
+
// Product source outside the module tiers (foundation modules, the other modules, the api transport), which are where storage is guarded.
|
|
232
|
+
if (!isProductSource(context) || ["foundation", "modules", "transport"].includes(tierOfFile(context))) return {}
|
|
146
233
|
return {
|
|
147
234
|
MemberExpression(node) {
|
|
148
235
|
if (node.computed || node.property.type !== "Identifier" || !STORAGE.has(node.property.name)) return
|
|
@@ -191,6 +278,7 @@ export const noDangerousHtml = {
|
|
|
191
278
|
export const rules = {
|
|
192
279
|
"use-client-only-at-boundary": useClientOnlyAtBoundary,
|
|
193
280
|
"client-no-server-import": clientNoServerImport,
|
|
281
|
+
"server-module-marks-server-only": serverModuleMarksServerOnly,
|
|
194
282
|
"web-storage-only-in-modules": webStorageOnlyInModules,
|
|
195
283
|
"no-dangerous-html": noDangerousHtml,
|
|
196
284
|
}
|
package/comments.mjs
CHANGED
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
* pragma: user-facing text comes from a `next-intl` catalogue, and the catalogue is the only place
|
|
12
12
|
* a second language is content.
|
|
13
13
|
*
|
|
14
|
-
* The exceptions that remain are
|
|
14
|
+
* The exceptions that remain are placements, not judgements, and that is on purpose: a locale dictionary
|
|
15
15
|
* IS the other language, and a fixture reproducing a real string has to reproduce it exactly. A
|
|
16
16
|
* judgement-based exception would be argued per file forever.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { fileOf, inSlot, isSpecFile } from "./lib/scope.mjs"
|
|
20
20
|
|
|
21
21
|
/** The letters that mark the second language a source string must not carry outside a catalogue. */
|
|
22
22
|
export const SECOND_LANGUAGE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìíò-õùúýĂăĐđĨĩŨũƠơƯưẠ-ỿ]/
|
|
@@ -33,26 +33,19 @@ export const hasEmoji = (text) =>
|
|
|
33
33
|
(/\p{Extended_Pictographic}/u.test(text) || /[\u{1F1E6}-\u{1F1FF}]{2}/u.test(text))
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
36
|
+
* Files whose second-language text or emoji is CONTENT rather than authoring, decided by what the file IS.
|
|
37
37
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* copy that skipped the catalogue.
|
|
38
|
+
* Three kinds only: the locale dictionaries (a `.json` file of an i18n slot, `fe.modules.i18n` or `fe.package.i18n`),
|
|
39
|
+
* which are the product's other language; the e2e support tree (slot `fe.e2e-support`: fixtures and support),
|
|
40
|
+
* and specs, which reproduce real strings and would be testing something else if they translated them. There is no
|
|
41
|
+
* "copy module": a `resources/` folder of strings is copy that skipped the catalogue.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} context - The ESLint rule context.
|
|
44
|
+
* @returns {boolean} True when the linted file holds content rather than authoring.
|
|
42
45
|
*/
|
|
43
|
-
export const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/\/fixtures?\//,
|
|
47
|
-
/\.fixture\.(ts|tsx|js|mjs|cjs)$/i,
|
|
48
|
-
/\.test\.(ts|tsx|js|mjs|cjs)$/i,
|
|
49
|
-
/\.spec\.(ts|tsx|js|mjs|cjs)$/i,
|
|
50
|
-
]
|
|
51
|
-
|
|
52
|
-
/** True when a file holds content rather than authoring. */
|
|
53
|
-
export const isContentFile = (filename) => {
|
|
54
|
-
const file = normalizePath(filename)
|
|
55
|
-
return CONTENT_PATHS.some((pattern) => pattern.test(file))
|
|
46
|
+
export const isContentFile = (context) => {
|
|
47
|
+
const file = fileOf(context)
|
|
48
|
+
return isSpecFile(file) || inSlot(context, "fe.e2e-support") || (inSlot(context, "fe.modules.i18n", "fe.package.i18n") && file.endsWith(".json"))
|
|
56
49
|
}
|
|
57
50
|
|
|
58
51
|
/** Walk every place prose can hide, and hand each to one check. */
|
|
@@ -121,7 +114,7 @@ export const noEmojiInSource = {
|
|
|
121
114
|
},
|
|
122
115
|
},
|
|
123
116
|
create(context) {
|
|
124
|
-
if (isContentFile(context
|
|
117
|
+
if (isContentFile(context)) return {}
|
|
125
118
|
return proseVisitors(context, (node, text) => {
|
|
126
119
|
if (hasEmoji(text)) context.report({ node, messageId: "emoji" })
|
|
127
120
|
})
|
package/docs/client-boundary.md
CHANGED
|
@@ -32,7 +32,7 @@ export const Feed = () => null
|
|
|
32
32
|
|
|
33
33
|
## `starci-fe/client-no-server-import`
|
|
34
34
|
|
|
35
|
-
A `"use client"` file does not import `server-only`, `next/headers`, a Node built-in or a server reader (sub-check `FE_CLIENT_SERVER_IMPORT`).
|
|
35
|
+
A `"use client"` file does not import `server-only`, `next/headers`, `next/server`, `next-intl/server`, a Node built-in or a server reader (sub-check `FE_CLIENT_SERVER_IMPORT`).
|
|
36
36
|
|
|
37
37
|
**Invalid** (`src/components/blocks/Feed/index.tsx`)
|
|
38
38
|
|
|
@@ -54,6 +54,31 @@ import useSWR from "swr"
|
|
|
54
54
|
|
|
55
55
|
**Cách sửa:** Đọc dữ liệu ở server component hoặc server reader rồi truyền xuống bằng props; hoặc dùng SWR gọi client của app.
|
|
56
56
|
|
|
57
|
+
## `starci-fe/server-module-marks-server-only`
|
|
58
|
+
|
|
59
|
+
A module that imports `next/headers`, `next/server` (a value import), `next-intl/server`, a Node built-in, or a module that is itself server-only starts with `import "server-only"` (sub-check `FE_SERVER_ONLY_MARK`). A module is server-only when the TypeScript module resolution of the import specifier reaches a source file whose first statement is `import "server-only"`; `import type` and `import { type X }` carry no runtime and are not judged. Route files are server components by construction and need no marker: they are recognized by their slot (`fe.route`, `fe.source-root-pinned`), not by their name. A `"use client"` file that imports the server is `client-no-server-import`'s finding. The multi-hop client reachability (a client block reaching a marked module through a hook) is the architecture machine's.
|
|
60
|
+
|
|
61
|
+
**Invalid** (`src/modules/api/courses/read-courses.ts`)
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { headers } from "next/headers"
|
|
65
|
+
export const readCourses = async () => (await headers()).get("x-locale")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Valid** (`src/modules/api/courses/read-courses.ts`)
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import "server-only"
|
|
72
|
+
import { headers } from "next/headers"
|
|
73
|
+
export const readCourses = async () => (await headers()).get("x-locale")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Finding code:** `FE_SERVER_ONLY_MARK`
|
|
77
|
+
|
|
78
|
+
**Vì sao (why):** Module `<file>` nhập API chỉ có ở server (`next/headers`, `next/server`, `next-intl/server`, module Node hoặc module đã là server-only) nhưng không mở đầu bằng `import "server-only"`.
|
|
79
|
+
|
|
80
|
+
**Cách sửa:** Đặt `import "server-only"` làm câu lệnh đầu tiên của tệp; tệp route (`page`, `layout`, `route`, `proxy`) là server component sẵn nên không cần.
|
|
81
|
+
|
|
57
82
|
## `starci-fe/web-storage-only-in-modules`
|
|
58
83
|
|
|
59
84
|
`localStorage` and `sessionStorage` are touched only inside `modules/` (sub-check `FE_STORAGE_OUTSIDE_MODULES`).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Grammar boundary
|
|
2
|
+
|
|
3
|
+
Law module: `grammar-boundary.mjs`. Catalogue: R62 FE_NATIVE_FORM_CONTROL (the grammar owns the element).
|
|
4
|
+
|
|
5
|
+
Page structure and text are composed from `@starci/grammar` (or the repository's `<family>-ui` package), not drawn as raw HTML.
|
|
6
|
+
|
|
7
|
+
Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
|
|
8
|
+
|
|
9
|
+
## `starci-fe/no-raw-structural-element`
|
|
10
|
+
|
|
11
|
+
Page structure and text are grammar components, not raw `div`-family HTML; only `fe.package.ui` renderers draw raw tags.
|
|
12
|
+
|
|
13
|
+
Applies to app source that composes the grammar (slots `fe.feature`, `fe.components`, `fe.route`, `fe.hooks`) and to every workspace package except the `<family>-ui` package (`fe.package.ui`), whose components are the renderers. Spec files render raw HTML as a harness and are not judged; a file no slot owns is not judged.
|
|
14
|
+
|
|
15
|
+
The refused tags are exactly those `@starci/grammar` has a component for (the twin test reads the package source): `h1-h6` (`Heading`), `p` and `span` (`Text`), `hr` (`Divider`), `label` (`Label`), `form` (`Form`), `fieldset` (`Fieldset`), `dl` `dt` `dd` (`DescriptionList`), `table` `thead` `tbody` `tr` `td` `th` (`DataTable`), `ul` `ol` `li` (`SurfaceListCard`, `StaticStateRow`), `header` (`SectionHeader`, `TopBar`), `footer` (`Footer`), `nav` (`Subnav`, `Breadcrumbs`, `NavigationFeatureNav`), `section` (`SurfaceCard`), `main` (`WorkspaceShell`), `figure` and `figcaption` (`MediaFrame`). `div`, `br`, `aside` and `article` are not refused: the grammar has no component for them.
|
|
16
|
+
|
|
17
|
+
**Invalid** (`src/features/pages/home/component.tsx`)
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
export const Home = () => <section><p>{t("intro")}</p></section>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Valid** (`src/features/pages/home/component.tsx`)
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
export const Home = () => <SurfaceCard><Text>{t("intro")}</Text></SurfaceCard>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Finding code:** `FE_NATIVE_FORM_CONTROL`
|
|
30
|
+
|
|
31
|
+
**Vì sao (why):** `<tag>` thô ở `<file>`. Cấu trúc trang và chữ được ghép từ component của grammar, không viết HTML thô.
|
|
32
|
+
|
|
33
|
+
**Cách sửa:** Thay bằng component grammar tương ứng (`Heading`, `Text`, `SurfaceCard`, ...); nếu grammar chưa có, thêm vào grammar hoặc gói ui thay vì vẽ tại chỗ.
|