@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +86 -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 +104 -36
  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/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`: `timer-needs-effect-cleanup` (`FE_EFFECT_CLEANUP`), `no-data-fetch-in-effect` (`FE_EFFECT_FETCH`), `no-empty-catch` (`FE_SWALLOWED_ERROR`), `no-console` (`FE_CONSOLE_CALL`).
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
- ```js
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
- export default [...starciFeConfig({ layout: "single-app" })]
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
- The factory returns two flat-config blocks and owns everything about the law: which rules are on
18
- (every one, at `error` - nothing is off and nothing is a warning), the React Hooks rules
19
- (`eslint-plugin-react-hooks` 7, its recommended set lifted to `error`, including
20
- `set-state-in-effect` and `refs`), and the inline-directive fence (`noInlineConfig` plus
21
- `reportUnusedDisableDirectives`). A repository names its layout and nothing else.
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 **source** block governs `src/**` (`single-app`) or `packages/*/src/**` (every workspace package, not one named `ui`) and `apps/*/src/**`
24
- (`monorepo`). A package keeps its grammar tiers at `src/{composites,branches,leaves}`; the client-boundary rule reads that layout as well as an app's `src/components/`.
25
- - The **e2e** block governs `e2e/**` and `playwright.config.*` with the e2e rules and the
26
- escape-hatch fence.
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`, `audits` and the plugin itself are also
29
- exported. `sourceRecommended` is the map to compare against a production probe file with
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). `timer-needs-effect-cleanup` also accepts a timer whose enclosing function returns a cleanup clearing the same handle (5.1.2). 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.
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
- const filename = context.filename || context.getFilename()
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 { normalizePath } from "./lib/path.mjs"
7
- const isComponentSource = (filename) => normalizePath(filename).includes("/src/components/")
8
- const isClassNamesFile = (filename) => /\/classNames\.tsx?$/.test(normalizePath(filename))
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 (!isComponentSource(context.filename || context.getFilename())) return {}
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
- const filename = context.filename || context.getFilename()
106
- if (!isComponentSource(filename)) return {}
106
+ if (!isComponentFile(context)) return {}
107
+ const styles = isClassNamesFile(context)
107
108
  return {
108
109
  CallExpression(node) {
109
- if (!isCn(node.callee) || isClassNamesFile(filename)) return
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
- const filename = context.filename || context.getFilename()
130
- if (!isClassNamesFile(filename)) return {}
130
+ if (!isClassNamesFile(context)) return {}
131
131
  return {
132
132
  CallExpression(node) {
133
133
  if (!isCn(node.callee)) return
@@ -15,11 +15,12 @@
15
15
  * above it is a server component and must not have one.
16
16
  */
17
17
 
18
- import { PACKAGE_TIERS, ROUTE_SLOTS, isSpecFile, stem } from "./lib/scope.mjs"
19
- import { normalizePath } from "./lib/path.mjs"
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 client-only files. */
22
- const FRAMEWORK_CLIENT = new Set(["error", "global-error"])
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
- * The component tiers of a workspace package sit directly under `src/` (grammar tier names,
32
- * `slots.yaml` `fe.package.ui`: composites, branches, leaves), with no `components/` folder above
33
- * them. A package has no data layer, so its interactive tiers, branches and leaves, are where the
34
- * directive is legitimate; a composite is presentation and stays a server-safe component.
35
- */
36
- const PACKAGE_CLIENT_TIERS = PACKAGE_TIERS.filter((tier) => tier !== "composites")
37
- const PACKAGE_TIER = new RegExp(`/packages/[^/]+/src/(?:${PACKAGE_CLIENT_TIERS.join("|")})/`)
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 = (file) =>
41
- FRAMEWORK_CLIENT.has(stem(file)) ||
42
- (/\/components\/blocks\//.test(file) && stem(file) === "index") ||
43
- (/\/features\/overlays\//.test(file) && stem(file) === "index") ||
44
- /\/components\/leaves\//.test(file) ||
45
- PACKAGE_TIER.test(file)
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
- const filename = normalizePath(context.filename || context.getFilename())
71
- if (isSpecFile(filename) || isBoundary(filename)) return {}
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-only source: the marker package, the request-scoped Next APIs, a Node built-in, a server reader. */
90
- const serverOnlyReason = (source) => {
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
- if (/(?:^|\/)modules\/api\/(?:.+\/)?read-[^/]+$/.test(source)) return `the server reader \`${source}\``
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
- const file = normalizePath(context.filename || context.getFilename())
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
- const file = normalizePath(context.filename || context.getFilename())
145
- if (isSpecFile(file) || !file.includes("/src/") || /\/modules\//.test(file)) return {}
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 paths, not judgements, and that is on purpose: a locale dictionary
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 { normalizePath } from "./lib/path.mjs"
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
- * Paths whose second-language text or emoji is CONTENT rather than authoring.
36
+ * Files whose second-language text or emoji is CONTENT rather than authoring, decided by what the file IS.
37
37
  *
38
- * Two kinds only: the locale dictionaries (`messages/<locale>.json`), which are the product's other
39
- * language, and fixtures and specs, which reproduce real strings and would be testing something else
40
- * if they translated them. There is no "copy module" path: a `resources/` folder of strings is
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 CONTENT_PATHS = [
44
- /\/messages\/[a-z-]+\.json$/i,
45
- /\/__fixtures?__\//,
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.filename || context.getFilename())) return {}
117
+ if (isContentFile(context)) return {}
125
118
  return proseVisitors(context, (node, text) => {
126
119
  if (hasEmoji(text)) context.report({ node, messageId: "emoji" })
127
120
  })
@@ -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ỗ.