@starci/eslint-canon-fe 8.4.39 → 8.4.41

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 (67) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +10 -0
  3. package/app-shell.mjs +161 -0
  4. package/callback-shape.mjs +1 -13
  5. package/docs/app-shell.md +50 -0
  6. package/docs/grammar-boundary.md +25 -1
  7. package/docs/surface-rules.md +41 -0
  8. package/grammar-boundary.mjs +112 -0
  9. package/hooks-folder.mjs +9 -3
  10. package/icon.mjs +31 -1
  11. package/index.mjs +6 -0
  12. package/lib/app-shell-cluster.mjs +66 -0
  13. package/lib/app-shell-create.mjs +33 -0
  14. package/lib/app-shell-owner-source.mjs +124 -0
  15. package/lib/app-shell-owner.mjs +65 -0
  16. package/lib/app-shell-scope.mjs +79 -0
  17. package/lib/app-shell-visible.mjs +92 -0
  18. package/lib/app-shell.mjs +148 -0
  19. package/lib/bindings.mjs +15 -0
  20. package/lib/config.mjs +1 -0
  21. package/lib/fe-stack.mjs +8 -4
  22. package/lib/scope.mjs +2 -2
  23. package/lib/typed-class-values.mjs +72 -0
  24. package/lib/why.mjs +42 -2
  25. package/lint-escape-hatch.mjs +2 -2
  26. package/menu-entries.mjs +133 -0
  27. package/package.json +1 -1
  28. package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
  29. package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
  30. package/runtime/knowledge/hfs/slots.yaml +113 -53
  31. package/runtime/knowledge/sonar-gate.yaml +1 -1
  32. package/runtime/modules/goal/source-phrases.yaml +95 -0
  33. package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
  34. package/runtime/modules/models/registry.yaml +3 -1
  35. package/runtime/modules/models/runtimes.yaml +2 -0
  36. package/runtime/scripts/api/git/ls-tree.mjs +5 -0
  37. package/runtime/scripts/api/git/show.mjs +6 -0
  38. package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
  39. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
  40. package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
  41. package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
  42. package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
  43. package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
  44. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
  45. package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
  46. package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
  47. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -1
  48. package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
  49. package/runtime/scripts/hfs/declaration-slots.mjs +14 -3
  50. package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
  51. package/runtime/scripts/hfs/rule-params-shape.mjs +18 -6
  52. package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
  53. package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
  54. package/runtime/scripts/hfs/slot-side-problems.mjs +3 -3
  55. package/runtime/scripts/hfs/slots.mjs +2 -1
  56. package/runtime/scripts/hfs/view.mjs +2 -1
  57. package/runtime/scripts/lib/in-order.mjs +71 -0
  58. package/runtime/scripts/lib/language.mjs +94 -1
  59. package/runtime/scripts/lib/package-at.mjs +62 -0
  60. package/runtime/scripts/lib/same-text.mjs +5 -0
  61. package/runtime/scripts/lib/secret-patterns.mjs +58 -9
  62. package/runtime/scripts/lib/source-phrases.mjs +34 -0
  63. package/runtime/scripts/lib/ts-analysis.mjs +10 -0
  64. package/size-and-state-budget.mjs +3 -3
  65. package/surface-rules.mjs +230 -0
  66. package/swr-family.mjs +136 -7
  67. package/transport.mjs +2 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 8.4.41 - 2026-10-26
4
+
5
+ - Enforce the app-shell canon through fixed WorkspaceShell frames, drawer navigation, ordered navbar controls, control sizing and page-owned creation, with full and lite regression coverage.
6
+ - 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.
7
+ - 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.
8
+ - 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`.
9
+ - 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.
10
+
11
+ ## 8.4.40 - 2026-10-25
12
+ - Changed: the bundled canon pins select @starci/stylelint-canon 2.0.11, @starci/hfs 4.7.33 and @starci/cli 1.7.33.
13
+
3
14
  ## 8.4.39 - 2026-10-25
4
15
  - Changed: the bundled canon pins select @starci/grammar 0.11.0, @starci/hfs 4.7.32 and @starci/cli 1.7.32.
5
16
 
package/README.md CHANGED
@@ -32,6 +32,10 @@ exported; `why[rule]` is `{ code, vi, fixVi }`, the finding code and the Vietnam
32
32
 
33
33
  ## Rules
34
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.
38
+
35
39
  Each law has a page under [`docs/`](docs) with, for every rule, an invalid and a valid example and
36
40
  its Vietnamese why. New in 5.0: [env-owner](docs/env-owner.md), [transport](docs/transport.md),
37
41
  [client-boundary](docs/client-boundary.md), [hooks-folder](docs/hooks-folder.md),
@@ -76,6 +80,12 @@ The one locale pattern everywhere is `next-intl`, a `[locale]` segment, default
76
80
  Grammar is a business-neutral HeroUI-backed component package. Its components accept ordinary
77
81
  typed React props and children.
78
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
+
79
89
  ## Requirements
80
90
 
81
91
  ESLint 9+ (flat config), `eslint-plugin-react-hooks` 7+, and Node.js 20.9+.
package/app-shell.mjs ADDED
@@ -0,0 +1,161 @@
1
+ /** FE-APP-SHELL-1..5: static app-frame and visible navbar composition from the UI canon. */
2
+ import { isProductSource } from "./lib/scope.mjs"
3
+ import { grammarName, isBarControl, propOf, resolveLocal, semanticText, textProp } from "./lib/app-shell.mjs"
4
+ import { appBarOpenings, isAppBar, isAppShell, isNavigationDrawer, ownsAppFrame } from "./lib/app-shell-scope.mjs"
5
+ import { visitVisible } from "./lib/app-shell-visible.mjs"
6
+ import { clusterAlternatives } from "./lib/app-shell-cluster.mjs"
7
+ import { isCreateAction, menuHasCreate } from "./lib/app-shell-create.mjs"
8
+ import { provesImportedBar } from "./lib/app-shell-owner.mjs"
9
+ import { variableOf } from "./lib/bindings.mjs"
10
+
11
+ const CANON = "knowledge/ui/composition/app-shell.yaml"
12
+ const BOTTOM_TABS = /^(?:BottomNav|BottomNavigation|BottomTabBar|MobileBottomNav|MobileTabBar)$/
13
+ const EXPECTED_CLUSTER = ["language:IconButton", "theme:Switch", "notifications:IconButton", "account:AvatarButton"]
14
+
15
+ const meta = (code, description, messages) => ({
16
+ type: "problem",
17
+ docs: { description: `${code}: ${description}`, url: CANON },
18
+ schema: [],
19
+ messages,
20
+ })
21
+
22
+ /** The app frame is the fixed-viewport Grammar shell; hand-written frame landmarks are rejected. */
23
+ export const appShellFrame = {
24
+ meta: meta("FE_APP_SHELL_FRAME", "FE-APP-SHELL-1 uses WorkspaceShell viewport=fixed.", {
25
+ frame: "FE_APP_SHELL_FRAME: compose the app frame with WorkspaceShell viewport=\"fixed\"; Grammar owns its header, navigation and independent scroll regions.",
26
+ }),
27
+ create(context) {
28
+ if (!isProductSource(context)) return {}
29
+ return { JSXOpeningElement(node) {
30
+ const wrongViewport = isAppShell(context, node) && textProp(context, node, "viewport") !== "fixed"
31
+ const handFrame = ownsAppFrame(context, node) && (node.name.type === "JSXIdentifier" && ["header", "nav", "main"].includes(node.name.name) || isAppBar(context, node) && !appBarOpenings(context).has(node))
32
+ if (wrongViewport || handFrame) {
33
+ context.report({ node, messageId: "frame" })
34
+ }
35
+ } }
36
+ },
37
+ }
38
+
39
+ /** Bottom navigation is not an alternative compact app-shell frame. */
40
+ export const noShellBottomTabs = {
41
+ meta: meta("FE_APP_SHELL_BOTTOM_TABS", "FE-APP-SHELL-4 keeps mobile navigation in the sidebar drawer.", {
42
+ bottom: "FE_APP_SHELL_BOTTOM_TABS: the app shell uses a mobile subnav opener and Sidebar drawer. Remove bottom tab navigation and WorkspaceShell compactNavigation.",
43
+ }),
44
+ create(context) {
45
+ if (!isProductSource(context)) return {}
46
+ return { JSXOpeningElement(node) {
47
+ const name = grammarName(context, node)
48
+ const compact = name === "WorkspaceShell" && propOf(context, node, "compactNavigation")
49
+ const value = compact && resolveLocal(context, compact.value)
50
+ const absent = value?.type === "Identifier" && value.name === "undefined" && !variableOf(context, value)?.defs.length
51
+ if (BOTTOM_TABS.test(name ?? "") || compact && !absent && (!compact.known || compact.value !== null || compact.present)) context.report({ node, messageId: "bottom" })
52
+ } }
53
+ },
54
+ }
55
+
56
+ const visitBar = (context, node, visit) => {
57
+ const name = grammarName(context, node)
58
+ if (name === "WorkspaceShell" && isAppShell(context, node)) {
59
+ for (const slot of ["header", "compactHeader"]) visitVisible(context, propOf(context, node, slot).value, visit)
60
+ } else if (isAppBar(context, node)) {
61
+ visitVisible(context, node.parent, visit)
62
+ } else if (isNavigationDrawer(context, node)) {
63
+ visitVisible(context, propOf(context, node, "trigger").value, visit)
64
+ }
65
+ }
66
+
67
+ const delegatesImportedBar = (context, expression) => {
68
+ let imported = false
69
+ let controls = false
70
+ let unknown = false
71
+ visitVisible(context, expression, (opening, name) => {
72
+ if (isBarControl(context, opening, name)) controls = true
73
+ if (name !== null) return
74
+ const proof = provesImportedBar(context, opening)
75
+ if (proof === true) imported = true
76
+ if (proof === false) unknown = true
77
+ })
78
+ return imported && !controls && !unknown
79
+ }
80
+
81
+ /** Every visible control in a navbar band uses the shared control size. */
82
+ export const navbarControlSize = {
83
+ meta: meta("FE_NAVBAR_CONTROL_SIZE", "FE-APP-SHELL-2 requires size=control on bar controls.", {
84
+ control: "FE_NAVBAR_CONTROL_SIZE: {{name}} in the navbar band needs size=\"control\" so every bar control shares Grammar's control height.",
85
+ }),
86
+ create(context) {
87
+ if (!isProductSource(context)) return {}
88
+ const reported = new Set()
89
+ return { JSXOpeningElement(node) {
90
+ visitBar(context, node, (control, name) => {
91
+ if (!isBarControl(context, control, name) || textProp(context, control, "size") === "control" || reported.has(control)) return
92
+ reported.add(control)
93
+ context.report({ node: control, messageId: "control", data: { name } })
94
+ })
95
+ } }
96
+ },
97
+ }
98
+
99
+ /** The right cluster is checked at the bar's actions composition, in visual source order. */
100
+ export const navbarCluster = {
101
+ meta: meta("FE_NAVBAR_CLUSTER", "FE-APP-SHELL-3 fixes navbar action kinds and order.", {
102
+ cluster: "FE_NAVBAR_CLUSTER: navbar actions must be a tertiary Language IconButton in DropdownMenu, theme Switch, notification IconButton in Popover, account AvatarButton in DropdownMenu, in that order. Mobile retains notifications then account and moves language/theme into the drawer.",
103
+ }),
104
+ create(context) {
105
+ if (!isProductSource(context)) return {}
106
+ return { JSXOpeningElement(node) {
107
+ const name = grammarName(context, node)
108
+ if (!isAppShell(context, node) && !isAppBar(context, node)) return
109
+ const shellHeader = name === "WorkspaceShell"
110
+ const actions = propOf(context, node, shellHeader ? "header" : "actions")
111
+ const { alternatives, ownsBar } = clusterAlternatives(context, actions.value, shellHeader)
112
+ if (ownsBar) return
113
+ if (shellHeader && delegatesImportedBar(context, actions.value)) return
114
+ const desktop = EXPECTED_CLUSTER.join("|")
115
+ const mobile = EXPECTED_CLUSTER.slice(2).join("|")
116
+ const sequences = alternatives.map((sequence) => sequence.join("|"))
117
+ if (!sequences.includes(desktop) || sequences.some((sequence) => sequence !== desktop && sequence !== mobile)) context.report({ node, messageId: "cluster" })
118
+ } }
119
+ },
120
+ }
121
+
122
+ /** Creation belongs to its page, never to the global app bar. */
123
+ export const noNavbarCreate = {
124
+ meta: meta("FE_NAVBAR_CREATE", "FE-APP-SHELL-5 keeps create actions on pages.", {
125
+ create: "FE_NAVBAR_CREATE: move this create action from the navbar to its page.",
126
+ }),
127
+ create(context) {
128
+ if (!isProductSource(context)) return {}
129
+ const reported = new Set()
130
+ const report = (node) => {
131
+ if (reported.has(node)) return
132
+ reported.add(node)
133
+ context.report({ node, messageId: "create" })
134
+ }
135
+ const inspect = (control, name) => {
136
+ if (name === "DropdownMenu" || name === "Popover") {
137
+ if (["entries", "items", "sections"].some((slot) => menuHasCreate(context, propOf(context, control, slot).value))) report(control)
138
+ visitVisible(context, propOf(context, control, "content").value, inspect)
139
+ for (const child of control.parent.children) visitVisible(context, child, inspect)
140
+ }
141
+ if (!isBarControl(context, control, name) || reported.has(control)) return
142
+ const text = `${semanticText(context, control)} ${control.parent.children.map((child) => context.sourceCode.getText(child)).join(" ")}`
143
+ if (isCreateAction(text)) report(control)
144
+ }
145
+ return { JSXOpeningElement(node) {
146
+ visitBar(context, node, inspect)
147
+ } }
148
+ },
149
+ }
150
+
151
+ /** Every app-shell enforcer is gathered into the ordinary FE lint and gate path. */
152
+ export const rules = {
153
+ "app-shell-frame": appShellFrame,
154
+ "no-shell-bottom-tabs": noShellBottomTabs,
155
+ "navbar-control-size": navbarControlSize,
156
+ "navbar-cluster": navbarCluster,
157
+ "no-navbar-create": noNavbarCreate,
158
+ }
159
+
160
+ /** Every rule is mandatory. */
161
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
@@ -7,6 +7,7 @@
7
7
  * This syntax check follows lexical bindings and local aliases rather than trusting a method's spelling.
8
8
  */
9
9
  import { stringOf, walk } from "./lib/ast.mjs"
10
+ import { patternPath } from "./lib/bindings.mjs"
10
11
  import { normalizePath } from "./lib/path.mjs"
11
12
  import { ownerExportKind } from "./lib/pure-owner-helper.mjs"
12
13
  import { classifyImport, inSlot, resolveImport } from "./lib/scope.mjs"
@@ -32,19 +33,6 @@ const bindingOf = (sourceCode, node) => {
32
33
  /** Whether an identifier refers to the actual unshadowed platform global. */
33
34
  const isGlobal = (sourceCode, node, name) => node?.type === "Identifier" && node.name === name && !(bindingOf(sourceCode, node)?.defs.length)
34
35
 
35
- /** The property path that a destructured binding selects from its initializer. */
36
- const patternPath = (pattern, name) => {
37
- if (pattern.type === "Identifier") return pattern.name === name ? [] : null
38
- if (pattern.type === "AssignmentPattern") return patternPath(pattern.left, name)
39
- if (pattern.type !== "ObjectPattern") return null
40
- for (const property of pattern.properties) {
41
- if (property.type !== "Property") continue
42
- const nested = patternPath(property.value, name)
43
- if (nested !== null) return [property.computed ? stringOf(property.key) : property.key.name ?? property.key.value, ...nested]
44
- }
45
- return null
46
- }
47
-
48
36
  /** Route configuration is inert; a second HTTP door or an exported helper is not. */
49
37
  const CONFIG_EXPORTS = new Set(["runtime", "dynamic", "revalidate", "fetchCache", "preferredRegion", "maxDuration"])
50
38
  const FUNCTIONS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"])
@@ -0,0 +1,50 @@
1
+ # App shell
2
+
3
+ Owner: knowledge/ui/composition/app-shell.yaml
4
+
5
+ The application composes `WorkspaceShell viewport="fixed"`. The `app-shell-frame` rule identifies
6
+ workspace chrome from a fixed shell, sidebar/navigation slot, workspace identity, HFS layout owner,
7
+ or a shell owner combining a bar and sidebar. It rejects hand-written app-frame landmarks and
8
+ workspace shells without the fixed viewport. Generic flow-based site chrome stays in its own scope.
9
+ `no-shell-bottom-tabs` rejects bottom navigation components and the shell's `compactNavigation` slot.
10
+
11
+ The `navbar-cluster` rule reads the visible `TopBar` or `NavigationFeatureNav` actions in source order:
12
+ tertiary Language `IconButton` triggering `DropdownMenu`, theme `Switch`, notification `IconButton`
13
+ triggering `Popover`, account `AvatarButton` triggering `DropdownMenu`. It follows
14
+ local JSX constants and components, constant object members, imported Grammar aliases, and namespace imports. Translated
15
+ labels may be dynamic; icon sources identify language and notifications. Menu contents and popover
16
+ content belong to their overlays and are outside the bar's control sequence. Conditional render
17
+ branches are checked separately. Grammar `TopBar` and `NavigationFeatureNav` compositions are app
18
+ bars by default. A proven `WorkspaceShell viewport="flow"` with no workspace/sidebar evidence owns
19
+ generic site chrome. A shell hosting an imported navbar delegates only after its owned public
20
+ export and rendered Base chain prove a `TopBar` or `NavigationFeatureNav` in every alternative.
21
+ Workspace exports resolve back to source. Raw or unknown imported headers are red; the actual
22
+ bar's own actions are checked where composed. Each lint source snapshot has fresh dependency proofs.
23
+
24
+ `navbar-control-size` requires `size="control"` on each bar control. Unknown trailing prop spreads
25
+ cannot prove a size; put the explicit size after a spread. A left `Drawer` containing
26
+ `Sidebar presentation="drawer"` also identifies a mobile bar opener when composed in a separate
27
+ client owner. Its trigger is checked; drawer bodies and footers retain their normal field sizes.
28
+ `no-navbar-create` places creation on its
29
+ own page and rejects create controls in the navbar and its attached menus or popovers. Static
30
+ evidence includes literal labels, translated keys with create semantics, and conventional create
31
+ handlers. Runtime-generated labels and arbitrary handler behavior need semantic review; the rule
32
+ does not execute application code.
33
+
34
+ | Rule | Invalid composition | Valid composition |
35
+ | --- | --- | --- |
36
+ | `app-shell-frame` | An app `Shell` returns a hand-written `<header>` and `<nav>`. | `WorkspaceShell viewport="fixed"` owns the app frame. |
37
+ | `no-shell-bottom-tabs` | The shell supplies `compactNavigation={<BottomNav />}`. | The shell supplies `navigationDrawer` with a drawer `Sidebar`. |
38
+ | `navbar-control-size` | A navbar `IconButton` uses `size="md"`. | The same control uses `size="control"`. |
39
+ | `navbar-cluster` | Account precedes notifications, or a language trigger uses a regular `Button`. | Language menu, theme switch, notification popover, account menu use their stated Grammar trigger kinds in that order. |
40
+ | `no-navbar-create` | A navbar button or attached menu item creates a record. | The page owns its create action. |
41
+
42
+ ```tsx
43
+ // Invalid: the navbar action creates a page record and has a different height.
44
+ <TopBar actions={<Button onPress={on.create}>Create</Button>} />
45
+
46
+ // Valid: a page action belongs to page content.
47
+ <SurfaceCard><Button onPress={on.create}>Create</Button></SurfaceCard>
48
+ ```
49
+
50
+ All five rules are errors in the gathered FE canon and run through the ordinary application gate.
@@ -1,6 +1,6 @@
1
1
  # Grammar boundary
2
2
 
3
- Law module: `grammar-boundary.mjs`. Catalogue: R62 FE_NATIVE_FORM_CONTROL (the grammar owns the element).
3
+ Law module: `grammar-boundary.mjs`. Catalogue: R62 FE_NATIVE_FORM_CONTROL (the grammar owns the element), R65 FE_SIZE_AND_STATE_BUDGET (timer ownership).
4
4
 
5
5
  Page structure and text are composed from `@starci/grammar` (or the repository's `<family>-ui` package), not drawn as raw HTML.
6
6
 
@@ -14,6 +14,8 @@ Applies to app source that composes the grammar (slots `fe.feature`, `fe.compone
14
14
 
15
15
  The refused tags are exactly those `@starci/grammar` has a component for (the twin test reads the package source): `h1-h6` (`Heading`), `p` and `span` (`Text`), `hr` (`Divider`), `label` (`Label`), `form` (`Form`), `fieldset` (`Fieldset`), `dl` `dt` `dd` (`DescriptionList`), `table` `thead` `tbody` `tr` `td` `th` (`DataTable`), `ul` `ol` `li` (`List` and `ListItem` for a plain semantic list, `SurfaceListCard`, `StaticStateRow`), `header` (`SectionHeader`, `TopBar`), `footer` (`Footer`), `nav` (`NavLandmark` for a named row of links, `Subnav`, `Breadcrumbs`, `NavigationFeatureNav`), `section` (`Region` for a named non-card region, `SurfaceCard`), `main` (`WorkspaceShell`), `figure` and `figcaption` (`MediaFrame`). `div`, `br`, `aside` and `article` are not refused: the grammar has no component for them.
16
16
 
17
+ Native `<time>` remains available for an absolute timestamp. Live relative time and countdown labels use `RelativeTime`.
18
+
17
19
  **Invalid** (`src/features/pages/home/component.tsx`)
18
20
 
19
21
  ```ts
@@ -31,3 +33,25 @@ export const Home = () => <SurfaceCard><Text>{t("intro")}</Text></SurfaceCard>
31
33
  **Why:** Raw `<tag>` in `<file>`. Page structure and text are composed from grammar components, not written as raw HTML.
32
34
 
33
35
  **Fix:** Replace it with the matching grammar component (`Heading`, `Text`, `SurfaceCard`, ...); if the grammar lacks one, add it to the grammar or the ui package instead of drawing it in place.
36
+
37
+ ## `starci-fe/no-app-relative-clock`
38
+
39
+ Grammar owns the shared clock for live relative time and countdowns. App components, hooks, modules and shared packages, including a product UI package, pass `dateTime`, `locale` and `mode` to `RelativeTime`.
40
+
41
+ The rule rejects a recurring `setInterval` or self-scheduling `setTimeout` whose callback or same-file helper reads `Date.now()`, `Date()`, zero-argument `new Date()` or `performance.now()`. It follows local aliases and destructured platform methods. Static reads, one-shot delays and timers that do not read the clock remain available. Indirect external clock helpers and counters that only decrement state need semantic review.
42
+
43
+ **Invalid** (`src/hooks/time/useNow.ts`)
44
+
45
+ ```ts
46
+ const timer = setInterval(() => update(Date.now()), 1000)
47
+ ```
48
+
49
+ **Valid** (`src/components/leaves/UpdatedAt/index.tsx`)
50
+
51
+ ```tsx
52
+ const UpdatedAt = () => <RelativeTime dateTime={instant} locale={locale} mode="relative" />
53
+ ```
54
+
55
+ **Finding code:** `FE_SIZE_AND_STATE_BUDGET`
56
+
57
+ **Fix:** Render the Grammar primitive and pass the instant; its single shared ticker owns updates and its deterministic fallback owns the server render.
@@ -0,0 +1,41 @@
1
+ # Grammar surface composition
2
+
3
+ Law module: `surface-rules.mjs`. Canon: ANATOMY-2 and PADDING-0.
4
+
5
+ ## `starci-fe/nested-control-variant`
6
+
7
+ Finding code: `FE_NESTED_VARIANT`. Grammar surfaces supply the secondary field default through their nesting context. An explicit primary field inside a surface subtree in the same component is refused. Named import aliases and namespace imports preserve Grammar ownership. A different component owns a separate static subtree; overlay triggers belong outside the overlay content.
8
+
9
+ App `className` values carry layout. A raw `bg-surface` utility paints a surface and is refused, including conditional classes, class builders, responsive variants, important modifiers and opacity suffixes. Static props spreads keep the same obligations. Typed app lint follows imported colocated class declarations as well as local constants.
10
+
11
+ Invalid:
12
+
13
+ ```tsx
14
+ import { SurfaceCard, SearchField } from "@starci/grammar/core"
15
+ const Panel = () => <SurfaceCard><SearchField variant="primary" /></SurfaceCard>
16
+ ```
17
+
18
+ Valid:
19
+
20
+ ```tsx
21
+ import { SurfaceCard, SearchField } from "@starci/grammar/core"
22
+ const Panel = () => <SurfaceCard><SearchField /></SurfaceCard>
23
+ ```
24
+
25
+ ## `starci-fe/no-list-panel-padding`
26
+
27
+ Finding code: `FE_LIST_PANEL_PADDING`. A wrapper around Grammar `ListBox` or its section caption cannot add `px-*`, `ps-*` or `pe-*`. These insets add to the list's own padding. Grammar's panel inset aligns rows, section captions and the surface header to one content edge.
28
+
29
+ Invalid:
30
+
31
+ ```tsx
32
+ import { SurfaceCard, ListBox } from "@starci/grammar/core"
33
+ const Panel = () => <SurfaceCard><div className="px-2"><ListBox label="Members" items={members} /></div></SurfaceCard>
34
+ ```
35
+
36
+ Valid:
37
+
38
+ ```tsx
39
+ import { SurfaceCard, ListBox } from "@starci/grammar/core"
40
+ const Panel = () => <SurfaceCard><ListBox inset="panel" label="Members" items={members} /></SurfaceCard>
41
+ ```
@@ -17,7 +17,10 @@
17
17
  * rule refuses to guess a scope.
18
18
  */
19
19
 
20
+ import { walk } from "./lib/ast.mjs"
21
+ import { functionOf, isFn, unwrap, variableOf } from "./lib/bindings.mjs"
20
22
  import { hfsOf } from "./lib/hfs.mjs"
23
+ import { isProductSource } from "./lib/scope.mjs"
21
24
 
22
25
  /**
23
26
  * The intrinsic elements of page structure and text, each with the grammar components that render it.
@@ -82,9 +85,118 @@ export const noRawStructuralElement = {
82
85
  },
83
86
  }
84
87
 
88
+ /** The static property name of an access or destructured property. */
89
+ const propertyName = (node) => {
90
+ const key = node.property ?? node.key
91
+ if (!node.computed && key?.type === "Identifier") return key.name
92
+ return key?.type === "Literal" && typeof key.value === "string" ? key.value : null
93
+ }
94
+
95
+ /** The platform member a local destructured binding selects. */
96
+ const destructuredPath = (context, declarator, local, seen) => {
97
+ if (declarator.id.type !== "ObjectPattern") return null
98
+ const property = declarator.id.properties.find((entry) => entry.type === "Property" && entry.value.type === "Identifier" && entry.value.name === local)
99
+ const name = property && propertyName(property)
100
+ const object = platformPath(context, declarator.init, seen)
101
+ return object && name ? [...object, name] : null
102
+ }
103
+
104
+ /** A platform path, following local aliases and destructuring while refusing shadowed globals. */
105
+ const platformPath = (context, node, seen = new Set()) => {
106
+ const inner = unwrap(node)
107
+ if (!inner || seen.has(inner)) return null
108
+ seen.add(inner)
109
+ if (inner.type === "MemberExpression") {
110
+ const object = platformPath(context, inner.object, seen)
111
+ const name = propertyName(inner)
112
+ return object && name ? [...object, name] : null
113
+ }
114
+ if (inner.type !== "Identifier") return null
115
+ const variable = variableOf(context, inner)
116
+ if (!variable || variable.defs.length === 0) return [inner.name]
117
+ for (const definition of variable.defs) {
118
+ if (definition.type !== "Variable" || !definition.node.init) continue
119
+ const declarator = definition.node
120
+ if (declarator.id.type === "Identifier") return platformPath(context, declarator.init, seen)
121
+ return destructuredPath(context, declarator, inner.name, seen)
122
+ }
123
+ return null
124
+ }
125
+
126
+ /** A platform API spelling with the optional global-object prefix removed. */
127
+ const platformName = (context, node) => {
128
+ const parts = platformPath(context, node)
129
+ if (!parts) return null
130
+ if (["window", "globalThis", "self"].includes(parts[0])) parts.shift()
131
+ return parts.join(".")
132
+ }
133
+
134
+ /** A wall-clock read: Date.now(), Date(), zero-argument new Date(), or performance.now(). */
135
+ const readsClock = (context, root, seen = new Set()) => {
136
+ if (!root || seen.has(root)) return false
137
+ seen.add(root)
138
+ let found = false
139
+ walk(context.sourceCode, root, (node) => {
140
+ if (found) return false
141
+ if (node.type === "NewExpression" && node.arguments.length === 0 && platformName(context, node.callee) === "Date") found = true
142
+ if (node.type !== "CallExpression") return undefined
143
+ if (["Date", "Date.now", "performance.now"].includes(platformName(context, node.callee))) found = true
144
+ const helper = functionOf(context, node.callee)
145
+ if (helper && readsClock(context, helper.body, seen)) found = true
146
+ return undefined
147
+ })
148
+ return found
149
+ }
150
+
151
+ /** Whether a timer callback runs its enclosing function again, directly or through a same-file helper. */
152
+ const callsFunction = (context, root, target, seen = new Set()) => {
153
+ if (!root || seen.has(root)) return false
154
+ if (root === target) return true
155
+ seen.add(root)
156
+ let found = false
157
+ walk(context.sourceCode, root.body ?? root, (node) => {
158
+ if (found) return false
159
+ if (node.type !== "CallExpression") return undefined
160
+ const called = functionOf(context, node.callee)
161
+ if (called === target || called && callsFunction(context, called, target, seen)) found = true
162
+ return undefined
163
+ })
164
+ return found
165
+ }
166
+
167
+ /** Grammar owns the shared clock for live time labels; product components and hooks only pass instants. */
168
+ export const noAppRelativeClock = {
169
+ meta: {
170
+ type: "problem",
171
+ docs: { description: "Live relative time and countdowns use Grammar's shared clock rather than a product-owned ticker." },
172
+ schema: [],
173
+ messages: {
174
+ clock: "This recurring timer reads the clock. Render RelativeTime with dateTime, locale and mode; Grammar owns its single shared ticker and its server-safe first render. Product components and hooks pass the target instant and never start another clock for time labels.",
175
+ },
176
+ },
177
+ create(context) {
178
+ if (!isProductSource(context)) return {}
179
+ return {
180
+ CallExpression(node) {
181
+ const timer = platformName(context, node.callee)
182
+ if (timer !== "setInterval" && timer !== "setTimeout") return
183
+ const callback = functionOf(context, node.arguments[0])
184
+ if (!callback) return
185
+ let owner = node.parent
186
+ while (owner && !isFn(owner)) owner = owner.parent
187
+ const repeats = timer === "setInterval" || owner && callsFunction(context, callback, owner)
188
+ if (repeats && (readsClock(context, callback.body) || timer === "setTimeout" && readsClock(context, owner.body))) {
189
+ context.report({ node, messageId: "clock" })
190
+ }
191
+ },
192
+ }
193
+ },
194
+ }
195
+
85
196
  /** The rules this law contributes to the plugin. */
86
197
  export const rules = {
87
198
  "no-raw-structural-element": noRawStructuralElement,
199
+ "no-app-relative-clock": noAppRelativeClock,
88
200
  }
89
201
 
90
202
  /** Every rule is an error. */
package/hooks-folder.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  * saying where the non-hook code goes. It now does, per family of the front-end stack (each its own slot):
8
8
  *
9
9
  * hooks/swr/api/{graphql,rest}/{queries,mutations}/use{Query,Mutate,Get,Post,Put,Patch,Delete}<X>Swr.ts
10
+ * hooks/swr/api/sse/subscriptions/useSubscribe<X>Swr.ts
10
11
  * one SWR hook per modules/api operation (fe.hooks.swr), plus SwrSideEffects.tsx
11
12
  * hooks/rhf/use<X>Form.ts one form (fe.hooks.rhf)
12
13
  * hooks/zustand/<store>/store.ts one local store and its use*.ts selectors (fe.hooks.zustand)
@@ -37,7 +38,7 @@ import { rules as swrFamilyRules } from "./swr-family.mjs"
37
38
  const isHookName = (name) => /^use[A-Z0-9]/.test(name)
38
39
 
39
40
  /**
40
- * The SWR key of a hook (`QUERY_<X>_SWR_KEY`, `MUTATE_<X>_SWR_KEY`, fe-stack data): the one constant a hook file exports beside its
41
+ * The SWR key of a hook (`QUERY_<X>_SWR_KEY`, `MUTATE_<X>_SWR_KEY`, `SUBSCRIBE_<X>_SWR_KEY`, fe-stack data): the one constant a hook file exports beside its
41
42
  * hook, so a mutation or a socket message revalidates exactly that cache entry.
42
43
  */
43
44
  const isSwrKeyName = (name) => /^[A-Z][A-Z0-9_]*_SWR_KEY$/.test(name)
@@ -85,8 +86,9 @@ const SWR_HOOKS = new Map([
85
86
  ["swr/immutable", new Set(["useSWRImmutable"])],
86
87
  ["swr/infinite", new Set(["useSWRInfinite"])],
87
88
  ["swr/mutation", new Set(["useSWRMutation"])],
89
+ ["swr/subscription", new Set(["useSWRSubscription"])],
88
90
  ])
89
- const SWR_DEFAULTS = new Map([["swr", "useSWR"], ["swr/immutable", "useSWRImmutable"], ["swr/infinite", "useSWRInfinite"], ["swr/mutation", "useSWRMutation"]])
91
+ const SWR_DEFAULTS = new Map([["swr", "useSWR"], ["swr/immutable", "useSWRImmutable"], ["swr/infinite", "useSWRInfinite"], ["swr/mutation", "useSWRMutation"], ["swr/subscription", "useSWRSubscription"]])
90
92
  /**
91
93
  * The library hooks a composite hook may stand on (an explicit list, owner ruling 2026-10-16): next-intl, next/navigation,
92
94
  * react-hook-form and zustand's React entry points. A name outside the list is not a hook by its spelling.
@@ -205,7 +207,8 @@ const hookFileOf = (context) => {
205
207
  const stray = Boolean(verdict) && !verdict.allowed
206
208
  const allows = (verdict?.allows ?? hfs.slot(found.slot)?.allows ?? []).join("`, `")
207
209
  // A role the slot names (`entry`, `effects`, `store`) or a non-hook file it allows (sockets.ts, enums/, types/, barrels) is no hook file.
208
- return { stray, directlyInHooks: false, hookFile: !stray && !found.role && isHookFileName(name), sharedFile: false, family: found.slot, allows }
210
+ const subscriptions = ["/api/sse/subscriptions/", "/api/graphql/subscriptions/"].some((folder) => file.split(String.fromCharCode(92)).join("/").includes(folder))
211
+ return { stray, directlyInHooks: false, hookFile: !stray && !found.role && isHookFileName(name), sharedFile: false, family: found.slot, allows, subscriptions }
209
212
  }
210
213
  if (!inSlot(context, "fe.hooks")) return null
211
214
  // A file directly in `hooks/` is its own root: the slot binds the file name as the `domain`.
@@ -232,6 +235,8 @@ export const hooksFolderHoldsHooksOnly = {
232
235
  "`{{name}}` is in `hooks/` but is not a React hook. Hooks are `use<Name>.ts`; the domain's non-hook helpers go in `<domain>.shared.ts`; a call to the back end is an operation of `modules/api`, wrapped by an SWR hook in `hooks/swr/`.",
233
236
  family:
234
237
  "`{{name}}` is not a file `{{family}}` names. This family holds `{{allows}}`: an SWR hook is `use{Query,Mutate}<X>Swr.ts` (GraphQL) or `use{Get,Post,Put,Patch,Delete}<X>Swr.ts` (REST) in its `api/<protocol>/<queries|mutations>/` folder, a form is `use<X>Form.ts`. A name outside the list is a hook nobody finds by its family, or code that is not a hook.",
238
+ subscription:
239
+ "`{{name}}` is not a file `{{family}}` names in a subscriptions folder. A stream or GraphQL subscription hook is `useSubscribe<X>Swr.ts` in `api/sse/subscriptions/` or `api/graphql/subscriptions/`, one hook per file, exporting at most its `SUBSCRIBE_<X>_SWR_KEY`.",
235
240
  export:
236
241
  "`{{name}}` is exported from a hook file but is not a hook. A hook file exports the hook (a function named `use...`) and at most its SWR key (`QUERY_<X>_SWR_KEY`, `MUTATE_<X>_SWR_KEY`); a helper belongs in `<domain>.shared.ts`.",
237
242
  many:
@@ -256,6 +261,7 @@ export const hooksFolderHoldsHooksOnly = {
256
261
  Program(program) {
257
262
  if (judged.directlyInHooks) return context.report({ node: program, messageId: "domain" })
258
263
  if (!judged.stray) return
264
+ if (judged.family && judged.subscriptions) return context.report({ node: program, messageId: "subscription", data: { name, family: judged.family } })
259
265
  if (judged.family) context.report({ node: program, messageId: "family", data: { name, family: judged.family, allows: judged.allows } })
260
266
  else context.report({ node: program, messageId: "notHook", data: { name } })
261
267
  },
package/icon.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  * generalises on its own, and the gap between them is exactly where a third step gets invented.
13
13
  */
14
14
 
15
- import { isClassAttribute, staticText } from "./lib/ast.mjs"
15
+ import { attribute, isClassAttribute, staticText, tagName } from "./lib/ast.mjs"
16
16
  import { canonicalPackages } from "./lib/fe-stack.mjs"
17
17
  import { hfsOf } from "./lib/hfs.mjs"
18
18
  import { fileOf, inSlot, isComponentFile, isProductSource, kindOfFile, roleOfFile } from "./lib/scope.mjs"
@@ -171,6 +171,35 @@ export const noOffScaleGlyphSize = {
171
171
  },
172
172
  }
173
173
 
174
+ // -- ICON-1 (call site) ----------------------------------------------------------------------------
175
+
176
+ /** A size utility on the class of a glyph: `size-5`, `w-4`, `h-6`, `min-w-4`. */
177
+ const SIZE_UTILITY = /(?:^|\s)(?:size|w|h|min-w|min-h|max-w|max-h)-\S+/
178
+
179
+ /** The icon's size is its usage's: the call site passes the glyph and never a size class. */
180
+ export const noIconSizeClass = {
181
+ meta: {
182
+ type: "problem",
183
+ docs: { description: "A glyph component takes its size from its usage (the 20px leading step is size-5), never from a size class at the call site." },
184
+ schema: [],
185
+ messages: {
186
+ size:
187
+ "`{{cls}}` sizes a glyph at the call site. The size of a glyph is its usage's (`Icon` and the Grammar components that draw one: 24px heading, 20px leading = size-5, 16px chip), so the app passes the glyph and no size class; a size written here is a third step nobody applies the same way twice.",
188
+ },
189
+ },
190
+ create(context) {
191
+ if (!isProductSource(context) || isIconLeafFile(context)) return {}
192
+ return {
193
+ JSXOpeningElement(node) {
194
+ const name = tagName(node)
195
+ if (name === null || !/Icon$/.test(name)) return
196
+ const hit = SIZE_UTILITY.exec(staticText(attribute(node, "className")?.value) ?? "")
197
+ if (hit) context.report({ node, messageId: "size", data: { cls: hit[0].trim() } })
198
+ },
199
+ }
200
+ },
201
+ }
202
+
174
203
  // -- ICON-10 ---------------------------------------------------------------------------------------
175
204
 
176
205
  /** A repeated metric cell does not invent a feature glyph absent from the reference. */
@@ -266,6 +295,7 @@ export const rules = {
266
295
  "no-vendor-icon-outside-icon-leaf": noVendorIconOutsideIconLeaf,
267
296
  "heroicons-is-the-glyph-vendor": heroiconsIsTheGlyphVendor,
268
297
  "no-off-scale-glyph-size": noOffScaleGlyphSize,
298
+ "no-icon-size-class": noIconSizeClass,
269
299
  "no-decorative-icon-in-metric-cell": noDecorativeIconInMetricCell,
270
300
  "no-inline-svg-outside-brand-mark-leaf": noInlineSvgOutsideBrandMarkLeaf,
271
301
  }