@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/README.md +41 -84
  3. package/brand-values.mjs +1 -1
  4. package/button-intent.mjs +320 -0
  5. package/docs/button-intent.md +22 -0
  6. package/docs/hooks-folder.md +5 -3
  7. package/docs/native-controls.md +1 -5
  8. package/docs/next-conventions.md +8 -8
  9. package/docs/size-and-state-budget.md +22 -0
  10. package/docs/surface-rules.md +11 -9
  11. package/drawing.mjs +4 -66
  12. package/hooks-folder.mjs +5 -3
  13. package/index.mjs +9 -25
  14. package/landmark.mjs +0 -3
  15. package/lib/class-text.mjs +29 -0
  16. package/lib/config.mjs +5 -6
  17. package/lib/drawing-scope.mjs +15 -0
  18. package/lib/typed-class-values.mjs +10 -1
  19. package/lib/why.mjs +35 -40
  20. package/next-conventions.mjs +33 -16
  21. package/package.json +4 -3
  22. package/runtime/knowledge/grammars/index.yaml +111 -0
  23. package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
  24. package/runtime/knowledge/hfs/fe-stack.yaml +12 -4
  25. package/runtime/knowledge/hfs/slots.yaml +3 -3
  26. package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
  27. package/runtime/scripts/hfs/architecture/config.mjs +8 -8
  28. package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
  29. package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
  30. package/runtime/scripts/hfs/slots.mjs +1 -0
  31. package/runtime/scripts/hfs/view.mjs +1 -0
  32. package/runtime/scripts/lib/grammar-lint.mjs +48 -0
  33. package/size-and-state-budget.mjs +116 -4
  34. package/surface-rules.mjs +159 -26
  35. package/tokens.mjs +5 -217
  36. package/app-shell.mjs +0 -161
  37. package/docs/app-shell.md +0 -50
  38. package/docs/grammar-boundary.md +0 -57
  39. package/docs/status-colors.md +0 -32
  40. package/grammar-boundary.mjs +0 -203
  41. package/icon.mjs +0 -310
  42. package/lib/app-shell-cluster.mjs +0 -66
  43. package/lib/app-shell-create.mjs +0 -33
  44. package/lib/app-shell-owner-source.mjs +0 -124
  45. package/lib/app-shell-owner.mjs +0 -65
  46. package/lib/app-shell-scope.mjs +0 -79
  47. package/lib/app-shell-visible.mjs +0 -92
  48. package/lib/app-shell.mjs +0 -148
  49. package/lib/status-tones.generated.mjs +0 -9
  50. package/menu-entries.mjs +0 -133
  51. package/runtime/modules/goal/source-phrases.yaml +0 -95
  52. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  53. package/status-colors.mjs +0 -137
  54. package/typography.mjs +0 -78
  55. 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
- - Added: `nested-control-variant` (`FE_NESTED_VARIANT`) refuses explicit primary fields in Grammar surface subtrees and app-authored `bg-surface` paint. `no-list-panel-padding` (`FE_LIST_PANEL_PADDING`) refuses stacked horizontal wrapper padding around Grammar lists and section captions.
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
- The StarCi front-end lint canon for Next.js and TypeScript applications. It keeps
4
- component ownership, accessibility, loading behavior, vendor boundaries, translations, naming,
5
- and colocated class-name composition consistent without prescribing a custom rendering protocol.
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
- export default starciFeConfig({ hfs: loadHfs(import.meta.url) })
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
- Each law has a page under [`docs/`](docs) with, for every rule, an invalid and a valid example and
40
- its Vietnamese why. New in 5.0: [env-owner](docs/env-owner.md), [transport](docs/transport.md),
41
- [client-boundary](docs/client-boundary.md), [hooks-folder](docs/hooks-folder.md),
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
- ESLint 9+ (flat config), `eslint-plugin-react-hooks` 7+, and Node.js 20.9+.
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 `color="primary"` prop (that is a variant name, not a colour), and
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.
@@ -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
- export const useCourse = () => 1
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
- export const useQueryCourseSwr = () => 1
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`.
@@ -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** (`src/components/blocks/Feed/index.tsx`)
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
 
@@ -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/i18n/navigation`, not from Next (catalogue R59, sub-check `FE_I18N_NAVIGATION`).
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/i18n/navigation"
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/i18n/navigation`, which next-intl builds from `routing.ts`.
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/i18n/navigation`; an external `_blank` link carries `rel` (catalogue R59, sub-check `FE_I18N_NAVIGATION`).
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/i18n/navigation` with an href built by `modules/routes`; for `_blank` links add `rel="noopener noreferrer"`.
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
- next-intl's routing, navigation, request config and middleware factories are called only in the i18n package (or the only app's `modules/i18n`).
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` (`next-intl/routing`), `createNavigation` (`next-intl/navigation`), `getRequestConfig` (`next-intl/server`) and `createMiddleware` (`next-intl/middleware`), resolved by the import that binds the called name (a renamed import or a namespace member counts), are called only in a file of slot `fe.package.i18n` (`packages/<family>-i18n`, exporting `createAppI18n`), or in a `fe.modules.i18n` file of a repository that declares exactly one app. In a multi-app repository each app's `modules/i18n` calls the package factory. It sits beside `no-second-i18n-stack`, which refuses another i18n library at its import; this rule refuses a second copy of next-intl's own stack.
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:** Call the `createAppI18n` factory of the shared i18n package in `modules/i18n/index.ts` and import the result; in a single-app repo, write the stack only in that app's `modules/i18n`.
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.