@gost-kit/eslint-plugin 0.0.0-stage → 0.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 +26 -0
- package/LICENSE +21 -0
- package/README.md +45 -2
- package/RULES.md +249 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/lib/common-source.d.ts +2 -0
- package/dist/lib/common-source.js +39 -0
- package/dist/lib/component-refs.d.ts +5 -0
- package/dist/lib/component-refs.js +58 -0
- package/dist/lib/dispatch-type.d.ts +2 -0
- package/dist/lib/dispatch-type.js +103 -0
- package/dist/lib/handler-body.d.ts +2 -0
- package/dist/lib/handler-body.js +32 -0
- package/dist/lib/member-key.d.ts +2 -0
- package/dist/lib/member-key.js +13 -0
- package/dist/lib/optional-call.d.ts +2 -0
- package/dist/lib/optional-call.js +55 -0
- package/dist/lib/raw-state.d.ts +2 -0
- package/dist/lib/raw-state.js +27 -0
- package/dist/lib/reactive-context.d.ts +6 -0
- package/dist/lib/reactive-context.js +48 -0
- package/dist/lib/rune-callee.d.ts +2 -0
- package/dist/lib/rune-callee.js +19 -0
- package/dist/lib/selector-fix.d.ts +2 -0
- package/dist/lib/selector-fix.js +122 -0
- package/dist/lib/store-state.d.ts +2 -0
- package/dist/lib/store-state.js +57 -0
- package/dist/lib/svelte-scripts.d.ts +5 -0
- package/dist/lib/svelte-scripts.js +25 -0
- package/dist/lib/ui-handlers.d.ts +10 -0
- package/dist/lib/ui-handlers.js +84 -0
- package/dist/lib/value-variable.d.ts +2 -0
- package/dist/lib/value-variable.js +25 -0
- package/dist/rules/no-component-state.d.ts +2 -0
- package/dist/rules/no-component-state.js +46 -0
- package/dist/rules/require-feature-state-selector.d.ts +2 -0
- package/dist/rules/require-feature-state-selector.js +58 -0
- package/dist/rules/single-intent-dispatch.d.ts +2 -0
- package/dist/rules/single-intent-dispatch.js +89 -0
- package/package.json +55 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @gost-kit/eslint-plugin
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 11cc6af: Add opt-in no-component-state diagnostics for state and bindable rune creation,
|
|
8
|
+
with binding-aware empty bind:this ref exceptions and no autofix. Keep the
|
|
9
|
+
public Svelte factory policy unchanged; consumers select application and UI-Kit
|
|
10
|
+
scopes with explicit ESLint overrides.
|
|
11
|
+
|
|
12
|
+
Reuse value-namespace rune resolution in the existing selector rule so type-only
|
|
13
|
+
names/imports do not hide derived or raw-state diagnostics. Consolidate rune and
|
|
14
|
+
Svelte script matching; document explicit plugin registration for extra JS scopes.
|
|
15
|
+
|
|
16
|
+
- d6883b7: Add the typed require-feature-state-selector architecture rule for GOST Store
|
|
17
|
+
and explicit local Svelte raw state. Enable it in typed Svelte config scopes,
|
|
18
|
+
with conservative selector autofix and V2/V3 collision-safe names.
|
|
19
|
+
- 730b73a: Add opt-in single-intent-dispatch for typed business UI callbacks, using public
|
|
20
|
+
Dispatch declaration/signature provenance. Diagnose multiple dispatches or
|
|
21
|
+
conditional intent selection without autofix. Keep the shared Svelte factory
|
|
22
|
+
default off and allow consumer UI-Kit exclusions through ordinary overrides.
|
|
23
|
+
|
|
24
|
+
Include optional calls/accesses that gate Dispatch in conditional-delivery
|
|
25
|
+
diagnostics, without treating unrelated optional data reads as dispatch gates.
|
|
26
|
+
Document the on-prefix and whole-handler syntactic-policy boundaries explicitly.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anton Smolko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,46 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @gost-kit/eslint-plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
GOST architecture rules for frontend development. Independently versioned;
|
|
4
|
+
see package.json and the release changelog. Requires Node ^26.0.0,
|
|
5
|
+
ESLint ^10.14.0 and TypeScript ^5.9.3.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
`@gost-kit/eslint-config-svelte` installs and enables the plugin for typed Svelte
|
|
10
|
+
components and `.svelte.ts` modules. The selector rule is enabled by default;
|
|
11
|
+
`gost/no-component-state` and `gost/single-intent-dispatch` require explicit
|
|
12
|
+
consumer overrides for business component paths. UI-Kit paths can opt out.
|
|
13
|
+
No separate plugin registration is needed.
|
|
14
|
+
Other consumers may register the named public export in their flat config:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { plugin } from '@gost-kit/eslint-plugin';
|
|
18
|
+
|
|
19
|
+
export default [
|
|
20
|
+
// Configure a typed parser and a consumer TypeScript project first.
|
|
21
|
+
{
|
|
22
|
+
files: ['**/*.svelte', '**/*.svelte.ts'],
|
|
23
|
+
plugins: { gost: plugin },
|
|
24
|
+
rules: { 'gost/require-feature-state-selector': 'error' },
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The plugin does not install application runtime packages. Store detection uses
|
|
30
|
+
`@gost-kit/common` types already present in the consumer's TypeScript program.
|
|
31
|
+
Nested common installations are supported; a direct common dependency of the
|
|
32
|
+
application is not required for inferred Store detection.
|
|
33
|
+
Local `$state.raw` bindings are recognized from their original lexical scope.
|
|
34
|
+
For `require-feature-state-selector`, type information is required; an enabled typed rule without parser services
|
|
35
|
+
reports a configuration error. Stateless and JavaScript-script components are
|
|
36
|
+
skipped. Mixed components are checked per script: a typed module script does
|
|
37
|
+
not enable the rule for a JavaScript instance script. The Svelte config does not enable this typed rule for `.svelte.js`.
|
|
38
|
+
|
|
39
|
+
`single-intent-dispatch` uses an `on*` name heuristic, including function-valued
|
|
40
|
+
non-event props such as `once`/`only`. Its whole-handler condition policy also
|
|
41
|
+
reports conditions after an unconditional Dispatch, without control-flow proof.
|
|
42
|
+
Optional invocation/access that gates a genuine Dispatch is conditional delivery;
|
|
43
|
+
ordinary optional data reads remain outside that gate check.
|
|
44
|
+
|
|
45
|
+
See [RULES.md](RULES.md) for diagnostics, autofix boundaries and examples.
|
|
46
|
+
Tests and test helpers are excluded from published artifacts.
|
package/RULES.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# GOST architecture rules
|
|
2
|
+
|
|
3
|
+
## gost/require-feature-state-selector
|
|
4
|
+
|
|
5
|
+
Select an immutable reactive branch before passing it to a computation in
|
|
6
|
+
`$derived` or the synchronous callback of `$derived.by`:
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
// Reported; a simple standalone declaration can be fixed with eslint --fix.
|
|
10
|
+
const view = $derived(userPresenter(root.state.user));
|
|
11
|
+
|
|
12
|
+
// Correct.
|
|
13
|
+
const user = $derived(root.state.user);
|
|
14
|
+
const view = $derived(userPresenter(user));
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`root` is not a special name. The rule recognizes the declaration provenance of
|
|
18
|
+
`Store.state` from the public `@gost-kit/common` API, including aliases,
|
|
19
|
+
reexports, inferred `createStore` returns, nullable Store and supported mapped
|
|
20
|
+
or intersection types preserving that provenance. Declaration ownership is checked
|
|
21
|
+
against the actual common package, including nested installations where the
|
|
22
|
+
consumer cannot directly resolve common. It does not identify Store
|
|
23
|
+
solely by its name or structural assignability. Mixed unions and any/unknown
|
|
24
|
+
are not assumed to be Store.
|
|
25
|
+
|
|
26
|
+
A directly declared local `$state.raw` also represents a replaceable snapshot:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
let model = $state.raw({ user: { name: 'Ada' } });
|
|
30
|
+
const user = $derived(model.user);
|
|
31
|
+
const view = $derived(userPresenter(user));
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A plain `const snapshot = model` captures the current raw snapshot and is not
|
|
35
|
+
itself a reactive alias. Ordinary objects, deep-proxy `$state` and ordinary
|
|
36
|
+
functions named `$state`/`$derived` are not automatically subject to this rule.
|
|
37
|
+
TypeScript types alone cannot prove Svelte reactivity: both state runes return
|
|
38
|
+
`T`. `$state.raw` detection therefore inspects original variable bindings.
|
|
39
|
+
|
|
40
|
+
### Diagnostic scope
|
|
41
|
+
|
|
42
|
+
Direct reads used in call arguments or receivers, constructor arguments and
|
|
43
|
+
tagged template substitutions inside reactive expressions are reported, including multiple branches, method chains, static bracket keys,
|
|
44
|
+
optional calls/access, and passing an entire `Store.state` snapshot.
|
|
45
|
+
Selectors without a computation call remain valid, including `$derived.by`
|
|
46
|
+
selectors. Event handlers and other nonreactive reads are outside the rule.
|
|
47
|
+
Callbacks passed as values are not assumed to run synchronously; the explicit
|
|
48
|
+
`$derived.by` callback is inspected when it is neither async nor a generator.
|
|
49
|
+
Instance field initializers are deferred; static fields and computed keys are
|
|
50
|
+
evaluated with the class expression and remain in scope. Helpers, arbitrary returned reactive values,
|
|
51
|
+
aliased snapshots, dynamic state keys and dependencies hidden across modules
|
|
52
|
+
are not traced. No claim of complete interprocedural analysis or presenter
|
|
53
|
+
purity is made.
|
|
54
|
+
|
|
55
|
+
### Autofix
|
|
56
|
+
|
|
57
|
+
Autofix handles a standalone top-level derived declaration in a rune module or
|
|
58
|
+
component instance script, with a single direct function call and one immediate
|
|
59
|
+
feature-field argument. A selector is inserted immediately before the declaration.
|
|
60
|
+
The final field supplies the name; occupied names use `V2`, `V3`, etc. Imports,
|
|
61
|
+
later declarations and references visible from outer scopes are considered.
|
|
62
|
+
Repeated fixing does not add selectors. Field names beginning with `$` receive
|
|
63
|
+
no fix: Svelte reserves dollar-prefixed bindings, including suffixed names.
|
|
64
|
+
|
|
65
|
+
Constructors and tagged templates have diagnostics only.
|
|
66
|
+
Complex `$derived.by`, nested declarations, multiple inputs/declarators,
|
|
67
|
+
receivers, optional/dynamic access, deeper property/getter paths, conditional
|
|
68
|
+
arguments, spreads and exported declarations retain diagnostics without fixes.
|
|
69
|
+
Fixes preserve surrounding comments and line endings; Prettier can then normalize
|
|
70
|
+
layout. The normal derived contract still requires pure computations.
|
|
71
|
+
|
|
72
|
+
Separate selection lets Svelte skip downstream updates when the feature's value
|
|
73
|
+
is referentially unchanged. It does not make a mutable feature immutable or
|
|
74
|
+
prevent recomputation when the selected feature actually changes.
|
|
75
|
+
|
|
76
|
+
Sources: [Svelte update propagation](https://svelte.dev/docs/svelte/$derived#Update-propagation),
|
|
77
|
+
[Svelte state](https://svelte.dev/docs/svelte/$state),
|
|
78
|
+
[typed ESLint rules](https://typescript-eslint.io/developers/custom-rules/).
|
|
79
|
+
|
|
80
|
+
## gost/no-component-state
|
|
81
|
+
|
|
82
|
+
Opt in for application components whose logical state belongs in root/feature
|
|
83
|
+
state. The shared Svelte config registers this rule but leaves it off:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
export default [
|
|
87
|
+
...(await createSvelteConfig({ tsconfigRootDir: import.meta.dirname })),
|
|
88
|
+
{
|
|
89
|
+
files: ['src/**/*.svelte'],
|
|
90
|
+
rules: { 'gost/no-component-state': 'error' },
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
files: ['src/ui-kit/**/*.svelte'],
|
|
94
|
+
rules: { 'gost/no-component-state': 'off' },
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The UI-Kit path is illustrative: the consumer selects its own boundaries.
|
|
100
|
+
Independent UI components may own local state. Disabling this rule preserves
|
|
101
|
+
all other shared Svelte and TypeScript checks. No architecture flag, runtime
|
|
102
|
+
Store dependency or type-aware parser services are required by this rule.
|
|
103
|
+
|
|
104
|
+
Within selected files, direct intrinsic `$state(...)`, `$state.raw(...)` and
|
|
105
|
+
`$bindable(...)` calls report `componentState`, including module scripts,
|
|
106
|
+
nested helpers and class fields. Lexically shadowed ordinary functions are
|
|
107
|
+
ignored. Use state selectors, read-only `$derived`, pure presenter calls and
|
|
108
|
+
intent actions for application logic. `$props`, ordinary variables and temporary
|
|
109
|
+
browser handles are permitted.
|
|
110
|
+
|
|
111
|
+
A simple DOM/component ref is allowed when a rune initializes a direct identifier
|
|
112
|
+
in the instance script with no argument, `null` or unshadowed `undefined`, and
|
|
113
|
+
that same variable binding appears directly in `bind:this`:
|
|
114
|
+
|
|
115
|
+
```svelte
|
|
116
|
+
<script lang="ts">
|
|
117
|
+
let input = $state<HTMLInputElement>();
|
|
118
|
+
</script>
|
|
119
|
+
|
|
120
|
+
<input bind:this={input} />
|
|
121
|
+
<button type="button" onclick={() => input?.focus()}>Focus</button>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
An unused empty rune, nonempty initializer, module-script binding or same name
|
|
125
|
+
in a different scope does not qualify. `bind:value` is not a ref exception.
|
|
126
|
+
The exception identifies ref initialization; it does not verify every subsequent
|
|
127
|
+
write to the ref.
|
|
128
|
+
|
|
129
|
+
There is no autofix: state ownership, actions and lifecycle require design.
|
|
130
|
+
This first rule checks the listed rune creations, not all mutations or state
|
|
131
|
+
hidden behind imports/helpers. Props/derived values still require read-only
|
|
132
|
+
usage in review. Dispatch composition (#17), boolean template conditions (#18)
|
|
133
|
+
and presenter computations (#19) are separate checks. `.svelte.ts` shell modules
|
|
134
|
+
remain available outside the component-only override.
|
|
135
|
+
|
|
136
|
+
The shared config registers `gost` for `.svelte` and `.svelte.ts`, not `.svelte.js`.
|
|
137
|
+
If deliberately extending this policy to `.svelte.js`, install `@gost-kit/eslint-plugin`
|
|
138
|
+
explicitly in the consumer and register the plugin
|
|
139
|
+
in that override (`plugins: { gost: plugin }`, with the named `plugin` import from
|
|
140
|
+
`@gost-kit/eslint-plugin`). Enabling the rule alone for that extension fails plugin
|
|
141
|
+
lookup. The default typed selector rule remains excluded from `.svelte.js`.
|
|
142
|
+
|
|
143
|
+
## gost/single-intent-dispatch
|
|
144
|
+
|
|
145
|
+
Business UI callbacks forward one intent action. The reducer chooses business
|
|
146
|
+
transitions/effects from current state. UI-Kit owns local state, browser events
|
|
147
|
+
and their conditional processing. When a business feature needs such a mechanism,
|
|
148
|
+
extract a library component and consume ready values/semantic callbacks.
|
|
149
|
+
|
|
150
|
+
The shared config registers this typed rule but leaves it off. Enable it for
|
|
151
|
+
business component files and exclude UI-Kit with ordinary late overrides:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
export default [
|
|
155
|
+
...(await createSvelteConfig({ tsconfigRootDir: import.meta.dirname })),
|
|
156
|
+
{
|
|
157
|
+
files: ['src/**/*.svelte'],
|
|
158
|
+
rules: { 'gost/single-intent-dispatch': 'error' },
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
files: ['src/ui-kit/**/*.svelte'],
|
|
162
|
+
rules: { 'gost/single-intent-dispatch': 'off' },
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Paths are illustrative. Other Svelte/TypeScript rules keep their configured
|
|
168
|
+
levels. Type services are required for TypeScript instance scripts; a missing
|
|
169
|
+
project is a configuration error. JS/stateless/module-only scripts and ordinary
|
|
170
|
+
TS/rune modules are skipped before requesting services.
|
|
171
|
+
|
|
172
|
+
Handlers are found through `on*` props/attributes (including `onConfirm` and `{onConfirm}` shorthand) and
|
|
173
|
+
legacy `on:` directives, rather than function names. This is a lexical prefix
|
|
174
|
+
heuristic, not event-prop type discovery: a function-valued prop named `once` or
|
|
175
|
+
`only` is also treated as a handler. Such non-event props can produce false
|
|
176
|
+
positives; use an appropriate consumer scope or a targeted ESLint exception.
|
|
177
|
+
Lowercase custom callbacks such as `onemailchange` remain supported. Inline functions, local
|
|
178
|
+
function declarations, const function expressions and simple const aliases are
|
|
179
|
+
supported, including transparent TypeScript `satisfies`/`as` wrappers. Handlers
|
|
180
|
+
declared in template `{@const}` tags are outside this first version's discovery. Reassigned callbacks, arbitrary member methods/imported functions,
|
|
181
|
+
spread props and callback names without the `on` prefix are not traced.
|
|
182
|
+
Forwarding `onconfirm={presenter.confirm}` is permitted without claiming to
|
|
183
|
+
verify the method body or presenter purity.
|
|
184
|
+
|
|
185
|
+
A dispatch is recognized by declaration provenance of the public
|
|
186
|
+
`@gost-kit/common` `Dispatch` type and its call signature, including simple type
|
|
187
|
+
aliases/reexports, const/destructured aliases, `Store.dispatch` and inferred
|
|
188
|
+
`createStore` returns. Signature ownership matters: TypeScript may reuse alias
|
|
189
|
+
metadata for structurally equal local callbacks. Name/shape alone, plain
|
|
190
|
+
`AsyncTap`, any/unknown and mixed unions do not prove Dispatch; genuine nullable
|
|
191
|
+
Dispatch is supported. Optional invocation/access that can suppress that
|
|
192
|
+
Dispatch (`send?.(action)`, `store?.dispatch(action)` or `store.dispatch?.(action)`)
|
|
193
|
+
is conditional intent delivery and reports `conditionalDispatch` for a single call.
|
|
194
|
+
This includes a Dispatch evaluated in skipped arguments/computed keys of an
|
|
195
|
+
outer optional expression, such as `consume?.(dispatch(action))` or
|
|
196
|
+
`values?.[String(dispatch(action))]`.
|
|
197
|
+
Ordinary optional data reads and optional accesses after the Dispatch result are
|
|
198
|
+
not treated as a dispatch gate. Parenthesized chains that end before an
|
|
199
|
+
unconditional call follow JavaScript chain boundaries, not optional-call semantics.
|
|
200
|
+
No application runtime packages are installed by tooling.
|
|
201
|
+
|
|
202
|
+
The handler's own body and parameter defaults report:
|
|
203
|
+
|
|
204
|
+
- `multipleDispatch`: two or more direct Dispatch calls, even in mutually
|
|
205
|
+
exclusive branches. Send one intent and move selection to the reducer.
|
|
206
|
+
- `conditionalDispatch`: one Dispatch plus `if`, `switch`, loops, a conditional
|
|
207
|
+
or logical expression/assignment (`??=`, `&&=`, `||=`), including early guards and an action's
|
|
208
|
+
conditional `type`. Browser-event guards have no special exception.
|
|
209
|
+
|
|
210
|
+
This is a syntactic whole-handler policy, not control-flow dependence analysis:
|
|
211
|
+
a listed condition anywhere in the handler's own evaluation is reported when
|
|
212
|
+
that handler contains a Dispatch, including a condition after an unconditional
|
|
213
|
+
Dispatch and a condition unrelated to choosing its action. The diagnostic directs
|
|
214
|
+
conditional logic to the reducer/UI-Kit; it does not prove that a particular
|
|
215
|
+
condition selected the dispatched action.
|
|
216
|
+
|
|
217
|
+
Dispatch in a parameter default is conditional on the argument being absent;
|
|
218
|
+
it is counted and diagnosed accordingly. Ordinary ready-value defaults are
|
|
219
|
+
permitted. Nested callback defaults are deferred like other nested functions.
|
|
220
|
+
|
|
221
|
+
Multiple-call diagnostics take priority; one handler produces one report even
|
|
222
|
+
if bound to several events. Direct/void/await calls with a ready action/value
|
|
223
|
+
are permitted. Nested function bodies and nonstatic instance initializers are
|
|
224
|
+
not part of the enclosing handler's evaluation. Static initialization/computed
|
|
225
|
+
keys remain included. Helper/constructor call graphs are not followed.
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
// Reported: business choice remains in the component.
|
|
229
|
+
const handleSubmit = () => {
|
|
230
|
+
if (hasUser) {
|
|
231
|
+
void dispatch({ type: 'UpdateUserAction' });
|
|
232
|
+
} else {
|
|
233
|
+
void dispatch({ type: 'CreateUserAction' });
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// Use an intent callback supplied to a UI-Kit component.
|
|
238
|
+
const handleSubmit = () => {
|
|
239
|
+
void dispatch({ type: 'SubmitUserAction' });
|
|
240
|
+
};
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
There is no autofix: action/reducer/UI component contracts require design.
|
|
244
|
+
This rule does not prove absence of all browser APIs, hidden dispatches or
|
|
245
|
+
business logic. A lone `preventDefault()` without a branch is not automatically
|
|
246
|
+
a dispatch diagnostic, although it violates the business-component boundary.
|
|
247
|
+
Event adaptation belongs in UI-Kit. Effects outside discovered UI callbacks may
|
|
248
|
+
perform several dispatches. Presentation computations (including templates) are
|
|
249
|
+
#19, boolean template conditions #18, and testable local UI-state #27.
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { singleIntentDispatch } from './rules/single-intent-dispatch.js';
|
|
2
|
+
import { noComponentState } from './rules/no-component-state.js';
|
|
3
|
+
import { requireFeatureStateSelector } from './rules/require-feature-state-selector.js';
|
|
4
|
+
export const plugin = {
|
|
5
|
+
meta: { name: '@gost-kit/eslint-plugin' },
|
|
6
|
+
// typescript-eslint still types the legacy context members removed in ESLint 10.
|
|
7
|
+
// At this public boundary the implementation uses only the current APIs.
|
|
8
|
+
rules: {
|
|
9
|
+
'single-intent-dispatch': singleIntentDispatch,
|
|
10
|
+
'no-component-state': noComponentState,
|
|
11
|
+
'require-feature-state-selector': requireFeatureStateSelector,
|
|
12
|
+
},
|
|
13
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
// Identify the package owning the declaration, without requiring a hoisted
|
|
4
|
+
// @gost-kit/common import from the consumer's source directory.
|
|
5
|
+
export const createCommonSourceCheck = () => {
|
|
6
|
+
const directories = new Map();
|
|
7
|
+
const isCommonDirectory = (directory) => {
|
|
8
|
+
const cached = directories.get(directory);
|
|
9
|
+
if (cached !== undefined) {
|
|
10
|
+
return cached;
|
|
11
|
+
}
|
|
12
|
+
const filename = join(directory, 'package.json');
|
|
13
|
+
const contents = ts.sys.readFile(filename);
|
|
14
|
+
let common = false;
|
|
15
|
+
if (contents !== undefined) {
|
|
16
|
+
const statement = ts.parseJsonText(filename, contents).statements[0];
|
|
17
|
+
if (statement &&
|
|
18
|
+
ts.isExpressionStatement(statement) &&
|
|
19
|
+
ts.isObjectLiteralExpression(statement.expression)) {
|
|
20
|
+
common = statement.expression.properties.some((property) => {
|
|
21
|
+
return (ts.isPropertyAssignment(property) &&
|
|
22
|
+
ts.isStringLiteral(property.name) &&
|
|
23
|
+
property.name.text === 'name' &&
|
|
24
|
+
ts.isStringLiteral(property.initializer) &&
|
|
25
|
+
property.initializer.text === '@gost-kit/common');
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const parent = dirname(directory);
|
|
31
|
+
common = parent !== directory && isCommonDirectory(parent);
|
|
32
|
+
}
|
|
33
|
+
directories.set(directory, common);
|
|
34
|
+
return common;
|
|
35
|
+
};
|
|
36
|
+
return (source) => {
|
|
37
|
+
return isCommonDirectory(dirname(source.fileName));
|
|
38
|
+
};
|
|
39
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { svelteScripts, scriptHasAttribute, inScript } from './svelte-scripts.js';
|
|
2
|
+
import { findValueVariable } from './value-variable.js';
|
|
3
|
+
import { AST_NODE_TYPES, } from '@typescript-eslint/utils';
|
|
4
|
+
export const createComponentRefCheck = (source) => {
|
|
5
|
+
const variables = new Set();
|
|
6
|
+
const instanceScripts = (svelteScripts(source) ?? []).filter((script) => {
|
|
7
|
+
return (!scriptHasAttribute(script, 'module') &&
|
|
8
|
+
!scriptHasAttribute(script, 'context', 'module'));
|
|
9
|
+
});
|
|
10
|
+
const isEmpty = (call) => {
|
|
11
|
+
if (call.arguments.length === 0) {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
if (call.arguments.length !== 1) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const value = call.arguments[0];
|
|
18
|
+
if (value.type === AST_NODE_TYPES.Literal && value.raw === 'null') {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (value.type === AST_NODE_TYPES.Identifier &&
|
|
22
|
+
value.name === 'undefined') {
|
|
23
|
+
const variable = findValueVariable(source.getScope(value), value.name);
|
|
24
|
+
return !variable || variable.defs.length === 0;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
recordBinding(node) {
|
|
30
|
+
// This listener runs only for SvelteDirective nodes supplied by the parser.
|
|
31
|
+
const directive = node;
|
|
32
|
+
if (directive.kind !== 'Binding' ||
|
|
33
|
+
directive.key.name.name !== 'this' ||
|
|
34
|
+
directive.expression?.type !== AST_NODE_TYPES.Identifier) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const identifier = directive.expression;
|
|
38
|
+
const variable = findValueVariable(source.getScope(identifier), identifier.name);
|
|
39
|
+
if (variable) {
|
|
40
|
+
variables.add(variable);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
allows(call) {
|
|
44
|
+
const declaration = call.parent;
|
|
45
|
+
if (declaration.type !== AST_NODE_TYPES.VariableDeclarator ||
|
|
46
|
+
declaration.init !== call ||
|
|
47
|
+
declaration.id.type !== AST_NODE_TYPES.Identifier ||
|
|
48
|
+
!isEmpty(call)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (!inScript(call, instanceScripts)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const variable = findValueVariable(source.getScope(declaration.id), declaration.id.name);
|
|
55
|
+
return !!variable && variables.has(variable);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { createCommonSourceCheck } from './common-source.js';
|
|
3
|
+
export const createDispatchCheck = (services) => {
|
|
4
|
+
const checker = services.program.getTypeChecker();
|
|
5
|
+
const isCommonSource = createCommonSourceCheck();
|
|
6
|
+
const signatures = new Set();
|
|
7
|
+
const symbols = new Map();
|
|
8
|
+
const isPublicDispatch = (symbol) => {
|
|
9
|
+
const cached = symbols.get(symbol);
|
|
10
|
+
if (cached !== undefined) {
|
|
11
|
+
return cached;
|
|
12
|
+
}
|
|
13
|
+
const matches = symbol.getDeclarations()?.some((declaration) => {
|
|
14
|
+
const source = declaration.getSourceFile();
|
|
15
|
+
if (!isCommonSource(source)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const module = checker.getSymbolAtLocation(source);
|
|
19
|
+
const exported = module &&
|
|
20
|
+
checker.getExportsOfModule(module).find((item) => {
|
|
21
|
+
return item.name === 'Dispatch';
|
|
22
|
+
});
|
|
23
|
+
const target = exported &&
|
|
24
|
+
(exported.flags & ts.SymbolFlags.Alias
|
|
25
|
+
? checker.getAliasedSymbol(exported)
|
|
26
|
+
: exported);
|
|
27
|
+
if (target !== symbol) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
checker
|
|
31
|
+
.getDeclaredTypeOfSymbol(target)
|
|
32
|
+
.getCallSignatures()
|
|
33
|
+
.forEach((signature) => {
|
|
34
|
+
if (signature.declaration) {
|
|
35
|
+
signatures.add(signature.declaration);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
return true;
|
|
39
|
+
}) ?? false;
|
|
40
|
+
symbols.set(symbol, matches);
|
|
41
|
+
return matches;
|
|
42
|
+
};
|
|
43
|
+
const hasDispatchAlias = (original, seen) => {
|
|
44
|
+
const symbol = original.flags & ts.SymbolFlags.Alias
|
|
45
|
+
? checker.getAliasedSymbol(original)
|
|
46
|
+
: original;
|
|
47
|
+
if (seen.has(symbol)) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (isPublicDispatch(symbol)) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
const next = new Set(seen).add(symbol);
|
|
54
|
+
return (symbol.getDeclarations()?.some((declaration) => {
|
|
55
|
+
if (!ts.isTypeAliasDeclaration(declaration)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
let target = declaration.type;
|
|
59
|
+
while (ts.isParenthesizedTypeNode(target)) {
|
|
60
|
+
target = target.type;
|
|
61
|
+
}
|
|
62
|
+
if (!ts.isTypeReferenceNode(target)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
const referenced = checker.getSymbolAtLocation(target.typeName);
|
|
66
|
+
return !!referenced && hasDispatchAlias(referenced, next);
|
|
67
|
+
}) ?? false);
|
|
68
|
+
};
|
|
69
|
+
const isDispatch = (type, seen) => {
|
|
70
|
+
if (seen.has(type) ||
|
|
71
|
+
type.flags &
|
|
72
|
+
(ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const next = new Set(seen).add(type);
|
|
76
|
+
if (type.isUnion()) {
|
|
77
|
+
const members = type.types.filter((member) => {
|
|
78
|
+
return !(member.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined));
|
|
79
|
+
});
|
|
80
|
+
return (members.length > 0 &&
|
|
81
|
+
members.every((member) => {
|
|
82
|
+
return isDispatch(member, next);
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
const symbol = type.aliasSymbol;
|
|
86
|
+
if (!symbol) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
if (!hasDispatchAlias(symbol, new Set())) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
// TS may intern structurally equal aliases. Their signature declarations
|
|
93
|
+
// still distinguish a local callback from the public Dispatch contract.
|
|
94
|
+
const calls = type.getCallSignatures();
|
|
95
|
+
return (calls.length > 0 &&
|
|
96
|
+
calls.every((signature) => {
|
|
97
|
+
return !!signature.declaration && signatures.has(signature.declaration);
|
|
98
|
+
}));
|
|
99
|
+
};
|
|
100
|
+
return (node) => {
|
|
101
|
+
return isDispatch(services.getTypeAtLocation(node), new Set());
|
|
102
|
+
};
|
|
103
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, } from '@typescript-eslint/utils';
|
|
2
|
+
// Visit only the handler's own evaluation. Deferred function bodies are separate callbacks.
|
|
3
|
+
export const visitHandlerBody = (root, source, visit) => {
|
|
4
|
+
const walk = (node) => {
|
|
5
|
+
if (node.type === AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
6
|
+
node.type === AST_NODE_TYPES.FunctionExpression ||
|
|
7
|
+
node.type === AST_NODE_TYPES.FunctionDeclaration) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
visit(node);
|
|
11
|
+
(source.visitorKeys[node.type] ?? []).forEach((key) => {
|
|
12
|
+
if ((node.type === AST_NODE_TYPES.PropertyDefinition ||
|
|
13
|
+
node.type === AST_NODE_TYPES.AccessorProperty) &&
|
|
14
|
+
!node.static &&
|
|
15
|
+
key === 'value') {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const value = node[key];
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
value.forEach((child) => {
|
|
21
|
+
if (child) {
|
|
22
|
+
walk(child);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
else if (value) {
|
|
27
|
+
walk(value);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
walk(root);
|
|
32
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
|
2
|
+
export const memberKey = (node) => {
|
|
3
|
+
if (!node.computed && node.property.type === AST_NODE_TYPES.Identifier) {
|
|
4
|
+
return node.property.name;
|
|
5
|
+
}
|
|
6
|
+
if (node.computed && node.property.type === AST_NODE_TYPES.Literal) {
|
|
7
|
+
return node.property.raw.startsWith("'") ||
|
|
8
|
+
node.property.raw.startsWith('"')
|
|
9
|
+
? String(node.property.value)
|
|
10
|
+
: undefined;
|
|
11
|
+
}
|
|
12
|
+
return undefined;
|
|
13
|
+
};
|