@starci/eslint-canon-fe 4.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +111 -0
- package/README.md +33 -16
- package/brand-values.mjs +172 -0
- package/class-names.mjs +9 -9
- package/client-boundary.mjs +287 -0
- package/comments.mjs +137 -190
- package/docs/brand-values.md +30 -0
- package/docs/client-boundary.md +124 -0
- package/docs/e2e-shape.md +161 -0
- package/docs/env-owner.md +52 -0
- package/docs/formatting.md +30 -0
- package/docs/grammar-boundary.md +33 -0
- package/docs/hooks-folder.md +29 -0
- package/docs/hygiene.md +122 -0
- package/docs/lint-escape-hatch.md +34 -0
- package/docs/lists.md +74 -0
- package/docs/native-controls.md +73 -0
- package/docs/next-conventions.md +232 -0
- package/docs/size-and-state-budget.md +73 -0
- package/docs/size-growth.md +29 -0
- package/docs/spec-quality.md +147 -0
- package/docs/status-colors.md +32 -0
- package/docs/translation.md +54 -0
- package/docs/transport.md +195 -0
- package/docs/type-safety.md +95 -0
- package/e2e-shape.mjs +345 -0
- package/env-owner.mjs +134 -0
- package/file-layout.mjs +105 -104
- package/formatting.mjs +104 -0
- package/grammar-boundary.mjs +85 -9
- package/hooks-folder.mjs +120 -0
- package/hygiene.mjs +513 -0
- package/icon.mjs +212 -212
- package/index.mjs +243 -142
- package/landmark.mjs +0 -11
- package/lib/ast.mjs +94 -0
- 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/path.mjs +2 -0
- package/lib/platform.mjs +66 -0
- package/lib/react.mjs +28 -0
- package/lib/scope.mjs +142 -0
- package/lib/status-tones.generated.mjs +9 -0
- package/lib/types.mjs +63 -0
- package/lib/why.mjs +334 -0
- package/lint-escape-hatch.mjs +93 -56
- package/lists.mjs +197 -0
- package/loading.mjs +156 -164
- package/naming.mjs +149 -143
- package/native-controls.mjs +112 -0
- package/next-conventions.mjs +430 -0
- package/package.json +15 -6
- 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 +174 -160
- package/shape-slot.mjs +22 -21
- package/size-and-state-budget.mjs +214 -0
- package/size-growth.mjs +58 -0
- package/spec-quality.mjs +318 -0
- package/status-colors.mjs +139 -0
- package/the-split.mjs +133 -134
- package/tokens.mjs +259 -268
- package/translation.mjs +295 -122
- package/transport.mjs +752 -0
- package/type-safety.mjs +147 -74
- package/typography.mjs +78 -83
- package/vendor-boundary.mjs +5 -27
- package/lint-adoption.mjs +0 -116
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 6.0.0 - 2026-09-30
|
|
4
|
+
|
|
5
|
+
- **Census fixes (starci-next-fe main, lane F0):** `one-outcome-union` treats a `kind` union as a result union only with an `ok`
|
|
6
|
+
arm beside a failure kind (a mapper's view union - `LessonViewerView`, `StudyStepView` - is a screen state);
|
|
7
|
+
`client-maps-auth-to-refused` accepts the Outcome constructor named by its kind (`failed("refused", ...)`) and a status branch
|
|
8
|
+
inside a helper whose parameter every call fills with the response status;
|
|
9
|
+
`i18n-stack-in-one-module` lets the proxy of a one-app repository build the locale middleware; `server-module-marks-server-only`
|
|
10
|
+
requires the marker of server modules and of every server reader (role `reader`), not of components (reachability is
|
|
11
|
+
FE_CLIENT_REACHES_SERVER's); `route-tree-holds-routes-only` admits next-intl's root `not-found.tsx` and its root `layout.tsx`.
|
|
12
|
+
|
|
13
|
+
- **Every law scopes by slot, never by folder regex (lane F0-H).** The path predicates of `class-names`, `props-and-slots`,
|
|
14
|
+
`vendor-boundary`, `loading`, `type-safety`, `typography`, `brand-values`, `formatting`, `comments`, `hygiene`, `translation`,
|
|
15
|
+
`lint-escape-hatch`, `client-boundary`, `file-layout`, `shape-slot`, `the-split`, `landmark`, `icon`, `served-locale`, `e2e-shape`,
|
|
16
|
+
`hooks-folder`, `naming`, `next-conventions`, `tokens`, `size-and-state-budget`, `spec-quality` and `transport` ask the slot view
|
|
17
|
+
(`kind`, `role`, tier, owner, `hfs.allows(file)`, `classifyImport`) instead of testing a path. Shared-package layers (`fe.package.ui`)
|
|
18
|
+
are governed like app components; a file no slot owns is judged by `hfs check` (`HFS_SLOT_UNDECLARED`), not by a folder-name rule.
|
|
19
|
+
Line and hook budgets (`component.tsx`, `index.tsx`, `file`, `useState`, `dataHooks`) are read from the slot manifest.
|
|
20
|
+
`navigation-from-intl` exempts only the `proxy` and `global-error` roles; `no-middleware-file` reads slot `fe.source-root-retired`;
|
|
21
|
+
`route-tree-holds-routes-only` reads the `allows` of `fe.route`; `no-unresolved-token-class` reads the app's route and brand stylesheets.
|
|
22
|
+
|
|
23
|
+
- New law `status-colors` (R61 `FE_STYLE_TOKEN_ONLY`): `status-text-uses-soft-foreground` refuses a class that paints text, an icon or a
|
|
24
|
+
text decoration with a solid status tone (`text-success`, `fill-danger`, `stroke-warning`, `decoration-info`, with any variant, opacity or
|
|
25
|
+
`!`), read from `className`/`class`, `cn()`/`clsx()` arguments, conditionals, arrays, constants and `classes` entries. The soft pair
|
|
26
|
+
(`text-<tone>-soft-foreground`, `bg-<tone>-soft`), a solid fill with its own ink (`bg-<tone> text-<tone>-foreground`) and solid borders,
|
|
27
|
+
rings and outlines pass. The tones come from `lib/status-tones.generated.mjs`, generated from the grammar by
|
|
28
|
+
`packages/stylelint` (`npm run vocabulary`), never listed in the rule.
|
|
29
|
+
|
|
30
|
+
- **Breaking: `starciFeConfig({ hfs: loadHfs(import.meta.url) })` replaces `starciFeConfig({ layout })`.** The factory reads the
|
|
31
|
+
repository's `hfs.json` and the shipped slot manifest (`runtime/`, refreshed by `packages/hfs/scripts/sync-runtime.mjs`), lints
|
|
32
|
+
every app's and every workspace package's `src/` with typed linting (`parserOptions.projectService`) and the e2e tree
|
|
33
|
+
syntax-only, ignores build output, `__generated__/` and `.starci/`, and puts the slot view in `settings.starci.hfs`
|
|
34
|
+
(`lib/hfs.mjs` `hfsOf(context)`); `lib/types.mjs` holds the type-aware helpers. `LAYOUTS`/`LAYOUT_GLOBS` are deleted.
|
|
35
|
+
- **Breaking: the `lint-adoption` law and its `effective-config` audit are deleted**, with `audits`/`auditOwners`: the
|
|
36
|
+
managed `eslint.config.mjs` one-liner that `hfs check` compares with its render (R17) is the one proof that no rule is off.
|
|
37
|
+
- `linterOptions` lives in `lib/config.mjs` only (the copy in `lint-escape-hatch.mjs` is gone).
|
|
38
|
+
- New peer dependency `typescript >=5.9`; `@typescript-eslint/parser` is a dependency. Tests build typed cases with
|
|
39
|
+
`fixtures/typed/tester.mjs` (`typedTester`, `slotTester`, `at`).
|
|
40
|
+
|
|
41
|
+
- **Breaking: `no-direct-heroicon-import` is deleted.** It was a second name for a second copy of
|
|
42
|
+
`no-vendor-icon-outside-icon-leaf` (icon law); one obligation has one rule (redundancy RED20). The copy in
|
|
43
|
+
`vendor-boundary.mjs` is gone; `no-vendor-icon-outside-icon-leaf` is unchanged.
|
|
44
|
+
- 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.
|
|
45
|
+
- **Breaking: `no-internal-starci-href` is deleted.** It was a second, weaker copy of `no-hardcoded-route` (which already refuses a
|
|
46
|
+
route literal in any JSX `href` and any navigation call, specs excluded); one obligation has one rule. `no-hardcoded-route` gained a
|
|
47
|
+
passing case for `mailto:` on `<a>` and a violating case for `<a href="/tasks">`. R93 no longer lists it.
|
|
48
|
+
- **Fix: `no-direct-const-alias` fired on `export const generateMetadata = x` in a Next route segment file.** The names Next reserves
|
|
49
|
+
(`generateMetadata`, `metadata`, `viewport`, `dynamic`, ... in `lib/next.mjs`, the list of the architecture machine's
|
|
50
|
+
`NEXT_RESERVED_EXPORTS`) are exempt when the file is a route segment file (slot `fe.route`, stem `page`/`layout`/`template`/...);
|
|
51
|
+
a non-reserved alias in a route file and a reserved name elsewhere still fire. The rule now needs the slot view (`hfsOf`).
|
|
52
|
+
- **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
|
|
53
|
+
sibling holds nothing but import and export declarations (a barrel); an `index.ts` with a function, class or value (starci-next-fe
|
|
54
|
+
`modules/browser-storage`) may have its spec, and a missing sibling is no finding. It also covers `.tsx`.
|
|
55
|
+
- **Fix: `no-mocked-translations` refused a `next-intl/server` mock that serves the real catalogue.** A server helper has no provider to
|
|
56
|
+
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
|
|
57
|
+
`modules/i18n/messages/`. Client `next-intl` mocks, literal/key-echoing server mocks and automocks still fire, now with a
|
|
58
|
+
server-specific message (`mockedServer`) naming `createTranslator` over the real messages. The rule needs the slot view (`hfsOf`).
|
|
59
|
+
- **Fix: `no-hand-typed-wire` fired on `(await response.json()) as unknown`, the narrowing entry `no-type-assertion` allows.** It now
|
|
60
|
+
fires only when a value read from `.json()` (directly or through a `const`) is asserted or annotated to a type that is not `unknown`
|
|
61
|
+
and not imported from a `__generated__/` module (decided from the file's import declarations). **Breaking:** the `declared` branch
|
|
62
|
+
(a name suffix `Wire|Dto|DTO|Response|Payload` on an interface or alias in `modules/api`) and the `graphqlType` branch (a name
|
|
63
|
+
pattern `GraphQl*`) are deleted; detection by name is not a detection.
|
|
64
|
+
- Every rule is catalogued under an HFS rule id (R91 FE_SOURCE_FORM, R92 FE_COMPONENT_API, R93 FE_VENDOR_BOUNDARY added).
|
|
65
|
+
- **One client, one Outcome, one i18n stack, grammar components (lane F0-D).**
|
|
66
|
+
- `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.
|
|
67
|
+
- `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).
|
|
68
|
+
- `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.
|
|
69
|
+
- 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.
|
|
70
|
+
- 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.
|
|
71
|
+
- 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.
|
|
72
|
+
|
|
73
|
+
- **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.
|
|
74
|
+
- **`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.
|
|
75
|
+
- **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`.
|
|
76
|
+
- **`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.
|
|
77
|
+
- **`no-inline-lint-config` (R18)** also refuses `NOSONAR`, `@sonar-ignore`, `istanbul ignore`, `c8 ignore`, `v8 ignore`, `prettier-ignore` and `stylelint-disable` comments.
|
|
78
|
+
- New `lib/bindings.mjs` (scope bindings, imports, stable keys), `lib/platform.mjs` (platform symbols) and `lib/react.mjs` (React exports by import).
|
|
79
|
+
|
|
80
|
+
## 5.1.2 - 2026-09-30
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
## 5.1.1 - 2026-09-30
|
|
84
|
+
|
|
85
|
+
- **Fix: `use-client-only-at-boundary` knew only the app layout.** A workspace package keeps its grammar tiers directly under `src/` (`packages/<pkg>/src/{composites,branches,leaves}`, the `fe.package.ui` slot), but the rule accepted the directive only under `/components/...`, so nivo-fe's `packages/nivo-ui` reported 13 false findings. The tier names come from `PACKAGE_TIERS` in `lib/scope.mjs` (the slot's layers, no repo name); a package branch or leaf is a boundary, a package composite is not, and the app layout is unchanged.
|
|
86
|
+
- **Fix: the `monorepo` layout scanned only `packages/ui`.** `LAYOUT_GLOBS.monorepo` named the shared package literally (`packages/ui/src/**`), so a workspace package with any other name (nivo-fe's `packages/nivo-ui`) was governed by no starci-fe rule and a deliberate bare `<img>` there printed nothing. The source glob is now `packages/*/src/**/*.{ts,tsx}` and the e2e glob adds `packages/*/e2e/**/*.{ts,tsx}`; every package is judged by the same rules.
|
|
87
|
+
|
|
88
|
+
## 5.1.0 - 2026-09-29
|
|
89
|
+
|
|
90
|
+
Round 2 of the FE enforcers, measured on nivo-fe, starci-next-fe and miamia-fe. Twenty-two rules, every one an error; no rule of 5.0.0 changed. Each is registered in `knowledge/hfs/rules.yaml` under the catalogue rule it enforces, with a Vietnamese failure code.
|
|
91
|
+
|
|
92
|
+
- **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.
|
|
93
|
+
- **Lists (R65, `FE_LIST_KEY`)**, new law `lists`: `list-item-has-key`, `no-index-key`, `no-inline-literal-prop-in-list`.
|
|
94
|
+
- **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`).
|
|
95
|
+
- **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).
|
|
96
|
+
- **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`).
|
|
97
|
+
- **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`).
|
|
98
|
+
- **Native controls (R62, `FE_NATIVE_IMAGE`)**: `no-native-img`, `image-has-size`.
|
|
99
|
+
- **Transport (R51, `FE_OUTCOME_KIND_UNHANDLED`)**: `outcome-kinds-exhaustive`.
|
|
100
|
+
- `lib/ast.mjs` holds the JSX attribute, call-name and effect-callback readers the new laws share. One doc page per new law; type-safety gained its page.
|
|
101
|
+
|
|
102
|
+
## 5.0.0 - 2026-09-29
|
|
103
|
+
|
|
104
|
+
HFS Phase 0 item 0.6. Breaking: the factory signature, the retired rules and the level of every rule.
|
|
105
|
+
|
|
106
|
+
- **`starciFeConfig({ layout })`** returns two flat-config blocks (source and e2e) and owns the law: every published rule at `error` (the factory throws if one is not), the React Hooks rules (`eslint-plugin-react-hooks` 7, recommended set lifted to `error`, `set-state-in-effect` and `refs` required), and `linterOptions` (`noInlineConfig` plus `reportUnusedDisableDirectives: "error"`). The `plugin`, `recommended` and `linterOptions` inputs are gone. `eslint-plugin-react-hooks >=7` is a new peer dependency.
|
|
107
|
+
- **`vn-ok` is removed entirely.** `no-second-language-in-source` and `no-hardcoded-copy-in-vocabulary` are replaced by `no-hardcoded-copy` (R58): no literal copy at any tier, in any language, no pragma, no endonym or `resources/` exemption. Same notion of literal as nivo-fe's `check-i18n-catalog.mjs` (a word is two or more letters). A `vn-ok:` comment is now itself a finding of `no-inline-lint-config`, which also refuses `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck`, `eslint-env` and inline `eslint rule:` config, and now covers specs and the e2e tree.
|
|
108
|
+
- New laws and rules: `env-owner` (R49: `no-env-outside-config`, `no-hardcoded-endpoint-fallback`), `transport` (R50-R52: `fetch-only-in-api-client`, `client-fetch-has-signal`, `no-shared-transport-state`, `client-maps-auth-to-refused`, `no-http-status-collapse`, `no-hand-typed-wire`), `client-boundary` (R55: `use-client-only-at-boundary`), `hooks-folder` (R56 lint half: `hooks-folder-holds-hooks-only`), `next-conventions` (`no-middleware-file`, `locale-segment-is-locale`, `no-second-i18n-stack`, `html-lang-from-locale`), `brand-values` (R61 TypeScript half: `no-raw-brand-value`), `native-controls` (R62: `no-native-form-control`), `size-and-state-budget` (R65: `component-line-budget`, `unit-hook-budget`, `no-hand-rolled-polling`), `e2e-shape` (R66, seven rules on `e2e/**` and `playwright.config.*`), `spec-quality` (R67 and the real-catalogue half of R60: six rules).
|
|
109
|
+
- `sourceRecommended` and `e2eRecommended` are exported beside `recommended` (their union); the effective-config audit compares a production probe against `sourceRecommended`.
|
|
110
|
+
- `why[rule]` is `{ code, vi, fixVi }`: the catalogue finding code and the Vietnamese why for every rule of the catalogued laws.
|
|
111
|
+
- One doc page per law under `docs/`.
|
package/README.md
CHANGED
|
@@ -1,32 +1,46 @@
|
|
|
1
1
|
# @starci/eslint-canon-fe
|
|
2
2
|
|
|
3
|
-
The StarCi front-end lint canon for
|
|
3
|
+
The StarCi front-end lint canon for Next.js and TypeScript applications. It keeps
|
|
4
4
|
component ownership, accessibility, loading behavior, vendor boundaries, translations, naming,
|
|
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
|
|
8
|
+
npm i -D @starci/eslint-canon-fe eslint-plugin-react-hooks typescript
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
A repository's `eslint.config.mjs` is the managed one-liner `hfs sync` renders (and `hfs check` compares, R05/R17):
|
|
12
|
+
|
|
11
13
|
```js
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
export default [starciFeConfig({
|
|
15
|
-
layout: "single-app",
|
|
16
|
-
plugin: starciFe,
|
|
17
|
-
recommended,
|
|
18
|
-
linterOptions,
|
|
19
|
-
})]
|
|
14
|
+
import { loadHfs, starciFeConfig } from "@starci/eslint-canon-fe"
|
|
15
|
+
export default starciFeConfig({ hfs: loadHfs(import.meta.url) })
|
|
20
16
|
```
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
the
|
|
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.
|
|
23
|
+
|
|
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.
|
|
28
|
+
|
|
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.
|
|
25
31
|
|
|
26
32
|
## Rules
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
Each law has a page under [`docs/`](docs) with, for every rule, an invalid and a valid example and
|
|
35
|
+
its Vietnamese why. New in 5.0: [env-owner](docs/env-owner.md), [transport](docs/transport.md),
|
|
36
|
+
[client-boundary](docs/client-boundary.md), [hooks-folder](docs/hooks-folder.md),
|
|
37
|
+
[next-conventions](docs/next-conventions.md), [translation](docs/translation.md) (no literal copy at any
|
|
38
|
+
tier, no `vn-ok`), [brand-values](docs/brand-values.md), [native-controls](docs/native-controls.md),
|
|
39
|
+
[size-and-state-budget](docs/size-and-state-budget.md), [e2e-shape](docs/e2e-shape.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/`).
|
|
41
|
+
|
|
42
|
+
Older laws cover comments, file layout, icons and vendor ownership, landmarks, loading states,
|
|
43
|
+
naming, props, served locales, component splits, design tokens, type safety, typography, and
|
|
30
44
|
class-name ownership. Reusable class names belong in a colocated `classNames.ts` module and should
|
|
31
45
|
be composed with HeroUI `cn` using one utility token per argument.
|
|
32
46
|
|
|
@@ -37,9 +51,12 @@ shapes and never a data status (`no-data-status-shape`), and a block renders eac
|
|
|
37
51
|
loading/forbidden/error/empty through `SlotView` (`slot-status-through-slotview`). Reference tree:
|
|
38
52
|
`.claude/examples/shape-slot`.
|
|
39
53
|
|
|
54
|
+
The one locale pattern everywhere is `next-intl`, a `[locale]` segment, default `vi`,
|
|
55
|
+
`localePrefix: "as-needed"`, and `proxy.ts` instead of `middleware.ts`.
|
|
56
|
+
|
|
40
57
|
Grammar is a business-neutral HeroUI-backed component package. Its components accept ordinary
|
|
41
58
|
typed React props and children.
|
|
42
59
|
|
|
43
60
|
## Requirements
|
|
44
61
|
|
|
45
|
-
ESLint 9+ (flat config) and Node.js 20.9+.
|
|
62
|
+
ESLint 9+ (flat config), `eslint-plugin-react-hooks` 7+, and Node.js 20.9+.
|
package/brand-values.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds `brand-values.md` (HFS R61 `FE_STYLE_TOKEN_ONLY`, the TypeScript half; the CSS
|
|
3
|
+
* half belongs to `@starci/stylelint-canon`).
|
|
4
|
+
*
|
|
5
|
+
* ONE PLACE FOR A COLOUR. Colour values exist in `modules/brand/brand.css`, set only on the tokens
|
|
6
|
+
* the grammar declares, with a light and a dark value, and checked against the brand record. A hex
|
|
7
|
+
* in a component is a colour that has no dark value, cannot be re-branded, and drifts from the
|
|
8
|
+
* record: one repository carried three different ways to override the brand, and another 4.3k lines
|
|
9
|
+
* of hand-made CSS with a private token namespace.
|
|
10
|
+
*
|
|
11
|
+
* LENGTHS FOLLOW. A pixel length in TypeScript (`"12px"`, `{ width: 12 }` in a `style` prop) is a
|
|
12
|
+
* spacing decision that skipped the grammar's scale, so it does not move with density, zoom or a
|
|
13
|
+
* breakpoint. Spacing and sizing come from the grammar's utility tokens.
|
|
14
|
+
*
|
|
15
|
+
* WHAT IT DELIBERATELY LEAVES ALONE: a fragment identifier (`href="#top"`, `id`, `htmlFor`), a
|
|
16
|
+
* `data:` URI, a component's `color="primary"` prop (that is a variant name, not a colour), and
|
|
17
|
+
* Tailwind arbitrary values (`w-[12px]`), which `no-arbitrary-value` already reports - two rules
|
|
18
|
+
* for one span would be two findings for one fix.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { isContentFile } from "./comments.mjs"
|
|
22
|
+
|
|
23
|
+
/** A hex colour: 3, 4, 6 or 8 digits, not a longer word and not part of an identifier. */
|
|
24
|
+
const HEX_COLOR = /(?<![\w&])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![\w-])/
|
|
25
|
+
|
|
26
|
+
/** A functional colour notation. */
|
|
27
|
+
const COLOR_FUNCTION = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix)\(/i
|
|
28
|
+
|
|
29
|
+
/** A pixel length, not inside an arbitrary-value bracket and not part of a longer number or word. */
|
|
30
|
+
const PIXEL_LENGTH = /(?<![\w.#[-])-?(?:\d+\.?\d*|\.\d+)px\b/
|
|
31
|
+
|
|
32
|
+
/** Attributes whose value is a fragment or an id, where a `#abc` is not a colour. */
|
|
33
|
+
const NON_COLOR_ATTRS = new Set(["href", "id", "htmlFor", "to", "name", "aria-controls", "aria-labelledby", "aria-describedby"])
|
|
34
|
+
|
|
35
|
+
/** SVG paint attributes: a named colour here is a raw brand value. */
|
|
36
|
+
const PAINT_ATTRS = new Set(["fill", "stroke", "stopColor", "floodColor", "lightingColor"])
|
|
37
|
+
|
|
38
|
+
/** Style properties that take a colour. */
|
|
39
|
+
const COLOR_KEYS = new Set([
|
|
40
|
+
"color",
|
|
41
|
+
"background",
|
|
42
|
+
"backgroundColor",
|
|
43
|
+
"borderColor",
|
|
44
|
+
"outlineColor",
|
|
45
|
+
"fill",
|
|
46
|
+
"stroke",
|
|
47
|
+
"caretColor",
|
|
48
|
+
"accentColor",
|
|
49
|
+
"textDecorationColor",
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
/** Style properties that take a length. */
|
|
53
|
+
const LENGTH_KEYS = new Set([
|
|
54
|
+
"width",
|
|
55
|
+
"height",
|
|
56
|
+
"minWidth",
|
|
57
|
+
"maxWidth",
|
|
58
|
+
"minHeight",
|
|
59
|
+
"maxHeight",
|
|
60
|
+
"top",
|
|
61
|
+
"right",
|
|
62
|
+
"bottom",
|
|
63
|
+
"left",
|
|
64
|
+
"gap",
|
|
65
|
+
"margin",
|
|
66
|
+
"marginTop",
|
|
67
|
+
"marginRight",
|
|
68
|
+
"marginBottom",
|
|
69
|
+
"marginLeft",
|
|
70
|
+
"padding",
|
|
71
|
+
"paddingTop",
|
|
72
|
+
"paddingRight",
|
|
73
|
+
"paddingBottom",
|
|
74
|
+
"paddingLeft",
|
|
75
|
+
"fontSize",
|
|
76
|
+
"borderRadius",
|
|
77
|
+
"letterSpacing",
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
/** Values that carry no raw colour: a token reference or a keyword. */
|
|
81
|
+
const NEUTRAL_COLOR = /^(?:var\(|inherit$|initial$|unset$|revert$|currentcolor$|transparent$|none$)/i
|
|
82
|
+
|
|
83
|
+
/** Static string of a literal or an expression-free template, else null. */
|
|
84
|
+
const staticString = (node) => {
|
|
85
|
+
if (!node) return null
|
|
86
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value
|
|
87
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value.cooked ?? ""
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** No raw colour, hex or pixel value outside `brand.css`. */
|
|
92
|
+
export const noRawBrandValue = {
|
|
93
|
+
meta: {
|
|
94
|
+
type: "problem",
|
|
95
|
+
docs: { description: "No hex, colour function, named paint colour or px length in TypeScript; tokens only." },
|
|
96
|
+
schema: [],
|
|
97
|
+
messages: {
|
|
98
|
+
color:
|
|
99
|
+
"A raw colour value (`{{value}}`). Colour values exist only in `modules/brand/brand.css`, on the tokens the grammar declares, with a light and a dark value. Here it has no dark value, cannot be re-branded and drifts from the brand record. Use a grammar token class or variable.",
|
|
100
|
+
length:
|
|
101
|
+
"A raw pixel length (`{{value}}`). Spacing and size come from the grammar's scale so they move with density, zoom and breakpoint. Use a grammar utility token.",
|
|
102
|
+
named:
|
|
103
|
+
"`{{key}}: {{value}}` puts a named colour in a style. Colour is a token, not a word: use a grammar token (`var(--...)` or a token class).",
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
create(context) {
|
|
107
|
+
if (isContentFile(context)) return {}
|
|
108
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
109
|
+
/** Values already reported through an owner, so a string reports once. */
|
|
110
|
+
const claimed = new WeakSet()
|
|
111
|
+
|
|
112
|
+
/** True when a node sits inside a JSX `style={...}` attribute. */
|
|
113
|
+
const inStyleAttribute = (node) =>
|
|
114
|
+
source
|
|
115
|
+
.getAncestors(node)
|
|
116
|
+
.some((ancestor) => ancestor.type === "JSXAttribute" && ancestor.name.type === "JSXIdentifier" && ancestor.name.name === "style")
|
|
117
|
+
|
|
118
|
+
/** Reports the first raw value in a string, once. */
|
|
119
|
+
const scan = (node, text) => {
|
|
120
|
+
if (claimed.has(node) || typeof text !== "string" || text.startsWith("data:")) return
|
|
121
|
+
const parent = node.parent
|
|
122
|
+
if (parent && parent.type === "JSXAttribute" && NON_COLOR_ATTRS.has(parent.name.name)) return
|
|
123
|
+
if (parent && parent.type === "JSXExpressionContainer" && parent.parent.type === "JSXAttribute") {
|
|
124
|
+
if (NON_COLOR_ATTRS.has(parent.parent.name.name)) return
|
|
125
|
+
}
|
|
126
|
+
if (parent && (parent.type === "ImportDeclaration" || parent.type === "ExportAllDeclaration" || parent.type === "ExportNamedDeclaration")) return
|
|
127
|
+
const color = HEX_COLOR.exec(text) || COLOR_FUNCTION.exec(text)
|
|
128
|
+
if (color) return context.report({ node, messageId: "color", data: { value: color[0].replace(/\($/, "(...)") } })
|
|
129
|
+
const length = PIXEL_LENGTH.exec(text)
|
|
130
|
+
if (length) context.report({ node, messageId: "length", data: { value: length[0] } })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
Literal(node) {
|
|
135
|
+
scan(node, node.value)
|
|
136
|
+
},
|
|
137
|
+
TemplateElement(node) {
|
|
138
|
+
scan(node, node.value && node.value.cooked)
|
|
139
|
+
},
|
|
140
|
+
JSXAttribute(node) {
|
|
141
|
+
if (node.name.type !== "JSXIdentifier" || !PAINT_ATTRS.has(node.name.name)) return
|
|
142
|
+
const value = node.value && node.value.type === "JSXExpressionContainer" ? node.value.expression : node.value
|
|
143
|
+
const text = staticString(value)
|
|
144
|
+
if (text === null || NEUTRAL_COLOR.test(text) || HEX_COLOR.test(text) || COLOR_FUNCTION.test(text)) return
|
|
145
|
+
claimed.add(value)
|
|
146
|
+
context.report({ node, messageId: "named", data: { key: node.name.name, value: text } })
|
|
147
|
+
},
|
|
148
|
+
Property(node) {
|
|
149
|
+
if (node.computed || node.key.type !== "Identifier" || !inStyleAttribute(node)) return
|
|
150
|
+
const key = node.key.name
|
|
151
|
+
if (COLOR_KEYS.has(key)) {
|
|
152
|
+
const text = staticString(node.value)
|
|
153
|
+
if (text === null || NEUTRAL_COLOR.test(text) || HEX_COLOR.test(text) || COLOR_FUNCTION.test(text)) return
|
|
154
|
+
claimed.add(node.value)
|
|
155
|
+
context.report({ node, messageId: "named", data: { key, value: text } })
|
|
156
|
+
} else if (LENGTH_KEYS.has(key) && node.value.type === "Literal" && typeof node.value.value === "number") {
|
|
157
|
+
if (node.value.value === 0) return
|
|
158
|
+
claimed.add(node.value)
|
|
159
|
+
context.report({ node: node.value, messageId: "length", data: { value: `${key}: ${node.value.value}` } })
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The rules this law contributes to the plugin. */
|
|
167
|
+
export const rules = {
|
|
168
|
+
"no-raw-brand-value": noRawBrandValue,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Every rule is an error. */
|
|
172
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/class-names.mjs
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* classNames files own the utility-token composition.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
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
|