@opetope/lint 0.11.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +667 -4
  2. package/README.md +69 -596
  3. package/dist/ast.d.ts +3 -1
  4. package/dist/ast.js +1 -1
  5. package/dist/ast.js.map +1 -1
  6. package/dist/command-hooks.d.ts +2 -2
  7. package/dist/command-hooks.js +1 -1
  8. package/dist/command-hooks.js.map +1 -1
  9. package/dist/context-members.d.ts +22 -0
  10. package/dist/context-members.js +2 -0
  11. package/dist/context-members.js.map +1 -0
  12. package/dist/declaration-ingress.d.ts +10 -2
  13. package/dist/declaration-ingress.js +1 -1
  14. package/dist/declaration-ingress.js.map +1 -1
  15. package/dist/effect-declarations.d.ts +20 -0
  16. package/dist/effect-declarations.js +2 -0
  17. package/dist/effect-declarations.js.map +1 -0
  18. package/dist/index.d.ts +11 -3
  19. package/dist/index.js +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/retired-vocabulary.d.ts +31 -0
  22. package/dist/retired-vocabulary.js +2 -0
  23. package/dist/retired-vocabulary.js.map +1 -0
  24. package/dist/rules/capture-command-cleanup.js +1 -1
  25. package/dist/rules/capture-command-cleanup.js.map +1 -1
  26. package/dist/rules/define-feature-property-order.js +1 -1
  27. package/dist/rules/define-feature-property-order.js.map +1 -1
  28. package/dist/rules/enabled-predicate.d.ts +6 -0
  29. package/dist/rules/enabled-predicate.js +2 -0
  30. package/dist/rules/enabled-predicate.js.map +1 -0
  31. package/dist/rules/no-command-in-deps.js +1 -1
  32. package/dist/rules/no-command-in-deps.js.map +1 -1
  33. package/dist/rules/no-internal-imports.js +1 -1
  34. package/dist/rules/no-internal-imports.js.map +1 -1
  35. package/dist/rules/no-retired-vocabulary.d.ts +5 -0
  36. package/dist/rules/no-retired-vocabulary.js +2 -0
  37. package/dist/rules/no-retired-vocabulary.js.map +1 -0
  38. package/dist/rules/no-write-after-source-write.d.ts +6 -0
  39. package/dist/rules/no-write-after-source-write.js +2 -0
  40. package/dist/rules/no-write-after-source-write.js.map +1 -0
  41. package/dist/rules/prefer-effect-current.js +1 -1
  42. package/dist/rules/prefer-effect-current.js.map +1 -1
  43. package/oxlintrc.json +3 -1
  44. package/package.json +1 -2
  45. package/README.ru.md +0 -648
  46. package/dist/rules/when-predicate.d.ts +0 -6
  47. package/dist/rules/when-predicate.js +0 -2
  48. package/dist/rules/when-predicate.js.map +0 -1
package/README.md CHANGED
@@ -1,26 +1,20 @@
1
- # @opetope/lint
1
+ # `@opetope/lint`
2
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.
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.
9
4
 
10
5
  ## Installation
11
6
 
12
7
  ```sh
13
- npm install --save-dev @opetope/lint 'eslint@^9' '@typescript-eslint/parser@^8'
8
+ npm install --save-exact --save-dev @opetope/lint
14
9
  ```
15
10
 
16
- Use matching Opetope versions. For release candidates, append `@next` to every `@opetope/*` package in the command.
17
- 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.
18
13
 
19
- The normative EN/RU guides are shipped in `@opetope/runtime`: after installing it, open
20
- `node_modules/@opetope/runtime/docs/spec.md` or `spec.ru.md`; recipes are in `cookbook.md` and `cookbook.ru.md`.
21
- No GitHub access is needed to read those installed guides.
14
+ ## Example
22
15
 
23
- ## 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.
24
18
 
25
19
  ```js
26
20
  // eslint.config.mjs
@@ -34,614 +28,93 @@ export default [
34
28
  ];
35
29
  ```
36
30
 
37
- The plugin is both the default export and the named `opetopeLint`. The configuration registers it under the
38
- namespace `opetope`, so a rule is named `opetope/when-predicate`. Parsing requires `@typescript-eslint/parser`;
39
- both it and `eslint` are peer dependencies.
40
-
41
- ## Configs
42
-
43
- ### `recommended`
44
-
45
- Every rule that holds wherever Opetope is written: `when-predicate`, `define-feature-property-order`,
46
- `require-literal-id`, `id-naming`, `no-internal-imports`, `no-snapshot-read-in-render`, `no-snapshot-in-update`,
47
- `no-redundant-const-tuple`, `no-command-in-deps`, `prefer-effect-current`, `capture-command-cleanup` and `require-declared-models` as errors. A rule that needs to know where a host keeps its files is off here and arrives
48
- through `layers`; `prefer-model-selection` is off because the number of hooks a component may keep is a taste a
49
- project settles for itself.
50
-
51
- `configs.recommended.rules` in full, which is also what `@opetope/lint/oxlintrc.json` ships:
52
-
53
- | Rule | `recommended` | Turned on by |
54
- | ------------------------------- | ------------- | ------------------------------------------- |
55
- | `capture-command-cleanup` | `error` | |
56
- | `define-feature-property-order` | `error` | |
57
- | `id-naming` | `error` | |
58
- | `layer-placement` | `off` | `layers({ integration, models, ui })` |
59
- | `no-command-in-deps` | `error` | |
60
- | `no-internal-imports` | `error` | also scoped by `internalImports({ allow })` |
61
- | `no-redundant-const-tuple` | `error` | |
62
- | `no-snapshot-in-update` | `error` | |
63
- | `no-snapshot-read-in-render` | `error` | |
64
- | `no-subscribe-outside-models` | `off` | `layers({ models })` |
65
- | `prefer-effect-current` | `error` | |
66
- | `prefer-model-selection` | `off` | the project, with its own `threshold` |
67
- | `require-declared-models` | `error` | |
68
- | `require-literal-id` | `error` | |
69
- | `when-predicate` | `error` | |
70
-
71
- The three `off` rules are the three that need something only the project knows: which directories are which layer,
72
- and how many hooks one component may keep.
73
-
74
- ### `layers`
75
-
76
- `opetope.configs.layers({ integration, models, ui, tests })` takes the globs of a project's own layers and returns
77
- the configuration each of them earns. A layer without globs is a layer this project does not have, and it produces
78
- nothing; a file no glob claims is placed by nobody.
79
-
80
- ```js
81
- ...opetope.configs.layers({
82
- integration: ['src/features/*/integration/**/*.{ts,tsx}'],
83
- models: ['src/features/*/models/**/*.ts'],
84
- ui: ['src/features/*/ui/**/*.{ts,tsx}'],
85
- }),
86
- ```
87
-
88
- `integration`, `models` and `ui` each turn on `layer-placement` for their files. `models` also turns on
89
- `no-subscribe-outside-models` for every source file and steps aside in that layer and in `tests`, which defaults to
90
- `['**/__tests__/**', '**/*.spec.ts', '**/*.spec.tsx']`. A subscription is covered even in a file no layer claims,
91
- because forgetting one is how a subscription outlives its reader.
92
-
93
- ### `internalImports`
94
-
95
- `opetope.configs.internalImports({ allow, files })` configures `opetope/no-internal-imports` across JavaScript and
96
- TypeScript, including `.mjs` model tests. The rule is already enabled by `recommended`; this helper is useful for
97
- scoping it or naming trusted library implementation and host integration files. `allow` explicitly switches off
98
- only this rule for those files. Place the helper's blocks **after** `recommended`: a later `recommended` block
99
- re-enables the rule, including for the allowed files. Other project restrictions stay intact.
100
-
101
- ```js
102
- export default [
103
- opetope.configs.recommended,
104
- ...opetope.configs.internalImports({ allow: ['src/bootstrap/**/devtools.ts'] }),
105
- ];
106
- ```
107
-
108
- The dedicated rule never changes `no-restricted-imports`. The former `internalImportPattern` export is removed:
109
- enable this rule or use the helper instead of merging an Opetope pattern into the host's restrictions (D261).
110
-
111
- ## Rules
112
-
113
- ### `no-internal-imports`
114
-
115
- Application code and tests import public Opetope entries. The rule rejects `@opetope/*/internal` and its deeper
116
- paths in imports, re-exports, literal `import()` calls, unshadowed `require()` calls, TypeScript import types and
117
- `import = require`. It includes type-only imports; an internal type is still coupled to the private ABI.
118
- Use `runCall` from `@opetope/core/testing` to run a real model Call, `openModel` from `@opetope/runtime/testing` to open one without a feature, and `command` from `@opetope/react/testing` to construct a fixture Call.
119
- Tests do not receive an automatic exception. Trusted library implementation or host integration can use an explicit
120
- file override; the rule has no autofix because the correct public replacement depends on the imported operation.
121
-
122
- **The boundary.** This is a syntactic check of literal module paths and templates without substitutions. It does
123
- not resolve aliases, computed import paths, re-export graphs or custom loaders. A locally shadowed `require` is not
124
- treated as Node's loader. Apply the config to every JavaScript/TypeScript source and test glob the project uses.
125
-
126
- ### `when-predicate`
127
-
128
- A contribution's `when` answers the visibility fact of this instance, not the source that carries it (D220). The
129
- rule reads the `when` of a `slot`, `pipe` or `register` contribution, and any `when` whose function destructures the
130
- evaluation context `{ exports, imports, own, read }`, and reports three shapes:
131
-
132
- | Written | Reported |
133
- | ------------------------------------------ | ----------------------------------------------------------------------------- |
134
- | `when: ({ imports }) => imports.x.allowed` | returns a source; fixed to `({ imports, read }) => read(imports.x.allowed)` |
135
- | `when: async ({ read }) => read(x)` | a predicate answers synchronously; no fix |
136
- | `when: () => allowed` | a `Readable<boolean>` is passed as `when` directly, without a wrapper; no fix |
137
-
138
- The fix wraps the returned member expression in `read(...)` and adds `read` to the destructured context when it is
139
- absent. A named context is read through itself: `context => context.imports.x.allowed` becomes
140
- `context => context.read(context.imports.x.allowed)`.
141
-
142
- **The boundary.** The rule reads shapes, not types. `({ read }) => read(counter)` over a non-boolean source stays a
143
- type error, and so does a `read` of something that is not a `Readable`. Where a member access of the evaluation
144
- context is a plain value rather than a source, the predicate cannot change its answer, and the rule reports it as
145
- one written for a source; hoist that decision out of `when`, or silence the line. `when` outside a contribution —
146
- the positional `(current, previous)` of `effect`, or the source value of `scope.while` — is left alone.
147
-
148
- ### `define-feature-property-order`
149
-
150
- A feature declares its sections in one order: `imports`, `requires`, `own`, `exports`, `provides`, `when`, and the
151
- `body` loader that replaces the last three stages (spec §2.1, D186). The identity is not among them — a feature
152
- names itself with its first argument (D280). The order is the reading order of the declaration — the edges, the
153
- stages, then the lifetime and the loader — so a reader finds a section by position, and a key sorter can be
154
- configured to hold the same order instead of a second one.
155
-
156
- The fix reorders the properties. It stands down when a comment sits between two sections, because such a comment
157
- belongs to neither and reordering would move it away from the line it explains; a comment inside a section travels
158
- with it. An object with a key that is not a section, or with a spread, is left to the type checker.
159
-
160
- ### `no-redundant-const-tuple`
161
-
162
- `derive` takes its tuple of sources as a `const` type parameter, so `[left, right]` infers its own tuple and the
163
- selector already sees exact values. The `as const` written beside it states what the signature already states
164
- (D279, D309):
165
-
166
- ```ts
167
- const summary = derive([total, label], (value, suffix) => `${value} ${suffix}`);
168
- ```
169
-
170
- The rule reads the first argument of a `derive` resolved to `@opetope/core`, and only where that argument is an
171
- array literal carrying `as const`. The fix removes the assertion and nothing else. It stands down when a comment
172
- sits between the tuple and the assertion, because such a comment belongs to neither and the removal would take it
173
- along.
174
-
175
- **The boundary.** An assertion written anywhere else is left alone: on one element of the tuple, where it says
176
- something about that element; on the result of the selector; on the options record; and on the single-source form,
177
- whose argument is not a tuple at all. `as Sources` is a different assertion and says something else. The import is
178
- resolved within one module — a local alias, a `const` that holds the import and a namespace binding all reach the
179
- same export — and, unlike `no-command-in-deps`, this rule recognizes the import from `@opetope/core` and nothing
180
- else: a re-export through a host's own barrel it does not see. That is the deliberate price of a safe fix. This
181
- rule rewrites code, and a host's own `derive` has no `const` type parameter to make the assertion redundant, so
182
- removing it there would change the inferred types in silence.
183
-
184
- ### `require-declared-models`
185
-
186
- A component that reads a per-mount UI model declares that model with `requiresModels`. Owner models remain
187
- available to the contribution subtree without this declaration (D158, D250).
188
-
189
- ```tsx
190
- import { defineFeature } from '@opetope/runtime';
191
- import { requiresModels, useModel } from '@opetope/react';
192
-
193
- const Form = () => {
194
- const actions = useModel(FormActions);
195
- return null;
196
- };
197
- const DeclaredForm = requiresModels([FormActions])(Form);
198
-
199
- defineFeature('example.form', {
200
- provides: ({ slot }) => ({
201
- form: slot(FormSlot, ({ model }) => ({
202
- Component: DeclaredForm,
203
- models: [model(FormActions, createActions)],
204
- })),
205
- }),
206
- });
207
- ```
208
-
209
- The rule checks contributions passed to the `slot` capability of a visible `defineFeature` or
210
- `defineFeature.body` `provides` callback. A local component receiving UI models must declare its requirements;
211
- each visible component or hook that reads one of those models declares its own list. Wrapping a parent does not
212
- declare the requirements of a nested function.
213
-
214
- Named and namespace imports, import aliases, local aliases, a named `provides` context, and both inline and named
215
- components are recognized. Bindings are compared within their lexical scopes; an unrelated local `useModel`,
216
- `requiresModels` or `slot` does not become an Opetope API by sharing its name. Named APIs from relative re-exports
217
- are recognized by their exported names, without reading the other file.
218
-
219
- **The boundary.** Analysis stays within one module and follows visible local declarations and factory returns.
220
- Arbitrary objects with `Component` and `models` are not contribution sites. Imported component implementations,
221
- dynamic model lists and opaque helpers remain unverified: silence is not proof that their model requirements are
222
- complete. Model references must be locally visible identifiers or aliases; the rule does not inspect types or walk
223
- the rendered React tree. It has no autofix, because choosing which model a component should read is an authoring
224
- choice. Types and runtime authority checks continue to apply.
225
-
226
- ### `require-literal-id`
227
-
228
- A declaration names itself with a string that is written, not built where the declaration stands. The rule reads
229
- every declaration of the public vocabulary that names itself — `defineFeature`, `defineApplication`,
230
- `defineCondition`, `defineHostContract`, `defineModel`, `definePort`, `defineSlot`, `defineSwitchSlot`,
231
- `definePipe` and `defineRegistry`, and the four of the optional Navigation package: `defineScreen`,
232
- `defineSurface`, `defineLink` and `defineLinkHandlers`. D280 gives them one form: the id is the first argument of
233
- every one of them.
234
-
235
- Written means a string literal, a template with no expressions, a name that resolves to an import or to a `const`
236
- of the same module, and a property read from such a name. Built means anything the call site assembles: a template
237
- with an expression, a concatenation, a call, or a name that resolves to a parameter.
238
-
239
- A project that generates a set of declarations from a name says so once, by naming that factory:
240
-
241
- ```js
242
- 'opetope/require-literal-id': ['error', { allowInCallees: ['createSlots'] }],
243
- ```
244
-
245
- The name is matched against the function the declaration is written in and against the call it is passed to, so
246
- both shapes of a factory are covered.
31
+ ## Documentation
247
32
 
248
- **The boundary.** The rule follows names inside one module only: an id imported from another file is written
249
- there, and that is where its own text is checked. The kernel's `defineModule`, `defineCallTarget` and
250
- `defineCallLane` are not read, because an author never writes them.
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.
251
36
 
252
- ### `id-naming`
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.
253
40
 
254
- An id starts with a letter and joins segments of letters and digits with one of `.`, `/`, `:` or `-`, within 160
255
- characters. That is the grammar `declarationId` accepts in `@opetope/core`; an id outside it is a `DeclarationError`
256
- the moment the declaration runs, and this rule says so before the code runs. A screen, a surface and a link handler
257
- registry pass their id through the same `declarationId`, so this rule reads them too; `defineLink` is left out of it
258
- because a link id is a canonical lower-case path segment of a URL, which the package checks itself and which no
259
- reserved prefix applies to (D313).
41
+ <a id="opetopelint"></a>
42
+ [See @opetope/lint](../runtime/docs/reference/lint.md).
260
43
 
261
- A project that reserves a first segment names it, and every declaration of those files must open with it:
44
+ <a id="usage"></a>
45
+ [See Usage](../runtime/docs/reference/lint.md#lint-usage).
262
46
 
263
- ```js
264
- 'opetope/id-naming': ['error', { prefix: 'workspace' }],
265
- ```
47
+ <a id="configs"></a>
48
+ [See Configs](../runtime/docs/reference/lint.md#configs).
266
49
 
267
- **The boundary.** The rule checks the ids whose text it can see in the file — a literal, or a name that resolves to
268
- one in the same module. An id that arrives from another module is checked where it is written.
50
+ <a id="recommended"></a>
51
+ [See `recommended`](../runtime/docs/reference/lint.md#recommended).
269
52
 
270
- ### `layer-placement`
53
+ <a id="layers"></a>
54
+ [See `layers`](../runtime/docs/reference/lint.md#layers).
271
55
 
272
- Each declaration is written in the layer that owns it. The rule reports nothing until a project names its layers
273
- through `configs.layers`, because the names of directories are not a law of the framework:
56
+ <a id="internalimports"></a>
57
+ [See `internalImports`](../runtime/docs/reference/lint.md#internalimports).
274
58
 
275
- | Declaration | Layer |
276
- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
277
- | `defineFeature` | integration — a feature composes the other two layers |
278
- | `defineModel` | models, or the UI contracts that declare a mount model |
279
- | `defineSlot`, `defineSwitchSlot`, `defineSurface`, `definePipe`, `defineRegistry`, `definePort`, `defineCondition`, `defineHostContract` | integration, or the UI contracts beside the component |
280
- | `defineApplication` | none of them: the host bootstrap owns it |
59
+ <a id="rules"></a>
60
+ [See Rules](../runtime/docs/reference/lint.md#rules).
281
61
 
282
- **This is a convention of the project, not a law of the library.** The framework says a feature's UI imports only
283
- that feature's own contracts, and that a model knows nothing about the feature; where those files live is the
284
- project's choice, and this rule holds whatever choice it declared.
62
+ <a id="no-internal-imports"></a>
63
+ [See `no-internal-imports`](../runtime/docs/reference/lint.md#no-internal-imports).
285
64
 
286
- ### `no-snapshot-read-in-render`
65
+ <a id="enabled-predicate"></a>
66
+ [See `enabled-predicate`](../runtime/docs/reference/lint.md#enabled-predicate).
287
67
 
288
- `getSnapshot()` called while a component or a hook renders reads the value once and never hears the next one. Read
289
- it with `useReadable`, or select it together with the commands of the same model:
290
- `useModel(Declaration, (model, { read }) => ...)` (D205, D214).
68
+ <a id="define-feature-property-order"></a>
69
+ [See `define-feature-property-order`](../runtime/docs/reference/lint.md#define-feature-property-order).
291
70
 
292
- A render scope is a function named `use…`, or a capitalized function that returns elements. The rule looks at the
293
- function the call is written in, so a snapshot read in an event handler, an effect or any other nested callback
294
- stays: those run after render, and the value of that moment is the one they want.
71
+ <a id="no-redundant-const-tuple"></a>
72
+ [See `no-redundant-const-tuple`](../runtime/docs/reference/lint.md#no-redundant-const-tuple).
295
73
 
296
- **The boundary.** The rule reads the name of the receiver, not its type: every `getSnapshot()` in a render scope is
297
- reported, whichever object it belongs to. A capitalized function that returns no element is a factory and keeps its
298
- reads — including a contribution's model factory, which reads the props of its own mount.
74
+ <a id="require-declared-models"></a>
75
+ [See `require-declared-models`](../runtime/docs/reference/lint.md#require-declared-models).
299
76
 
300
- ### `no-snapshot-in-update`
77
+ <a id="require-literal-id"></a>
78
+ [See `require-literal-id`](../runtime/docs/reference/lint.md#require-literal-id).
301
79
 
302
- A value passed to `update` reads the state it is about to replace — a composite one,
303
- `update(sessions, { items: sessions.getSnapshot().items, kind: 'loading' })`, and a scalar one,
304
- `update(total, total.getSnapshot() + amount)`, alike. The read runs where the argument is written — before the
305
- write, and on a state that may already have stopped, where `getSnapshot()` answers a value nothing will move again
306
- — 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
307
- time, and a write dropped because its context was aborted never calls the updater at all (D282, D288, D295).
80
+ <a id="id-naming"></a>
81
+ [See `id-naming`](../runtime/docs/reference/lint.md#id-naming).
308
82
 
309
- ```ts
310
- update(sessions, previous => ({ ...previous, kind: 'loading' }));
311
- update(total, previous => previous + amount);
312
- ```
83
+ <a id="layer-placement"></a>
84
+ [See `layer-placement`](../runtime/docs/reference/lint.md#layer-placement).
313
85
 
314
- The rule reports a call of `update` — by that name or through a context, `ctx.update` — with exactly two arguments,
315
- whose first argument names a state and whose value reads `getSnapshot()` on that same state: written into the value
316
- itself, or through a name of the same function that holds the read — `const current = s.getSnapshot()`,
317
- `const { items } = s.getSnapshot()`, or a `let` nothing writes again. A `getSnapshot()` of another readable inside
318
- the value is a read of another state and stays; so do a value that is already an updater, a read written anywhere
319
- but in that value, and a second argument that is a spread, whose arguments are assembled somewhere else.
320
-
321
- The fix writes the updater: the value becomes `previous => ...` with every read of that state replaced by the
322
- parameter — a destructured field by the field of it, `previous.items` — and the `const` that held the read goes with
323
- it, comment and all, where this value was its only reader. The parameter is named `previous`, or `snapshot` where an
324
- enclosing scope already holds `previous`. Where the parameter would shadow a name the file has, where the named read
325
- has another reader that stays behind, and wherever the read was taken apart or opened with a `let`, the same rewrite
326
- arrives as a suggestion instead, because those are the rewrites a reader should look at. A value that cannot move
327
- into a function body at all — one that writes an `await` or a `yield` there, assigns, increments or `delete`s —
328
- gets the report and nothing else, and so does a read this value keeps for a later call.
329
-
330
- **The boundary.** `update(S, ...S.getSnapshot()...)` is specific enough on its own, so the rule resolves no import
331
- 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
332
- accepts an updater as its second argument. A name is the binding it resolves to, but a member expression is the
333
- path it spells, so two different objects written `this.state` are one state here. The rewrite moves the whole value
334
- into a function body, so everything else the value called — `Date.now()`, a helper, a formatter — is computed at
335
- write time instead of where it was written, and is not computed at all for a write the runtime drops; that is what
336
- the updater form means, and it is worth reading once before the rewrite lands. A read the value reaches by any other
337
- route — a helper that takes the state and reads it, a name declared in another function, a name the code writes
338
- again, a value that is already an updater — is not seen, and silence is not proof that a value computed itself from
339
- the previous one.
340
-
341
- ### `no-command-in-deps`
342
-
343
- A command hook is a snapshot of the render it was read in: the object is rebuilt on every `inFlight` and `outcome`
344
- change, while `run` keeps one identity for the life of the binding (D290). A dependency array that names the object
345
- therefore fires on each of those changes: an effect re-runs, a memo is voided, and an effect that calls `run` from
346
- it never settles — it starts the command, the status flips, the dependency changes, the cleanup runs, and the
347
- effect runs again.
348
-
349
- ```tsx
350
- const load = useCommand(Screen.load);
351
- const unload = useCommand(Screen.unload);
352
-
353
- useEffect(() => {
354
- load.run();
355
- return () => void unload.run();
356
- }, [load.run, unload.run]);
357
- ```
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).
358
88
 
359
- The rule reads the dependency array of `useEffect`, `useLayoutEffect`, `useInsertionEffect`, `useCallback`,
360
- `useMemo` and `useImperativeHandle`, and reports an element that is a command hook binding taken whole: the
361
- variable of a `useCommand`, a key or a destructured name of `useCommands`, and a field of a `useModel` selection
362
- the same code reads `run` on. `x.run` and the statuses `x.inFlight` and `x.outcome` are members of that object rather
363
- than the object, and they stay.
364
-
365
- The record itself is the worse shape and carries its own report: `useCommands` builds a new object on every render,
366
- and so does `useModel` with a selection, so `[hooks]` and `[order]` change on every render rather than on every
367
- status. The answer is the field the callback reads — `hooks.save.run`.
368
-
369
- The fix is written where the answer is one: a callback that reaches the binding through a single `run` gets that
370
- path in place of the element, whatever the element was written past — an `as`, a `satisfies` or a `!` is replaced
371
- along with it, and an array that already names the path loses the element instead of doubling it. Where the callback
372
- reads more than one path, or reads a status, the rule offers suggestions instead — one per path, statuses first —
373
- because which of them the dependency means is the author's to say. A callback this rule cannot read through — one
374
- written elsewhere, one that keeps the object itself, takes it apart, or reads a member outside those four — gets the
375
- report and no suggestion at all.
376
-
377
- **The boundary.** Names are resolved by import within one module, not by spelling: `useCommand`, `useCommands` and
378
- `useModel` are the imports of `@opetope/react`, the six hooks are React's own, and a local alias of either —
379
- `import { useEffect as effect }` — is the same import. React is the module named `react` exactly; an `@opetope/*`
380
- name is also taken from a relative import, which is how a package re-exports its own entry through a barrel, so a
381
- neighbouring `./scheduling` is React's no more than `@host/scheduling` is. React's hooks are read through a
382
- namespace or a default binding as well (`React.useEffect`); an `@opetope/*` entry has no default export, and only a
383
- namespace reaches it. Only a `const` holds the hook it was opened with, so a `let` is left alone. A selected
384
- `useModel` field is a hook only where the code proves it by reading `run` on it — a status name proves nothing,
385
- since plain data carries one as easily — and a field read as data is of unknown kind and is left alone, as is
386
- `useModel(Declaration)` without a selection, which keeps returning the granted model. A dependency array of a call
387
- that is not one of the six hooks belongs to whoever declared it.
388
-
389
- ### `no-subscribe-outside-models`
390
-
391
- A subscription written by hand owns a cleanup that nothing around it can see. The rule reports `x.subscribe(...)`
392
- and stays off until `configs.layers` names the model layer that is allowed to hold one; in a component the reader
393
- is `useReadable` or a model selection, and in a feature or a model it is `effect`, `event` or the `connect` of
394
- `resource.live`.
395
-
396
- Handing the function over without calling it is not a subscription written here:
397
- `useSyncExternalStore(source.subscribe, source.getSnapshot)` passes a reference, and the reader owns what it starts.
398
-
399
- Neither is a subscription written where a declared node opens its work and owns the release of it. The disposer
400
- such a callback returns belongs to that node and is drained with the generation that opened it, so the rule stays
401
- silent there — this is the shape its own message asks for (D299):
402
-
403
- | Callback | Where | Ingress |
404
- | ---------------------------------------------- | --------------------------- | ------- |
405
- | `resource.live({ connect })` | the `connect` member | yes |
406
- | `event(from, subscribe, run)` | second argument | yes |
407
- | `scope.while` / `scope.switch` / `scope.keyed` | `open`, second argument | yes |
408
- | `attach(source, { open })` | the `open` member | yes |
409
- | `attach(source, { open, close })` | the `close` member | no |
410
- | `apply`, `load`, `run`, `when`, `key` | modifiers of the same calls | no |
411
-
412
- `resource.load` opens no subscription and has no ingress at all, so a `subscribe` written in its `load` is reported
413
- like any other hand-written one (D349).
414
-
415
- ```ts
416
- own: ({ imports, resource }) => ({
417
- book: resource.live({
418
- apply: { change: (_current: ResourceData<Quote>, change: Quote) => change },
419
- connect: ({ emit, signal, source }) => source.repository.subscribe('BTCUSD', emit, signal),
420
- lifetime: 'owner',
421
- source: imports.exchange,
422
- }),
423
- });
424
- ```
89
+ <a id="no-snapshot-in-update"></a>
90
+ [See `no-snapshot-in-update`](../runtime/docs/reference/lint.md#no-snapshot-in-update).
425
91
 
426
- Inside counts all the way down — a nested arrow, a `function` body, an `await` on the subscription itself, a
427
- disposer named before it is returned. The context is resolved by import, not by spelling: the `own` section written
428
- right where a `defineFeature`/`defineFeature.body` **taken from `@opetope/runtime` itself** receives it, and a model
429
- factory — the second argument of `model(Declaration, factory)` on that same `own`, or a function whose first
430
- parameter is annotated `ModelContext` from `@opetope/core`. Unlike `no-command-in-deps`, these two anchors take the
431
- package name and nothing else: a relative import here would let a host's own `./my-own-context` silence the rule.
432
- `own.resource.live(...)` and a destructured `resource.live(...)` read alike, an alias answers with the key it was
433
- taken from (`{ resource: materialize }` is still `resource`), and `own.scope.while(...)` reads like the destructured
434
- `scope.while(...)`.
92
+ <a id="no-command-in-deps"></a>
93
+ [See `no-command-in-deps`](../runtime/docs/reference/lint.md#no-command-in-deps).
435
94
 
436
- Four shapes therefore keep the report, and each is a limit of what syntax proves: a `resource.live` traced to none of
437
- those anchors (imported from elsewhere, unresolvable, or belonging to another `defineFeature`); a callback written
438
- elsewhere and passed in by name, which is not lexically inside; an `own` section hoisted to its own binding
439
- (`const own = ({ resource }) => …; defineFeature(id, { own })`); and `model(Decl, createOrderModel)` where the factory
440
- is a named function without the `ModelContext` annotation.
95
+ <a id="no-subscribe-outside-models"></a>
96
+ [See `no-subscribe-outside-models`](../runtime/docs/reference/lint.md#no-subscribe-outside-models).
441
97
 
442
- ### `prefer-effect-current`
98
+ <a id="prefer-effect-current"></a>
99
+ [See `prefer-effect-current`](../runtime/docs/reference/lint.md#prefer-effect-current).
443
100
 
444
- The `run` of an effect is already given the value it was started for. Reading the same source through
445
- `source.getSnapshot()` inside that run reads it a second time and says nothing about which run this is, so the rule
446
- rewrites it to `execution.current` (D296, D323):
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).
447
103
 
448
- ```js
449
- // before
450
- ctx.effect(quotes, execution => publish(quotes.getSnapshot()));
451
- // after
452
- ctx.effect(quotes, execution => publish(execution.current));
453
- ```
104
+ <a id="capture-command-cleanup"></a>
105
+ [See `capture-command-cleanup`](../runtime/docs/reference/lint.md#capture-command-cleanup).
454
106
 
455
- The fix writes the word the run already has: the name of the context for `execution => …`, and the local name
456
- `current` was destructured under for `({ current }) => …`. A run that named neither has no word to write, so the
457
- report stands alone.
458
-
459
- **The two cases it reports without rewriting.** Past the first `await` of the run, `current` is the value this run
460
- started with and the snapshot is the value of the moment; a change of the source normally restarts the run, so the
461
- two normally agree — and «normally» is not a licence to rewrite. Under `when` they legitimately differ: the filter
462
- admits some values and skips others, so the source moves without restarting the run (D168, D296). A modifier record
463
- this rule cannot read — one assembled elsewhere — is treated as if it carried the predicate.
464
-
465
- A read inside a nested callback of the run — a `timers.delay`, a subscription handler — is left alone: that code
466
- runs later than the run does, and the value of that moment is the one it wants. Like every rule that rewrites code,
467
- this one resolves its context exactly: the `effect` it acts on is the one declared on a `ModelContext` parameter or
468
- on the context of a `model(Declaration, factory)` written inside a `defineFeature` of `@opetope/runtime` (D309).
469
-
470
- ### `capture-command-cleanup`
471
-
472
- The writer of a command lives as long as its caller (D288). A write in the `finally` or the `catch` of a `try` that
473
- awaits runs after the `await`, and once the call is cancelled it is dropped without a record — so the cleanup that
474
- was supposed to clear a busy flag or a pending mark is exactly the write that never lands. The rule reports that write
475
- and names the word that does land, a commit taken before the `await` (D319, D330):
476
-
477
- ```ts
478
- // reported: once the caller is cancelled, `busy` stays `true`
479
- ctx.call(async (input, { signal, update }) => {
480
- update(busy, true);
481
- try {
482
- await host.send(input, signal);
483
- } finally {
484
- update(busy, false);
485
- }
486
- });
487
-
488
- // the cleanup lands while the model lives
489
- ctx.call(async (input, { capture, signal, update }) => {
490
- update(busy, true);
491
- const done = capture(busy);
492
- try {
493
- await host.send(input, signal);
494
- } finally {
495
- done.update(() => false);
496
- }
497
- });
498
- ```
107
+ <a id="prefer-model-selection"></a>
108
+ [See `prefer-model-selection`](../runtime/docs/reference/lint.md#prefer-model-selection).
499
109
 
500
- There is no fix: whether a cleanup must land after its caller left is the author's decision, and the drop is the law
501
- for a reason — a late write of a cancelled call must not overwrite what a newer call wrote. The report therefore names
502
- both ways out. A cleanup that must run for a cancelled call goes through `capture(state)` taken before the `await`; an
503
- answer that must not land after cancellation — a failure, a result — belongs in a `catch` after
504
- `rethrowIfCancelled(cause)`. Where a caller cancels one run to start the next — a search its consumer issues again, a
505
- Call an effect run invokes and a newer value cancels — the older run's write is exactly the one that should not land,
506
- and a disable comment says so.
507
-
508
- Under `policy: 'parallel'` the report does not advise a commit. Calls of such a command overlap, so a cancelled call's
509
- captured cleanup would clear a flag another call still holds — a flag shared by parallel calls is racy either way —
510
- and the report says to keep that state per call, or to leave progress to the consumer's `inFlight`. The rule sees the
511
- policy only when `{ policy: 'parallel' }` is written on the `call` itself; a modifier record assembled elsewhere reads
512
- as the default queue, where the lane waits for the physical body and a captured cleanup lands in order.
513
-
514
- The rule is a heuristic over syntax, and it proves nothing about a write it does not report. It reads the writer of a
515
- command body under these names: `execution.update(…)`; `({ update })` or `({ update: write })` in the parameter list;
516
- `const { update } = execution` inside the body; and a `const` that gives one of those a second name —
517
- `const write = update` or `const write = execution.update`. It follows a body passed as a local function. It misses a
518
- closure that holds the writer (`const clear = () => update(busy, false)` called in `finally`), a writer stored anywhere
519
- but a `const`, and a context handed to a helper. A `try` counts when its protected block awaits in the body's own
520
- function: an `await` or a `for await` inside a nested callback suspends that callback, not the command.
521
-
522
- In a `catch`, a write that follows `rethrowIfCancelled(cause)`, `signal.throwIfAborted()` or an `if (signal.aborted)`
523
- that throws or returns — in the block that holds the write or in any block around it — is not reported. Such a guard
524
- is the author's statement that what follows must not run for a cancelled call, and the rule takes it at its word: in
525
- `catch (cause) { rethrowIfCancelled(cause); update(busy, false); throw cause }` the flag stays `true` on cancellation
526
- and nothing is reported. A flag that must clear on cancellation is therefore written in `finally` through
527
- `capture(state)`. A guard in a `finally` saves nothing and is still reported, and so is a check of `signal.aborted`
528
- that neither throws nor returns. `effect`, `event` and the other reactions are left alone: a newer value is what
529
- cancelled their run, and dropping its write is the point (D288). The context is resolved exactly: the `call` of a
530
- `ModelContext` parameter or of the context of `model(Declaration, factory)` inside a `defineFeature` of
531
- `@opetope/runtime` (D309); a feature's own `call` hands its body no writer.
532
-
533
- ### `prefer-model-selection`
534
-
535
- A component that takes one granted model and reads its fields through a hook each repeats the same wiring. From
536
- three fields upwards the rule points at one selection instead (D205, D214):
110
+ <a id="no-retired-vocabulary"></a>
111
+ [See `no-retired-vocabulary`](../runtime/docs/reference/lint.md#no-retired-vocabulary).
537
112
 
538
- ```js
539
- 'opetope/prefer-model-selection': ['error', { threshold: 3 }],
540
- ```
541
-
542
- It counts `useReadable(model.field)` and `useCommand(model.field)` over the variable of a `useModel(Declaration)`
543
- without a selection, and it counts each granted model separately: two models in one component are two grants, and
544
- the same variable name in two components is two counts. There is no fix — the shape of the selection is the
545
- author's to write.
546
-
547
- ## Coexistence with key sorting
548
-
549
- A host that sorts object keys alphabetically will disagree with `define-feature-property-order`, because the
550
- sections of a feature are ordered by meaning and not by name. `recommended` touches no sorting rule: reconciling
551
- them belongs to the host, and there are two ways.
552
-
553
- **`perfectionist/sort-objects`** takes a named order for exactly the two callees that declare sections, and keeps
554
- its plain configuration for every other object:
555
-
556
- ```js
557
- 'perfectionist/sort-objects': [
558
- 'error',
559
- {
560
- customGroups: [
561
- { elementNamePattern: '^id$', groupName: 'feature-id' },
562
- { elementNamePattern: '^imports$', groupName: 'feature-imports' },
563
- { elementNamePattern: '^requires$', groupName: 'feature-requires' },
564
- { elementNamePattern: '^own$', groupName: 'feature-own' },
565
- { elementNamePattern: '^exports$', groupName: 'feature-exports' },
566
- { elementNamePattern: '^provides$', groupName: 'feature-provides' },
567
- { elementNamePattern: '^when$', groupName: 'feature-when' },
568
- { elementNamePattern: '^body$', groupName: 'feature-body' },
569
- ],
570
- groups: [
571
- 'feature-id',
572
- 'feature-imports',
573
- 'feature-requires',
574
- 'feature-own',
575
- 'feature-exports',
576
- 'feature-provides',
577
- 'feature-when',
578
- 'feature-body',
579
- 'unknown',
580
- ],
581
- order: 'asc',
582
- type: 'natural',
583
- useConfigurationIf: { callingFunctionNamePattern: '^(?:defineFeature|defineFeature\\.body)$' },
584
- },
585
- { order: 'asc', type: 'natural' },
586
- ],
587
- ```
588
-
589
- **ESLint core `sort-keys`, and the rule of the same id in Oxlint**, has no per-callee configuration and no
590
- per-declaration exception: it reports each key that follows a larger one, wherever the object is. Turn it off for
591
- the files that declare features, or wrap a declaration in `/* eslint-disable sort-keys */` and
592
- `/* eslint-enable sort-keys */` — a `// eslint-disable-next-line sort-keys` covers one line, so it silences one
593
- section, not a multi-line declaration. Oxlint reads the same directives under its own prefix,
594
- `// oxlint-disable-next-line sort-keys`.
113
+ <a id="coexistence-with-key-sorting"></a>
114
+ [See Coexistence with key sorting](../runtime/docs/reference/lint.md#coexistence-with-key-sorting).
595
115
 
596
- The fix of this rule moves keys only inside the object of a `defineFeature` call, so it never reorders anything a
597
- sorting rule owns elsewhere in the file.
598
-
599
- ## Running the rules under Oxlint
600
-
601
- Oxlint reads ESLint plugins through `jsPlugins`: it imports the module by path and takes its default export. The
602
- rules stay on the plain rule API — `meta`, `create(context)` with visitors, `context.report` with a `fix`, and
603
- `getText`/`getCommentsInside` on the source code — with no typed services and no ESLint-only capability, so both
604
- linters run them, autofix included:
605
-
606
- ```json
607
- {
608
- "extends": ["./node_modules/@opetope/lint/oxlintrc.json"],
609
- "jsPlugins": [{ "name": "opetope", "specifier": "./node_modules/@opetope/lint/dist/index.js" }]
610
- }
611
- ```
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).
612
118
 
613
- Those are the two lines, and neither of them is a transcription. `@opetope/lint/oxlintrc.json` is generated from
614
- `configs.recommended.rules` and shipped in the package, so a rule the package adds arrives with the upgrade instead
615
- of being missed by a hand-written severity map. `extends` in `.oxlintrc.json` takes a **file path**, resolved
616
- relative to the config that names it — not a package specifier — which is why the fragment sits at the root of the
617
- package and the path a host writes is the same string as its export name. Configurations merge first to last, so a
618
- rule the project wants stricter goes in its own `rules` after `extends`: the three `off` entries above are exactly
619
- the ones a project turns on by its own directories.
620
-
621
- The fragment carries rules and nothing else. Oxlint does merge a `jsPlugins` entry through `extends`, but a host
622
- that declares the plugin itself — under a wrapper package, as an isolated peer install needs — would then register
623
- the name `opetope` twice, and the second registration fails the whole configuration. Where the plugin comes from is
624
- the host's deployment; the severities are this package's contract.
625
-
626
- The alias `name` fixes the namespace, so a rule keeps the same id under either linter. `jsPlugins` is alpha and
627
- outside semver: `npm run ci:test` runs the built plugin under Oxlint on a valid and an invalid fixture, through a
628
- config that extends the shipped fragment, and checks the fix it writes, so a break in that bridge fails here
629
- instead of in a host.
630
-
631
- `--fix-suggestions` applies the first suggestion of a report without asking, so a suggestion here is only ever a
632
- rewrite the reported code already justifies. `no-command-in-deps` offers the paths the callback itself reads, the
633
- status it watches before the `run` it calls; where it cannot read the use through — the object passed on, taken
634
- apart, or read past the members the rule knows — it reports and offers nothing, and the dependency stays as written
635
- until its author changes it.
636
-
637
- `no-snapshot-in-update` holds the same line from the other side: its suggestion is the fix it would not write
638
- unasked — a parameter that shadows a name the file already has, or a named read that stays behind for its other
639
- reader — and both rewrites leave the code doing what it did. Where the value cannot become the body of an updater
640
- at all, there is no rewrite to offer and the report stands alone.
641
-
642
- ## Checks
643
-
644
- From this package: `npm run ci:test`, `npm run ci:type`, `npm run ci:eslint`, `npm run build`. The Oxlint bridge
645
- test reads `dist`, so `npm run build` comes first. `ci:test` also checks that the committed `oxlintrc.json` still
646
- equals `configs.recommended.rules`; after changing that config run `npm run oxlintrc:generate` and commit the
647
- result, which is why the build never writes it.
119
+ <a id="checks"></a>
120
+ [See contributor checks](../runtime/docs/maintainers/contributing.md).