@opetope/lint 0.12.0 → 0.12.1
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 +207 -0
- package/README.md +70 -848
- package/dist/declaration-ingress.js +1 -1
- package/dist/declaration-ingress.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/rules/no-retired-vocabulary.d.ts +1 -1
- package/dist/rules/no-retired-vocabulary.js +1 -1
- package/dist/rules/no-retired-vocabulary.js.map +1 -1
- package/package.json +1 -2
- package/README.ru.md +0 -838
package/README.md
CHANGED
|
@@ -1,67 +1,20 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
ESLint rules for the
|
|
4
|
-
declaration: shapes that compile and run, yet say something other than what their author meant. What the type
|
|
5
|
-
checker, the runtime and dead-code analysis already answer stays with them (see [`../docs/decisions.md`](https://www.npmjs.com/package/@opetope/runtime), D227).
|
|
6
|
-
|
|
7
|
-
The package depends on no other `@opetope/*` package and reads no types, so a host can lint sources it has not
|
|
8
|
-
built yet.
|
|
9
|
-
|
|
10
|
-
<!--examples
|
|
11
|
-
import { derive } from '@opetope/core';
|
|
12
|
-
import type {
|
|
13
|
-
Command,
|
|
14
|
-
ModelCommandContext,
|
|
15
|
-
ModelContext,
|
|
16
|
-
ModelStateWriter,
|
|
17
|
-
OwnedState,
|
|
18
|
-
Readable,
|
|
19
|
-
ResourceData,
|
|
20
|
-
} from '@opetope/core';
|
|
21
|
-
import { defineModel } from '@opetope/core';
|
|
22
|
-
import { defineSlot, requiresModels, useCommand, useModel } from '@opetope/react';
|
|
23
|
-
import { useEffect } from 'react';
|
|
24
|
-
|
|
25
|
-
type Quote = Readonly<{ bid: number; pair: string }>;
|
|
26
|
-
type Payload = Readonly<{ amount: number }>;
|
|
27
|
-
/**
|
|
28
|
-
* `kind` is a plain string on purpose: an updater that spreads the previous value and writes a string literal into a
|
|
29
|
-
* field typed as a union of literals widens that literal and stops compiling, which is a defect of the writer's own
|
|
30
|
-
* signature rather than of this example (D419).
|
|
31
|
-
*/
|
|
32
|
-
type Sessions = Readonly<{ items: readonly string[]; kind: string }>;
|
|
33
|
-
|
|
34
|
-
declare const amount: number;
|
|
35
|
-
declare const busy: OwnedState<boolean>;
|
|
36
|
-
declare const context: ModelContext;
|
|
37
|
-
declare const ctx: ModelContext;
|
|
38
|
-
declare const frames: Readable<string>;
|
|
39
|
-
declare const host: Readonly<{ send(input: Payload, signal: AbortSignal): Promise<void> }>;
|
|
40
|
-
declare const label: Readable<string>;
|
|
41
|
-
declare const sessions: OwnedState<Sessions>;
|
|
42
|
-
declare const total: OwnedState<number>;
|
|
43
|
-
declare const update: ModelStateWriter['update'];
|
|
44
|
-
|
|
45
|
-
const FormActions = defineModel<{ readonly submit: Command<void, void> }>('example.form.actions');
|
|
46
|
-
const FormSlot = defineSlot('example.form.slot');
|
|
47
|
-
declare const createActions: (context: ModelContext) => import('@opetope/core').ModelOf<typeof FormActions>;
|
|
48
|
-
declare const Screen: Readonly<{ load: Command<void, void>; unload: Command<void, void> }>;
|
|
49
|
-
-->
|
|
1
|
+
# `@opetope/lint`
|
|
2
|
+
|
|
3
|
+
ESLint rules for the Opetope authoring and layer boundaries. The rules guide model writes, feature declarations, hook dependencies and imports; they do not replace type checking or runtime validation.
|
|
50
4
|
|
|
51
5
|
## Installation
|
|
52
6
|
|
|
53
7
|
```sh
|
|
54
|
-
npm install --save-dev @opetope/lint
|
|
8
|
+
npm install --save-exact --save-dev @opetope/lint
|
|
55
9
|
```
|
|
56
10
|
|
|
57
|
-
Use matching Opetope versions
|
|
58
|
-
|
|
11
|
+
Use matching exact Opetope versions; for release candidates, install every Opetope package from `@next`.
|
|
12
|
+
Packages are ESM-only and support Node 20.19+. React integrations support React and React DOM 19.
|
|
59
13
|
|
|
60
|
-
|
|
61
|
-
`node_modules/@opetope/runtime/docs/spec.md` or `spec.ru.md`; recipes are in `cookbook.md` and `cookbook.ru.md`.
|
|
62
|
-
No GitHub access is needed to read those installed guides.
|
|
14
|
+
## Example
|
|
63
15
|
|
|
64
|
-
|
|
16
|
+
This focused example shows the package boundary. [Start](../runtime/docs/start.md) includes a complete
|
|
17
|
+
feature, host, React entry and cleanup in one visible module.
|
|
65
18
|
|
|
66
19
|
```js
|
|
67
20
|
// eslint.config.mjs
|
|
@@ -75,824 +28,93 @@ export default [
|
|
|
75
28
|
];
|
|
76
29
|
```
|
|
77
30
|
|
|
78
|
-
|
|
79
|
-
namespace `opetope`, so a rule is named `opetope/enabled-predicate`. Parsing requires `@typescript-eslint/parser`;
|
|
80
|
-
both it and `eslint` are peer dependencies.
|
|
81
|
-
|
|
82
|
-
## Configs
|
|
83
|
-
|
|
84
|
-
### `recommended`
|
|
85
|
-
|
|
86
|
-
Every rule that holds wherever Opetope is written: `enabled-predicate`, `define-feature-property-order`,
|
|
87
|
-
`require-literal-id`, `id-naming`, `no-internal-imports`, `no-snapshot-read-in-render`, `no-snapshot-in-update`,
|
|
88
|
-
`no-redundant-const-tuple`, `no-command-in-deps`, `prefer-effect-current`, `no-write-after-source-write`,
|
|
89
|
-
`capture-command-cleanup`, `require-declared-models` and `no-retired-vocabulary` as errors. A rule that needs to know where a host keeps its
|
|
90
|
-
files is off here and arrives through `layers`; `prefer-model-selection` is off because the number of hooks a
|
|
91
|
-
component may keep is a taste a project settles for itself.
|
|
92
|
-
|
|
93
|
-
`configs.recommended.rules` in full, which is also what `@opetope/lint/oxlintrc.json` ships:
|
|
94
|
-
|
|
95
|
-
| Rule | `recommended` | Turned on by |
|
|
96
|
-
| ------------------------------- | ------------- | ------------------------------------------- |
|
|
97
|
-
| `capture-command-cleanup` | `error` | |
|
|
98
|
-
| `define-feature-property-order` | `error` | |
|
|
99
|
-
| `id-naming` | `error` | |
|
|
100
|
-
| `layer-placement` | `off` | `layers({ integration, models, ui })` |
|
|
101
|
-
| `no-command-in-deps` | `error` | |
|
|
102
|
-
| `no-internal-imports` | `error` | also scoped by `internalImports({ allow })` |
|
|
103
|
-
| `no-redundant-const-tuple` | `error` | |
|
|
104
|
-
| `no-retired-vocabulary` | `error` | |
|
|
105
|
-
| `no-snapshot-in-update` | `error` | |
|
|
106
|
-
| `no-snapshot-read-in-render` | `error` | |
|
|
107
|
-
| `no-subscribe-outside-models` | `off` | `layers({ models })` |
|
|
108
|
-
| `no-write-after-source-write` | `error` | |
|
|
109
|
-
| `prefer-effect-current` | `error` | |
|
|
110
|
-
| `prefer-model-selection` | `off` | the project, with its own `threshold` |
|
|
111
|
-
| `require-declared-models` | `error` | |
|
|
112
|
-
| `require-literal-id` | `error` | |
|
|
113
|
-
| `enabled-predicate` | `error` | |
|
|
114
|
-
|
|
115
|
-
The three `off` rules are the three that need something only the project knows: which directories are which layer,
|
|
116
|
-
and how many hooks one component may keep.
|
|
117
|
-
|
|
118
|
-
### `layers`
|
|
119
|
-
|
|
120
|
-
`opetope.configs.layers({ integration, models, ui, tests })` takes the globs of a project's own layers and returns
|
|
121
|
-
the configuration each of them earns. A layer without globs is a layer this project does not have, and it produces
|
|
122
|
-
nothing; a file no glob claims is placed by nobody.
|
|
123
|
-
|
|
124
|
-
```js
|
|
125
|
-
...opetope.configs.layers({
|
|
126
|
-
integration: ['src/features/*/integration/**/*.{ts,tsx}'],
|
|
127
|
-
models: ['src/features/*/models/**/*.ts'],
|
|
128
|
-
ui: ['src/features/*/ui/**/*.{ts,tsx}'],
|
|
129
|
-
}),
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
`integration`, `models` and `ui` each turn on `layer-placement` for their files. `models` also turns on
|
|
133
|
-
`no-subscribe-outside-models` for every source file and steps aside in that layer and in `tests`, which defaults to
|
|
134
|
-
`['**/__tests__/**', '**/*.spec.ts', '**/*.spec.tsx']`. A subscription is covered even in a file no layer claims,
|
|
135
|
-
because forgetting one is how a subscription outlives its reader.
|
|
136
|
-
|
|
137
|
-
### `internalImports`
|
|
138
|
-
|
|
139
|
-
`opetope.configs.internalImports({ allow, files })` configures `opetope/no-internal-imports` across JavaScript and
|
|
140
|
-
TypeScript, including `.mjs` model tests. The rule is already enabled by `recommended`; this helper is useful for
|
|
141
|
-
scoping it or naming trusted library implementation and host integration files. `allow` explicitly switches off
|
|
142
|
-
only this rule for those files. Place the helper's blocks **after** `recommended`: a later `recommended` block
|
|
143
|
-
re-enables the rule, including for the allowed files. Other project restrictions stay intact.
|
|
144
|
-
|
|
145
|
-
```js
|
|
146
|
-
export default [
|
|
147
|
-
opetope.configs.recommended,
|
|
148
|
-
...opetope.configs.internalImports({ allow: ['src/bootstrap/**/devtools.ts'] }),
|
|
149
|
-
];
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
The dedicated rule never changes `no-restricted-imports`. The former `internalImportPattern` export is removed:
|
|
153
|
-
enable this rule or use the helper instead of merging an Opetope pattern into the host's restrictions (D261).
|
|
154
|
-
|
|
155
|
-
## Rules
|
|
156
|
-
|
|
157
|
-
### `no-internal-imports`
|
|
158
|
-
|
|
159
|
-
Application code and tests import public Opetope entries. The rule rejects `@opetope/*/internal` and its deeper
|
|
160
|
-
paths in imports, re-exports, literal `import()` calls, unshadowed `require()` calls, TypeScript import types and
|
|
161
|
-
`import = require`. It includes type-only imports; an internal type is still coupled to the private ABI.
|
|
162
|
-
Use `runCommand` from `@opetope/core/testing` to run a real model Command, `openModel` from `@opetope/runtime/testing` to open one without a feature, and `command` from `@opetope/react/testing` to construct a fixture Command.
|
|
163
|
-
Tests do not receive an automatic exception. Trusted library implementation or host integration can use an explicit
|
|
164
|
-
file override; the rule has no autofix because the correct public replacement depends on the imported operation.
|
|
165
|
-
|
|
166
|
-
**The boundary.** This is a syntactic check of literal module paths and templates without substitutions. It does
|
|
167
|
-
not resolve aliases, computed import paths, re-export graphs or custom loaders. A locally shadowed `require` is not
|
|
168
|
-
treated as Node's loader. Apply the config to every JavaScript/TypeScript source and test glob the project uses.
|
|
31
|
+
## Documentation
|
|
169
32
|
|
|
170
|
-
|
|
33
|
+
The [reference](../runtime/docs/reference/lint.md) owns the detailed contract, options and failure semantics.
|
|
34
|
+
[Guides](../runtime/docs/guides/index.md) show individual tasks. Documentation is shipped with
|
|
35
|
+
`@opetope/runtime` in `docs/`, so an installed application can read it without access to this repository.
|
|
171
36
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
37
|
+
For a contributor checkout, use `npm run ci:type --workspace @opetope/lint` and
|
|
38
|
+
`npm run ci:test --workspace @opetope/lint` where provided. The root `npm run check` performs full acceptance;
|
|
39
|
+
[contributor commands](../runtime/docs/maintainers/contributing.md) describe the build and package checks.
|
|
175
40
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
| `enabled: ({ imports }) => imports.x.allowed` | returns a source; fixed to `({ imports, read }) => read(imports.x.allowed)` |
|
|
179
|
-
| `enabled: async ({ read }) => read(x)` | a predicate answers synchronously; no fix |
|
|
180
|
-
| `enabled: () => allowed` | a `Readable<boolean>` is passed as `enabled` directly, without a wrapper; no fix |
|
|
41
|
+
<a id="opetopelint"></a>
|
|
42
|
+
[See @opetope/lint](../runtime/docs/reference/lint.md).
|
|
181
43
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
`context => context.read(context.imports.x.allowed)`.
|
|
44
|
+
<a id="usage"></a>
|
|
45
|
+
[See Usage](../runtime/docs/reference/lint.md#lint-usage).
|
|
185
46
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
context is a plain value rather than a source, the predicate cannot change its answer, and the rule reports it as
|
|
189
|
-
one written for a source; hoist that decision out of `enabled`, or silence the line. A `when` is a different field
|
|
190
|
-
and is left alone wherever it stands — the source value of `scope.while` — and the change filter of an effect is not
|
|
191
|
-
a `when` at all: it is `filter`, and D379 gave it that name so one word would not answer two questions.
|
|
47
|
+
<a id="configs"></a>
|
|
48
|
+
[See Configs](../runtime/docs/reference/lint.md#configs).
|
|
192
49
|
|
|
193
|
-
|
|
50
|
+
<a id="recommended"></a>
|
|
51
|
+
[See `recommended`](../runtime/docs/reference/lint.md#recommended).
|
|
194
52
|
|
|
195
|
-
|
|
196
|
-
`
|
|
197
|
-
names itself with its first argument (D280). The order is the reading order of the declaration — the edges, the
|
|
198
|
-
stages, then the lifetime and the loader — so a reader finds a section by position, and a key sorter can be
|
|
199
|
-
configured to hold the same order instead of a second one.
|
|
53
|
+
<a id="layers"></a>
|
|
54
|
+
[See `layers`](../runtime/docs/reference/lint.md#layers).
|
|
200
55
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
with it. An object with a key that is not a section, or with a spread, is left to the type checker.
|
|
56
|
+
<a id="internalimports"></a>
|
|
57
|
+
[See `internalImports`](../runtime/docs/reference/lint.md#internalimports).
|
|
204
58
|
|
|
205
|
-
|
|
59
|
+
<a id="rules"></a>
|
|
60
|
+
[See Rules](../runtime/docs/reference/lint.md#rules).
|
|
206
61
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
(D279, D309):
|
|
62
|
+
<a id="no-internal-imports"></a>
|
|
63
|
+
[See `no-internal-imports`](../runtime/docs/reference/lint.md#no-internal-imports).
|
|
210
64
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
The rule reads the first argument of a `derive` resolved to `@opetope/core`, and only where that argument is an
|
|
216
|
-
array literal carrying `as const`. The fix removes the assertion and nothing else. It stands down when a comment
|
|
217
|
-
sits between the tuple and the assertion, because such a comment belongs to neither and the removal would take it
|
|
218
|
-
along.
|
|
219
|
-
|
|
220
|
-
**The boundary.** An assertion written anywhere else is left alone: on one element of the tuple, where it says
|
|
221
|
-
something about that element; on the result of the selector; on the options record; and on the single-source form,
|
|
222
|
-
whose argument is not a tuple at all. `as Sources` is a different assertion and says something else. The import is
|
|
223
|
-
resolved within one module — a local alias, a `const` that holds the import and a namespace binding all reach the
|
|
224
|
-
same export — and, unlike `no-command-in-deps`, this rule recognizes the import from `@opetope/core` and nothing
|
|
225
|
-
else: a re-export through a host's own barrel it does not see. That is the deliberate price of a safe fix. This
|
|
226
|
-
rule rewrites code, and a host's own `derive` has no `const` type parameter to make the assertion redundant, so
|
|
227
|
-
removing it there would change the inferred types in silence.
|
|
228
|
-
|
|
229
|
-
### `require-declared-models`
|
|
230
|
-
|
|
231
|
-
A component that reads a per-mount UI model declares that model with `requiresModels`. Owner models remain
|
|
232
|
-
available to the contribution subtree without this declaration (D158, D250).
|
|
233
|
-
|
|
234
|
-
```tsx
|
|
235
|
-
import { defineFeature } from '@opetope/runtime';
|
|
236
|
-
import { requiresModels, useCommand, useModel } from '@opetope/react';
|
|
237
|
-
|
|
238
|
-
const Form = () => {
|
|
239
|
-
const submit = useCommand(useModel(FormActions).submit);
|
|
240
|
-
return (
|
|
241
|
-
<button disabled={submit.inFlight} onClick={() => void submit.run()}>
|
|
242
|
-
Submit
|
|
243
|
-
</button>
|
|
244
|
-
);
|
|
245
|
-
};
|
|
246
|
-
const DeclaredForm = requiresModels([FormActions])(Form);
|
|
247
|
-
|
|
248
|
-
defineFeature('example.form', {
|
|
249
|
-
provides: ({ slot }) => ({
|
|
250
|
-
form: slot(FormSlot, ({ model }) => ({
|
|
251
|
-
Component: DeclaredForm,
|
|
252
|
-
models: [model(FormActions, createActions)],
|
|
253
|
-
})),
|
|
254
|
-
}),
|
|
255
|
-
});
|
|
256
|
-
```
|
|
65
|
+
<a id="enabled-predicate"></a>
|
|
66
|
+
[See `enabled-predicate`](../runtime/docs/reference/lint.md#enabled-predicate).
|
|
257
67
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
each visible component or hook that reads one of those models declares its own list. Wrapping a parent does not
|
|
261
|
-
declare the requirements of a nested function.
|
|
68
|
+
<a id="define-feature-property-order"></a>
|
|
69
|
+
[See `define-feature-property-order`](../runtime/docs/reference/lint.md#define-feature-property-order).
|
|
262
70
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
`requiresModels` or `slot` does not become an Opetope API by sharing its name. Named APIs from relative re-exports
|
|
266
|
-
are recognized by their exported names, without reading the other file.
|
|
71
|
+
<a id="no-redundant-const-tuple"></a>
|
|
72
|
+
[See `no-redundant-const-tuple`](../runtime/docs/reference/lint.md#no-redundant-const-tuple).
|
|
267
73
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
dynamic model lists and opaque helpers remain unverified: silence is not proof that their model requirements are
|
|
271
|
-
complete. Model references must be locally visible identifiers or aliases; the rule does not inspect types or walk
|
|
272
|
-
the rendered React tree. It has no autofix, because choosing which model a component should read is an authoring
|
|
273
|
-
choice. Types and runtime authority checks continue to apply.
|
|
74
|
+
<a id="require-declared-models"></a>
|
|
75
|
+
[See `require-declared-models`](../runtime/docs/reference/lint.md#require-declared-models).
|
|
274
76
|
|
|
275
|
-
|
|
77
|
+
<a id="require-literal-id"></a>
|
|
78
|
+
[See `require-literal-id`](../runtime/docs/reference/lint.md#require-literal-id).
|
|
276
79
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
`defineCondition`, `defineHostContract`, `defineModel`, `definePort`, `defineSlot`, `defineSwitchSlot`,
|
|
280
|
-
`definePipe` and `defineRegistry`, and the four of the optional Navigation package: `defineScreen`,
|
|
281
|
-
`defineSurface`, `defineLink` and `defineLinkHandlers`. D280 gives them one form: the id is the first argument of
|
|
282
|
-
every one of them.
|
|
80
|
+
<a id="id-naming"></a>
|
|
81
|
+
[See `id-naming`](../runtime/docs/reference/lint.md#id-naming).
|
|
283
82
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
with an expression, a concatenation, a call, or a name that resolves to a parameter.
|
|
83
|
+
<a id="layer-placement"></a>
|
|
84
|
+
[See `layer-placement`](../runtime/docs/reference/lint.md#layer-placement).
|
|
287
85
|
|
|
288
|
-
|
|
86
|
+
<a id="no-snapshot-read-in-render"></a>
|
|
87
|
+
[See `no-snapshot-read-in-render`](../runtime/docs/reference/lint.md#no-snapshot-read-in-render).
|
|
289
88
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
```
|
|
89
|
+
<a id="no-snapshot-in-update"></a>
|
|
90
|
+
[See `no-snapshot-in-update`](../runtime/docs/reference/lint.md#no-snapshot-in-update).
|
|
293
91
|
|
|
294
|
-
|
|
295
|
-
|
|
92
|
+
<a id="no-command-in-deps"></a>
|
|
93
|
+
[See `no-command-in-deps`](../runtime/docs/reference/lint.md#no-command-in-deps).
|
|
296
94
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
`defineCallLane` are not read, because an author never writes them.
|
|
95
|
+
<a id="no-subscribe-outside-models"></a>
|
|
96
|
+
[See `no-subscribe-outside-models`](../runtime/docs/reference/lint.md#no-subscribe-outside-models).
|
|
300
97
|
|
|
301
|
-
|
|
98
|
+
<a id="prefer-effect-current"></a>
|
|
99
|
+
[See `prefer-effect-current`](../runtime/docs/reference/lint.md#prefer-effect-current).
|
|
302
100
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
the moment the declaration runs, and this rule says so before the code runs. A screen, a surface and a link handler
|
|
306
|
-
registry pass their id through the same `declarationId`, so this rule reads them too; `defineLink` is left out of it
|
|
307
|
-
because a link id is a canonical lower-case path segment of a URL, which the package checks itself and which no
|
|
308
|
-
reserved prefix applies to (D313).
|
|
309
|
-
|
|
310
|
-
A project that reserves a first segment names it, and every declaration of those files must open with it:
|
|
311
|
-
|
|
312
|
-
```js
|
|
313
|
-
'opetope/id-naming': ['error', { prefix: 'workspace' }],
|
|
314
|
-
```
|
|
101
|
+
<a id="no-write-after-source-write"></a>
|
|
102
|
+
[See `no-write-after-source-write`](../runtime/docs/reference/lint.md#no-write-after-source-write).
|
|
315
103
|
|
|
316
|
-
|
|
317
|
-
|
|
104
|
+
<a id="capture-command-cleanup"></a>
|
|
105
|
+
[See `capture-command-cleanup`](../runtime/docs/reference/lint.md#capture-command-cleanup).
|
|
318
106
|
|
|
319
|
-
|
|
107
|
+
<a id="prefer-model-selection"></a>
|
|
108
|
+
[See `prefer-model-selection`](../runtime/docs/reference/lint.md#prefer-model-selection).
|
|
320
109
|
|
|
321
|
-
|
|
322
|
-
|
|
110
|
+
<a id="no-retired-vocabulary"></a>
|
|
111
|
+
[See `no-retired-vocabulary`](../runtime/docs/reference/lint.md#no-retired-vocabulary).
|
|
323
112
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
| `defineFeature` | integration — a feature composes the other two layers |
|
|
327
|
-
| `defineModel` | models, or the UI contracts that declare a mount model |
|
|
328
|
-
| `defineSlot`, `defineSwitchSlot`, `defineSurface`, `definePipe`, `defineRegistry`, `definePort`, `defineCondition`, `defineHostContract` | integration, or the UI contracts beside the component |
|
|
329
|
-
| `defineApplication` | none of them: the host bootstrap owns it |
|
|
113
|
+
<a id="coexistence-with-key-sorting"></a>
|
|
114
|
+
[See Coexistence with key sorting](../runtime/docs/reference/lint.md#coexistence-with-key-sorting).
|
|
330
115
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
project's choice, and this rule holds whatever choice it declared.
|
|
334
|
-
|
|
335
|
-
### `no-snapshot-read-in-render`
|
|
336
|
-
|
|
337
|
-
`getSnapshot()` called while a component or a hook renders reads the value once and never hears the next one. Read
|
|
338
|
-
it with `useReadable`, or select it together with the commands of the same model:
|
|
339
|
-
`useModel(Declaration, (model, { read }) => ...)` (D205, D214).
|
|
340
|
-
|
|
341
|
-
A render scope is a function named `use…`, or a capitalized function that returns elements. The rule looks at the
|
|
342
|
-
function the call is written in, so a snapshot read in an event handler, an effect or any other nested callback
|
|
343
|
-
stays: those run after render, and the value of that moment is the one they want.
|
|
344
|
-
|
|
345
|
-
**The boundary.** The rule reads the name of the receiver, not its type: every `getSnapshot()` in a render scope is
|
|
346
|
-
reported, whichever object it belongs to. A capitalized function that returns no element is a factory and keeps its
|
|
347
|
-
reads — including a contribution's model factory, which reads the props of its own mount.
|
|
348
|
-
|
|
349
|
-
### `no-snapshot-in-update`
|
|
350
|
-
|
|
351
|
-
A value passed to `update` reads the state it is about to replace — a composite one,
|
|
352
|
-
`update(sessions, { items: sessions.getSnapshot().items, kind: 'loading' })`, and a scalar one,
|
|
353
|
-
`update(total, total.getSnapshot() + amount)`, alike. The read runs where the argument is written — before the
|
|
354
|
-
write, and on a state that may already have stopped, where `getSnapshot()` answers a value nothing will move again
|
|
355
|
-
— and the value it computed then lands on a state that may have moved since. The updater form has neither problem: it reads at write
|
|
356
|
-
time, and a write dropped because its context was aborted never calls the updater at all (D282, D288, D295).
|
|
357
|
-
|
|
358
|
-
```ts
|
|
359
|
-
update(sessions, previous => ({ ...previous, kind: 'loading' }));
|
|
360
|
-
update(total, previous => previous + amount);
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
The rule reports a call of `update` — by that name or through a context, `ctx.update` — with exactly two arguments,
|
|
364
|
-
whose first argument names a state and whose value reads `getSnapshot()` on that same state: written into the value
|
|
365
|
-
itself, or through a name of the same function that holds the read — `const current = s.getSnapshot()`,
|
|
366
|
-
`const { items } = s.getSnapshot()`, or a `let` nothing writes again. A `getSnapshot()` of another readable inside
|
|
367
|
-
the value is a read of another state and stays; so do a value that is already an updater, a read written anywhere
|
|
368
|
-
but in that value, and a second argument that is a spread, whose arguments are assembled somewhere else.
|
|
369
|
-
|
|
370
|
-
The fix writes the updater: the value becomes `previous => ...` with every read of that state replaced by the
|
|
371
|
-
parameter — a destructured field by the field of it, `previous.items` — and the `const` that held the read goes with
|
|
372
|
-
it, comment and all, where this value was its only reader. The parameter is named `previous`, or `snapshot` where an
|
|
373
|
-
enclosing scope already holds `previous`. Where the parameter would shadow a name the file has, where the named read
|
|
374
|
-
has another reader that stays behind, and wherever the read was taken apart or opened with a `let`, the same rewrite
|
|
375
|
-
arrives as a suggestion instead, because those are the rewrites a reader should look at. A value that cannot move
|
|
376
|
-
into a function body at all — one that writes an `await` or a `yield` there, assigns, increments or `delete`s —
|
|
377
|
-
gets the report and nothing else, and so does a read this value keeps for a later call.
|
|
378
|
-
|
|
379
|
-
**The boundary.** `update(S, ...S.getSnapshot()...)` is specific enough on its own, so the rule resolves no import
|
|
380
|
-
and reads no type: a host's own `update` of the same shape is reported too, and the fix is right for it only if it
|
|
381
|
-
accepts an updater as its second argument. A name is the binding it resolves to, but a member expression is the
|
|
382
|
-
path it spells, so two different objects written `this.state` are one state here. The rewrite moves the whole value
|
|
383
|
-
into a function body, so everything else the value called — `Date.now()`, a helper, a formatter — is computed at
|
|
384
|
-
write time instead of where it was written, and is not computed at all for a write the runtime drops; that is what
|
|
385
|
-
the updater form means, and it is worth reading once before the rewrite lands. A read the value reaches by any other
|
|
386
|
-
route — a helper that takes the state and reads it, a name declared in another function, a name the code writes
|
|
387
|
-
again, a value that is already an updater — is not seen, and silence is not proof that a value computed itself from
|
|
388
|
-
the previous one.
|
|
389
|
-
|
|
390
|
-
### `no-command-in-deps`
|
|
391
|
-
|
|
392
|
-
A command hook is a snapshot of the render it was read in: the object is rebuilt on every `inFlight` and `outcome`
|
|
393
|
-
change, while `run` keeps one identity for the life of the binding (D290). A dependency array that names the object
|
|
394
|
-
therefore fires on each of those changes: an effect re-runs, a memo is voided, and an effect that calls `run` from
|
|
395
|
-
it never settles — it starts the command, the status flips, the dependency changes, the cleanup runs, and the
|
|
396
|
-
effect runs again.
|
|
397
|
-
|
|
398
|
-
```tsx
|
|
399
|
-
const load = useCommand(Screen.load);
|
|
400
|
-
const unload = useCommand(Screen.unload);
|
|
401
|
-
|
|
402
|
-
useEffect(() => {
|
|
403
|
-
load.run();
|
|
404
|
-
return () => void unload.run();
|
|
405
|
-
}, [load.run, unload.run]);
|
|
406
|
-
```
|
|
407
|
-
|
|
408
|
-
The rule reads the dependency array of `useEffect`, `useLayoutEffect`, `useInsertionEffect`, `useCallback`,
|
|
409
|
-
`useMemo` and `useImperativeHandle`, and reports an element that is a command hook binding taken whole: the
|
|
410
|
-
variable of a `useCommand`, and a key or a destructured name of a `useModel` selection the same code reads `run` on.
|
|
411
|
-
`x.run` and the statuses `x.inFlight` and `x.outcome` are members of that object rather than the object, and they
|
|
412
|
-
stay.
|
|
413
|
-
|
|
414
|
-
The record itself is the worse shape and carries its own report: `useModel` with a selection builds a new object on
|
|
415
|
-
every render, so `[hooks]` and `[order]` change on every render rather than on every status. The answer is the field
|
|
416
|
-
the callback reads — `hooks.save.run`.
|
|
417
|
-
|
|
418
|
-
The fix is written where the answer is one: a callback that reaches the binding through a single `run` gets that
|
|
419
|
-
path in place of the element, whatever the element was written past — an `as`, a `satisfies` or a `!` is replaced
|
|
420
|
-
along with it, and an array that already names the path loses the element instead of doubling it. Where the callback
|
|
421
|
-
reads more than one path, or reads a status, the rule offers suggestions instead — one per path, statuses first —
|
|
422
|
-
because which of them the dependency means is the author's to say. A callback this rule cannot read through — one
|
|
423
|
-
written elsewhere, one that keeps the object itself, takes it apart, or reads a member outside those four — gets the
|
|
424
|
-
report and no suggestion at all.
|
|
425
|
-
|
|
426
|
-
**The boundary.** Names are resolved by import within one module, not by spelling: `useCommand` and `useModel` are
|
|
427
|
-
the imports of `@opetope/react`, the six hooks are React's own, and a local alias of either —
|
|
428
|
-
`import { useEffect as effect }` — is the same import. React is the module named `react` exactly; an `@opetope/*`
|
|
429
|
-
name is also taken from a relative import, which is how a package re-exports its own entry through a barrel, so a
|
|
430
|
-
neighbouring `./scheduling` is React's no more than `@host/scheduling` is. React's hooks are read through a
|
|
431
|
-
namespace or a default binding as well (`React.useEffect`); an `@opetope/*` entry has no default export, and only a
|
|
432
|
-
namespace reaches it. Only a `const` holds the hook it was opened with, so a `let` is left alone. A selected
|
|
433
|
-
`useModel` field is a hook only where the code proves it by reading `run` on it — a status name proves nothing,
|
|
434
|
-
since plain data carries one as easily — and a field read as data is of unknown kind and is left alone, as is
|
|
435
|
-
`useModel(Declaration)` without a selection, which keeps returning the granted model. A dependency array of a call
|
|
436
|
-
that is not one of the six hooks belongs to whoever declared it.
|
|
437
|
-
|
|
438
|
-
### `no-subscribe-outside-models`
|
|
439
|
-
|
|
440
|
-
A subscription written by hand owns a cleanup that nothing around it can see. The rule reports `x.subscribe(...)`
|
|
441
|
-
and stays off until `configs.layers` names the model layer that is allowed to hold one; in a component the reader
|
|
442
|
-
is `useReadable` or a model selection, and in a feature or a model it is `effect`, `event` or the `connect` of
|
|
443
|
-
`resource.live`.
|
|
444
|
-
|
|
445
|
-
Handing the function over without calling it is not a subscription written here:
|
|
446
|
-
`useSyncExternalStore(source.subscribe, source.getSnapshot)` passes a reference, and the reader owns what it starts.
|
|
447
|
-
|
|
448
|
-
Neither is a subscription written where a declared node opens its work and owns the release of it. The disposer
|
|
449
|
-
such a callback returns belongs to that node and is drained with the generation that opened it, so the rule stays
|
|
450
|
-
silent there — this is the shape its own message asks for (D299):
|
|
451
|
-
|
|
452
|
-
| Callback | Where | Ingress |
|
|
453
|
-
| --------------------------------------- | --------------------------- | ------- |
|
|
454
|
-
| `resource.live({ connect })` | the `connect` member | yes |
|
|
455
|
-
| `event(source, subscribe, run)` | second argument | yes |
|
|
456
|
-
| `scope.while` / `scope.switch` | `open`, second argument | yes |
|
|
457
|
-
| `scope.each(source, key, open)` | `open`, third argument | yes |
|
|
458
|
-
| `attach(source, { open })` | the `open` member | yes |
|
|
459
|
-
| `attach(source, { open, close })` | the `close` member | no |
|
|
460
|
-
| `apply`, `load`, `run`, `filter`, `key` | modifiers of the same calls | no |
|
|
461
|
-
|
|
462
|
-
`resource.load` opens no subscription and has no ingress at all, so a `subscribe` written in its `load` is reported
|
|
463
|
-
like any other hand-written one (D349).
|
|
464
|
-
|
|
465
|
-
<!--example
|
|
466
|
-
import { defineFeature, defineHostContract } from '@opetope/runtime';
|
|
467
|
-
|
|
468
|
-
type Exchange = Readonly<{
|
|
469
|
-
repository: Readonly<{ subscribe(pair: string, emit: (quote: Quote) => boolean, signal: AbortSignal): () => void }>;
|
|
470
|
-
}>;
|
|
471
|
-
|
|
472
|
-
const exchangeContract = defineHostContract<Exchange>('example.exchange.platform');
|
|
473
|
-
const bookFeature = defineFeature('example.book', {
|
|
474
|
-
imports: { exchange: exchangeContract },
|
|
475
|
-
/* … */
|
|
476
|
-
});
|
|
477
|
-
-->
|
|
478
|
-
|
|
479
|
-
```ts
|
|
480
|
-
own: ({ imports, resource }) => ({
|
|
481
|
-
book: resource.live({
|
|
482
|
-
apply: { change: (_current: ResourceData<Quote>, change: Quote) => change },
|
|
483
|
-
connect: ({ emit, signal, source }) => source.repository.subscribe('BTCUSD', emit, signal),
|
|
484
|
-
lifetime: 'owner',
|
|
485
|
-
source: imports.exchange,
|
|
486
|
-
}),
|
|
487
|
-
}),
|
|
488
|
-
```
|
|
489
|
-
|
|
490
|
-
Inside counts all the way down — a nested arrow, a `function` body, an `await` on the subscription itself, a
|
|
491
|
-
disposer named before it is returned. The context is resolved by import, not by spelling: the `own` section written
|
|
492
|
-
right where a `defineFeature`/`defineFeature.body` **taken from `@opetope/runtime` itself** receives it, and a model
|
|
493
|
-
factory — the second argument of `model(Declaration, factory)` on that same `own`, or a function whose first
|
|
494
|
-
parameter is annotated `ModelContext` from `@opetope/core`. Unlike `no-command-in-deps`, these two anchors take the
|
|
495
|
-
package name and nothing else: a relative import here would let a host's own `./my-own-context` silence the rule.
|
|
496
|
-
`own.resource.live(...)` and a destructured `resource.live(...)` read alike, an alias answers with the key it was
|
|
497
|
-
taken from (`{ resource: materialize }` is still `resource`), and `own.scope.while(...)` reads like the destructured
|
|
498
|
-
`scope.while(...)`.
|
|
499
|
-
|
|
500
|
-
Four shapes therefore keep the report, and each is a limit of what syntax proves: a `resource.live` traced to none of
|
|
501
|
-
those anchors (imported from elsewhere, unresolvable, or belonging to another `defineFeature`); a callback written
|
|
502
|
-
elsewhere and passed in by name, which is not lexically inside; an `own` section hoisted to its own binding
|
|
503
|
-
(`const own = ({ resource }) => …; defineFeature(id, { own })`); and `model(Decl, createOrderModel)` where the factory
|
|
504
|
-
is a named function without the `ModelContext` annotation.
|
|
505
|
-
|
|
506
|
-
### `prefer-effect-current`
|
|
507
|
-
|
|
508
|
-
The `run` of an effect is already given the value it was started for — as its first parameter, since D379. Reading
|
|
509
|
-
the same source through `source.getSnapshot()` inside that run reads it a second time and says nothing about which
|
|
510
|
-
run this is, so the rule rewrites it to that parameter (D296, D323, D379):
|
|
511
|
-
|
|
512
|
-
```js
|
|
513
|
-
// before
|
|
514
|
-
ctx.effect(quotes, current => publish(quotes.getSnapshot()));
|
|
515
|
-
// after
|
|
516
|
-
ctx.effect(quotes, current => publish(current));
|
|
517
|
-
```
|
|
518
|
-
|
|
519
|
-
The fix writes the word the run already has: the name the author bound the first parameter under, whatever it is. A
|
|
520
|
-
run that destructured the value itself, or took no parameter at all, has no word for the whole of it, so the report
|
|
521
|
-
stands alone.
|
|
522
|
-
|
|
523
|
-
**The two cases it reports without rewriting.** Past the first `await` of the run, the parameter is the value this
|
|
524
|
-
run started with and the snapshot is the value of the moment; a change of the source normally restarts the run, so
|
|
525
|
-
the two normally agree — and «normally» is not a licence to rewrite. Under `filter` they legitimately differ: the
|
|
526
|
-
predicate admits some values and skips others, so the source moves without restarting the run (D168, D296, D379). A
|
|
527
|
-
modifier record this rule cannot read — one assembled elsewhere — is treated as if it carried the predicate.
|
|
528
|
-
|
|
529
|
-
A read inside a nested callback of the run — a `timers.delay`, a subscription handler — is left alone: that code
|
|
530
|
-
runs later than the run does, and the value of that moment is the one it wants. Like every rule that rewrites code,
|
|
531
|
-
this one resolves its context exactly: the `effect` it acts on is the one declared on a `ModelContext` parameter or
|
|
532
|
-
on the context of a `model(Declaration, factory)` written inside a `defineFeature` of `@opetope/runtime` (D309).
|
|
533
|
-
|
|
534
|
-
### `no-write-after-source-write`
|
|
535
|
-
|
|
536
|
-
A write of an effect run into a cell its own source reads changes the value that source publishes, and the
|
|
537
|
-
notification is synchronous: the run is aborted inside that write, so every write of the run after it is dropped and
|
|
538
|
-
an `invoke` after it is refused. The first of those dropped writes reaches the reporter as `LostWrite`, once per run
|
|
539
|
-
and saying the rest went with it (D432); the refused `invoke` is still silent, and so is every other way a write is
|
|
540
|
-
lost. The rule reports the write that moves the source when another write of the same run can follow it
|
|
541
|
-
(spec §2.11, D288, D365, D432, D447):
|
|
542
|
-
|
|
543
|
-
```ts
|
|
544
|
-
// reported: the deposit is never written down, because releasing the key ended the run
|
|
545
|
-
const key = context.state<string | null>('idempotency-1');
|
|
546
|
-
const deposit = context.state<string | null>(null);
|
|
547
|
-
const source = derive([frames, key], (frame, held) => `${frame}:${String(held)}`);
|
|
548
|
-
|
|
549
|
-
context.effect(source, (_current, { update }) => {
|
|
550
|
-
update(key, null);
|
|
551
|
-
update(deposit, 'deposit-1');
|
|
552
|
-
});
|
|
553
|
-
```
|
|
554
|
-
|
|
555
|
-
A command the run `invoke`s writes under the same authority. `invoke` hands the command the cancellation of the run,
|
|
556
|
-
so one abort ends them both, and a write of that command into a cell the run's source reads ends the run exactly as
|
|
557
|
-
the run's own write does. A write the run reaches after that is dropped and recorded as `LostWrite` like any other
|
|
558
|
-
(D447) — but the run is usually _awaiting_ that call when the abort lands, and then the promise it awaits is
|
|
559
|
-
cancelled and the body reaches no write at all. Nothing is dropped there for the runtime to record, so this rule is
|
|
560
|
-
the only finder of that shape, and it reports the call where it is written:
|
|
561
|
-
|
|
562
|
-
```ts
|
|
563
|
-
// reported: the write that releases the key is the command's, and the deposit below the call is never reached
|
|
564
|
-
const key = context.state<string | null>('idempotency-1');
|
|
565
|
-
const deposit = context.state<string | null>(null);
|
|
566
|
-
const source = derive([frames, key], (frame, held) => `${frame}:${String(held)}`);
|
|
567
|
-
const release = context.command(({ update }: ModelCommandContext<void>) => {
|
|
568
|
-
update(key, null);
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
context.effect(source, async (_current, { invoke, update }) => {
|
|
572
|
-
await invoke(release);
|
|
573
|
-
update(deposit, 'deposit-1');
|
|
574
|
-
});
|
|
575
|
-
```
|
|
576
|
-
|
|
577
|
-
Whether the drop shows in the data is a second question, and it is why such a defect used to be found in production
|
|
578
|
-
rather than in a test: the loop opens a successor run for the new value where `filter` admits it, and a successor
|
|
579
|
-
that repeats the write hides the loss — the cell ends up correct through the second run. The loss stands whole where
|
|
580
|
-
the successor does not repeat that write, which is what a guard does (`if (held === null) return`), and where no
|
|
581
|
-
successor opens at all, which is what a `filter` that rejects the new value does (D329). The record arrives in every
|
|
582
|
-
one of those shapes, the hidden one included: it speaks about the write that was dropped, not about the value the
|
|
583
|
-
cell ended up with (D432).
|
|
584
|
-
|
|
585
|
-
There is no fix: which of the two writes the author meant to keep is the author's to say, and the message names three
|
|
586
|
-
ways out — make the write into the source the run's last write, or keep what the run accumulates out of its source and
|
|
587
|
-
read it with `getSnapshot()`, which also saves the second run, or take `execution.capture(state)` before the moving
|
|
588
|
-
write, which is the one that saves this write whole although it saves nothing written after it. It names them in the
|
|
589
|
-
words the `LostWrite` record prints, and `ci:source` holds the two texts equal, because the author reads the rule
|
|
590
|
-
before any run reports (D319, D432, D442, D447).
|
|
591
|
-
|
|
592
|
-
**What it proves before it reports.** Three things, and a fourth where the moving write is a command's; it says
|
|
593
|
-
nothing where it cannot prove one of them.
|
|
594
|
-
|
|
595
|
-
_The declaration._ The `effect` is declared on a context of this library — the `effect` of a `ModelContext`
|
|
596
|
-
parameter, or of the context of `model(Declaration, factory)` written inside a `defineFeature` of `@opetope/runtime`
|
|
597
|
-
(D309). The run is the function the declaration takes, written there or bound to a name in this module.
|
|
598
|
-
|
|
599
|
-
_The cell is part of the source._ Either the source is that cell, or it is a `derive` of `@opetope/core` — written
|
|
600
|
-
where the declaration takes it, or held by a name in this scope — whose dependencies name the cell, and whose
|
|
601
|
-
selector publishes the value of that dependency. That last clause is the carve-out of the law itself: a write that
|
|
602
|
-
leaves the published value equal ends nothing, so a selector that only asks a question about its dependency —
|
|
603
|
-
`held === null ? 'none' : 'some'`, `!held`, `typeof held`, `held ? a : b` — publishes the same word for one key as
|
|
604
|
-
for another, and the rule stays silent over it. So does a selector that drops the dependency, and a selector this
|
|
605
|
-
file cannot read.
|
|
606
|
-
|
|
607
|
-
_The write can run after it._ The writes are the writer of this run, read under the same names as
|
|
608
|
-
`capture-command-cleanup` reads a command's: `execution.update(…)`, `({ update })` or `({ update: write })` in the
|
|
609
|
-
parameter list, and `const { update } = execution` in the body. The later write has to stand in the same block, in a
|
|
610
|
-
later statement, with no unconditional exit of the run between the two: `if (held === null) { update(key, null);
|
|
611
|
-
return; }` has a write below the `if` that cannot run after this one, and it is not reported. A `break` or a
|
|
612
|
-
`continue` is not such an exit, because the code below it runs.
|
|
613
|
-
|
|
614
|
-
_The command makes that write._ Where the moving write is a call rather than a write, the call is an `invoke` of the
|
|
615
|
-
run, read under the same names as the writer, and its first argument is a name this module holds a
|
|
616
|
-
`context.command(body)` in — declared on the context of a model, because the `command` of a feature's `own` takes its
|
|
617
|
-
body second and is handed no writer (D385, D386). The body is the function that declaration takes, written there or
|
|
618
|
-
bound to a name in this module, and at least one write through the context of that body names a cell of this source.
|
|
619
|
-
A later write of the run has to follow the call by the same rule as it follows a write.
|
|
620
|
-
|
|
621
|
-
**The boundary.** Silence is not proof that a write lands. Beyond the shapes above, the rule does not read: a
|
|
622
|
-
source assembled in another function, a source read off an object (`deps.sources`, the shape of the latch recipe), a
|
|
623
|
-
cell that arrived as a parameter, a `derive` over a `derive`, and a writer held by a closure. It reads only `update`
|
|
624
|
-
as the moving write, so a commit into the source — the same law, `update` or commit alike — is not reported; neither
|
|
625
|
-
is the third form of that loss, a `capture` taken _after_ the moving write, which throws the run's own cancellation
|
|
626
|
-
and is reported nowhere. Two writes of one statement are not a sequence — the branches of an `if`, the arms of a
|
|
627
|
-
ternary, the halves of a `try`, the sides of a comma — and neither are two writes of one `switch` case. A write in a
|
|
628
|
-
nested callback of the run is left alone, because the run is already over when a timer or a disposer runs and its
|
|
629
|
-
dropped write has its own reason. A loop whose write into the source goes last loses the first write of the next turn,
|
|
630
|
-
and the rule misses that one.
|
|
631
|
-
|
|
632
|
-
The call has a boundary of its own, and it is the boundary of one step. A command that arrived as a dependency
|
|
633
|
-
(`invoke(deps.release)`), one whose declaration is in another module, one declared on a context this file cannot
|
|
634
|
-
resolve, and one whose body is assembled elsewhere name nothing this rule can read. It reads one step only: a command
|
|
635
|
-
that moves the source through a second command it invokes, or through a `capture` of a cell of the source, is not
|
|
636
|
-
reported, and neither is a command whose body writes the source through a writer it took from somewhere other than
|
|
637
|
-
its own context. And the `invoke` of a run whose loss is the call itself — a run with no write after that call —
|
|
638
|
-
is not reported either, because nothing there is lost: the `await` does not return, and the run had nothing left to
|
|
639
|
-
do (D447).
|
|
640
|
-
|
|
641
|
-
### `capture-command-cleanup`
|
|
642
|
-
|
|
643
|
-
The writer of a command lives as long as its caller (D288). A write in the `finally` or the `catch` of a `try` that
|
|
644
|
-
awaits runs after the `await`, and once the call is cancelled it is dropped without a record — so the cleanup that
|
|
645
|
-
was supposed to clear a busy flag or a pending mark is exactly the write that never lands. The rule reports that write
|
|
646
|
-
and names the word that does land, a commit taken before the `await` (D319, D330):
|
|
647
|
-
|
|
648
|
-
```ts
|
|
649
|
-
// reported: once the caller is cancelled, `busy` stays `true`
|
|
650
|
-
ctx.command(async ({ input, signal, update }: ModelCommandContext<Payload>) => {
|
|
651
|
-
update(busy, true);
|
|
652
|
-
try {
|
|
653
|
-
await host.send(input, signal);
|
|
654
|
-
} finally {
|
|
655
|
-
update(busy, false);
|
|
656
|
-
}
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
// the cleanup lands while the model lives
|
|
660
|
-
ctx.command(async ({ capture, input, signal, update }: ModelCommandContext<Payload>) => {
|
|
661
|
-
update(busy, true);
|
|
662
|
-
const done = capture(busy);
|
|
663
|
-
try {
|
|
664
|
-
await host.send(input, signal);
|
|
665
|
-
} finally {
|
|
666
|
-
done.update(() => false);
|
|
667
|
-
}
|
|
668
|
-
});
|
|
669
|
-
```
|
|
670
|
-
|
|
671
|
-
There is no fix: whether a cleanup must land after its caller left is the author's decision, and the drop is the law
|
|
672
|
-
for a reason — a late write of a cancelled call must not overwrite what a newer call wrote. The report therefore names
|
|
673
|
-
both ways out. A cleanup that must run for a cancelled call goes through `capture(state)` taken before the `await`; an
|
|
674
|
-
answer that must not land after cancellation — a failure, a result — belongs in a `catch` after
|
|
675
|
-
`rethrowIfCancelled(cause)`. Where a caller cancels one run to start the next — a search its consumer issues again, a
|
|
676
|
-
Command an effect run invokes and a newer value cancels — the older run's write is exactly the one that should not land,
|
|
677
|
-
and a disable comment says so.
|
|
678
|
-
|
|
679
|
-
Under `concurrency: 'parallel'` the report does not advise a commit. Runs of such a command overlap, so a cancelled
|
|
680
|
-
call's captured cleanup would clear a flag another call still holds — a flag shared by parallel calls is racy either
|
|
681
|
-
way — and the report says to keep that state per call, or to leave progress to the consumer's `inFlight`. The rule
|
|
682
|
-
sees the order only when `{ concurrency: 'parallel' }` is written on the `command` itself; a modifier record assembled
|
|
683
|
-
elsewhere reads as the default queue, where the lane waits for the physical body and a captured cleanup lands in
|
|
684
|
-
order (D387).
|
|
685
|
-
|
|
686
|
-
The rule is a heuristic over syntax, and it proves nothing about a write it does not report. It reads the writer of a
|
|
687
|
-
command body under these names: `execution.update(…)`; `({ update })` or `({ update: write })` in the parameter list;
|
|
688
|
-
`const { update } = execution` inside the body; and a `const` that gives one of those a second name —
|
|
689
|
-
`const write = update` or `const write = execution.update`. It follows a body passed as a local function. It misses a
|
|
690
|
-
closure that holds the writer (`const clear = () => update(busy, false)` called in `finally`), a writer stored anywhere
|
|
691
|
-
but a `const`, and a context handed to a helper. A `try` counts when its protected block awaits in the body's own
|
|
692
|
-
function: an `await` or a `for await` inside a nested callback suspends that callback, not the command.
|
|
693
|
-
|
|
694
|
-
In a `catch`, a write that follows `rethrowIfCancelled(cause)`, `signal.throwIfAborted()` or an `if (signal.aborted)`
|
|
695
|
-
that throws or returns — in the block that holds the write or in any block around it — is not reported. Such a guard
|
|
696
|
-
is the author's statement that what follows must not run for a cancelled call, and the rule takes it at its word: in
|
|
697
|
-
`catch (cause) { rethrowIfCancelled(cause); update(busy, false); throw cause }` the flag stays `true` on cancellation
|
|
698
|
-
and nothing is reported. A flag that must clear on cancellation is therefore written in `finally` through
|
|
699
|
-
`capture(state)`. A guard in a `finally` saves nothing and is still reported, and so is a check of `signal.aborted`
|
|
700
|
-
that neither throws nor returns. `effect`, `event` and the other reactions are left alone: a newer value is what
|
|
701
|
-
cancelled their run, and dropping its write is the point (D288). The context is resolved exactly: the `command` of a
|
|
702
|
-
`ModelContext` parameter or of the context of `model(Declaration, factory)` inside a `defineFeature` of
|
|
703
|
-
`@opetope/runtime` (D309); a feature's own `command` hands its body no writer.
|
|
704
|
-
|
|
705
|
-
### `prefer-model-selection`
|
|
706
|
-
|
|
707
|
-
A component that takes one granted model and reads its fields through a hook each repeats the same wiring. From
|
|
708
|
-
three fields upwards the rule points at one selection instead (D205, D214):
|
|
709
|
-
|
|
710
|
-
```js
|
|
711
|
-
'opetope/prefer-model-selection': ['error', { threshold: 3 }],
|
|
712
|
-
```
|
|
713
|
-
|
|
714
|
-
It counts `useReadable(model.field)` and `useCommand(model.field)` over the variable of a `useModel(Declaration)`
|
|
715
|
-
without a selection, and it counts each granted model separately: two models in one component are two grants, and
|
|
716
|
-
the same variable name in two components is two counts. There is no fix — the shape of the selection is the
|
|
717
|
-
author's to write.
|
|
718
|
-
|
|
719
|
-
### `no-retired-vocabulary`
|
|
720
|
-
|
|
721
|
-
The words the D365–D411 wave retired, each named together with the word that replaced it. The rule is written for
|
|
722
|
-
one migration pass: this release ships no alias for any of them, so a source that still writes one has not been
|
|
723
|
-
migrated, and once it has been the rule never fires again.
|
|
724
|
-
|
|
725
|
-
| Retired | Is now | Decision |
|
|
726
|
-
| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------- |
|
|
727
|
-
| `invalidate()` of a Resource | `reset()` | D373 |
|
|
728
|
-
| `backpressure` of an event | `delivery` | D374 |
|
|
729
|
-
| `kind` inside a delivery record | `pending` | D374 |
|
|
730
|
-
| `key` inside a delivery record | `by` | D374 |
|
|
731
|
-
| `activity` of a `loading` state | `failure` | D375 |
|
|
732
|
-
| `scope.keyed(source, open, { key })` | `scope.each(source, key, open)` | D378 |
|
|
733
|
-
| `skipInitial` | `initial`, with the polarity inverted | D379 |
|
|
734
|
-
| `when` of an effect | `filter` **or** `initial` | D379 |
|
|
735
|
-
| `onDispose` | `finalize` | D379 |
|
|
736
|
-
| `externalReadable` | `fromExternal` | D380 |
|
|
737
|
-
| `Call`, `ctx.call`, `runCall` | `Command`, `ctx.command`, `runCommand` | D385 |
|
|
738
|
-
| `policy`, `queueBy`, `lane` | `concurrency` | D387 |
|
|
739
|
-
| `ctx.calls`, `own.calls`, `useCommands` | `ctx.select`, `own.select`, `useModel(Declaration, select)` | D388 |
|
|
740
|
-
| `once` | `memoize` | D389 |
|
|
741
|
-
| `from` of `defineCondition` | `source` | D411 |
|
|
742
|
-
| `when` of a contribution | `enabled` | D411 |
|
|
743
|
-
| `overflow: 'reject'` of a live Resource | `overflow: 'drop'` | D411 |
|
|
744
|
-
| `status` of a `CommandOutcome` | `kind` | D411 |
|
|
745
|
-
| `ResourceActivity` of `@opetope/devtools` | `ResourceNodeActivity` | D411 |
|
|
746
|
-
| `ResourceDisposer` | `Disposer` | D411 |
|
|
747
|
-
| `NavigatorPolicy`, `surface.policy` | `NavigatorCatalogue`, `surface.catalogue` | D411 |
|
|
748
|
-
| `ApplicationConditionBinding`, `ApplicationExecution` and `ApplicationImportBinding` of `@opetope/runtime/internal` | the same names, from `@opetope/runtime` | D411 |
|
|
749
|
-
|
|
750
|
-
**One row cannot be executed from the word alone, and the report says the rest of it.** `ctx.call` becomes
|
|
751
|
-
`ctx.command`, and with it a body of `(input, context) => …` becomes a body that takes its context alone — which
|
|
752
|
-
leaves the input with nowhere to be named, because TypeScript has no partial inference and `command<Deposit>(…)` is
|
|
753
|
-
not a route. The one place is the annotation of the destructured context,
|
|
754
|
-
`({ input, update }: ModelCommandContext<Deposit>)` in a model and
|
|
755
|
-
`({ input, source }: FeatureCommandContext<typeof within, Deposit>)` in a feature, so the report names that
|
|
756
|
-
annotation beside the word. A rename carried out to the letter without it leaves this rule silent and the build red,
|
|
757
|
-
with every refusal standing on code the author wrote correctly (D444).
|
|
758
|
-
|
|
759
|
-
**There is no autofix, and that is the decision rather than an omission (D404).** Several of these rows are not one
|
|
760
|
-
to one. `policy`, `queueBy` and `lane` collapse into a single `concurrency` whose shape depends on which of the
|
|
761
|
-
three a record wrote together: `{ by }`, `{ by, lane }` and `{ lane, pending: 'latest' }` are three different values
|
|
762
|
-
built from the same three words. `when` at an effect becomes `filter` **or** `initial`, and which one is a question
|
|
763
|
-
about what the predicate asks — `filter: (_current, previous) => previous !== undefined` is not a filter at all, it
|
|
764
|
-
is `initial: false`, and the authoring guide says so. `skipInitial` becomes `initial` with the polarity inverted, so
|
|
765
|
-
the one row that looks purely mechanical is the one that compiles and means the opposite. A fix that guessed wrong
|
|
766
|
-
would rewrite a consumer's source silently, which is worse than refusing, so every report here names the retired
|
|
767
|
-
word, names its replacement, and says outright where the replacement is a choice.
|
|
768
|
-
|
|
769
|
-
**The boundary.** Ownership is proved by symbol and never by spelling. A retired member is reported only on a
|
|
770
|
-
declaring context of this library — a parameter annotated `ModelContext` of `@opetope/core`, or the `own` section
|
|
771
|
-
written where a `defineFeature` of `@opetope/runtime` takes it — resolved through an alias or a destructured member
|
|
772
|
-
the same way `no-subscribe-outside-models` resolves the ingress of a declared node. A retired name is reported only
|
|
773
|
-
where an `@opetope/*` module is the one that exports it, and where the name is retired at one entry and live at
|
|
774
|
-
another the module is asked exactly: `ResourceActivity` is still what `@opetope/core` calls the background work of
|
|
775
|
-
a `ready` state, and the three names of `@opetope/runtime/internal` are still names on the entry an author takes
|
|
776
|
-
them from. A
|
|
777
|
-
contribution's gate is reported on the `provides` section a `defineFeature` of `@opetope/runtime` takes, the way a
|
|
778
|
-
declared node is reported on `own`; the `status` of a settled answer only on the `outcome` of a `useCommand` of
|
|
779
|
-
`@opetope/react`, and the `policy` of a surface only on the value a `defineSurface` of `@opetope/navigation/react`
|
|
780
|
-
declared. A Resource is the value a `resource.load` or
|
|
781
|
-
`resource.live` of such a context declared, or the record `useResource` of `@opetope/react` answers; its state is
|
|
782
|
-
that record's `state`, or a `getSnapshot()` of it. `activity` is reported only inside a
|
|
783
|
-
narrowing that proves the state is `loading` — an `if`, a ternary or the left half of an `&&`, written about the
|
|
784
|
-
same expression — because a `ready` state still carries the field.
|
|
785
|
-
|
|
786
|
-
The silence is the point. `Call` inside `'Margin Call'` and inside the translation keys of a catalogue, the
|
|
787
|
-
`{ once: true }` of `addEventListener`, a host's own `policy` option or `policy` of somebody else's object, `Navigator.reset()` and a
|
|
788
|
-
`delivery` field of
|
|
789
|
-
somebody else's record are all left alone, as is `when` at a feature and at `scope.while`, where the word is not
|
|
790
|
-
retired at all, and a `status` of somebody else's record. So is what the rule cannot see: a Resource reached through a model record instead
|
|
791
|
-
of through the declaration that built it, a state narrowed in a `switch` or inside a helper, and an options record
|
|
792
|
-
assembled in another module. Its silence is therefore not proof that a migration is complete — the compiler, which
|
|
793
|
-
now prints the replacement for every word it can name, is the other half (D401, D444). Six of the rows above are
|
|
794
|
-
words an options record used to carry, and each of those records names its retired word so the refusal carries the
|
|
795
|
-
replacement: `skipInitial`, `when` and `onDispose` at an effect, `backpressure` at an event, `when` at a
|
|
796
|
-
contribution and `from` at `defineCondition`.
|
|
797
|
-
|
|
798
|
-
## Coexistence with key sorting
|
|
799
|
-
|
|
800
|
-
A host that sorts object keys alphabetically will disagree with `define-feature-property-order`, because the
|
|
801
|
-
sections of a feature are ordered by meaning and not by name. `recommended` touches no sorting rule: reconciling
|
|
802
|
-
them belongs to the host, and there are two ways.
|
|
803
|
-
|
|
804
|
-
**`perfectionist/sort-objects`** takes a named order for exactly the two callees that declare sections, and keeps
|
|
805
|
-
its plain configuration for every other object:
|
|
806
|
-
|
|
807
|
-
```js
|
|
808
|
-
'perfectionist/sort-objects': [
|
|
809
|
-
'error',
|
|
810
|
-
{
|
|
811
|
-
customGroups: [
|
|
812
|
-
{ elementNamePattern: '^id$', groupName: 'feature-id' },
|
|
813
|
-
{ elementNamePattern: '^imports$', groupName: 'feature-imports' },
|
|
814
|
-
{ elementNamePattern: '^requires$', groupName: 'feature-requires' },
|
|
815
|
-
{ elementNamePattern: '^own$', groupName: 'feature-own' },
|
|
816
|
-
{ elementNamePattern: '^exports$', groupName: 'feature-exports' },
|
|
817
|
-
{ elementNamePattern: '^provides$', groupName: 'feature-provides' },
|
|
818
|
-
{ elementNamePattern: '^when$', groupName: 'feature-when' },
|
|
819
|
-
{ elementNamePattern: '^body$', groupName: 'feature-body' },
|
|
820
|
-
],
|
|
821
|
-
groups: [
|
|
822
|
-
'feature-id',
|
|
823
|
-
'feature-imports',
|
|
824
|
-
'feature-requires',
|
|
825
|
-
'feature-own',
|
|
826
|
-
'feature-exports',
|
|
827
|
-
'feature-provides',
|
|
828
|
-
'feature-when',
|
|
829
|
-
'feature-body',
|
|
830
|
-
'unknown',
|
|
831
|
-
],
|
|
832
|
-
order: 'asc',
|
|
833
|
-
type: 'natural',
|
|
834
|
-
useConfigurationIf: { callingFunctionNamePattern: '^(?:defineFeature|defineFeature\\.body)$' },
|
|
835
|
-
},
|
|
836
|
-
{ order: 'asc', type: 'natural' },
|
|
837
|
-
],
|
|
838
|
-
```
|
|
839
|
-
|
|
840
|
-
**ESLint core `sort-keys`, and the rule of the same id in Oxlint**, has no per-callee configuration and no
|
|
841
|
-
per-declaration exception: it reports each key that follows a larger one, wherever the object is. Turn it off for
|
|
842
|
-
the files that declare features, or wrap a declaration in `/* eslint-disable sort-keys */` and
|
|
843
|
-
`/* eslint-enable sort-keys */` — a `// eslint-disable-next-line sort-keys` covers one line, so it silences one
|
|
844
|
-
section, not a multi-line declaration. Oxlint reads the same directives under its own prefix,
|
|
845
|
-
`// oxlint-disable-next-line sort-keys`.
|
|
846
|
-
|
|
847
|
-
The fix of this rule moves keys only inside the object of a `defineFeature` call, so it never reorders anything a
|
|
848
|
-
sorting rule owns elsewhere in the file.
|
|
849
|
-
|
|
850
|
-
## Running the rules under Oxlint
|
|
851
|
-
|
|
852
|
-
Oxlint reads ESLint plugins through `jsPlugins`: it imports the module by path and takes its default export. The
|
|
853
|
-
rules stay on the plain rule API — `meta`, `create(context)` with visitors, `context.report` with a `fix`, and
|
|
854
|
-
`getText`/`getCommentsInside` on the source code — with no typed services and no ESLint-only capability, so both
|
|
855
|
-
linters run them, autofix included:
|
|
856
|
-
|
|
857
|
-
```json
|
|
858
|
-
{
|
|
859
|
-
"extends": ["./node_modules/@opetope/lint/oxlintrc.json"],
|
|
860
|
-
"jsPlugins": [{ "name": "opetope", "specifier": "./node_modules/@opetope/lint/dist/index.js" }]
|
|
861
|
-
}
|
|
862
|
-
```
|
|
116
|
+
<a id="running-the-rules-under-oxlint"></a>
|
|
117
|
+
[See Running the rules under Oxlint](../runtime/docs/reference/lint.md#running-the-rules-under-oxlint).
|
|
863
118
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
of being missed by a hand-written severity map. `extends` in `.oxlintrc.json` takes a **file path**, resolved
|
|
867
|
-
relative to the config that names it — not a package specifier — which is why the fragment sits at the root of the
|
|
868
|
-
package and the path a host writes is the same string as its export name. Configurations merge first to last, so a
|
|
869
|
-
rule the project wants stricter goes in its own `rules` after `extends`: the three `off` entries above are exactly
|
|
870
|
-
the ones a project turns on by its own directories.
|
|
871
|
-
|
|
872
|
-
The fragment carries rules and nothing else. Oxlint does merge a `jsPlugins` entry through `extends`, but a host
|
|
873
|
-
that declares the plugin itself — under a wrapper package, as an isolated peer install needs — would then register
|
|
874
|
-
the name `opetope` twice, and the second registration fails the whole configuration. Where the plugin comes from is
|
|
875
|
-
the host's deployment; the severities are this package's contract.
|
|
876
|
-
|
|
877
|
-
The alias `name` fixes the namespace, so a rule keeps the same id under either linter. `jsPlugins` is alpha and
|
|
878
|
-
outside semver: `npm run ci:test` runs the built plugin under Oxlint on a valid and an invalid fixture, through a
|
|
879
|
-
config that extends the shipped fragment, and checks the fix it writes, so a break in that bridge fails here
|
|
880
|
-
instead of in a host.
|
|
881
|
-
|
|
882
|
-
`--fix-suggestions` applies the first suggestion of a report without asking, so a suggestion here is only ever a
|
|
883
|
-
rewrite the reported code already justifies. `no-command-in-deps` offers the paths the callback itself reads, the
|
|
884
|
-
status it watches before the `run` it calls; where it cannot read the use through — the object passed on, taken
|
|
885
|
-
apart, or read past the members the rule knows — it reports and offers nothing, and the dependency stays as written
|
|
886
|
-
until its author changes it.
|
|
887
|
-
|
|
888
|
-
`no-snapshot-in-update` holds the same line from the other side: its suggestion is the fix it would not write
|
|
889
|
-
unasked — a parameter that shadows a name the file already has, or a named read that stays behind for its other
|
|
890
|
-
reader — and both rewrites leave the code doing what it did. Where the value cannot become the body of an updater
|
|
891
|
-
at all, there is no rewrite to offer and the report stands alone.
|
|
892
|
-
|
|
893
|
-
## Checks
|
|
894
|
-
|
|
895
|
-
From this package: `npm run ci:test`, `npm run ci:type`, `npm run ci:eslint`, `npm run build`. The Oxlint bridge
|
|
896
|
-
test reads `dist`, so `npm run build` comes first. `ci:test` also checks that the committed `oxlintrc.json` still
|
|
897
|
-
equals `configs.recommended.rules`; after changing that config run `npm run oxlintrc:generate` and commit the
|
|
898
|
-
result, which is why the build never writes it.
|
|
119
|
+
<a id="checks"></a>
|
|
120
|
+
[See contributor checks](../runtime/docs/maintainers/contributing.md).
|