@starci/eslint-canon-fe 8.4.43 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -1
- package/README.md +41 -84
- package/brand-values.mjs +1 -1
- package/button-intent.mjs +320 -0
- package/docs/button-intent.md +22 -0
- package/docs/hooks-folder.md +5 -3
- package/docs/native-controls.md +1 -5
- package/docs/next-conventions.md +8 -8
- package/docs/size-and-state-budget.md +22 -0
- package/docs/surface-rules.md +11 -9
- package/drawing.mjs +4 -66
- package/hooks-folder.mjs +5 -3
- package/index.mjs +9 -25
- package/landmark.mjs +0 -3
- package/lib/class-text.mjs +29 -0
- package/lib/config.mjs +5 -6
- package/lib/drawing-scope.mjs +15 -0
- package/lib/typed-class-values.mjs +10 -1
- package/lib/why.mjs +35 -40
- package/next-conventions.mjs +33 -16
- package/package.json +4 -3
- package/runtime/knowledge/grammars/index.yaml +111 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
- package/runtime/knowledge/hfs/fe-stack.yaml +12 -4
- package/runtime/knowledge/hfs/slots.yaml +3 -3
- package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
- package/runtime/scripts/hfs/architecture/config.mjs +8 -8
- package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
- package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/view.mjs +1 -0
- package/runtime/scripts/lib/grammar-lint.mjs +48 -0
- package/size-and-state-budget.mjs +116 -4
- package/surface-rules.mjs +159 -26
- package/tokens.mjs +5 -217
- package/app-shell.mjs +0 -161
- package/docs/app-shell.md +0 -50
- package/docs/grammar-boundary.md +0 -57
- package/docs/status-colors.md +0 -32
- package/grammar-boundary.mjs +0 -203
- package/icon.mjs +0 -310
- package/lib/app-shell-cluster.mjs +0 -66
- package/lib/app-shell-create.mjs +0 -33
- package/lib/app-shell-owner-source.mjs +0 -124
- package/lib/app-shell-owner.mjs +0 -65
- package/lib/app-shell-scope.mjs +0 -79
- package/lib/app-shell-visible.mjs +0 -92
- package/lib/app-shell.mjs +0 -148
- package/lib/status-tones.generated.mjs +0 -9
- package/menu-entries.mjs +0 -133
- package/runtime/modules/goal/source-phrases.yaml +0 -95
- package/runtime/scripts/lib/source-phrases.mjs +0 -34
- package/status-colors.mjs +0 -137
- package/typography.mjs +0 -78
- package/vendor-boundary.mjs +0 -26
|
@@ -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.
|
package/docs/surface-rules.md
CHANGED
|
@@ -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
|
|
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 {
|
|
15
|
-
const
|
|
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`.
|
|
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="
|
|
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
|
|
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 {
|
|
23
|
-
import { classifyImport, fileOf, inSlot, kindOfFile, roleOfFile } from "./lib/scope.mjs"
|
|
22
|
+
import { classifyImport, roleOfFile } from "./lib/scope.mjs"
|
|
24
23
|
|
|
25
|
-
|
|
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
|
|
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
|
|
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
|
|
2
|
+
* The universal frontend plugin: every applicable law's rules, gathered once.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
|
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", "
|
|
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>`
|
|
25
|
-
fix: "
|
|
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
|
|
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
|
|
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: "
|
|
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",
|