@starci/eslint-canon-fe 8.4.43 → 9.1.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 +17 -1
- package/README.md +41 -84
- package/brand-values.mjs +1 -1
- package/button-intent.mjs +320 -0
- package/docs/button-intent.md +22 -0
- package/docs/hooks-folder.md +5 -3
- package/docs/native-controls.md +1 -5
- package/docs/next-conventions.md +8 -8
- package/docs/size-and-state-budget.md +22 -0
- package/docs/surface-rules.md +11 -9
- package/drawing.mjs +4 -66
- package/hooks-folder.mjs +5 -3
- package/index.mjs +9 -25
- package/landmark.mjs +0 -3
- package/lib/class-text.mjs +29 -0
- package/lib/config.mjs +5 -6
- package/lib/drawing-scope.mjs +15 -0
- package/lib/typed-class-values.mjs +10 -1
- package/lib/why.mjs +35 -40
- package/next-conventions.mjs +33 -16
- package/package.json +4 -3
- package/runtime/knowledge/grammars/index.yaml +111 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
- package/runtime/knowledge/hfs/fe-stack.yaml +12 -4
- package/runtime/knowledge/hfs/slots.yaml +3 -3
- package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
- package/runtime/scripts/hfs/architecture/config.mjs +8 -8
- package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
- package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/view.mjs +1 -0
- package/runtime/scripts/lib/grammar-lint.mjs +48 -0
- package/size-and-state-budget.mjs +116 -4
- package/surface-rules.mjs +159 -26
- package/tokens.mjs +5 -217
- package/app-shell.mjs +0 -161
- package/docs/app-shell.md +0 -50
- package/docs/grammar-boundary.md +0 -57
- package/docs/status-colors.md +0 -32
- package/grammar-boundary.mjs +0 -203
- package/icon.mjs +0 -310
- package/lib/app-shell-cluster.mjs +0 -66
- package/lib/app-shell-create.mjs +0 -33
- package/lib/app-shell-owner-source.mjs +0 -124
- package/lib/app-shell-owner.mjs +0 -65
- package/lib/app-shell-scope.mjs +0 -79
- package/lib/app-shell-visible.mjs +0 -92
- package/lib/app-shell.mjs +0 -148
- package/lib/status-tones.generated.mjs +0 -9
- package/menu-entries.mjs +0 -133
- package/runtime/modules/goal/source-phrases.yaml +0 -95
- package/runtime/scripts/lib/source-phrases.mjs +0 -34
- package/status-colors.mjs +0 -137
- package/typography.mjs +0 -78
- package/vendor-boundary.mjs +0 -26
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 9.1.0 - 2026-10-26
|
|
4
|
+
- Added: universal CTA-6 button intent laws require `intent`, refuse app `variant`, contain destructive confirms in confirmation dialogs, keep one commit per render path and reserve commit for the view rather than collection items. Run `starci app codemod button-intent` to prepare migration and review its flagged cases.
|
|
5
|
+
|
|
6
|
+
## 9.0.0 - 2026-10-26
|
|
7
|
+
- Changed: concrete Grammar component, variant, token and vendor rules move to `@starci/eslint-grammar-starci` under the `starci-grammar` namespace. Consumers compose both flat-config factories for their selected family.
|
|
8
|
+
- Added: explicit shared infrastructure exports under `@starci/eslint-canon-fe/lib/*`; family packages reuse these readers without copying them.
|
|
9
|
+
- Kept universal: the R318 surface composition law (`nested-control-variant`, `no-list-panel-padding`, `no-app-surface-paint`) and the R65 `no-app-relative-clock`, now in `size-and-state-budget.mjs`.
|
|
10
|
+
|
|
11
|
+
### Migrating from 8.x
|
|
12
|
+
- What moved: twenty rules leave this package with their IDs, finding codes and severity unchanged; only the namespace changes from `starci-fe/` to `starci-grammar/`. They are `app-shell-frame`, `no-shell-bottom-tabs`, `navbar-control-size`, `navbar-cluster`, `no-navbar-create`, `no-raw-structural-element`, `no-heading-tag-outside-heading-component`, `status-text-uses-soft-foreground`, `vendor-primitive-has-named-owner`, `no-vendor-icon-outside-icon-leaf`, `heroicons-is-the-glyph-vendor`, `no-off-scale-glyph-size`, `no-icon-size-class`, `destructive-menu-entry-needs-danger-tone`, `no-decorative-icon-in-metric-cell`, `no-inline-svg-outside-brand-mark-leaf`, `no-fractional-step`, `no-hand-rolled-heading`, `no-unresolved-token-class` and `slot-status-through-slotview`. StarCi apps get them from `@starci/eslint-grammar-starci`; Offset Pop apps get the same implementations from `@starci/eslint-grammar-offset-pop`.
|
|
13
|
+
- Select the family explicitly: set `sides.fe.grammar` to `starci` or `offset-pop` in `hfs.json`. No family is inferred.
|
|
14
|
+
- Install the family package at its canon-pins version beside this one (app root `devDependencies`). `starci app check` refuses an app that selects a family and declares canon-fe 9 without that package (`FE_STACK_FAMILY_LINT_MISSING`, R288), so the twenty rules cannot be lost silently.
|
|
15
|
+
- Flat config order: the universal factory first, then the family factory, both with the same HFS view: `export default [...starciFeConfig({ hfs }), ...starciGrammarConfig({ hfs })]` (Offset Pop: `offsetPopGrammarConfig`). `starci app sync` renders this managed `fe/eslint.config.mjs`.
|
|
16
|
+
- Rename any reference to a moved rule from `starci-fe/<rule>` to `starci-grammar/<rule>`; the removed-vocabulary check refuses the old spelling.
|
|
17
|
+
- NIVO moves in v0.3.7.
|
|
18
|
+
|
|
3
19
|
## 8.4.43 - 2026-10-26
|
|
4
20
|
- Changed: the bundled canon pins follow back-end canon 3.4.43, hfs 4.7.34 and cli 1.7.34.
|
|
5
21
|
|
|
@@ -12,7 +28,7 @@
|
|
|
12
28
|
- Fixed: `operations-only-in-swr-hooks` (FE_OP_OUTSIDE_SWR_HOOK) no longer reports named imports that all resolve, through relative re-exports, to a generated `__generated__/input-bounds` module (`INPUT_BOUNDS`, `inputBoundsIssues`): they are contract data, not API operations. Mixed, default and namespace imports of the barrel still report.
|
|
13
29
|
- Added: the stream reader of server-sent events (`fe.transport.stream`, `modules/api/sse/clients/`) is part of the API layer, so it may call `fetch` (FE_TRANSPORT_OWNER); a stream operation (`sse/<area>/stream-<channel>.ts`) must import it.
|
|
14
30
|
- Added: `useSWRSubscription` (`swr/subscription`) is an SWR request hook of the family (FE_SWR_OUTSIDE_FAMILY, FE_OP_OUTSIDE_SWR_HOOK, FE_HOOKS_ARE_HOOKS): a stream operation is consumed only by `hooks/swr/api/sse/subscriptions/useSubscribe<X>Swr.ts`.
|
|
15
|
-
-
|
|
31
|
+
- Changed: `nested-control-variant` (`FE_NESTED_VARIANT`) refuses static variants on layer-aware fields throughout app code and primary Tabs inside painted Grammar surfaces. R318 owns Grammar composition; `no-app-surface-paint` uses the separate layout-only code `FE_GRAMMAR_LAYOUT_ONLY`. `no-list-panel-padding` (`FE_LIST_PANEL_PADDING`) checks all horizontal padding forms only within the nearest painted surface.
|
|
16
32
|
|
|
17
33
|
## 8.4.40 - 2026-10-25
|
|
18
34
|
- Changed: the bundled canon pins select @starci/stylelint-canon 2.0.11, @starci/hfs 4.7.33 and @starci/cli 1.7.33.
|
package/README.md
CHANGED
|
@@ -1,91 +1,48 @@
|
|
|
1
1
|
# @starci/eslint-canon-fe
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm i -D @starci/eslint-canon-fe eslint-plugin-react-hooks typescript
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
A repository's `eslint.config.mjs` is the managed one-liner `starci app sync` renders (and `starci app check` compares, R05/R17):
|
|
3
|
+
Universal front-end laws for TypeScript and Next.js applications, classified by
|
|
4
|
+
[FE-RULE-OWNERSHIP](../../../knowledge/patterns/fe/index.yaml#FE-RULE-OWNERSHIP).
|
|
5
|
+
The canon owns composition, data and effect boundaries, translations, accessibility, source
|
|
6
|
+
structure, typed contracts, token discipline and check integrity across Grammar families.
|
|
12
7
|
|
|
13
8
|
```js
|
|
14
9
|
import { loadHfs, starciFeConfig } from "@starci/eslint-canon-fe"
|
|
15
|
-
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
`loadHfs` reads the repository's `hfs.json` and the slot manifest shipped in `runtime/`. The factory returns two
|
|
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 **Sonar parity** law (R235): the smells SonarCloud measures that a lint rule can decide, `no-await-in-loop`, `prefer-code-point`, `prefer-string-raw`, `no-nested-conditional`, `no-void-operator`, `no-unused-import`, `prefer-export-from` and `no-unused-prop-types`, plus the typescript-eslint rules `no-deprecated`, `no-base-to-string` and `require-await` the factory turns on (they read types, so the source block is typed). A Next `matcher` string keeps its escapes (the segment config is read statically); `*.spec.ts` and `*.e2e-spec.ts` are outside the scan, so outside these rules.
|
|
25
|
-
- The **fe-stack** law: `fe-stack-import` refuses an import of a library the front-end stack catalog forbids (one canonical library per concern, read from `runtime/knowledge/hfs/fe-stack.yaml`) and `@supabase/*` outside `modules/auth`.
|
|
26
|
-
- The **ignore** block: `node_modules`, `.next`, `dist`, `coverage`, `__generated__` and `.starci/`.
|
|
27
|
-
- The **source** block governs `apps/*/src/**` and `packages/*/src/**` with types (`parserOptions.projectService`) and
|
|
28
|
-
`settings.starci.hfs`, the slot view path-scoped rules ask (`lib/hfs.mjs` `hfsOf`), never a path pattern.
|
|
29
|
-
|
|
30
|
-
`recommended`, `why`, `loadHfs`, `linterOptions` and the plugin itself are also
|
|
31
|
-
exported; `why[rule]` is `{ code, vi, fixVi }`, the finding code and the Vietnamese why the harness quotes.
|
|
32
|
-
|
|
33
|
-
## Rules
|
|
34
|
-
|
|
35
|
-
The [app-shell](docs/app-shell.md) law holds the fixed `WorkspaceShell` frame, mobile drawer navigation,
|
|
36
|
-
navbar action kinds and order, shared bar control size, and page ownership of create actions. Its
|
|
37
|
-
rules are gathered into the normal FE lint and application gate.
|
|
10
|
+
import { starciGrammarConfig } from "@starci/eslint-grammar-starci"
|
|
38
11
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
[next-conventions](docs/next-conventions.md), [translation](docs/translation.md) (no literal copy at any
|
|
43
|
-
tier), [brand-values](docs/brand-values.md), [native-controls](docs/native-controls.md),
|
|
44
|
-
[size-and-state-budget](docs/size-and-state-budget.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.1: `no-vietnamese-in-source` (English-only identifiers, strings, comments and test titles; the i18n fixtures slot `e2e/fixtures/i18n/` is the one exemption). 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/`).
|
|
45
|
-
|
|
46
|
-
Older laws cover comments, file layout, icons and vendor ownership, landmarks, loading states,
|
|
47
|
-
naming, props, served locales, component splits, design tokens, type safety, typography, and
|
|
48
|
-
class-name ownership. Reusable class names belong in a colocated `classNames.ts` module and should
|
|
49
|
-
be composed with HeroUI `cn` using one utility token per argument.
|
|
50
|
-
|
|
51
|
-
The `drawing` law holds the split surfaces (blocks, pages, layouts, overlays under `components/`
|
|
52
|
-
or `features/`): the pure `XBase` takes `{ state, props, on }` of atoms, with named `render`/`renderX`
|
|
53
|
-
callbacks in feature `props` for block composition (`base-props-atom`), only its
|
|
54
|
-
sibling `index.tsx` imports it and never re-exports it (`base-import-pair`), `XState` names drawn
|
|
55
|
-
shapes and never a data status (`no-data-status-shape`), and a block renders each slot's
|
|
56
|
-
loading/forbidden/error/empty through `SlotView` (`slot-status-through-slotview`). The law's name is
|
|
57
|
-
the `drawing` slot role of `component.tsx`; a split-surface reference tree lives under
|
|
58
|
-
`.claude/examples/`.
|
|
59
|
-
|
|
60
|
-
Page and layout owners in `features/{pages,layouts}/<Name>/index.tsx` may compose overlay and block public entries. A server
|
|
61
|
-
page may import and render a client overlay entry while remaining a server component: `FE_CLIENT_BOUNDARY` accepts the
|
|
62
|
-
overlay entry, and `FE_CLIENT_SERVER_IMPORT` / `FE_CLIENT_REACHES_SERVER` reject server-only imports from its client closure.
|
|
63
|
-
The only feature-to-feature edge is pages or layouts to overlays; layouts never import pages, overlays never import other
|
|
64
|
-
overlays, components never import overlays, and blocks never import another block. For a client list, a server page mounts an overlay whose connected entry reads list
|
|
65
|
-
identities and supplies a block renderer to its pure Base. A pure component or branch may render that keyed list by
|
|
66
|
-
calling the supplied render prop; it imports no block and fetches no data. The supplied renderer composes blocks only,
|
|
67
|
-
with no intrinsic markup or hook calls, and each connected item block still renders its exact sibling `XBase`.
|
|
68
|
-
|
|
69
|
-
A layout-level overlay host passes rendered overlay candidates into a client `features/overlays/OverlayRouteHost` entry.
|
|
70
|
-
Its hook under `hooks/overlays` reads `usePathname()` and `useSearchParams()` and supplies scalar selection to the pure
|
|
71
|
-
`components/branches/OverlayHost` branch. The layout imports the candidate overlay entries; the host and branch only
|
|
72
|
-
receive rendered nodes. No callback crosses the server-to-client boundary, and pages stay server components. This
|
|
73
|
-
composition-only host renders an existing public pure branch without a forwarding private Base; connected blocks still
|
|
74
|
-
require their exact sibling Base. The worked tree is in
|
|
75
|
-
[`knowledge/patterns/fe/boundaries.yaml`](../../../knowledge/patterns/fe/boundaries.yaml), `FE-BOUNDARY-5`.
|
|
76
|
-
|
|
77
|
-
The one locale pattern everywhere is `next-intl`, a `[locale]` segment, default `vi`,
|
|
78
|
-
`localePrefix: "as-needed"`, and `proxy.ts` instead of `middleware.ts`.
|
|
79
|
-
|
|
80
|
-
Grammar is a business-neutral HeroUI-backed component package. Its components accept ordinary
|
|
81
|
-
typed React props and children.
|
|
82
|
-
|
|
83
|
-
The [surface composition law](docs/surface-rules.md) holds ANATOMY-2 and PADDING-0:
|
|
84
|
-
`nested-control-variant` refuses explicit primary fields inside Grammar surfaces and raw app
|
|
85
|
-
`bg-surface` paint (`FE_NESTED_VARIANT`); `no-list-panel-padding` refuses horizontal padding
|
|
86
|
-
wrappers around Grammar lists and section captions (`FE_LIST_PANEL_PADDING`). Grammar supplies
|
|
87
|
-
the nested secondary default and owns the list's panel inset.
|
|
88
|
-
|
|
89
|
-
## Requirements
|
|
12
|
+
const hfs = loadHfs(import.meta.url)
|
|
13
|
+
export default [...starciFeConfig({ hfs }), ...starciGrammarConfig({ hfs })]
|
|
14
|
+
```
|
|
90
15
|
|
|
91
|
-
|
|
16
|
+
`starci app sync` renders the selected family's managed flat config. Both factories cover every
|
|
17
|
+
app and workspace package's source. Universal rules, React Hooks rules, type-aware rules and
|
|
18
|
+
Sonar rules run at error; the factory sets typed parsing and refuses inline lint directives.
|
|
19
|
+
Generated wire types, dependencies and build outputs follow the shared source-scope contract.
|
|
20
|
+
|
|
21
|
+
Concrete Grammar component names, props, variants, scales and vendor recipes belong to
|
|
22
|
+
`@starci/eslint-grammar-starci` or the selected family's package. The family rules keep their
|
|
23
|
+
bare IDs under the `starci-grammar` namespace. This package exports shared AST, binding, type,
|
|
24
|
+
slot and configuration infrastructure through `@starci/eslint-canon-fe/lib/*`; family packages
|
|
25
|
+
import those readers without copying them.
|
|
26
|
+
|
|
27
|
+
Every universal law remains gathered by `index.mjs`; its recommendation enables every rule.
|
|
28
|
+
The `docs/` directory carries the detailed behavior of the documented laws. `why` supplies
|
|
29
|
+
failure codes and English guidance for the catalogued rules.
|
|
30
|
+
|
|
31
|
+
`starciPlainSourceConfig({ root })` applies slot-free universal rules to a TypeScript `src/`
|
|
32
|
+
tree. `starciTypedSourceConfig({ root })` provides the shared typed, React Hooks and Sonar
|
|
33
|
+
configuration for source that has not adopted the product composition laws.
|
|
34
|
+
|
|
35
|
+
The [surface composition law](docs/surface-rules.md) (R318) holds ANATOMY-2 and PADDING-0 for every
|
|
36
|
+
family that publishes the shared HeroUI surface and field API: `nested-control-variant` refuses static
|
|
37
|
+
variants on layer-aware fields and primary Tabs inside painted Grammar surfaces (`FE_NESTED_VARIANT`),
|
|
38
|
+
`no-app-surface-paint` keeps surface paint in Grammar (`FE_GRAMMAR_LAYOUT_ONLY`) and
|
|
39
|
+
`no-list-panel-padding` refuses horizontal padding wrappers around Grammar lists and section captions
|
|
40
|
+
within the nearest painted surface (`FE_LIST_PANEL_PADDING`). The R65 budget law's
|
|
41
|
+
`no-app-relative-clock` leaves live relative time and countdowns to Grammar's shared clock.
|
|
42
|
+
|
|
43
|
+
The [button intent law](docs/button-intent.md) (CTA-6) requires every app Button and IconButton to
|
|
44
|
+
declare intent and leave variants to Grammar. It contains destructive confirms in their same-file
|
|
45
|
+
dialog, counts mutually exclusive render paths separately and reserves commit for the view's
|
|
46
|
+
decision rather than repeated collection items.
|
|
47
|
+
|
|
48
|
+
Regression specs belong to this package. Application front ends and Grammar libraries have no specs.
|
package/brand-values.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* breakpoint. Spacing and sizing come from the grammar's utility tokens.
|
|
14
14
|
*
|
|
15
15
|
* WHAT IT DELIBERATELY LEAVES ALONE: a fragment identifier (`href="#top"`, `id`, `htmlFor`), a
|
|
16
|
-
* `data:` URI, a component's
|
|
16
|
+
* `data:` URI, a component's semantic appearance prop (a named role rather than a raw colour), and
|
|
17
17
|
* Tailwind arbitrary values (`w-[12px]`), which `no-arbitrary-value` already reports - two rules
|
|
18
18
|
* for one span would be two findings for one fix.
|
|
19
19
|
*/
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/** CTA-6: app buttons declare consequences; Grammar owns their visual variants. */
|
|
2
|
+
import { attributeValue, stringOf, walk } from "./lib/ast.mjs"
|
|
3
|
+
import { functionOf, importOf, isFn, patternPath, unwrap, variableOf } from "./lib/bindings.mjs"
|
|
4
|
+
import { typedPropertyStrings } from "./lib/typed-class-values.mjs"
|
|
5
|
+
import ts from "typescript"
|
|
6
|
+
|
|
7
|
+
const GRAMMAR_ENTRY = /^@starci\/grammar(?:\/(?:common|core|heritage|offset-pop))?$/
|
|
8
|
+
const BUTTONS = new Set(["Button", "IconButton"])
|
|
9
|
+
const DIALOGS = new Set(["AlertDialog", "ConfirmDialog"])
|
|
10
|
+
const ITEMS = new Set(["CollectionItem", "ItemActions", "ListItem", "ListRow", "ListBox.Item", "GridList.Item", "Table.Row", "TableRow"])
|
|
11
|
+
|
|
12
|
+
/** The consuming TypeScript program exposes literal intents and spread properties without executing app code. */
|
|
13
|
+
const typedInfo = (context, node, key) => {
|
|
14
|
+
const services = context.sourceCode.parserServices
|
|
15
|
+
if (!services?.program || !services.esTreeNodeToTSNodeMap || !node) return null
|
|
16
|
+
const checker = services.program.getTypeChecker()
|
|
17
|
+
const source = services.esTreeNodeToTSNodeMap.get(node)
|
|
18
|
+
const owner = checker.getTypeAtLocation(source)
|
|
19
|
+
const symbol = key === undefined ? null : owner.getProperty(key)
|
|
20
|
+
if (key !== undefined && !symbol) return null
|
|
21
|
+
const type = symbol ? checker.getTypeOfSymbolAtLocation(symbol, source) : owner
|
|
22
|
+
const members = type.isUnion() ? type.types : [type]
|
|
23
|
+
return { values: members.filter((member) => member.isStringLiteral()).map((member) => member.value), optional: Boolean(symbol && (symbol.flags & ts.SymbolFlags.Optional) !== 0) }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolve the imported Grammar role without confusing a shadowed local component with its namesake. */
|
|
27
|
+
const roleOf = (context, name) => {
|
|
28
|
+
const parts = []
|
|
29
|
+
let root = name
|
|
30
|
+
while (root.type === "JSXMemberExpression") {
|
|
31
|
+
parts.unshift(root.property.name)
|
|
32
|
+
root = root.object
|
|
33
|
+
}
|
|
34
|
+
if (root.type !== "JSXIdentifier") return null
|
|
35
|
+
const imported = importOf(context, root)
|
|
36
|
+
if (!imported || !GRAMMAR_ENTRY.test(imported.source)) return null
|
|
37
|
+
if (imported.imported) parts.unshift(imported.imported)
|
|
38
|
+
else if (!variableOf(context, root)?.defs.some((definition) => definition.node.type === "ImportNamespaceSpecifier")) return null
|
|
39
|
+
return parts.join(".")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Static object properties, including aliases and spreads, retain their button obligations. */
|
|
43
|
+
const objectProperty = (context, input, key, seen = new Set()) => {
|
|
44
|
+
const node = unwrap(input)
|
|
45
|
+
if (!node || seen.has(node)) return []
|
|
46
|
+
const next = new Set(seen).add(node)
|
|
47
|
+
if (node.type === "ObjectExpression") return node.properties.flatMap((property) => {
|
|
48
|
+
if (property.type === "SpreadElement") return objectProperty(context, property.argument, key, next)
|
|
49
|
+
const name = property.computed ? stringOf(property.key) : property.key.name ?? property.key.value
|
|
50
|
+
return name === key ? [property.value] : []
|
|
51
|
+
})
|
|
52
|
+
if (node.type === "ConditionalExpression") return [node.consequent, node.alternate].flatMap((branch) => objectProperty(context, branch, key, next))
|
|
53
|
+
if (node.type === "MemberExpression") {
|
|
54
|
+
const member = node.computed ? stringOf(node.property) : node.property.name
|
|
55
|
+
return objectProperty(context, node.object, member, next).flatMap((value) => objectProperty(context, value, key, next))
|
|
56
|
+
}
|
|
57
|
+
if (node.type !== "Identifier") return []
|
|
58
|
+
const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
|
|
59
|
+
if (!definition) return []
|
|
60
|
+
const path = patternPath(definition.node.id, node.name)
|
|
61
|
+
if (path === null) return []
|
|
62
|
+
const values = path.reduce((entries, name) => entries.flatMap((entry) => objectProperty(context, entry, name, next)), [definition.node.init])
|
|
63
|
+
return values.flatMap((value) => objectProperty(context, value, key, next))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Possible statically authored values of an intent or step; dynamic values remain TypeScript's responsibility. */
|
|
67
|
+
const valuesOf = (context, input, seen = new Set()) => {
|
|
68
|
+
const node = unwrap(input)
|
|
69
|
+
if (!node || seen.has(node)) return []
|
|
70
|
+
const next = new Set(seen).add(node)
|
|
71
|
+
const text = stringOf(node)
|
|
72
|
+
if (text !== null) return [text]
|
|
73
|
+
if (node.type === "ConditionalExpression") return [node.consequent, node.alternate].flatMap((branch) => valuesOf(context, branch, next))
|
|
74
|
+
if (node.type === "LogicalExpression") return valuesOf(context, node.right, next)
|
|
75
|
+
if (node.type === "MemberExpression") {
|
|
76
|
+
const name = node.computed ? stringOf(node.property) : node.property.name
|
|
77
|
+
const values = objectProperty(context, node.object, name, next).flatMap((value) => valuesOf(context, value, next))
|
|
78
|
+
return values.length ? values : typedInfo(context, node)?.values ?? []
|
|
79
|
+
}
|
|
80
|
+
if (node.type !== "Identifier") return typedInfo(context, node)?.values ?? []
|
|
81
|
+
const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
|
|
82
|
+
if (!definition) return typedInfo(context, node)?.values ?? []
|
|
83
|
+
const path = patternPath(definition.node.id, node.name)
|
|
84
|
+
if (path === null) return []
|
|
85
|
+
const values = path.reduce((entries, name) => entries.flatMap((entry) => objectProperty(context, entry, name, next)), [definition.node.init])
|
|
86
|
+
return values.flatMap((value) => valuesOf(context, value, next))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** An explicit attribute or statically resolved spread exposing a prop. */
|
|
90
|
+
const propsOf = (context, opening, key) => opening.attributes.flatMap((entry) => {
|
|
91
|
+
if (entry.type === "JSXAttribute") return entry.name.name === key ? [{ node: entry, value: attributeValue(entry) }] : []
|
|
92
|
+
const values = objectProperty(context, entry.argument, key)
|
|
93
|
+
if (values.length) return values.map((value) => ({ node: entry, value }))
|
|
94
|
+
const authored = typedPropertyStrings(context, entry.argument, key, "variant")
|
|
95
|
+
if (authored.length) return authored.map((value) => ({ node: entry, text: value.text }))
|
|
96
|
+
const typed = typedInfo(context, entry.argument, key)
|
|
97
|
+
return typed ? [{ node: entry, texts: typed.values }] : []
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const hasValue = (context, opening, key, value) => propsOf(context, opening, key).some((prop) => prop.text === value || prop.texts?.includes(value) || valuesOf(context, prop.value).includes(value))
|
|
101
|
+
const isButton = (context, opening) => BUTTONS.has(roleOf(context, opening.name))
|
|
102
|
+
|
|
103
|
+
/** A conditional props spread supplies intent only when every branch supplies it. */
|
|
104
|
+
const suppliesIntent = (context, input, seen = new Set()) => {
|
|
105
|
+
const node = unwrap(input)
|
|
106
|
+
if (!node || seen.has(node)) return false
|
|
107
|
+
const next = new Set(seen).add(node)
|
|
108
|
+
if (node.type === "ConditionalExpression") return [node.consequent, node.alternate].every((branch) => suppliesIntent(context, branch, next))
|
|
109
|
+
if (node.type === "Identifier") {
|
|
110
|
+
const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
|
|
111
|
+
if (definition?.node.id.type === "Identifier") return suppliesIntent(context, definition.node.init, next)
|
|
112
|
+
const typed = typedInfo(context, node, "intent")
|
|
113
|
+
return Boolean(typed && !typed.optional)
|
|
114
|
+
}
|
|
115
|
+
return objectProperty(context, node, "intent").length > 0 || typedPropertyStrings(context, node, "intent", "variant").length > 0
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const hasIntent = (context, opening) => opening.attributes.some((entry) => {
|
|
119
|
+
if (entry.type === "JSXSpreadAttribute") return suppliesIntent(context, entry.argument)
|
|
120
|
+
if (entry.name.name !== "intent") return false
|
|
121
|
+
const value = unwrap(attributeValue(entry))
|
|
122
|
+
return Boolean(value) && !(value.type === "Literal" && value.value === null) && !(value.type === "Identifier" && value.name === "undefined")
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/** JSX child render callbacks contribute content to their enclosing component render. */
|
|
126
|
+
const inlineRenderCallback = (node) => node.parent?.type === "JSXExpressionContainer" && node.parent.parent?.type !== "JSXAttribute"
|
|
127
|
+
|
|
128
|
+
const lexicalFunction = (node) => {
|
|
129
|
+
for (let current = node.parent; current; current = current.parent) if (isFn(current)) return current
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The nearest function owns a render; a component defined in a dialog is still its own subtree. */
|
|
134
|
+
const functionOwner = (node) => {
|
|
135
|
+
for (let current = node.parent; current; current = current.parent) if (isFn(current) && !inlineRenderCallback(current)) return current
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A trigger opens a dialog and is outside its confirmation subtree. */
|
|
140
|
+
const insideDialog = (context, opening) => {
|
|
141
|
+
const triggers = new Set()
|
|
142
|
+
for (let current = opening.parent; current; current = current.parent) {
|
|
143
|
+
if (isFn(current) && !inlineRenderCallback(current)) return false
|
|
144
|
+
if (current.type === "JSXAttribute" && current.name.name === "trigger") triggers.add(current.parent)
|
|
145
|
+
if (current.type === "JSXElement" && !triggers.has(current.openingElement) && DIALOGS.has(roleOf(context, current.openingElement.name))) return true
|
|
146
|
+
}
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Normalize a condition so `ready` and `!ready` select opposite render paths. */
|
|
151
|
+
const guardOf = (context, input, positive) => {
|
|
152
|
+
const node = unwrap(input)
|
|
153
|
+
if (node.type === "UnaryExpression" && node.operator === "!") return guardOf(context, node.argument, !positive)
|
|
154
|
+
if (node.type === "BinaryExpression" && ["===", "==", "!==", "!="].includes(node.operator)) {
|
|
155
|
+
const literal = node.right.type === "Literal" ? node.right : node.left.type === "Literal" ? node.left : null
|
|
156
|
+
if (literal) {
|
|
157
|
+
const selector = context.sourceCode.getText(literal === node.right ? node.left : node.right).replace(/\s+/g, "")
|
|
158
|
+
const choice = JSON.stringify(literal.value)
|
|
159
|
+
const value = node.operator.startsWith("!") ? !positive : positive
|
|
160
|
+
return { key: `${selector}===${choice}`, value, selector, choice }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { key: context.sourceCode.getText(node).replace(/\s+/g, ""), value: positive }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const constrainPath = (context, path, test, positive) => {
|
|
167
|
+
const guard = guardOf(context, test, positive)
|
|
168
|
+
path.guards.set(guard.key, guard.value)
|
|
169
|
+
if (guard.selector && guard.value) path.choices.set(guard.selector, guard.choice)
|
|
170
|
+
return path
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Conditional intent values retain the guards selecting commit instead of flattening both alternatives. */
|
|
174
|
+
const valuePaths = (context, input, seen = new Set()) => {
|
|
175
|
+
const node = unwrap(input)
|
|
176
|
+
if (!node || seen.has(node)) return []
|
|
177
|
+
const next = new Set(seen).add(node)
|
|
178
|
+
if (node.type === "ConditionalExpression") return [node.consequent, node.alternate].flatMap((branch, index) => valuePaths(context, branch, next).map((path) => constrainPath(context, path, node.test, index === 0)))
|
|
179
|
+
if (node.type === "Identifier") {
|
|
180
|
+
const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
|
|
181
|
+
if (definition?.node.id.type === "Identifier") return valuePaths(context, definition.node.init, next)
|
|
182
|
+
}
|
|
183
|
+
return valuesOf(context, node).includes("commit") ? [{ guards: new Map(), choices: new Map() }] : []
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Branch guards and return ownership identify mutually exclusive render paths. */
|
|
187
|
+
const renderPath = (context, opening, owner) => {
|
|
188
|
+
const guards = new Map()
|
|
189
|
+
const choices = new Map()
|
|
190
|
+
const path = { guards, choices }
|
|
191
|
+
let returned = null
|
|
192
|
+
let child = opening
|
|
193
|
+
for (let current = opening.parent; current && current !== owner; current = current.parent) {
|
|
194
|
+
if (current.type === "ReturnStatement" && lexicalFunction(current) === owner) returned = current
|
|
195
|
+
if (current.type === "ConditionalExpression" && child !== current.test) constrainPath(context, path, current.test, child === current.consequent)
|
|
196
|
+
if (current.type === "IfStatement" && child !== current.test) constrainPath(context, path, current.test, child === current.consequent)
|
|
197
|
+
if (current.type === "LogicalExpression" && child === current.right && current.operator !== "??") constrainPath(context, path, current.left, current.operator === "&&")
|
|
198
|
+
child = current
|
|
199
|
+
}
|
|
200
|
+
return { guards, choices, returned }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const canCoexist = (left, right) => {
|
|
204
|
+
if (left.returned && right.returned && left.returned !== right.returned) return false
|
|
205
|
+
if ([...left.choices].some(([selector, choice]) => right.choices.has(selector) && right.choices.get(selector) !== choice)) return false
|
|
206
|
+
return [...left.guards].every(([condition, value]) => !right.guards.has(condition) || right.guards.get(condition) === value)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** No explicit visual prop: any authored variant, including a known props spread, is an app bypass. */
|
|
210
|
+
const buttonNoVariant = {
|
|
211
|
+
meta: { type: "problem", docs: { description: "CTA-6: app buttons never choose a variant." }, schema: [], messages: {
|
|
212
|
+
FE_BUTTON_NO_VARIANT: "FE_BUTTON_NO_VARIANT: declare Button intent instead of variant; Grammar resolves the variant from intent and placement.",
|
|
213
|
+
} },
|
|
214
|
+
create(context) {
|
|
215
|
+
return { JSXOpeningElement(opening) {
|
|
216
|
+
if (!isButton(context, opening)) return
|
|
217
|
+
for (const node of new Set(propsOf(context, opening, "variant").map((prop) => prop.node))) context.report({ node, messageId: "FE_BUTTON_NO_VARIANT" })
|
|
218
|
+
} }
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Every app button states its command's intent explicitly. */
|
|
223
|
+
const buttonIntentRequired = {
|
|
224
|
+
meta: { type: "problem", docs: { description: "CTA-6: every app Button and IconButton declares intent." }, schema: [], messages: {
|
|
225
|
+
FE_BUTTON_INTENT_REQUIRED: "FE_BUTTON_INTENT_REQUIRED: Button and IconButton require intent (commit, alternative, dismiss, neutral or destructive). Choose it from the command's consequence and rank.",
|
|
226
|
+
} },
|
|
227
|
+
create(context) {
|
|
228
|
+
return { JSXOpeningElement(opening) {
|
|
229
|
+
if (isButton(context, opening) && !hasIntent(context, opening)) context.report({ node: opening, messageId: "FE_BUTTON_INTENT_REQUIRED" })
|
|
230
|
+
} }
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A destructive final press is contained by its same-file confirmation dialog. */
|
|
235
|
+
const destructiveConfirmInDialog = {
|
|
236
|
+
meta: { type: "problem", docs: { description: "CTA-6: destructive confirm buttons stay inside AlertDialog or ConfirmDialog." }, schema: [], messages: {
|
|
237
|
+
FE_DESTRUCTIVE_CONFIRM_IN_DIALOG: "FE_DESTRUCTIVE_CONFIRM_IN_DIALOG: a destructive confirm belongs inside the same-file AlertDialog or ConfirmDialog subtree. The trigger uses step=\"entry\".",
|
|
238
|
+
} },
|
|
239
|
+
create(context) {
|
|
240
|
+
return { JSXOpeningElement(opening) {
|
|
241
|
+
if (!isButton(context, opening) || !hasValue(context, opening, "intent", "destructive") || !hasValue(context, opening, "step", "confirm")) return
|
|
242
|
+
if (!insideDialog(context, opening)) context.report({ node: opening, messageId: "FE_DESTRUCTIVE_CONFIRM_IN_DIALOG" })
|
|
243
|
+
} }
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A component render exposes one next action, with exclusive branches counted separately. */
|
|
248
|
+
const singleCommitPerRender = {
|
|
249
|
+
meta: { type: "problem", docs: { description: "CTA-6: at most one commit is visible in a component render." }, schema: [], messages: {
|
|
250
|
+
FE_SINGLE_COMMIT_PER_RENDER: "FE_SINGLE_COMMIT_PER_RENDER: this render contains another commit action. Keep the one next action as commit and classify its alternatives by consequence.",
|
|
251
|
+
} },
|
|
252
|
+
create(context) {
|
|
253
|
+
const owners = new Map()
|
|
254
|
+
return { JSXOpeningElement(opening) {
|
|
255
|
+
if (!isButton(context, opening) || !hasValue(context, opening, "intent", "commit")) return
|
|
256
|
+
const owner = functionOwner(opening) ?? context.sourceCode.ast
|
|
257
|
+
const base = renderPath(context, opening, owner)
|
|
258
|
+
const paths = propsOf(context, opening, "intent").flatMap((prop) => prop.value ? valuePaths(context, prop.value) : prop.text === "commit" || prop.texts?.includes("commit") ? [{ guards: new Map(), choices: new Map() }] : [])
|
|
259
|
+
.map((path) => ({ returned: base.returned, guards: new Map([...base.guards, ...path.guards]), choices: new Map([...base.choices, ...path.choices]) }))
|
|
260
|
+
const prior = owners.get(owner) ?? []
|
|
261
|
+
if (paths.some((path) => prior.some((entry) => canCoexist(entry, path)))) context.report({ node: opening, messageId: "FE_SINGLE_COMMIT_PER_RENDER" })
|
|
262
|
+
prior.push(...paths)
|
|
263
|
+
owners.set(owner, prior)
|
|
264
|
+
} }
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A repeated row's next action is alternative; the enclosing view keeps the commit. */
|
|
269
|
+
const noCommitInCollectionItem = {
|
|
270
|
+
meta: { type: "problem", docs: { description: "CTA-6: collection item renders cannot expose commit actions." }, schema: [], messages: {
|
|
271
|
+
FE_NO_COMMIT_IN_COLLECTION_ITEM: "FE_NO_COMMIT_IN_COLLECTION_ITEM: collection items cannot use commit. Use alternative for an item's next action and reserve commit for the view's decision.",
|
|
272
|
+
} },
|
|
273
|
+
create(context) {
|
|
274
|
+
const reported = new Set()
|
|
275
|
+
const report = (opening) => {
|
|
276
|
+
if (!reported.has(opening) && isButton(context, opening) && hasValue(context, opening, "intent", "commit")) {
|
|
277
|
+
reported.add(opening)
|
|
278
|
+
context.report({ node: opening, messageId: "FE_NO_COMMIT_IN_COLLECTION_ITEM" })
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const scanCallback = (callback) => {
|
|
282
|
+
if (!callback) return
|
|
283
|
+
walk(context.sourceCode, callback.body, (node) => {
|
|
284
|
+
if (isFn(node) && !inlineRenderCallback(node)) return false
|
|
285
|
+
if (node.type === "JSXOpeningElement") report(node)
|
|
286
|
+
return undefined
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
JSXAttribute(attribute) {
|
|
291
|
+
if (attribute.name.name === "renderCell" && roleOf(context, attribute.parent.name) === "DataTable") scanCallback(functionOf(context, attributeValue(attribute)))
|
|
292
|
+
},
|
|
293
|
+
JSXOpeningElement(opening) {
|
|
294
|
+
for (let current = opening.parent; current; current = current.parent) {
|
|
295
|
+
if (isFn(current) && !inlineRenderCallback(current)) return
|
|
296
|
+
if (current.type === "JSXElement" && ITEMS.has(roleOf(context, current.openingElement.name))) return report(opening)
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
CallExpression(call) {
|
|
300
|
+
const callee = unwrap(call.callee)
|
|
301
|
+
if (callee.type !== "MemberExpression") return
|
|
302
|
+
const name = callee.computed ? stringOf(callee.property) : callee.property.name
|
|
303
|
+
if (name !== "map") return
|
|
304
|
+
scanCallback(functionOf(context, call.arguments[0]))
|
|
305
|
+
},
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Every intent law is universal across Grammar families and app editions. */
|
|
311
|
+
export const rules = {
|
|
312
|
+
"button-intent-required": buttonIntentRequired,
|
|
313
|
+
"button-no-variant": buttonNoVariant,
|
|
314
|
+
"destructive-confirm-in-dialog": destructiveConfirmInDialog,
|
|
315
|
+
"single-commit-per-render": singleCommitPerRender,
|
|
316
|
+
"no-commit-in-collection-item": noCommitInCollectionItem,
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** All five laws reach consumers at error. */
|
|
320
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Button intent (CTA-6)
|
|
2
|
+
|
|
3
|
+
Owner: knowledge/ui/composition/cta.yaml
|
|
4
|
+
|
|
5
|
+
App `Button` and `IconButton` commands declare `intent`. Grammar derives their variant from that intent and its placement context. These laws apply to every Grammar family in both app editions; aliases and namespace imports retain the same obligations.
|
|
6
|
+
|
|
7
|
+
| Rule | Finding code | Required behavior |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `starci-fe/button-intent-required` | `FE_BUTTON_INTENT_REQUIRED` | Pass the command's `intent`. A known props spread may supply it. |
|
|
10
|
+
| `starci-fe/button-no-variant` | `FE_BUTTON_NO_VARIANT` | Omit `variant`, including variants in known props spreads. |
|
|
11
|
+
| `starci-fe/destructive-confirm-in-dialog` | `FE_DESTRUCTIVE_CONFIRM_IN_DIALOG` | Put `intent="destructive" step="confirm"` inside the same-file `AlertDialog` or `ConfirmDialog` content subtree. Its trigger uses `step="entry"`. A separate component's definition does not inherit its caller's JSX ancestry. |
|
|
12
|
+
| `starci-fe/single-commit-per-render` | `FE_SINGLE_COMMIT_PER_RENDER` | Render at most one commit per component path. Different return paths, opposite conditional arms and opposite guards are mutually exclusive. |
|
|
13
|
+
| `starci-fe/no-commit-in-collection-item` | `FE_NO_COMMIT_IN_COLLECTION_ITEM` | Use `alternative` for an item's next action. A `.map` callback or explicit Grammar collection item context cannot expose commit. |
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
<AlertDialog>
|
|
17
|
+
<Button intent="dismiss">Cancel</Button>
|
|
18
|
+
<Button intent="destructive" step="confirm">Delete record</Button>
|
|
19
|
+
</AlertDialog>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`starci app codemod button-intent` defaults to a dry run. Use `--write` to apply deterministic mappings, then resolve every case in its JSON and Markdown report from the command's actual consequence. A destructive-looking label is a review signal rather than authority to choose an intent.
|
package/docs/hooks-folder.md
CHANGED
|
@@ -8,7 +8,7 @@ Every rule below is an error in `starciFeConfig`; none can be switched off or su
|
|
|
8
8
|
|
|
9
9
|
## `starci-fe/hooks-folder-holds-hooks-only`
|
|
10
10
|
|
|
11
|
-
`hooks/` holds `use*.ts` hooks under the names their family slot gives and, per domain, one `<domain>.shared.ts`; nothing else. A hook file exports one hook; `server-only` and `next/headers` are never imported there. A hook is a function that calls a hook: a React, SWR or Redux hook, a `use*` imported from the app's own `hooks/` folders (a `hooks/swr` wrapper or a sibling domain hook, resolved by TypeScript symbol resolution through barrels; that hook's own file is judged when it is linted), or a library hook from an explicit list (`next-intl` `useTranslations`/`useLocale`/`useFormatter`/`useNow`/`useTimeZone`/`useMessages`, `next/navigation`, `react-hook-form`, `zustand`). A `use*` from outside every `hooks/` folder or an unlisted library is not a hook by its spelling, and a file that exports a helper, a constant or a component still fails (owner ruling 2026-10-16).
|
|
11
|
+
`hooks/` holds `use*.ts` hooks under the names their family slot gives and, per domain, one `<domain>.shared.ts`; nothing else. A hook file exports one hook; `server-only` and `next/headers` are never imported there. A hook is a function that calls a hook: a React, SWR or Redux hook, a `use*` imported from the app's own `hooks/` folders (a `hooks/swr` wrapper or a sibling domain hook, resolved by TypeScript symbol resolution through barrels; that hook's own file is judged when it is linted), or a library hook from an explicit list (`next-intl` `useTranslations`/`useLocale`/`useFormatter`/`useNow`/`useTimeZone`/`useMessages`, `next/navigation`, `next-themes` `useTheme`, `react-hook-form`, `zustand`). A `use*` from outside every `hooks/` folder or an unlisted library is not a hook by its spelling, and a file that exports a helper, a constant or a component still fails (owner ruling 2026-10-16).
|
|
12
12
|
|
|
13
13
|
**Invalid** (`src/hooks/course/readCourse.ts`)
|
|
14
14
|
|
|
@@ -19,7 +19,8 @@ export const readCourse = () => 1
|
|
|
19
19
|
**Valid** (`src/hooks/course/useCourse.ts`)
|
|
20
20
|
|
|
21
21
|
```ts
|
|
22
|
-
|
|
22
|
+
import { useState } from "react"
|
|
23
|
+
export const useCourse = () => useState(1)
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
**Finding code:** `FE_HOOKS_ARE_HOOKS`
|
|
@@ -33,7 +34,8 @@ export const useGetCourseSwr = () => 1
|
|
|
33
34
|
**Valid** (`src/hooks/swr/api/graphql/queries/useQueryCourseSwr.ts`)
|
|
34
35
|
|
|
35
36
|
```ts
|
|
36
|
-
|
|
37
|
+
import { useState } from "react"
|
|
38
|
+
export const useQueryCourseSwr = () => useState(1)
|
|
37
39
|
```
|
|
38
40
|
|
|
39
41
|
**Why:** `<file>` inside `hooks/` is not a React hook, or is not a file its hooks family names. A call to the back end goes to `modules/api`, a helper to `<domain>.shared.ts`.
|
package/docs/native-controls.md
CHANGED
|
@@ -16,11 +16,7 @@ Use the grammar's renderers; never a bare select, input, textarea or button.
|
|
|
16
16
|
<button onClick={go} />
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
**Valid
|
|
20
|
-
|
|
21
|
-
```ts
|
|
22
|
-
<Button onPress={go}>{t("go")}</Button>
|
|
23
|
-
```
|
|
19
|
+
**Valid:** Compose the selected grammar's published control with its supported event props and a translated label.
|
|
24
20
|
|
|
25
21
|
**Finding code:** `FE_NATIVE_FORM_CONTROL`
|
|
26
22
|
|
package/docs/next-conventions.md
CHANGED
|
@@ -141,7 +141,7 @@ export default function Page() { return <CoursesPage /> }
|
|
|
141
141
|
|
|
142
142
|
## `starci-fe/navigation-from-intl`
|
|
143
143
|
|
|
144
|
-
`Link`, `useRouter`, `usePathname` and `redirect` come from `modules/
|
|
144
|
+
`Link`, `useRouter`, `usePathname` and `redirect` come from `modules/routes`, not from Next (catalogue R59, sub-check `FE_I18N_NAVIGATION`).
|
|
145
145
|
|
|
146
146
|
**Invalid** (`src/components/blocks/Feed/component.tsx`)
|
|
147
147
|
|
|
@@ -152,18 +152,18 @@ import { useRouter } from "next/navigation"
|
|
|
152
152
|
**Valid** (`src/components/blocks/Feed/component.tsx`)
|
|
153
153
|
|
|
154
154
|
```tsx
|
|
155
|
-
import { useRouter } from "@/modules/
|
|
155
|
+
import { useRouter } from "@/modules/routes"
|
|
156
156
|
```
|
|
157
157
|
|
|
158
158
|
**Finding code:** `FE_I18N_NAVIGATION`
|
|
159
159
|
|
|
160
160
|
**Why:** `<file>` imports `Link`, `useRouter`, `usePathname` or `redirect` from Next, which is unaware of the locale: paths lose the `[locale]` prefix.
|
|
161
161
|
|
|
162
|
-
**Fix:** Import from `modules/
|
|
162
|
+
**Fix:** Import from `modules/routes`, which next-intl builds from `routing.ts`.
|
|
163
163
|
|
|
164
164
|
## `starci-fe/no-native-anchor`
|
|
165
165
|
|
|
166
|
-
An internal link is `Link` from `modules/
|
|
166
|
+
An internal link is `Link` from `modules/routes`; an external `_blank` link carries `rel` (catalogue R59, sub-check `FE_I18N_NAVIGATION`).
|
|
167
167
|
|
|
168
168
|
**Invalid** (`src/components/blocks/Feed/component.tsx`)
|
|
169
169
|
|
|
@@ -181,7 +181,7 @@ An internal link is `Link` from `modules/i18n/navigation`; an external `_blank`
|
|
|
181
181
|
|
|
182
182
|
**Why:** `<a>` in `<file>` points at an internal route (reloads the whole page, loses the locale, loses prefetch) or opens a new tab without `rel`.
|
|
183
183
|
|
|
184
|
-
**Fix:** Use `Link` from `modules/
|
|
184
|
+
**Fix:** Use `Link` from `modules/routes` with an href built by `modules/routes`; for `_blank` links add `rel="noopener noreferrer"`.
|
|
185
185
|
|
|
186
186
|
## `starci-fe/no-hardcoded-route`
|
|
187
187
|
|
|
@@ -207,9 +207,9 @@ router.push(routes.newWorkspace())
|
|
|
207
207
|
|
|
208
208
|
## `starci-fe/i18n-stack-in-one-module`
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
The shared i18n package owns a multi-app stack. A single app keeps routing/request config in `modules/i18n` and navigation in the `modules/routes` public entry.
|
|
211
211
|
|
|
212
|
-
`defineRouting`
|
|
212
|
+
`defineRouting` and `getRequestConfig` remain in the i18n owner. `createNavigation` belongs to a shared `fe.package.i18n` owner or the single app's `fe.modules.routes` owner. A single app's proxy mounts locale middleware using the routing-only public i18n entry. A shared package exposes its routing-only proxy entry as a declared package export. The proxy does not import `next-intl/navigation` or the app's routes/navigation barrel. Named import aliases and namespace imports keep their factory identity.
|
|
213
213
|
|
|
214
214
|
**Invalid** (`apps/web/src/modules/i18n/request.ts`, a repository with two apps)
|
|
215
215
|
|
|
@@ -229,4 +229,4 @@ export const i18n = createAppI18n({ locales: ["vi"] })
|
|
|
229
229
|
|
|
230
230
|
**Why:** `<file>` builds a layer of the next-intl stack itself (`defineRouting`, `createNavigation`, `getRequestConfig`, `createMiddleware`). The stack may be written only once per repo.
|
|
231
231
|
|
|
232
|
-
**Fix:**
|
|
232
|
+
**Fix:** Use the shared i18n factory, or keep routing/request configuration in the single app's i18n module and navigation in its public routes entry. Keep navigation imports out of the proxy.
|