@starci/eslint-canon-fe 8.4.38 → 8.4.40
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 +12 -0
- package/README.md +6 -0
- package/callback-shape.mjs +1 -13
- package/docs/grammar-boundary.md +25 -1
- package/docs/surface-rules.md +41 -0
- package/grammar-boundary.mjs +112 -0
- package/hooks-folder.mjs +9 -3
- package/icon.mjs +31 -1
- package/index.mjs +4 -0
- package/lib/bindings.mjs +15 -0
- package/lib/config.mjs +1 -0
- package/lib/fe-stack.mjs +8 -4
- package/lib/scope.mjs +2 -2
- package/lib/typed-class-values.mjs +72 -0
- package/lib/why.mjs +17 -2
- package/lint-escape-hatch.mjs +2 -2
- package/menu-entries.mjs +133 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
- package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
- package/runtime/knowledge/hfs/slots.yaml +110 -52
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
- package/runtime/modules/models/registry.yaml +3 -1
- package/runtime/modules/models/runtimes.yaml +2 -0
- package/runtime/scripts/api/git/ls-tree.mjs +5 -0
- package/runtime/scripts/api/git/show.mjs +6 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
- package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
- package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
- package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
- package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
- package/runtime/scripts/hfs/declaration-slots.mjs +12 -3
- package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +16 -6
- package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/view.mjs +2 -1
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +94 -1
- package/runtime/scripts/lib/package-at.mjs +62 -0
- package/runtime/scripts/lib/same-text.mjs +5 -0
- package/runtime/scripts/lib/ts-analysis.mjs +10 -0
- package/size-and-state-budget.mjs +3 -3
- package/surface-rules.mjs +230 -0
- package/swr-family.mjs +136 -7
- package/transport.mjs +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
- 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.
|
|
5
|
+
- 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.
|
|
6
|
+
- 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`.
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
## 8.4.40 - 2026-10-25
|
|
10
|
+
- Changed: the bundled canon pins select @starci/stylelint-canon 2.0.11, @starci/hfs 4.7.33 and @starci/cli 1.7.33.
|
|
11
|
+
|
|
12
|
+
## 8.4.39 - 2026-10-25
|
|
13
|
+
- Changed: the bundled canon pins select @starci/grammar 0.11.0, @starci/hfs 4.7.32 and @starci/cli 1.7.32.
|
|
14
|
+
|
|
3
15
|
## 8.4.38 - 2026-10-24
|
|
4
16
|
- Changed: the bundled runtime copies and canon pins carry the alpha.30 canon (the `zod` pin is `side: both`) and select @starci/hfs 4.7.31 and @starci/cli 1.7.31.
|
|
5
17
|
|
package/README.md
CHANGED
|
@@ -76,6 +76,12 @@ The one locale pattern everywhere is `next-intl`, a `[locale]` segment, default
|
|
|
76
76
|
Grammar is a business-neutral HeroUI-backed component package. Its components accept ordinary
|
|
77
77
|
typed React props and children.
|
|
78
78
|
|
|
79
|
+
The [surface composition law](docs/surface-rules.md) holds ANATOMY-2 and PADDING-0:
|
|
80
|
+
`nested-control-variant` refuses explicit primary fields inside Grammar surfaces and raw app
|
|
81
|
+
`bg-surface` paint (`FE_NESTED_VARIANT`); `no-list-panel-padding` refuses horizontal padding
|
|
82
|
+
wrappers around Grammar lists and section captions (`FE_LIST_PANEL_PADDING`). Grammar supplies
|
|
83
|
+
the nested secondary default and owns the list's panel inset.
|
|
84
|
+
|
|
79
85
|
## Requirements
|
|
80
86
|
|
|
81
87
|
ESLint 9+ (flat config), `eslint-plugin-react-hooks` 7+, and Node.js 20.9+.
|
package/callback-shape.mjs
CHANGED
|
@@ -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"])
|
package/docs/grammar-boundary.md
CHANGED
|
@@ -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
|
+
```
|
package/grammar-boundary.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/index.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { recommended as landmarkRecommended, rules as landmarkRules } from "./la
|
|
|
31
31
|
import { recommended as listsRecommended, rules as listsRules } from "./lists.mjs"
|
|
32
32
|
import { recommended as loadingRecommended, rules as loadingRules } from "./loading.mjs"
|
|
33
33
|
import { recommended as lintEscapeRecommended, rules as lintEscapeRules } from "./lint-escape-hatch.mjs"
|
|
34
|
+
import { recommended as menuEntriesRecommended, rules as menuEntriesRules } from "./menu-entries.mjs"
|
|
34
35
|
import { recommended as namingRecommended, rules as namingRules } from "./naming.mjs"
|
|
35
36
|
import { recommended as nativeControlsRecommended, rules as nativeControlsRules } from "./native-controls.mjs"
|
|
36
37
|
import { recommended as nextConventionsRecommended, rules as nextConventionsRules } from "./next-conventions.mjs"
|
|
@@ -40,6 +41,7 @@ import { recommended as drawingRecommended, rules as drawingRules } from "./draw
|
|
|
40
41
|
import { recommended as sizeBudgetRecommended, rules as sizeBudgetRules } from "./size-and-state-budget.mjs"
|
|
41
42
|
import { recommended as sizeGrowthRecommended, rules as sizeGrowthRules } from "./size-growth.mjs"
|
|
42
43
|
import { recommended as statusColorsRecommended, rules as statusColorsRules } from "./status-colors.mjs"
|
|
44
|
+
import { recommended as surfaceRulesRecommended, rules as surfaceRules } from "./surface-rules.mjs"
|
|
43
45
|
import { recommended as splitRecommended, rules as splitRules } from "./the-split.mjs"
|
|
44
46
|
import { recommended as tokensRecommended, rules as tokensRules } from "./tokens.mjs"
|
|
45
47
|
import { recommended as transportRecommended, rules as transportRules } from "./transport.mjs"
|
|
@@ -75,6 +77,7 @@ const CONTRIBUTIONS = [
|
|
|
75
77
|
{ law: "lists", rules: listsRules, recommended: listsRecommended },
|
|
76
78
|
{ law: "loading", rules: loadingRules, recommended: loadingRecommended },
|
|
77
79
|
{ law: "lint-escape-hatch", rules: lintEscapeRules, recommended: lintEscapeRecommended },
|
|
80
|
+
{ law: "menu-entries", rules: menuEntriesRules, recommended: menuEntriesRecommended },
|
|
78
81
|
{ law: "naming", rules: namingRules, recommended: namingRecommended },
|
|
79
82
|
{ law: "native-controls", rules: nativeControlsRules, recommended: nativeControlsRecommended },
|
|
80
83
|
{ law: "next-conventions", rules: nextConventionsRules, recommended: nextConventionsRecommended },
|
|
@@ -84,6 +87,7 @@ const CONTRIBUTIONS = [
|
|
|
84
87
|
{ law: "size-and-state-budget", rules: sizeBudgetRules, recommended: sizeBudgetRecommended },
|
|
85
88
|
{ law: "size-growth", rules: sizeGrowthRules, recommended: sizeGrowthRecommended },
|
|
86
89
|
{ law: "status-colors", rules: statusColorsRules, recommended: statusColorsRecommended },
|
|
90
|
+
{ law: "surface-rules", rules: surfaceRules, recommended: surfaceRulesRecommended },
|
|
87
91
|
{ law: "the-split", rules: splitRules, recommended: splitRecommended },
|
|
88
92
|
{ law: "tokens", rules: tokensRules, recommended: tokensRecommended },
|
|
89
93
|
{ law: "translation", rules: translationRules, recommended: translationRecommended },
|
package/lib/bindings.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* written.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { stringOf } from "./ast.mjs"
|
|
10
|
+
|
|
9
11
|
/** The globals that all name the browser's global object. */
|
|
10
12
|
const GLOBAL_OBJECT = new Set(["window", "globalThis", "self"])
|
|
11
13
|
|
|
@@ -72,6 +74,19 @@ export const importOf = (context, identifier) => {
|
|
|
72
74
|
return { source, imported: null }
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
/** The property path a destructured binding selects from its initializer; null for an unselected or rest binding. */
|
|
78
|
+
export const patternPath = (pattern, name) => {
|
|
79
|
+
if (pattern.type === "Identifier") return pattern.name === name ? [] : null
|
|
80
|
+
if (pattern.type === "AssignmentPattern") return patternPath(pattern.left, name)
|
|
81
|
+
if (pattern.type !== "ObjectPattern") return null
|
|
82
|
+
for (const property of pattern.properties) {
|
|
83
|
+
if (property.type !== "Property") continue
|
|
84
|
+
const nested = patternPath(property.value, name)
|
|
85
|
+
if (nested !== null) return [property.computed ? stringOf(property.key) : property.key.name ?? property.key.value, ...nested]
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
75
90
|
/**
|
|
76
91
|
* The function an expression stands for: the function itself, or the function a same-file variable or declaration holds.
|
|
77
92
|
*
|
package/lib/config.mjs
CHANGED
|
@@ -100,6 +100,7 @@ export const SLOT_FREE_RULES = Object.freeze([
|
|
|
100
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",
|
|
101
101
|
"outcome-kinds-exhaustive", "no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import",
|
|
102
102
|
"prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params",
|
|
103
|
+
"nested-control-variant", "no-list-panel-padding",
|
|
103
104
|
])
|
|
104
105
|
|
|
105
106
|
/** The source of a plain front-end tree: its `src/`. */
|
package/lib/fe-stack.mjs
CHANGED
|
@@ -11,16 +11,17 @@ import { readFileSync } from "node:fs"
|
|
|
11
11
|
import { dirname, join } from "node:path"
|
|
12
12
|
import { fileURLToPath } from "node:url"
|
|
13
13
|
import { parseYaml } from "../runtime/engine/yaml.mjs"
|
|
14
|
+
import { globExpression } from "../runtime/scripts/lib/glob.mjs"
|
|
14
15
|
|
|
15
16
|
const CATALOG = join(dirname(fileURLToPath(import.meta.url)), "..", "runtime", "knowledge", "hfs", "fe-stack.yaml")
|
|
16
17
|
|
|
17
|
-
/** @type {{ concerns: ReadonlyArray<object>, forbidden: ReadonlyMap<string, object> } | null} */
|
|
18
|
+
/** @type {{ concerns: ReadonlyArray<object>, forbidden: ReadonlyMap<string, object>, patterns: ReadonlyArray<object> } | null} */
|
|
18
19
|
let loaded = null
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* The parsed catalog: its concerns, and every forbidden package mapped to the concern that forbids it.
|
|
22
23
|
*
|
|
23
|
-
* @returns {{ concerns: ReadonlyArray<object>, forbidden: ReadonlyMap<string, object> }} The catalog.
|
|
24
|
+
* @returns {{ concerns: ReadonlyArray<object>, forbidden: ReadonlyMap<string, object>, patterns: ReadonlyArray<object> }} The catalog.
|
|
24
25
|
*/
|
|
25
26
|
export const feStack = () => {
|
|
26
27
|
if (loaded) return loaded
|
|
@@ -34,7 +35,8 @@ export const feStack = () => {
|
|
|
34
35
|
if (!Array.isArray(concerns) || concerns.length === 0) throw new Error("runtime/knowledge/hfs/fe-stack.yaml declares no concerns: the fe-stack catalog is malformed")
|
|
35
36
|
const forbidden = new Map()
|
|
36
37
|
for (const concern of concerns) for (const name of concern.forbids ?? []) forbidden.set(String(name), concern)
|
|
37
|
-
|
|
38
|
+
const patterns = [...forbidden].filter(([name]) => name.includes("*")).map(([name, concern]) => ({ expression: globExpression(name), concern }))
|
|
39
|
+
loaded = Object.freeze({ concerns: Object.freeze(concerns), forbidden, patterns: Object.freeze(patterns) })
|
|
38
40
|
return loaded
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -61,7 +63,9 @@ export const packageNameOf = (specifier) => {
|
|
|
61
63
|
*/
|
|
62
64
|
export const forbiddingConcern = (specifier) => {
|
|
63
65
|
const name = packageNameOf(specifier)
|
|
64
|
-
|
|
66
|
+
if (name === null) return null
|
|
67
|
+
const { forbidden, patterns } = feStack()
|
|
68
|
+
return forbidden.get(name) ?? patterns.find(({ expression }) => expression.test(name))?.concern ?? null
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
/**
|
package/lib/scope.mjs
CHANGED
|
@@ -67,8 +67,8 @@ export const isConfigModule = (context) => inSlot(context, "fe.modules.config")
|
|
|
67
67
|
*/
|
|
68
68
|
export const isApiClient = (context) => inSlot(context, "fe.transport.client", "fe.package.api.client")
|
|
69
69
|
|
|
70
|
-
/** The slots of the API layer: the app's `modules/api` (with its clients folder and its GraphQLResponse file) and the shared api package. */
|
|
71
|
-
const API_LAYER_SLOTS = Object.freeze(["fe.modules.api", "fe.transport.client", "fe.transport.outcome", "fe.package.api", "fe.package.api.client", "fe.package.api.outcome"])
|
|
70
|
+
/** The slots of the API layer: the app's `modules/api` (with its clients folder, its stream reader and its GraphQLResponse file) and the shared api package. */
|
|
71
|
+
const API_LAYER_SLOTS = Object.freeze(["fe.modules.api", "fe.transport.client", "fe.transport.stream", "fe.transport.outcome", "fe.package.api", "fe.package.api.client", "fe.package.api.outcome"])
|
|
72
72
|
|
|
73
73
|
/** True when the linted file sits in the API layer: the only place that talks to the back end (Apollo, axios, `fetch`). */
|
|
74
74
|
export const isApiLayer = (context) => inSlot(context, ...API_LAYER_SLOTS)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Static class declarations in imported colocated modules, resolved by the consuming TypeScript program. */
|
|
2
|
+
import ts from "typescript"
|
|
3
|
+
|
|
4
|
+
/** The expression's literal fragments, following imported const/property declarations without executing code. */
|
|
5
|
+
const fragments = (checker, node, seen, mode) => {
|
|
6
|
+
if (!node || seen.has(node)) return []
|
|
7
|
+
seen.add(node)
|
|
8
|
+
if (ts.isStringLiteralLike(node) || ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) return [node.text]
|
|
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
|
+
if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
|
|
11
|
+
const symbol = ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)
|
|
12
|
+
? checker.getTypeAtLocation(node.expression).getProperty(node.argumentExpression.text)
|
|
13
|
+
: checker.getSymbolAtLocation(ts.isPropertyAccessExpression(node) ? node.name : node)
|
|
14
|
+
return symbolFragments(checker, symbol, seen, mode)
|
|
15
|
+
}
|
|
16
|
+
return fragmentChildren(node, mode).flatMap((child) => fragments(checker, child, seen, mode))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** An object binding reads only its selected property's symbol, including aliases and nested patterns. */
|
|
20
|
+
const bindingProperty = (checker, declaration) => {
|
|
21
|
+
const source = declaration.parent.parent
|
|
22
|
+
const owner = ts.isBindingElement(source) ? bindingProperty(checker, source) : null
|
|
23
|
+
const type = owner ? checker.getTypeOfSymbolAtLocation(owner, source) : source.initializer ? checker.getTypeAtLocation(source.initializer) : null
|
|
24
|
+
const name = declaration.propertyName ?? declaration.name
|
|
25
|
+
return ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? type?.getProperty(name.text) : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Alias and destructuring symbols retain their declaration values without reading sibling properties. */
|
|
29
|
+
const symbolFragments = (checker, input, seen, mode) => {
|
|
30
|
+
let symbol = input
|
|
31
|
+
if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) symbol = checker.getAliasedSymbol(symbol)
|
|
32
|
+
return (symbol?.declarations ?? []).flatMap((declaration) => {
|
|
33
|
+
const selected = ts.isBindingElement(declaration) ? bindingProperty(checker, declaration) : null
|
|
34
|
+
return selected ? symbolFragments(checker, selected, seen, mode) : fragments(checker, declaration.initializer, seen, mode)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Class-producing positions inside a declaration, including cn dictionaries and conditional recipes. */
|
|
39
|
+
const fragmentChildren = (node, mode) => {
|
|
40
|
+
if (ts.isCallExpression(node)) return mode === "classes" ? node.arguments : []
|
|
41
|
+
if (ts.isArrayLiteralExpression(node)) return node.elements
|
|
42
|
+
if (ts.isConditionalExpression(node)) return [node.whenTrue, node.whenFalse]
|
|
43
|
+
if (ts.isBinaryExpression(node)) {
|
|
44
|
+
if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) return [node.right]
|
|
45
|
+
return [ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken].includes(node.operatorToken.kind) ? [node.left, node.right] : []
|
|
46
|
+
}
|
|
47
|
+
if (ts.isObjectLiteralExpression(node)) return node.properties.flatMap((property) => ts.isPropertyAssignment(property) ? [property.name, property.initializer] : [])
|
|
48
|
+
if (ts.isTemplateExpression(node) && mode === "classes") return [node.head, ...node.templateSpans.flatMap((span) => [span.expression, span.literal])]
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Imported class values are reported at their use in the linted file, while their text comes from the typed declaration.
|
|
54
|
+
* Syntax-only rule consumers have no imported-file program and retain same-file inspection.
|
|
55
|
+
*/
|
|
56
|
+
export const typedClassStrings = (context, node, mode) => {
|
|
57
|
+
const services = context.sourceCode.parserServices
|
|
58
|
+
if (!services?.program || !services.esTreeNodeToTSNodeMap) return []
|
|
59
|
+
const source = services.esTreeNodeToTSNodeMap.get(node)
|
|
60
|
+
const checker = services.program.getTypeChecker()
|
|
61
|
+
return fragments(checker, source, new Set(), mode).map((text) => ({ node, text }))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Known imported props spreads expose the same static property values as named JSX attributes. */
|
|
65
|
+
export const typedPropertyStrings = (context, node, key, mode) => {
|
|
66
|
+
const services = context.sourceCode.parserServices
|
|
67
|
+
if (!services?.program || !services.esTreeNodeToTSNodeMap) return []
|
|
68
|
+
const source = services.esTreeNodeToTSNodeMap.get(node)
|
|
69
|
+
const checker = services.program.getTypeChecker()
|
|
70
|
+
const symbol = checker.getTypeAtLocation(source).getProperty(key)
|
|
71
|
+
return symbolFragments(checker, symbol, new Set(), mode).map((text) => ({ node, text }))
|
|
72
|
+
}
|
package/lib/why.mjs
CHANGED
|
@@ -19,6 +19,16 @@
|
|
|
19
19
|
|
|
20
20
|
/** @type {Record<string, Why>} */
|
|
21
21
|
export const why = {
|
|
22
|
+
"nested-control-variant": {
|
|
23
|
+
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.",
|
|
26
|
+
},
|
|
27
|
+
"no-list-panel-padding": {
|
|
28
|
+
code: "FE_LIST_PANEL_PADDING",
|
|
29
|
+
en: "`<file>` adds horizontal wrapper padding to a Grammar list or its section caption, stacking the content inset.",
|
|
30
|
+
fix: "Remove the wrapper padding and use ListBox inset=\"panel\" so headers, captions and rows share the panel content edge.",
|
|
31
|
+
},
|
|
22
32
|
"no-env-outside-config": {
|
|
23
33
|
code: "FE_ENV_OWNER",
|
|
24
34
|
en: "`<file>` reads an environment variable. Only `modules/config` may read env.",
|
|
@@ -279,10 +289,15 @@ export const why = {
|
|
|
279
289
|
en: "Raw `<tag>` in `<file>`. Page structure and text are composed from grammar components, not hand-written HTML.",
|
|
280
290
|
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.",
|
|
281
291
|
},
|
|
292
|
+
"no-app-relative-clock": {
|
|
293
|
+
code: "FE_SIZE_AND_STATE_BUDGET",
|
|
294
|
+
en: "`<file>` starts a recurring timer that reads the wall clock for live time labels.",
|
|
295
|
+
fix: "Render Grammar RelativeTime with the target dateTime, locale and mode; its shared ticker owns live relative time and countdown updates.",
|
|
296
|
+
},
|
|
282
297
|
"fe-stack-import": {
|
|
283
298
|
code: "FE_STACK_FORBIDDEN",
|
|
284
|
-
en: "`<file>` imports `<what>`, a
|
|
285
|
-
fix: "Use the concern's canonical library from `knowledge/hfs/fe-stack.yaml` (the message names it)
|
|
299
|
+
en: "`<file>` imports `<what>`, a package or scoped package family the front-end stack forbids, or a Supabase package outside `modules/auth`.",
|
|
300
|
+
fix: "Use the concern's canonical library from `knowledge/hfs/fe-stack.yaml` (the message names it). For @dicebear/*, pass the identity id as seed on Grammar Avatar, AvatarButton or AvatarGroup members. Supabase on the front end is the auth client in `modules/auth` only, data goes through `modules/api`.",
|
|
286
301
|
},
|
|
287
302
|
"file-size-growth": {
|
|
288
303
|
code: "HFS_SIZE_GROWTH",
|
package/lint-escape-hatch.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* eslint-disable / -next-line / -line / -enable / -env, and `/* eslint rule: "off" *\/`
|
|
9
9
|
* @ts-ignore, @ts-expect-error, @ts-nocheck
|
|
10
|
-
*
|
|
10
|
+
* the no-sonar word, @sonar-ignore - Sonar's own switches
|
|
11
11
|
* istanbul ignore, c8 ignore, v8 ignore - a line taken out of the coverage the gate reads
|
|
12
12
|
* prettier-ignore - a region taken out of the formatter
|
|
13
13
|
* stylelint-disable / -enable - a stylelint switch that reached a TS file
|
|
@@ -39,7 +39,7 @@ const ESLINT_DIRECTIVE = /^\s*eslint-(?:disable(?:-next-line|-line)?|enable|env)
|
|
|
39
39
|
const TS_DIRECTIVE = /^\s*(?:\/\s*)?@ts-(?:ignore|expect-error|nocheck)\b/
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* A quality-tool switch other than ESLint's: Sonar honours
|
|
42
|
+
* A quality-tool switch other than ESLint's: Sonar honours its no-sonar word anywhere in a comment (case-insensitively), so it is
|
|
43
43
|
* matched anywhere; the others are read by their tool from the start of the comment, so they are anchored there too.
|
|
44
44
|
*/
|
|
45
45
|
const TOOL_DIRECTIVE =
|