@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
@@ -71,3 +71,25 @@ const d = useQueryFeedSwr({ refreshInterval: 5000 })
71
71
  **Why:** `<unit>` writes its own poll loop (`setInterval` or a self-calling `setTimeout`).
72
72
 
73
73
  **Fix:** Refresh through a data hook (`refreshInterval`) or a socket, exactly one mechanism per resource.
74
+
75
+ ## `starci-fe/no-app-relative-clock`
76
+
77
+ 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`.
78
+
79
+ 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.
80
+
81
+ **Invalid** (`src/hooks/time/useNow.ts`)
82
+
83
+ ```ts
84
+ const timer = setInterval(() => update(Date.now()), 1000)
85
+ ```
86
+
87
+ **Valid** (`src/components/leaves/UpdatedAt/index.tsx`)
88
+
89
+ ```tsx
90
+ const UpdatedAt = () => <RelativeTime dateTime={instant} locale={locale} mode="relative" />
91
+ ```
92
+
93
+ **Finding code:** `FE_SIZE_AND_STATE_BUDGET`
94
+
95
+ **Fix:** Render the Grammar primitive and pass the instant; its single shared ticker owns updates and its deterministic fallback owns the server render.
@@ -1,18 +1,16 @@
1
1
  # Grammar surface composition
2
2
 
3
- Law module: `surface-rules.mjs`. Canon: ANATOMY-2 and PADDING-0.
3
+ Law module: `surface-rules.mjs`. HFS rule: R318. Canon: ANATOMY-2 and PADDING-0.
4
4
 
5
5
  ## `starci-fe/nested-control-variant`
6
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.
7
+ Finding code: `FE_NESTED_VARIANT`. Grammar context supplies field variants on the page background and inside painted surfaces. App code omits every static `variant` on layer-aware fields, including fields rendered by imported or helper components. Named import aliases, namespace imports, static constants and props spreads preserve Grammar ownership. Explicit primary `Tabs` inside a painted surface is also refused; its default follows the surface context.
10
8
 
11
9
  Invalid:
12
10
 
13
11
  ```tsx
14
- import { SurfaceCard, SearchField } from "@starci/grammar/core"
15
- const Panel = () => <SurfaceCard><SearchField variant="primary" /></SurfaceCard>
12
+ import { SearchField } from "@starci/grammar/core"
13
+ const Search = () => <SearchField variant="secondary" />
16
14
  ```
17
15
 
18
16
  Valid:
@@ -22,20 +20,24 @@ import { SurfaceCard, SearchField } from "@starci/grammar/core"
22
20
  const Panel = () => <SurfaceCard><SearchField /></SurfaceCard>
23
21
  ```
24
22
 
23
+ ## `starci-fe/no-app-surface-paint`
24
+
25
+ Finding code: `FE_GRAMMAR_LAYOUT_ONLY`. App classes 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. Compose a painted Grammar surface such as `SurfaceCard`. Static props spreads keep the same obligations. Typed app lint follows imported colocated class declarations as well as local constants.
26
+
25
27
  ## `starci-fe/no-list-panel-padding`
26
28
 
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.
29
+ Finding code: `FE_LIST_PANEL_PADDING`. Within the nearest painted Grammar surface, a wrapper around `ListBox` or its section caption cannot add horizontal padding through `p-*`, `px-*`, `ps-*`, `pe-*`, `pl-*`, `pr-*` or arbitrary `padding-inline` properties. These insets add to the list's own padding. Page gutters outside that surface and lists outside any surface retain their layout padding. Unpainted containers such as `Rail` pass the parent context through.
28
30
 
29
31
  Invalid:
30
32
 
31
33
  ```tsx
32
34
  import { SurfaceCard, ListBox } from "@starci/grammar/core"
33
- const Panel = () => <SurfaceCard><div className="px-2"><ListBox label="Members" items={members} /></div></SurfaceCard>
35
+ const Panel = () => <SurfaceCard><div className="pl-2"><ListBox label="Members" items={members} /></div></SurfaceCard>
34
36
  ```
35
37
 
36
38
  Valid:
37
39
 
38
40
  ```tsx
39
41
  import { SurfaceCard, ListBox } from "@starci/grammar/core"
40
- const Panel = () => <SurfaceCard><ListBox inset="panel" label="Members" items={members} /></SurfaceCard>
42
+ const Panel = () => <div className="px-4"><SurfaceCard><ListBox inset="panel" label="Members" items={members} /></SurfaceCard></div>
41
43
  ```
package/drawing.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * TWO AXES, AND ONLY ONE IS DRAWN. A surface's `state` is its SHAPE: a layout the owner approves as
5
5
  * one drawing. A slot's data status (loading, forbidden, error, empty) is not a shape; it is a
6
- * recipe applied per api through `SlotView`, and it is never drawn. Mixing the two is what made
6
+ * status recipe applied per resource, and it is never a drawn shape. Mixing the two is what made
7
7
  * every block ship four hand-drawn variants that disagreed with each other.
8
8
  *
9
9
  * THE PURE HALF TAKES ATOMS. `component.tsx` receives `{ state, props, on }`: `props` is data
@@ -19,21 +19,9 @@
19
19
  * never wrong, beyond it.
20
20
  */
21
21
 
22
- import { hfsOf } from "./lib/hfs.mjs"
23
- import { classifyImport, fileOf, inSlot, kindOfFile, roleOfFile } from "./lib/scope.mjs"
22
+ import { classifyImport, roleOfFile } from "./lib/scope.mjs"
24
23
 
25
- /** The layers and feature kinds that split into a connected `index.tsx` and a pure `component.tsx`; a shared package's layers never split. */
26
- const SPLIT_KINDS = new Set(["blocks", "pages", "layouts", "overlays"])
27
-
28
- /** The folder name of the linted file: the surface a `component.tsx` or `index.tsx` belongs to. */
29
- const surfaceName = (context) => hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0]
30
-
31
- /** Whether the linted file sits in a component or feature owner of a split kind. */
32
- const inSplitTier = (context) => inSlot(context, "fe.components", "fe.feature") && SPLIT_KINDS.has(kindOfFile(context))
33
-
34
- /** The pure half of a split tier (the drawing role of a surface folder), and which tier it is. */
35
- const pureHalf = (context) =>
36
- inSplitTier(context) && roleOfFile(context) === "drawing" && /^[A-Z][A-Za-z0-9]*$/.test(surfaceName(context)) ? kindOfFile(context) : null
24
+ import { inSplitTier, pureHalf } from "./lib/drawing-scope.mjs"
37
25
 
38
26
  /** Type names that carry a rendered tree rather than data. */
39
27
  const NON_ATOM_TYPE = /^(?:ReactNode|ReactElement|ReactPortal|JSX\.Element|Element|ComponentType|FC|FunctionComponent|ElementType|ComponentProps|RenderFunction)$/
@@ -294,7 +282,7 @@ export const noDataStatusShape = {
294
282
  schema: [],
295
283
  messages: {
296
284
  status:
297
- "`{{name}}` lists \"{{value}}\" as a shape. Loading, forbidden, error and empty are a slot's data status: pass them as `Slot<T>` in `props` and render them through `SlotView`. Open/closed is an atom (`isOpen`). A shape is a layout the owner approves as one drawing.",
285
+ "`{{name}}` lists \"{{value}}\" as a shape. Loading, forbidden, error and empty describe resource status: pass them as data to the owned status renderer. Open/closed is a boolean atom. A shape is a layout the owner approves as one drawing.",
298
286
  },
299
287
  },
300
288
  create(context) {
@@ -316,61 +304,11 @@ export const noDataStatusShape = {
316
304
  },
317
305
  }
318
306
 
319
- // -- DRAWING-4 ----------------------------------------------------------------------------------
320
-
321
- /** Flags that are a slot's data status. */
322
- const STATUS_FLAG = /^(?:isLoading|isError|isForbidden|isPending|isValidating|isEmpty)$/
323
-
324
- /** The name a test expression reads, if it is a status flag. */
325
- const statusFlagOf = (node) => {
326
- if (!node) return null
327
- if (node.type === "UnaryExpression") return statusFlagOf(node.argument)
328
- if (node.type === "ChainExpression") return statusFlagOf(node.expression)
329
- if (node.type === "Identifier" && STATUS_FLAG.test(node.name)) return node.name
330
- if (node.type === "MemberExpression" && node.property?.type === "Identifier" && STATUS_FLAG.test(node.property.name)) {
331
- return node.property.name
332
- }
333
- if (node.type === "LogicalExpression" || node.type === "BinaryExpression") return statusFlagOf(node.left) ?? statusFlagOf(node.right)
334
- return null
335
- }
336
-
337
- /** A block's pure half never branches on a data status itself. */
338
- export const slotStatusThroughSlotView = {
339
- meta: {
340
- type: "problem",
341
- docs: { description: "A block's pure half renders data status through `SlotView`, never its own branch." },
342
- schema: [],
343
- messages: {
344
- branch:
345
- "This branches on `{{flag}}` by hand. Every slot renders its status through `SlotView`, the one recipe: a second branch drifts from it and becomes one more variant somebody draws.",
346
- },
347
- },
348
- create(context) {
349
- if (pureHalf(context) !== "blocks") return {}
350
- const check = (node, test) => {
351
- const flag = statusFlagOf(test)
352
- if (flag) context.report({ node, messageId: "branch", data: { flag } })
353
- }
354
- return {
355
- ConditionalExpression(node) {
356
- check(node, node.test)
357
- },
358
- IfStatement(node) {
359
- check(node, node.test)
360
- },
361
- LogicalExpression(node) {
362
- if (node.operator === "&&" && node.parent?.type === "JSXExpressionContainer") check(node, node.left)
363
- },
364
- }
365
- },
366
- }
367
-
368
307
  /** The rules this law contributes to the plugin. */
369
308
  export const rules = {
370
309
  "base-props-atom": basePropsAtom,
371
310
  "base-import-pair": baseImportPair,
372
311
  "no-data-status-shape": noDataStatusShape,
373
- "slot-status-through-slotview": slotStatusThroughSlotView,
374
312
  }
375
313
 
376
314
  /** Every FE canon rule is an error; existing debt is fixed before adoption. */
package/hooks-folder.mjs CHANGED
@@ -90,12 +90,13 @@ const SWR_HOOKS = new Map([
90
90
  ])
91
91
  const SWR_DEFAULTS = new Map([["swr", "useSWR"], ["swr/immutable", "useSWRImmutable"], ["swr/infinite", "useSWRInfinite"], ["swr/mutation", "useSWRMutation"], ["swr/subscription", "useSWRSubscription"]])
92
92
  /**
93
- * The library hooks a composite hook may stand on (an explicit list, owner ruling 2026-10-16): next-intl, next/navigation,
94
- * react-hook-form and zustand's React entry points. A name outside the list is not a hook by its spelling.
93
+ * The canonical library hooks a composite hook may call (R56, FE-THEME): next-intl, next/navigation,
94
+ * next-themes, react-hook-form and zustand's React entry points. A name outside the list is not a hook by its spelling.
95
95
  */
96
96
  const LIBRARY_HOOKS = new Map([
97
97
  ["next-intl", new Set(["useTranslations", "useLocale", "useFormatter", "useNow", "useTimeZone", "useMessages"])],
98
98
  ["next/navigation", new Set(["useRouter", "usePathname", "useSearchParams", "useParams", "useSelectedLayoutSegment", "useSelectedLayoutSegments"])],
99
+ ["next-themes", new Set(["useTheme"])],
99
100
  ["react-hook-form", new Set(["useForm", "useFormContext", "useWatch", "useController", "useFieldArray", "useFormState"])],
100
101
  ["zustand", new Set(["useStore"])],
101
102
  ["zustand/react/shallow", new Set(["useShallow"])],
@@ -121,6 +122,7 @@ const importOrigin = (definition) => {
121
122
  const source = String(definition.parent.source.value)
122
123
  if (definition.parent.importKind === "type" || specifier.importKind === "type") return null
123
124
  if (source !== "react" && !SWR_HOOKS.has(source) && !LIBRARY_HOOKS.has(source)) return null
125
+ if (source === "next-themes" && specifier.type === "ImportDefaultSpecifier") return null
124
126
  if (specifier.type === "ImportSpecifier") return namedOrigin(source, specifier.imported.name ?? specifier.imported.value)
125
127
  if (specifier.type === "ImportDefaultSpecifier" && SWR_DEFAULTS.has(source)) return { source, name: SWR_DEFAULTS.get(source) }
126
128
  return { source, name: null }
@@ -246,7 +248,7 @@ export const hooksFolderHoldsHooksOnly = {
246
248
  defaultExport:
247
249
  "FE_HOOKS_ARE_HOOKS: export the hook as a named use* function. A default export does not identify the hook this file owns.",
248
250
  fake:
249
- "FE_HOOKS_ARE_HOOKS: `{{name}}` must be a function that calls a real hook: a React, Redux or SWR hook, a listed library hook (next-intl, next/navigation, react-hook-form, zustand) or a hook of the app's own `hooks/` folders. A use* name alone does not make a reader or helper a hook.",
251
+ "FE_HOOKS_ARE_HOOKS: `{{name}}` must be a function that calls a real hook: a React, Redux or SWR hook, a listed library hook (next-intl, next/navigation, next-themes useTheme, react-hook-form, zustand) or a hook of the app's own `hooks/` folders. A use* name alone does not make a reader or helper a hook.",
250
252
  sharedHook:
251
253
  "`{{name}}` is a hook exported from the shared helper file. That file holds non-hook helpers; a hook there is a hook nobody will find. Give it its own `use<Name>.ts`.",
252
254
  server:
package/index.mjs CHANGED
@@ -1,38 +1,31 @@
1
1
  /**
2
- * The front-end plugin: every law's rules, gathered once.
2
+ * The universal frontend plugin: every applicable law's rules, gathered once.
3
3
  *
4
- * A consuming repository imports THIS and nothing below it. Registering rules one by one in the
5
- * target's own plugin file is how the two lists drift: a law gains a rule here, nobody adds it
6
- * there, and the rule ships as a document. `starciFeConfig` therefore owns BOTH questions - what
7
- * exists and what is switched on - and every rule is an error: the repository's `eslint.config.mjs`
8
- * names its layout and nothing else about the law.
4
+ * The public factory gathers every universal rule at error. A managed repository config composes
5
+ * it with the selected Grammar-family factory instead of registering individual rules. Family
6
+ * packages consume the public generic infrastructure exports; each rule implementation has one owner.
9
7
  *
10
- * WHAT THIS FILE REFUSES TO DO. It does not rename anything. A rule's published name is part of the
11
- * law that declares it, because that name is what appears in a build log, in a disable comment and
12
- * in every conversation about the failure. Aliasing one here to match a target's older spelling
13
- * would leave two names for one rule and no way to tell which a message came from.
8
+ * Published IDs identify their owning law. Namespace migrations are explicit in the removed-vocabulary
9
+ * catalog; compatibility aliases cannot create a second owner for a rule.
14
10
  */
15
11
  import { createRequire } from "node:module"
16
- import { recommended as appShellRecommended, rules as appShellRules } from "./app-shell.mjs"
17
12
  import { join } from "node:path"
18
13
  import { pathToFileURL } from "node:url"
19
14
  import { recommended as brandValuesRecommended, rules as brandValuesRules } from "./brand-values.mjs"
15
+ import { recommended as buttonIntentRecommended, rules as buttonIntentRules } from "./button-intent.mjs"
20
16
  import { recommended as clientBoundaryRecommended, rules as clientBoundaryRules } from "./client-boundary.mjs"
21
17
  import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
22
18
  import { recommended as classNamesRecommended, rules as classNamesRules } from "./class-names.mjs"
23
19
  import { recommended as envOwnerRecommended, rules as envOwnerRules } from "./env-owner.mjs"
24
20
  import { recommended as feStackRecommended, rules as feStackRules } from "./fe-stack.mjs"
25
21
  import { recommended as fileLayoutRecommended, rules as fileLayoutRules } from "./file-layout.mjs"
26
- import { recommended as grammarBoundaryRecommended, rules as grammarBoundaryRules } from "./grammar-boundary.mjs"
27
22
  import { recommended as formattingRecommended, rules as formattingRules } from "./formatting.mjs"
28
23
  import { recommended as hooksFolderRecommended, rules as hooksFolderRules } from "./hooks-folder.mjs"
29
24
  import { recommended as hygieneRecommended, rules as hygieneRules } from "./hygiene.mjs"
30
- import { recommended as iconRecommended, rules as iconRules } from "./icon.mjs"
31
25
  import { recommended as landmarkRecommended, rules as landmarkRules } from "./landmark.mjs"
32
26
  import { recommended as listsRecommended, rules as listsRules } from "./lists.mjs"
33
27
  import { recommended as loadingRecommended, rules as loadingRules } from "./loading.mjs"
34
28
  import { recommended as lintEscapeRecommended, rules as lintEscapeRules } from "./lint-escape-hatch.mjs"
35
- import { recommended as menuEntriesRecommended, rules as menuEntriesRules } from "./menu-entries.mjs"
36
29
  import { recommended as namingRecommended, rules as namingRules } from "./naming.mjs"
37
30
  import { recommended as nativeControlsRecommended, rules as nativeControlsRules } from "./native-controls.mjs"
38
31
  import { recommended as nextConventionsRecommended, rules as nextConventionsRules } from "./next-conventions.mjs"
@@ -41,15 +34,12 @@ import { recommended as servedLocaleRecommended, rules as servedLocaleRules } fr
41
34
  import { recommended as drawingRecommended, rules as drawingRules } from "./drawing.mjs"
42
35
  import { recommended as sizeBudgetRecommended, rules as sizeBudgetRules } from "./size-and-state-budget.mjs"
43
36
  import { recommended as sizeGrowthRecommended, rules as sizeGrowthRules } from "./size-growth.mjs"
44
- import { recommended as statusColorsRecommended, rules as statusColorsRules } from "./status-colors.mjs"
45
37
  import { recommended as surfaceRulesRecommended, rules as surfaceRules } from "./surface-rules.mjs"
46
38
  import { recommended as splitRecommended, rules as splitRules } from "./the-split.mjs"
47
39
  import { recommended as tokensRecommended, rules as tokensRules } from "./tokens.mjs"
48
40
  import { recommended as transportRecommended, rules as transportRules } from "./transport.mjs"
49
41
  import { recommended as translationRecommended, rules as translationRules } from "./translation.mjs"
50
42
  import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
51
- import { recommended as typographyRecommended, rules as typographyRules } from "./typography.mjs"
52
- import { recommended as vendorRecommended, rules as vendorRules } from "./vendor-boundary.mjs"
53
43
  import { recommended as projectGraphRecommended, rules as projectGraphRules } from "./project-graph.mjs"
54
44
  import { recommended as sonarParityRecommended, rules as sonarParityRules } from "./sonar-parity.mjs"
55
45
  import { buildFeConfig, buildPlainSourceConfig } from "./lib/config.mjs"
@@ -57,9 +47,9 @@ import { why } from "./lib/why.mjs"
57
47
 
58
48
  /** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
59
49
  const CONTRIBUTIONS = [
60
- { law: "app-shell", rules: appShellRules, recommended: appShellRecommended },
61
50
  { law: "project-graph", rules: projectGraphRules, recommended: projectGraphRecommended },
62
51
  { law: "brand-values", rules: brandValuesRules, recommended: brandValuesRecommended },
52
+ { law: "button-intent", rules: buttonIntentRules, recommended: buttonIntentRecommended },
63
53
  { law: "client-boundary", rules: clientBoundaryRules, recommended: clientBoundaryRecommended },
64
54
  { law: "comments", rules: commentsRules, recommended: commentsRecommended },
65
55
  {
@@ -70,16 +60,13 @@ const CONTRIBUTIONS = [
70
60
  { law: "env-owner", rules: envOwnerRules, recommended: envOwnerRecommended },
71
61
  { law: "fe-stack", rules: feStackRules, recommended: feStackRecommended },
72
62
  { law: "file-layout", rules: fileLayoutRules, recommended: fileLayoutRecommended },
73
- { law: "grammar-boundary", rules: grammarBoundaryRules, recommended: grammarBoundaryRecommended },
74
63
  { law: "formatting", rules: formattingRules, recommended: formattingRecommended },
75
64
  { law: "hooks-folder", rules: hooksFolderRules, recommended: hooksFolderRecommended },
76
65
  { law: "hygiene", rules: hygieneRules, recommended: hygieneRecommended },
77
- { law: "icon", rules: iconRules, recommended: iconRecommended },
78
66
  { law: "landmark", rules: landmarkRules, recommended: landmarkRecommended },
79
67
  { law: "lists", rules: listsRules, recommended: listsRecommended },
80
68
  { law: "loading", rules: loadingRules, recommended: loadingRecommended },
81
69
  { law: "lint-escape-hatch", rules: lintEscapeRules, recommended: lintEscapeRecommended },
82
- { law: "menu-entries", rules: menuEntriesRules, recommended: menuEntriesRecommended },
83
70
  { law: "naming", rules: namingRules, recommended: namingRecommended },
84
71
  { law: "native-controls", rules: nativeControlsRules, recommended: nativeControlsRecommended },
85
72
  { law: "next-conventions", rules: nextConventionsRules, recommended: nextConventionsRecommended },
@@ -88,15 +75,12 @@ const CONTRIBUTIONS = [
88
75
  { law: "drawing", rules: drawingRules, recommended: drawingRecommended },
89
76
  { law: "size-and-state-budget", rules: sizeBudgetRules, recommended: sizeBudgetRecommended },
90
77
  { law: "size-growth", rules: sizeGrowthRules, recommended: sizeGrowthRecommended },
91
- { law: "status-colors", rules: statusColorsRules, recommended: statusColorsRecommended },
92
78
  { law: "surface-rules", rules: surfaceRules, recommended: surfaceRulesRecommended },
93
79
  { law: "the-split", rules: splitRules, recommended: splitRecommended },
94
80
  { law: "tokens", rules: tokensRules, recommended: tokensRecommended },
95
81
  { law: "translation", rules: translationRules, recommended: translationRecommended },
96
82
  { law: "transport", rules: transportRules, recommended: transportRecommended },
97
83
  { law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
98
- { law: "typography", rules: typographyRules, recommended: typographyRecommended },
99
- { law: "vendor-boundary", rules: vendorRules, recommended: vendorRecommended },
100
84
  { law: "sonar-parity", rules: sonarParityRules, recommended: sonarParityRecommended },
101
85
  ]
102
86
 
@@ -225,7 +209,7 @@ export const reactHooksRules = (reactHooks) => {
225
209
  }
226
210
 
227
211
  /**
228
- * The whole flat config of a front end: `export default starciFeConfig({ hfs: loadHfs(import.meta.url) })`.
212
+ * The universal portion of a frontend's flat config, composed with its selected Grammar-family factory.
229
213
  *
230
214
  * It owns nothing the repository could choose: which files are linted comes from the HFS profile (every app's and every
231
215
  * workspace package's `src/`), not which rules are on, not their level, not whether an inline comment may
package/landmark.mjs CHANGED
@@ -5,9 +5,6 @@
5
5
  * application may pass children through layouts and pages normally.
6
6
  */
7
7
 
8
- /** Named branch owners that may provide landmark semantics. */
9
- export const LANDMARK_BRANCHES = new Set(["Main"])
10
-
11
8
  /** No custom landmark AST rule is needed; semantic ownership is enforced by the component code. */
12
9
  export const rules = {}
13
10
 
@@ -0,0 +1,29 @@
1
+ /** Shared static class-string readers for token discipline and Grammar scale checks. */
2
+ import { isClassAttribute, staticText } from "./ast.mjs"
3
+ import { isProductSource } from "./scope.mjs"
4
+
5
+ /** Static strings in JSX, constants and arrays, preserving the authored class order. */
6
+ const classText = (value) => {
7
+ if (value?.type === "JSXExpressionContainer") return classText(value.expression)
8
+ if (value?.type === "ArrayExpression") {
9
+ const members = value.elements.map((element) => classText(element)).filter(Boolean)
10
+ return members.length > 0 ? members.join(" ") : null
11
+ }
12
+ return staticText(value)
13
+ }
14
+
15
+ /** Visit class strings in product JSX, declarations and named class arrays. */
16
+ export const classTextVisitors = (context, report) => {
17
+ if (!isProductSource(context)) return {}
18
+ return {
19
+ JSXAttribute(node) {
20
+ if (isClassAttribute(node)) report(node, classText(node.value))
21
+ },
22
+ VariableDeclarator(node) {
23
+ report(node, classText(node.init))
24
+ },
25
+ Property(node) {
26
+ if (!node.computed && node.key.type === "Identifier" && node.key.name === "classes") report(node, classText(node.value))
27
+ },
28
+ }
29
+ }
package/lib/config.mjs CHANGED
@@ -1,10 +1,8 @@
1
1
  /**
2
2
  * The one flat config of a StarCi front end.
3
3
  *
4
- * A repository's `eslint.config.mjs` is a managed file (R05/R16/R17), rendered by `starci app sync` as exactly:
5
- *
6
- * import { loadHfs, starciFeConfig } from "@starci/eslint-canon-fe"
7
- * export default starciFeConfig({ hfs: loadHfs(import.meta.url) })
4
+ * A repository's `eslint.config.mjs` is managed by `starci app sync` (R05/R16/R17). Its template composes this universal
5
+ * factory with the explicitly selected Grammar family's factory, passing the same HFS view to both.
8
6
  *
9
7
  * The factory owns everything else: which files are linted (the source of every app and every workspace package), typed linting on the source (`parserOptions.projectService`, so a rule asks what a node IS rather than what
10
8
  * it is called), the ignores, the linter options that make a disable comment impossible, every canon rule at `error`,
@@ -97,10 +95,11 @@ export const SLOT_FREE_RULES = Object.freeze([
97
95
  "client-no-server-import", "no-server-action", "no-dangerous-html", "response-cookie-attributes", "require-export-jsdoc", "no-vietnamese-in-source",
98
96
  "no-hardcoded-endpoint-fallback", "no-runtime-namespace", "list-item-has-key", "no-index-key", "no-inline-literal-prop-in-list", "prefer-arrow-export",
99
97
  "handler-on-prefix", "no-native-form-control", "no-native-img", "image-has-size", "no-second-i18n-stack", "html-lang-from-locale", "no-null-suspense-fallback",
100
- "no-native-anchor", "no-inline-parameter-type", "no-hand-rolled-polling", "status-text-uses-soft-foreground", "no-copy-in-call", "no-alert", "no-failure-collapse",
98
+ "no-native-anchor", "no-inline-parameter-type", "no-hand-rolled-polling", "no-copy-in-call", "no-alert", "no-failure-collapse",
101
99
  "outcome-kinds-exhaustive", "no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import",
102
100
  "prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params",
103
- "nested-control-variant", "no-list-panel-padding",
101
+ "nested-control-variant", "no-list-panel-padding", "no-app-surface-paint",
102
+ "button-intent-required", "button-no-variant", "destructive-confirm-in-dialog", "single-commit-per-render", "no-commit-in-collection-item",
104
103
  ])
105
104
 
106
105
  /** The source of a plain front-end tree: its `src/`. */
@@ -0,0 +1,15 @@
1
+ /** Shared HFS scope of connected/presentational component pairs. */
2
+ import { hfsOf } from "./hfs.mjs"
3
+ import { fileOf, inSlot, kindOfFile, roleOfFile } from "./scope.mjs"
4
+
5
+ const SPLIT_KINDS = new Set(["blocks", "pages", "layouts", "overlays"])
6
+
7
+ /** The owning surface folder of the linted entry or presentation file. */
8
+ const surfaceName = (context) => hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0]
9
+
10
+ /** Whether the file belongs to an HFS owner with a connected/presentational pair. */
11
+ export const inSplitTier = (context) => inSlot(context, "fe.components", "fe.feature") && SPLIT_KINDS.has(kindOfFile(context))
12
+
13
+ /** The paired presentation file's component kind, or null outside that scope. */
14
+ export const pureHalf = (context) =>
15
+ inSplitTier(context) && roleOfFile(context) === "drawing" && /^[A-Z][A-Za-z0-9]*$/.test(surfaceName(context)) ? kindOfFile(context) : null
@@ -5,7 +5,7 @@ import ts from "typescript"
5
5
  const fragments = (checker, node, seen, mode) => {
6
6
  if (!node || seen.has(node)) return []
7
7
  seen.add(node)
8
- if (ts.isStringLiteralLike(node) || ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) return [node.text]
8
+ if (ts.isStringLiteralLike(node) || ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) return mode === "style-keys" ? [] : [node.text]
9
9
  if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node)) return fragments(checker, node.expression, seen, mode)
10
10
  if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
11
11
  const symbol = ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)
@@ -13,6 +13,15 @@ const fragments = (checker, node, seen, mode) => {
13
13
  : checker.getSymbolAtLocation(ts.isPropertyAccessExpression(node) ? node.name : node)
14
14
  return symbolFragments(checker, symbol, seen, mode)
15
15
  }
16
+ if (mode === "style-keys" && ts.isObjectLiteralExpression(node)) {
17
+ return node.properties.flatMap((property) => {
18
+ if (ts.isSpreadAssignment(property)) return fragments(checker, property.expression, seen, mode)
19
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) return []
20
+ return ts.isComputedPropertyName(property.name)
21
+ ? fragments(checker, property.name.expression, seen, "variant")
22
+ : [property.name.text]
23
+ })
24
+ }
16
25
  return fragmentChildren(node, mode).flatMap((child) => fragments(checker, child, seen, mode))
17
26
  }
18
27
 
package/lib/why.mjs CHANGED
@@ -19,41 +19,46 @@
19
19
 
20
20
  /** @type {Record<string, Why>} */
21
21
  export const why = {
22
+ "button-intent-required": {
23
+ code: "FE_BUTTON_INTENT_REQUIRED",
24
+ en: "`<file>` renders a Button or IconButton without the command's intent.",
25
+ fix: "Declare intent from the command's consequence and rank so Grammar supplies its variant.",
26
+ },
27
+ "button-no-variant": {
28
+ code: "FE_BUTTON_NO_VARIANT",
29
+ en: "`<file>` chooses a Button or IconButton variant directly.",
30
+ fix: "Replace variant with intent and let Grammar resolve placement and visual treatment.",
31
+ },
32
+ "destructive-confirm-in-dialog": {
33
+ code: "FE_DESTRUCTIVE_CONFIRM_IN_DIALOG",
34
+ en: "`<file>` renders a final destructive press outside its confirmation dialog.",
35
+ fix: "Place the destructive confirm inside the same-file AlertDialog or ConfirmDialog; use entry on its trigger.",
36
+ },
37
+ "single-commit-per-render": {
38
+ code: "FE_SINGLE_COMMIT_PER_RENDER",
39
+ en: "`<file>` renders more than one commit in the same component render path.",
40
+ fix: "Keep the next action as commit and assign the other commands their consequence-based intents.",
41
+ },
42
+ "no-commit-in-collection-item": {
43
+ code: "FE_NO_COMMIT_IN_COLLECTION_ITEM",
44
+ en: "`<file>` repeats a commit inside a collection item render.",
45
+ fix: "Use alternative for the item's next action and keep the enclosing view's commit unique.",
46
+ },
22
47
  "nested-control-variant": {
23
48
  code: "FE_NESTED_VARIANT",
24
- en: "`<file>` forces a primary field inside a Grammar surface, or paints a raw bg-surface in app CSS.",
25
- fix: "Compose a Grammar surface and omit the nested field variant so its surface context supplies secondary.",
49
+ en: "`<file>` sets a static Grammar field variant, or forces primary Tabs inside a painted surface.",
50
+ fix: "Omit the field or nested Tabs variant so Grammar supplies the default from its surface context.",
51
+ },
52
+ "no-app-surface-paint": {
53
+ code: "FE_GRAMMAR_LAYOUT_ONLY",
54
+ en: "`<file>` sets paint or typography in app CSS instead of composing Grammar's published components and props.",
55
+ fix: "Keep app CSS layout-only and compose a painted Grammar surface such as SurfaceCard.",
26
56
  },
27
57
  "no-list-panel-padding": {
28
58
  code: "FE_LIST_PANEL_PADDING",
29
59
  en: "`<file>` adds horizontal wrapper padding to a Grammar list or its section caption, stacking the content inset.",
30
60
  fix: "Remove the wrapper padding and use ListBox inset=\"panel\" so headers, captions and rows share the panel content edge.",
31
61
  },
32
- "app-shell-frame": {
33
- code: "FE_APP_SHELL_FRAME",
34
- en: "The app frame must use Grammar WorkspaceShell with its fixed viewport.",
35
- fix: "Compose WorkspaceShell viewport=\"fixed\" and supply its header, navigation and primary slots.",
36
- },
37
- "no-shell-bottom-tabs": {
38
- code: "FE_APP_SHELL_BOTTOM_TABS",
39
- en: "Bottom tab navigation replaces the required mobile sidebar drawer.",
40
- fix: "Use WorkspaceShell navigationDrawer and Sidebar presentation=\"drawer\".",
41
- },
42
- "navbar-control-size": {
43
- code: "FE_NAVBAR_CONTROL_SIZE",
44
- en: "A navbar control does not use Grammar's shared control height.",
45
- fix: "Set size=\"control\" on every visible bar control.",
46
- },
47
- "navbar-cluster": {
48
- code: "FE_NAVBAR_CLUSTER",
49
- en: "Navbar action component kinds or order differ from the app-shell canon.",
50
- fix: "Compose Language IconButton, theme Switch, notification IconButton and account AvatarButton in that order.",
51
- },
52
- "no-navbar-create": {
53
- code: "FE_NAVBAR_CREATE",
54
- en: "A create action is composed into the global navbar.",
55
- fix: "Move the create action to the page that owns it.",
56
- },
57
62
  "no-env-outside-config": {
58
63
  code: "FE_ENV_OWNER",
59
64
  en: "`<file>` reads an environment variable. Only `modules/config` may read env.",
@@ -134,11 +139,6 @@ export const why = {
134
139
  en: "`<file>` uses a raw colour/length value (`<what>`). Colour and spacing come only from grammar tokens; brand colour lives only in `brand.css`.",
135
140
  fix: "Use a grammar token class or variable; a colour value is declared only in `modules/brand/brand.css`.",
136
141
  },
137
- "status-text-uses-soft-foreground": {
138
- code: "FE_STYLE_TOKEN_ONLY",
139
- en: "`<file>` paints text or an icon with a solid status tone (`<what>`, e.g. `text-success`). A solid tone is a background colour; used as a text colour it reads below 4.5:1 on the page background.",
140
- fix: "Switch to the soft pair: `text-<tone>-soft-foreground` (with `bg-<tone>-soft` when it sits on a light surface); a solid tone is used only with `bg-<tone>` and `text-<tone>-foreground`.",
141
- },
142
142
  "no-native-form-control": {
143
143
  code: "FE_NATIVE_FORM_CONTROL",
144
144
  en: "Raw `<tag>` in `<file>`. Use the grammar renderer.",
@@ -232,12 +232,12 @@ export const why = {
232
232
  "navigation-from-intl": {
233
233
  code: "FE_I18N_NAVIGATION",
234
234
  en: "`<file>` imports `Link`, `useRouter`, `usePathname` or `redirect` from Next, which does not know the locale: the path loses the `[locale]` prefix.",
235
- fix: "Import from `modules/i18n/navigation`, built by next-intl from `routing.ts`.",
235
+ fix: "Import from the locale-aware public routes entry or shared i18n package, built from the one routing configuration.",
236
236
  },
237
237
  "no-native-anchor": {
238
238
  code: "FE_I18N_NAVIGATION",
239
239
  en: "`<a>` in `<file>` points at an internal route (reloads the whole page, loses the locale, loses prefetch) or opens a new tab without `rel`.",
240
- fix: "Use `Link` from `modules/i18n/navigation` with an href built by `modules/routes`; a `_blank` link adds `rel=\"noopener noreferrer\"`.",
240
+ fix: "Use the locale-aware Link with an href built by modules/routes; a _blank link adds rel=\"noopener noreferrer\".",
241
241
  },
242
242
  "use-intl-formatter": {
243
243
  code: "FE_I18N_FORMATTER",
@@ -307,12 +307,7 @@ export const why = {
307
307
  "i18n-stack-in-one-module": {
308
308
  code: "FE_I18N_PLACEMENT",
309
309
  en: "`<file>` builds a layer of the next-intl stack itself (`defineRouting`, `createNavigation`, `getRequestConfig`, `createMiddleware`). The stack is written once for the whole repo.",
310
- fix: "Call the `createAppI18n` factory of the shared i18n package inside `modules/i18n/index.ts` and import the result; a single-app repo writes the stack only in that app's `modules/i18n`.",
311
- },
312
- "no-raw-structural-element": {
313
- code: "FE_NATIVE_FORM_CONTROL",
314
- en: "Raw `<tag>` in `<file>`. Page structure and text are composed from grammar components, not hand-written HTML.",
315
- fix: "Replace it with the matching grammar component (`Heading`, `Text`, `SurfaceCard`, ...); if grammar lacks it, add it to grammar or the ui package instead of drawing it in place.",
310
+ fix: "Use the shared i18n factory; a single app keeps routing/request config in modules/i18n and navigation in modules/routes. The proxy imports routing without navigation.",
316
311
  },
317
312
  "no-app-relative-clock": {
318
313
  code: "FE_SIZE_AND_STATE_BUDGET",