@opetope/lint 0.12.0 → 0.12.2

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/README.md CHANGED
@@ -1,67 +1,20 @@
1
- # @opetope/lint
2
-
3
- ESLint rules for the code an Opetope author writes. The plugin holds only what a syntactic check can prove about a
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 'eslint@^9' '@typescript-eslint/parser@^8'
8
+ npm install --save-exact --save-dev @opetope/lint
55
9
  ```
56
10
 
57
- Use matching Opetope versions. For release candidates, append `@next` to every `@opetope/*` package in the command.
58
- The API is ESM-only; Node 20.19+ is required. Development check commands below apply to a contributor checkout.
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
- The normative EN/RU guides are shipped in `@opetope/runtime`: after installing it, open
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
- ## Usage
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
- The plugin is both the default export and the named `opetopeLint`. The configuration registers it under the
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
- ### `enabled-predicate`
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
- A contribution's `enabled` answers the visibility fact of this instance, not the source that carries it (D220). The
173
- rule reads the `enabled` of a `slot`, `pipe` or `register` contribution, and any `enabled` whose function
174
- destructures the evaluation context `{ exports, imports, own, read }`, and reports three shapes:
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
- | Written | Reported |
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
- The fix wraps the returned member expression in `read(...)` and adds `read` to the destructured context when it is
183
- absent. A named context is read through itself: `context => context.imports.x.allowed` becomes
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
- **The boundary.** The rule reads shapes, not types. `({ read }) => read(counter)` over a non-boolean source stays a
187
- type error, and so does a `read` of something that is not a `Readable`. Where a member access of the evaluation
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
- ### `define-feature-property-order`
50
+ <a id="recommended"></a>
51
+ [See `recommended`](../runtime/docs/reference/lint.md#recommended).
194
52
 
195
- A feature declares its sections in one order: `imports`, `requires`, `own`, `exports`, `provides`, `when`, and the
196
- `body` loader that replaces the last three stages (spec §2.1, D186). The identity is not among them — a feature
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
- The fix reorders the properties. It stands down when a comment sits between two sections, because such a comment
202
- belongs to neither and reordering would move it away from the line it explains; a comment inside a section travels
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
- ### `no-redundant-const-tuple`
59
+ <a id="rules"></a>
60
+ [See Rules](../runtime/docs/reference/lint.md#rules).
206
61
 
207
- `derive` takes its tuple of sources as a `const` type parameter, so `[left, right]` infers its own tuple and the
208
- selector already sees exact values. The `as const` written beside it states what the signature already states
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
- ```ts
212
- const summary = derive([total, label], (value, suffix) => `${value} ${suffix}`);
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
- The rule checks contributions passed to the `slot` capability of a visible `defineFeature` or
259
- `defineFeature.body` `provides` callback. A local component receiving UI models must declare its requirements;
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
- Named and namespace imports, import aliases, local aliases, a named `provides` context, and both inline and named
264
- components are recognized. Bindings are compared within their lexical scopes; an unrelated local `useModel`,
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
- **The boundary.** Analysis stays within one module and follows visible local declarations and factory returns.
269
- Arbitrary objects with `Component` and `models` are not contribution sites. Imported component implementations,
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
- ### `require-literal-id`
77
+ <a id="require-literal-id"></a>
78
+ [See `require-literal-id`](../runtime/docs/reference/lint.md#require-literal-id).
276
79
 
277
- A declaration names itself with a string that is written, not built where the declaration stands. The rule reads
278
- every declaration of the public vocabulary that names itself — `defineFeature`, `defineApplication`,
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
- Written means a string literal, a template with no expressions, a name that resolves to an import or to a `const`
285
- of the same module, and a property read from such a name. Built means anything the call site assembles: a template
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
- A project that generates a set of declarations from a name says so once, by naming that factory:
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
- ```js
291
- 'opetope/require-literal-id': ['error', { allowInCallees: ['createSlots'] }],
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
- The name is matched against the function the declaration is written in and against the call it is passed to, so
295
- both shapes of a factory are covered.
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
- **The boundary.** The rule follows names inside one module only: an id imported from another file is written
298
- there, and that is where its own text is checked. The kernel's `defineModule`, `defineCallTarget` and
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
- ### `id-naming`
98
+ <a id="prefer-effect-current"></a>
99
+ [See `prefer-effect-current`](../runtime/docs/reference/lint.md#prefer-effect-current).
302
100
 
303
- An id starts with a letter and joins segments of letters and digits with one of `.`, `/`, `:` or `-`, within 160
304
- characters. That is the grammar `declarationId` accepts in `@opetope/core`; an id outside it is a `DeclarationError`
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
- **The boundary.** The rule checks the ids whose text it can see in the file — a literal, or a name that resolves to
317
- one in the same module. An id that arrives from another module is checked where it is written.
104
+ <a id="capture-command-cleanup"></a>
105
+ [See `capture-command-cleanup`](../runtime/docs/reference/lint.md#capture-command-cleanup).
318
106
 
319
- ### `layer-placement`
107
+ <a id="prefer-model-selection"></a>
108
+ [See `prefer-model-selection`](../runtime/docs/reference/lint.md#prefer-model-selection).
320
109
 
321
- Each declaration is written in the layer that owns it. The rule reports nothing until a project names its layers
322
- through `configs.layers`, because the names of directories are not a law of the framework:
110
+ <a id="no-retired-vocabulary"></a>
111
+ [See `no-retired-vocabulary`](../runtime/docs/reference/lint.md#no-retired-vocabulary).
323
112
 
324
- | Declaration | Layer |
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
- **This is a convention of the project, not a law of the library.** The framework says a feature's UI imports only
332
- that feature's own contracts, and that a model knows nothing about the feature; where those files live is the
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
- Those are the two lines, and neither of them is a transcription. `@opetope/lint/oxlintrc.json` is generated from
865
- `configs.recommended.rules` and shipped in the package, so a rule the package adds arrives with the upgrade instead
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).