@opetope/lint 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,119 @@
1
1
  # @opetope/lint
2
2
 
3
+ ## 0.9.1
4
+
5
+ ### Patch Changes
6
+
7
+ - d7f346a: `no-subscribe-outside-models` leaves the ingress of a declared node alone, `@opetope/lint` ships an Oxlint config
8
+ fragment, and `combineAbortSignals` becomes host vocabulary on `@opetope/runtime` (D299).
9
+
10
+ **The rule.** `x.subscribe(...)` written lexically inside a callback where a declared node opens its work and owns
11
+ the release of it is no longer reported: the disposer that callback returns belongs to the node and is drained with
12
+ the generation that opened it. Four callbacks qualify, and each returns `Awaitable<Disposer | void>` — `connect` of
13
+ a `stream` (third argument), `subscribe` of an `event` (second), the `open` of
14
+ `scope.while`/`scope.switch`/`scope.keyed` (second), and the `open` member of an `attach`. This was a false
15
+ positive under this package's own `configs.layers` — a feature's `own.stream` lives in the integration layer, where
16
+ the rule is `error`, and it was reported for exactly the shape its own message prescribes. The position is the
17
+ contract, so `attach`'s `close`, and `consume`, `run`, `load`, `when` and `key`, keep the report; so does a node
18
+ whose origin the rule cannot prove. The context resolves by import: the `own` section written right where a
19
+ `defineFeature`/`defineFeature.body` from `@opetope/runtime` receives it, and a model factory — the second argument
20
+ of `model(Declaration, factory)` on that `own`, or a function whose first parameter is annotated `ModelContext`
21
+ from `@opetope/core`. Both anchors take the package name exactly, unlike `no-command-in-deps`, because a check that
22
+ uses an import to stay silent must not accept a host's own `./my-own-context`. `own.stream(...)` and a destructured
23
+ `stream(...)` read alike, alias included, as do `own.scope.while(...)` and `scope.while(...)`. There is no fix to
24
+ write: the change removes reports.
25
+
26
+ **The fragment.** `@opetope/lint/oxlintrc.json` is generated from `configs.recommended.rules` and shipped in the
27
+ package, so an Oxlint host extends the list instead of transcribing it:
28
+
29
+ ```json
30
+ {
31
+ "extends": ["./node_modules/@opetope/lint/oxlintrc.json"],
32
+ "jsPlugins": [{ "name": "opetope", "specifier": "./node_modules/@opetope/lint/dist/index.js" }]
33
+ }
34
+ ```
35
+
36
+ `extends` takes a file path, not a package specifier, which is why the fragment sits at the root of the package.
37
+ It carries rules only: Oxlint merges a `jsPlugins` entry through `extends` too, but a host that declares the plugin
38
+ itself would then register the name `opetope` twice, and that fails the configuration. Configurations merge first
39
+ to last, so rules a project wants stricter go after `extends`. `configs.recommended.rules` is also documented as a
40
+ readable table in the package README.
41
+
42
+ **The export.** `combineAbortSignals(signals)` and its result type `CombinedAbortSignalHandle` are published on
43
+ `@opetope/runtime`. It answers `{ signal, dispose }`: `signal` aborts with the exact reason of whichever input
44
+ aborts first, including one already aborted at the call, and `dispose` releases the listeners it took and is
45
+ idempotent. The name says handle because that is what it is — a handle over a signal, not a signal. The published
46
+ browser floor has no `AbortSignal.any`. The public surface moves to 39 values and 38 types against targets of 40
47
+ and 60; the size consumers are unchanged to the byte, because the symbol already reaches them from a module those
48
+ consumers treat as external.
49
+
50
+ **Migrating a host.** The gain is that a subscription inside an ingress callback is legal on its own, not because
51
+ a directory override silences the rule. In the application this was measured on, three such subscriptions — inside
52
+ the `connect` of a `stream` in three models — are today covered only by that project's `off` override for its model
53
+ layer; after this release the same code is correct one directory up as well, and the override can narrow to what it
54
+ is actually for. Directives are a smaller story: the one `// oxlint-disable-next-line
55
+ opetope/no-subscribe-outside-models` in that application sits on a subscription inside a `new Promise` executor,
56
+ which is not an ingress, is still reported, and keeps its directive until `refresh()` returns a promise (D297). A
57
+ hand-written `opetope/*` block in `.oxlintrc.json` is replaced by the two lines above, leaving only the rules the
58
+ project turns on for its own directories — for this package's own layers that is `layer-placement`,
59
+ `no-subscribe-outside-models` and `prefer-model-selection`. A local copy of a combined-signal helper — the one an
60
+ application kept because `@opetope/core/internal` is closed to it — is deleted in favour of the import from
61
+ `@opetope/runtime`.
62
+
63
+ - d7f346a: `opetope/no-command-in-deps` keeps a command hook out of a React dependency array.
64
+
65
+ A hook read from `useCommand`, from a key of `useCommands` or from a `Call` field of a `useModel` selection is a
66
+ snapshot of the render it was read in: the object is rebuilt on every `inFlight` and `outcome` change, while `run`
67
+ keeps one identity for the life of the binding. An application that wrote
68
+ `useEffect(() => { load.run(); return () => { unload.run(); }; }, [load, unload])` therefore started the command,
69
+ saw the status flip, saw the dependency change, ran the cleanup and ran the effect again — an infinite render loop.
70
+ The same application kept eight `useCallback(() => void submit.run('buy'), [submit])`, which looped over nothing but
71
+ voided their memo on each status of the command. Both shapes compile and run, and say something other than what
72
+ their author meant, which is what this package is for (D290).
73
+
74
+ The rule reads the dependency array of React's `useEffect`, `useLayoutEffect`, `useInsertionEffect`, `useCallback`,
75
+ `useMemo` and `useImperativeHandle`, and reports an element that is the hook binding taken whole; `x.run` and the
76
+ statuses `x.inFlight` and `x.outcome` are members of that object and stay. The record itself carries its own report:
77
+ `useCommands` builds a new object on every render, and so does `useModel` with a selection, so `[hooks]` and
78
+ `[order]` change on every render rather than on every status.
79
+
80
+ A callback that reaches the binding through a single `run` is fixed to that path — past an `as`, a `satisfies` or a
81
+ `!`, and without doubling a path the array already names. A callback that reads more than one path gets one
82
+ suggestion per path, statuses before `run`; a callback the rule cannot read through — written elsewhere, keeping
83
+ the object itself, taking it apart, or reading a member outside those three — gets the report and no suggestion,
84
+ because a suggestion is applied unattended under Oxlint's `--fix-suggestions`. Names are resolved by import and not
85
+ by spelling, a local alias included; a `useModel` selection field is a hook only where the same code proves it by
86
+ reading `run` on it, and a field read as data is of unknown kind and is left alone. The rule is `error` in
87
+ `configs.recommended`.
88
+
89
+ - d7f346a: `opetope/no-snapshot-in-update` keeps a snapshot read out of the value of the write that replaces it.
90
+
91
+ `update(sessions, { items: sessions.getSnapshot().items, kind: 'loading' })` reads the state it is about to
92
+ replace. The argument is computed where it is written and the write happens after it, so the value is built from
93
+ what the state held at one moment and lands on what it holds at another. After D288 the same shape has a second
94
+ fault: a write through a context whose signal is aborted is dropped silently and its updater is never called, but
95
+ an argument is already computed by then — and the cells of a closed model are closed, so `getSnapshot()` throws
96
+ `ReadableError('closed')` and a silently dropped write becomes an exception in the author's body. An application
97
+ wrote the shape nine times, eight of them `loading`/`failed` transitions that keep what is already loaded (D295).
98
+
99
+ The rule reads a call of `update` — by that name or through a context — with exactly two arguments, whose first
100
+ argument names a state and whose value reads `getSnapshot()` on that same state, written into the value or through
101
+ a name of the same function that holds the read: `const current = s.getSnapshot()`,
102
+ `const { items } = s.getSnapshot()`, or a `let` nothing writes again. A snapshot of another readable inside the
103
+ value is a read of another state and stays, as do a value that is already an updater and a second argument that is
104
+ a spread, whose arguments are assembled somewhere else. The scalar form counts too:
105
+ `update(total, total.getSnapshot() + amount)` is the same write.
106
+
107
+ The fix writes the updater: `update(sessions, previous => ({ ...previous, kind: 'loading' }))`, with every read of
108
+ that state replaced by the parameter — a destructured field by the field of it — and a `const` that held the read
109
+ removed, comment and all, where this value was its only reader. The parameter is `previous`, or `snapshot` where an
110
+ enclosing scope already holds `previous`. Where the parameter would shadow a name the file has, where the named
111
+ read has another reader, and wherever the read was taken apart or opened with a `let`, the same rewrite arrives as
112
+ a suggestion; a value that cannot become the body of an updater at all — one that writes an `await` or a `yield`
113
+ there, assigns, increments, `delete`s, or keeps a read for a later call — gets the report and nothing else, because
114
+ Oxlint applies the first suggestion unattended. Everything else the value called travels into the updater with it,
115
+ so it runs at write time and not at all for a dropped write. The rule is `error` in `configs.recommended`.
116
+
3
117
  ## 0.9.0
4
118
 
5
119
  No changes in this release.
package/README.md CHANGED
@@ -43,9 +43,30 @@ both it and `eslint` are peer dependencies.
43
43
  ### `recommended`
44
44
 
45
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` and `require-declared-models` as errors. A rule that needs to know where a
47
- host keeps its files is off here and arrives through `layers`; `prefer-model-selection` is off because the number
48
- of hooks a component may keep is a taste a project settles for itself.
46
+ `require-literal-id`, `id-naming`, `no-internal-imports`, `no-snapshot-read-in-render`, `no-snapshot-in-update`,
47
+ `no-command-in-deps` 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
+ | `define-feature-property-order` | `error` | |
56
+ | `id-naming` | `error` | |
57
+ | `layer-placement` | `off` | `layers({ integration, models, ui })` |
58
+ | `no-command-in-deps` | `error` | |
59
+ | `no-internal-imports` | `error` | also scoped by `internalImports({ allow })` |
60
+ | `no-snapshot-in-update` | `error` | |
61
+ | `no-snapshot-read-in-render` | `error` | |
62
+ | `no-subscribe-outside-models` | `off` | `layers({ models })` |
63
+ | `prefer-model-selection` | `off` | the project, with its own `threshold` |
64
+ | `require-declared-models` | `error` | |
65
+ | `require-literal-id` | `error` | |
66
+ | `when-predicate` | `error` | |
67
+
68
+ The three `off` rules are the three that need something only the project knows: which directories are which layer,
69
+ and how many hooks one component may keep.
49
70
 
50
71
  ### `layers`
51
72
 
@@ -244,6 +265,95 @@ stays: those run after render, and the value of that moment is the one they want
244
265
  reported, whichever object it belongs to. A capitalized function that returns no element is a factory and keeps its
245
266
  reads — including a contribution's model factory, which reads the props of its own mount.
246
267
 
268
+ ### `no-snapshot-in-update`
269
+
270
+ A value passed to `update` reads the state it is about to replace — a composite one,
271
+ `update(sessions, { items: sessions.getSnapshot().items, kind: 'loading' })`, and a scalar one,
272
+ `update(total, total.getSnapshot() + amount)`, alike. The read runs where the argument is written — before the
273
+ write, and on a state that may already be closed, where `getSnapshot()` throws a `ReadableError` — and the value it
274
+ computed then lands on a state that may have moved since. The updater form has neither problem: it reads at write
275
+ time, and a write dropped because its context was aborted never calls the updater at all (D282, D288, D295).
276
+
277
+ ```ts
278
+ update(sessions, previous => ({ ...previous, kind: 'loading' }));
279
+ update(total, previous => previous + amount);
280
+ ```
281
+
282
+ The rule reports a call of `update` — by that name or through a context, `ctx.update` — with exactly two arguments,
283
+ whose first argument names a state and whose value reads `getSnapshot()` on that same state: written into the value
284
+ itself, or through a name of the same function that holds the read — `const current = s.getSnapshot()`,
285
+ `const { items } = s.getSnapshot()`, or a `let` nothing writes again. A `getSnapshot()` of another readable inside
286
+ the value is a read of another state and stays; so do a value that is already an updater, a read written anywhere
287
+ but in that value, and a second argument that is a spread, whose arguments are assembled somewhere else.
288
+
289
+ The fix writes the updater: the value becomes `previous => ...` with every read of that state replaced by the
290
+ parameter — a destructured field by the field of it, `previous.items` — and the `const` that held the read goes with
291
+ it, comment and all, where this value was its only reader. The parameter is named `previous`, or `snapshot` where an
292
+ enclosing scope already holds `previous`. Where the parameter would shadow a name the file has, where the named read
293
+ has another reader that stays behind, and wherever the read was taken apart or opened with a `let`, the same rewrite
294
+ arrives as a suggestion instead, because those are the rewrites a reader should look at. A value that cannot move
295
+ into a function body at all — one that writes an `await` or a `yield` there, assigns, increments or `delete`s —
296
+ gets the report and nothing else, and so does a read this value keeps for a later call.
297
+
298
+ **The boundary.** `update(S, ...S.getSnapshot()...)` is specific enough on its own, so the rule resolves no import
299
+ 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
300
+ accepts an updater as its second argument. A name is the binding it resolves to, but a member expression is the
301
+ path it spells, so two different objects written `this.state` are one state here. The rewrite moves the whole value
302
+ into a function body, so everything else the value called — `Date.now()`, a helper, a formatter — is computed at
303
+ write time instead of where it was written, and is not computed at all for a write the runtime drops; that is what
304
+ the updater form means, and it is worth reading once before the rewrite lands. A read the value reaches by any other
305
+ route — a helper that takes the state and reads it, a name declared in another function, a name the code writes
306
+ again, a value that is already an updater — is not seen, and silence is not proof that a value computed itself from
307
+ the previous one.
308
+
309
+ ### `no-command-in-deps`
310
+
311
+ A command hook is a snapshot of the render it was read in: the object is rebuilt on every `inFlight` and `outcome`
312
+ change, while `run` keeps one identity for the life of the binding (D290). A dependency array that names the object
313
+ therefore fires on each of those changes: an effect re-runs, a memo is voided, and an effect that calls `run` from
314
+ it never settles — it starts the command, the status flips, the dependency changes, the cleanup runs, and the
315
+ effect runs again.
316
+
317
+ ```tsx
318
+ const load = useCommand(Screen.load);
319
+ const unload = useCommand(Screen.unload);
320
+
321
+ useEffect(() => {
322
+ load.run();
323
+ return () => void unload.run();
324
+ }, [load.run, unload.run]);
325
+ ```
326
+
327
+ The rule reads the dependency array of `useEffect`, `useLayoutEffect`, `useInsertionEffect`, `useCallback`,
328
+ `useMemo` and `useImperativeHandle`, and reports an element that is a command hook binding taken whole: the
329
+ variable of a `useCommand`, a key or a destructured name of `useCommands`, and a field of a `useModel` selection
330
+ the same code reads `run` on. `x.run` and the statuses `x.inFlight` and `x.outcome` are members of that object rather
331
+ than the object, and they stay.
332
+
333
+ The record itself is the worse shape and carries its own report: `useCommands` builds a new object on every render,
334
+ and so does `useModel` with a selection, so `[hooks]` and `[order]` change on every render rather than on every
335
+ status. The answer is the field the callback reads — `hooks.save.run`.
336
+
337
+ The fix is written where the answer is one: a callback that reaches the binding through a single `run` gets that
338
+ path in place of the element, whatever the element was written past — an `as`, a `satisfies` or a `!` is replaced
339
+ along with it, and an array that already names the path loses the element instead of doubling it. Where the callback
340
+ reads more than one path, or reads a status, the rule offers suggestions instead — one per path, statuses first —
341
+ because which of them the dependency means is the author's to say. A callback this rule cannot read through — one
342
+ written elsewhere, one that keeps the object itself, takes it apart, or reads a member outside those four — gets the
343
+ report and no suggestion at all.
344
+
345
+ **The boundary.** Names are resolved by import within one module, not by spelling: `useCommand`, `useCommands` and
346
+ `useModel` are the imports of `@opetope/react`, the six hooks are React's own, and a local alias of either —
347
+ `import { useEffect as effect }` — is the same import. React is the module named `react` exactly; an `@opetope/*`
348
+ name is also taken from a relative import, which is how a package re-exports its own entry through a barrel, so a
349
+ neighbouring `./scheduling` is React's no more than `@host/scheduling` is. React's hooks are read through a
350
+ namespace or a default binding as well (`React.useEffect`); an `@opetope/*` entry has no default export, and only a
351
+ namespace reaches it. Only a `const` holds the hook it was opened with, so a `let` is left alone. A selected
352
+ `useModel` field is a hook only where the code proves it by reading `run` on it — a status name proves nothing,
353
+ since plain data carries one as easily — and a field read as data is of unknown kind and is left alone, as is
354
+ `useModel(Declaration)` without a selection, which keeps returning the granted model. A dependency array of a call
355
+ that is not one of the six hooks belongs to whoever declared it.
356
+
247
357
  ### `no-subscribe-outside-models`
248
358
 
249
359
  A subscription written by hand owns a cleanup that nothing around it can see. The rule reports `x.subscribe(...)`
@@ -253,6 +363,47 @@ is `useReadable` or a model selection, and in a feature or a model it is `effect
253
363
  Handing the function over without calling it is not a subscription written here:
254
364
  `useSyncExternalStore(source.subscribe, source.getSnapshot)` passes a reference, and the reader owns what it starts.
255
365
 
366
+ Neither is a subscription written where a declared node opens its work and owns the release of it. The disposer
367
+ such a callback returns belongs to that node and is drained with the generation that opened it, so the rule stays
368
+ silent there — this is the shape its own message asks for (D299):
369
+
370
+ | Callback | Where | Ingress |
371
+ | ---------------------------------------------- | --------------------------- | ------- |
372
+ | `stream(from, target, connect)` | third argument | yes |
373
+ | `event(from, subscribe, run)` | second argument | yes |
374
+ | `scope.while` / `scope.switch` / `scope.keyed` | `open`, second argument | yes |
375
+ | `attach(source, { open })` | the `open` member | yes |
376
+ | `attach(source, { open, close })` | the `close` member | no |
377
+ | `consume`, `run`, `load`, `when`, `key` | modifiers of the same calls | no |
378
+
379
+ ```ts
380
+ own: ({ imports, stream }) => ({
381
+ book: stream(
382
+ imports.exchange,
383
+ () => 'BTCUSD',
384
+ async ({ emit, signal }) => {
385
+ return imports.exchange.repository.subscribe('BTCUSD', emit, signal);
386
+ },
387
+ ),
388
+ });
389
+ ```
390
+
391
+ Inside counts all the way down — a nested arrow, a `function` body, an `await` on the subscription itself, a
392
+ disposer named before it is returned. The context is resolved by import, not by spelling: the `own` section written
393
+ right where a `defineFeature`/`defineFeature.body` **taken from `@opetope/runtime` itself** receives it, and a model
394
+ factory — the second argument of `model(Declaration, factory)` on that same `own`, or a function whose first
395
+ parameter is annotated `ModelContext` from `@opetope/core`. Unlike `no-command-in-deps`, these two anchors take the
396
+ package name and nothing else: a relative import here would let a host's own `./my-own-context` silence the rule.
397
+ `own.stream(...)` and a destructured `stream(...)` read alike, an alias answers with the key it was taken from
398
+ (`{ stream: openStream }` is still `stream`), and `own.scope.while(...)` reads like the destructured
399
+ `scope.while(...)`.
400
+
401
+ Four shapes therefore keep the report, and each is a limit of what syntax proves: a `stream` traced to none of
402
+ those anchors (imported from elsewhere, unresolvable, or belonging to another `defineFeature`); a callback written
403
+ elsewhere and passed in by name, which is not lexically inside; an `own` section hoisted to its own binding
404
+ (`const own = ({ stream }) => …; defineFeature(id, { own })`); and `model(Decl, createOrderModel)` where the factory
405
+ is a named function without the `ModelContext` annotation.
406
+
256
407
  ### `prefer-model-selection`
257
408
 
258
409
  A component that takes one granted model and reads its fields through a hook each repeats the same wiring. From
@@ -328,16 +479,43 @@ linters run them, autofix included:
328
479
 
329
480
  ```json
330
481
  {
331
- "jsPlugins": [{ "name": "opetope", "specifier": "./node_modules/@opetope/lint/dist/index.js" }],
332
- "rules": { "opetope/when-predicate": "error" }
482
+ "extends": ["./node_modules/@opetope/lint/oxlintrc.json"],
483
+ "jsPlugins": [{ "name": "opetope", "specifier": "./node_modules/@opetope/lint/dist/index.js" }]
333
484
  }
334
485
  ```
335
486
 
487
+ Those are the two lines, and neither of them is a transcription. `@opetope/lint/oxlintrc.json` is generated from
488
+ `configs.recommended.rules` and shipped in the package, so a rule the package adds arrives with the upgrade instead
489
+ of being missed by a hand-written severity map. `extends` in `.oxlintrc.json` takes a **file path**, resolved
490
+ relative to the config that names it — not a package specifier — which is why the fragment sits at the root of the
491
+ package and the path a host writes is the same string as its export name. Configurations merge first to last, so a
492
+ rule the project wants stricter goes in its own `rules` after `extends`: the three `off` entries above are exactly
493
+ the ones a project turns on by its own directories.
494
+
495
+ The fragment carries rules and nothing else. Oxlint does merge a `jsPlugins` entry through `extends`, but a host
496
+ that declares the plugin itself — under a wrapper package, as an isolated peer install needs — would then register
497
+ the name `opetope` twice, and the second registration fails the whole configuration. Where the plugin comes from is
498
+ the host's deployment; the severities are this package's contract.
499
+
336
500
  The alias `name` fixes the namespace, so a rule keeps the same id under either linter. `jsPlugins` is alpha and
337
- outside semver: `npm run ci:test` runs the built plugin under Oxlint on a valid and an invalid fixture and checks
338
- the fix it writes, so a break in that bridge fails here instead of in a host.
501
+ outside semver: `npm run ci:test` runs the built plugin under Oxlint on a valid and an invalid fixture, through a
502
+ config that extends the shipped fragment, and checks the fix it writes, so a break in that bridge fails here
503
+ instead of in a host.
504
+
505
+ `--fix-suggestions` applies the first suggestion of a report without asking, so a suggestion here is only ever a
506
+ rewrite the reported code already justifies. `no-command-in-deps` offers the paths the callback itself reads, the
507
+ status it watches before the `run` it calls; where it cannot read the use through — the object passed on, taken
508
+ apart, or read past the members the rule knows — it reports and offers nothing, and the dependency stays as written
509
+ until its author changes it.
510
+
511
+ `no-snapshot-in-update` holds the same line from the other side: its suggestion is the fix it would not write
512
+ unasked — a parameter that shadows a name the file already has, or a named read that stays behind for its other
513
+ reader — and both rewrites leave the code doing what it did. Where the value cannot become the body of an updater
514
+ at all, there is no rewrite to offer and the report stands alone.
339
515
 
340
516
  ## Checks
341
517
 
342
518
  From this package: `npm run ci:test`, `npm run ci:type`, `npm run ci:eslint`, `npm run build`. The Oxlint bridge
343
- test reads `dist`, so `npm run build` comes first.
519
+ test reads `dist`, so `npm run build` comes first. `ci:test` also checks that the committed `oxlintrc.json` still
520
+ equals `configs.recommended.rules`; after changing that config run `npm run oxlintrc:generate` and commit the
521
+ result, which is why the build never writes it.
package/README.ru.md CHANGED
@@ -43,9 +43,30 @@ export default [
43
43
  ### `recommended`
44
44
 
45
45
  Все правила, которые действуют везде, где пишут на Opetope: `when-predicate`, `define-feature-property-order`,
46
- `require-literal-id`, `id-naming`, `no-internal-imports`, `no-snapshot-read-in-render` и `require-declared-models` как error. Правило, которому нужно знать, где хост
47
- держит свои файлы, здесь выключено и приходит через `layers`; `prefer-model-selection` выключено потому, что число
48
- hooks, которое компонент вправе держать, каждый проект решает для себя.
46
+ `require-literal-id`, `id-naming`, `no-internal-imports`, `no-snapshot-read-in-render`, `no-snapshot-in-update`,
47
+ `no-command-in-deps` и `require-declared-models` как error. Правило, которому нужно знать, где хост держит свои файлы, здесь выключено и
48
+ приходит через `layers`; `prefer-model-selection` выключено потому, что число hooks, которое компонент вправе
49
+ держать, каждый проект решает для себя.
50
+
51
+ `configs.recommended.rules` целиком — и ровно это поставляется как `@opetope/lint/oxlintrc.json`:
52
+
53
+ | Правило | `recommended` | Включает |
54
+ | ------------------------------- | ------------- | ----------------------------------------------- |
55
+ | `define-feature-property-order` | `error` | |
56
+ | `id-naming` | `error` | |
57
+ | `layer-placement` | `off` | `layers({ integration, models, ui })` |
58
+ | `no-command-in-deps` | `error` | |
59
+ | `no-internal-imports` | `error` | плюс сужение через `internalImports({ allow })` |
60
+ | `no-snapshot-in-update` | `error` | |
61
+ | `no-snapshot-read-in-render` | `error` | |
62
+ | `no-subscribe-outside-models` | `off` | `layers({ models })` |
63
+ | `prefer-model-selection` | `off` | проект, со своим `threshold` |
64
+ | `require-declared-models` | `error` | |
65
+ | `require-literal-id` | `error` | |
66
+ | `when-predicate` | `error` | |
67
+
68
+ Три `off` — это ровно те три, которым нужно знание, которое есть только у проекта: какие каталоги каким слоем
69
+ являются и сколько hooks вправе держать один компонент.
49
70
 
50
71
  ### `layers`
51
72
 
@@ -246,6 +267,96 @@ Id начинается с буквы и соединяет сегменты и
246
267
  какому бы объекту он ни принадлежал. Функция с заглавной буквы, не возвращающая элементов, — это фабрика, и её
247
268
  чтения остаются, включая фабрику модели вклада, которая читает props своего монтирования.
248
269
 
270
+ ### `no-snapshot-in-update`
271
+
272
+ Значение, переданное в `update`, читает то состояние, которое собирается заменить, — и составное,
273
+ `update(sessions, { items: sessions.getSnapshot().items, kind: 'loading' })`, и скалярное,
274
+ `update(total, total.getSnapshot() + amount)`. Чтение выполняется там, где написан аргумент, — до записи и на
275
+ состоянии, которое может быть уже закрыто, где `getSnapshot()` бросает `ReadableError`, — а вычисленное значение
276
+ ложится на состояние, которое с тех пор могло уйти вперёд. У формы с апдейтером нет ни одной из этих бед: она
277
+ читает в момент записи, а у записи, отброшенной вместе с отменённым контекстом, апдейтер не вызывается вовсе
278
+ (D282, D288, D295).
279
+
280
+ ```ts
281
+ update(sessions, previous => ({ ...previous, kind: 'loading' }));
282
+ update(total, previous => previous + amount);
283
+ ```
284
+
285
+ Правило сообщает о вызове `update` — по этому имени или через контекст, `ctx.update`, — ровно с двумя аргументами,
286
+ у которого первым аргументом назван state, а значение читает `getSnapshot()` на нём же: написанный прямо в
287
+ значении или через имя той же функции, которое держит это чтение, — `const current = s.getSnapshot()`,
288
+ `const { items } = s.getSnapshot()` или `let`, который больше никто не переписывает. `getSnapshot()` другого
289
+ readable внутри значения — чтение другого состояния, и оно остаётся; остаются и значение, которое уже является
290
+ апдейтером, и чтение, написанное где угодно, кроме этого значения, и второй аргумент-spread, чьи аргументы
291
+ собраны в другом месте.
292
+
293
+ Фикс пишет апдейтер: значение становится `previous => ...`, где каждое чтение этого состояния заменено параметром —
294
+ а разобранное поле тем же полем параметра, `previous.items`, — и `const`, державший чтение, уходит вместе с ним, с
295
+ комментарием на той же строке, там, где это значение было его единственным читателем. Параметр называется
296
+ `previous`, а где `previous` уже занят объемлющей областью — `snapshot`. Там, где параметр затенил бы имя, которое
297
+ в файле есть, там, где у именованного чтения остаётся другой читатель, и везде, где чтение разобрано или открыто
298
+ через `let`, та же правка приходит suggestion'ом: это ровно те правки, на которые стоит посмотреть. Значение,
299
+ которое вообще нельзя перенести в тело функции — написанные в нём `await` или `yield`, присваивание, инкремент,
300
+ `delete`, — получает сообщение и ничего больше, и так же остаётся чтение, отложенное до более позднего вызова.
301
+
302
+ **Граница.** Форма `update(S, ...S.getSnapshot()...)` достаточно определённа сама по себе, поэтому правило не
303
+ разрешает импортов и не читает типов: собственный `update` хоста той же формы тоже получает сообщение, и фикс
304
+ верен для него только тогда, когда вторым аргументом он принимает апдейтер. Имя — это привязка, к которой оно
305
+ разрешается, а member expression — путь, который он пишет, поэтому два разных объекта, записанных как `this.state`,
306
+ здесь одно состояние. Правка переносит всё значение в тело функции, поэтому и всё остальное, что значение вызывало
307
+ — `Date.now()`, помощник, форматтер, — вычисляется в момент записи, а не там, где написано, и не вычисляется вовсе
308
+ для записи, которую рантайм отбросил; это и значит форма с апдейтером, и это стоит прочитать один раз до того, как
309
+ правка ляжет. Чтение, до которого значение дотягивается любым другим путём — помощник, который принимает state и
310
+ читает его сам, имя, объявленное в другой функции, имя, которое код переписывает, значение, уже являющееся
311
+ апдейтером, — не видно, и молчание правила не доказывает, что значение вычислено из предыдущего.
312
+
313
+ ### `no-command-in-deps`
314
+
315
+ Хук команды — это снимок того рендера, в котором его прочитали: объект пересобирается на каждой смене `inFlight` и
316
+ `outcome`, а `run` держит одну идентичность всё время жизни привязки (D290). Массив зависимостей, который называет
317
+ этот объект, срабатывает поэтому на каждой такой смене: эффект перезапускается, memo обнуляется, а эффект, который
318
+ вызывает из него `run`, не останавливается никогда — он запускает команду, статус переключается, зависимость
319
+ меняется, выполняется уборка, и эффект запускается снова.
320
+
321
+ ```tsx
322
+ const load = useCommand(Screen.load);
323
+ const unload = useCommand(Screen.unload);
324
+
325
+ useEffect(() => {
326
+ load.run();
327
+ return () => void unload.run();
328
+ }, [load.run, unload.run]);
329
+ ```
330
+
331
+ Правило читает массив зависимостей `useEffect`, `useLayoutEffect`, `useInsertionEffect`, `useCallback`, `useMemo` и
332
+ `useImperativeHandle` и сообщает об элементе, который целиком является привязкой хука команды: о переменной
333
+ `useCommand`, о ключе или деструктурированном имени `useCommands` и о поле выбора `useModel`, на котором тот же код
334
+ читает `run`. `x.run` и статусы `x.inFlight` и `x.outcome` — это члены объекта, а не сам объект, и они остаются.
335
+
336
+ Сама запись — форма ещё хуже, и у неё собственное сообщение: `useCommands` строит новый объект на каждом рендере, и
337
+ то же делает `useModel` с выбором, поэтому `[hooks]` и `[order]` меняются на каждом рендере, а не на каждом
338
+ статусе. Ответ здесь — то поле, которое читает колбэк: `hooks.save.run`.
339
+
340
+ Фикс пишется там, где ответ один: колбэк, который дотягивается до привязки единственным путём через `run`, получает
341
+ на месте элемента этот путь — вместе со всем, через что элемент был написан: `as`, `satisfies` или `!` заменяются
342
+ вместе с ним, а массив, в котором этот путь уже есть, теряет элемент вместо того, чтобы удвоить его. Там, где
343
+ колбэк читает больше одного пути или читает статус, правило предлагает suggestions — по одной на путь, статусы
344
+ первыми, — потому что какой из них имеет в виду зависимость, говорит автор. Колбэк, сквозь который правило не
345
+ видит, — написанный в другом месте, держащий сам объект, разбирающий его или читающий член вне этих четырёх —
346
+ получает сообщение и ни одной suggestion.
347
+
348
+ **Граница.** Имена разрешаются импортом в пределах одного модуля, а не написанием: `useCommand`, `useCommands` и
349
+ `useModel` — это импорты `@opetope/react`, шесть хуков — собственные хуки React, а локальный алиас любого из них —
350
+ `import { useEffect as effect }` — это тот же импорт. React — это модуль, названный ровно `react`; имя `@opetope/*`
351
+ берётся ещё и из относительного импорта, потому что так пакет реэкспортирует собственный вход через barrel, и
352
+ соседний `./scheduling` — React не больше, чем `@host/scheduling`. Хуки React читаются и через namespace, и через
353
+ default-привязку (`React.useEffect`); у входа `@opetope/*` default-экспорта нет, и до него дотягивается только
354
+ namespace. Только `const` держит тот хук, которым его открыли, поэтому `let` остаётся в стороне. Выбранное поле
355
+ `useModel` — хук только там, где код доказал это чтением `run` на нём: имя статуса не доказывает ничего, такое имя
356
+ столь же легко носят обычные данные. Поле, прочитанное как данные, остаётся полем неизвестного рода, и так же
357
+ остаётся `useModel(Declaration)` без выбора, который по-прежнему возвращает выданную модель. Массив зависимостей
358
+ вызова, который не является одним из шести хуков, принадлежит тому, кто его объявил.
359
+
249
360
  ### `no-subscribe-outside-models`
250
361
 
251
362
  Подписка, написанная руками, владеет уборкой, которой не видит ничто вокруг. Правило сообщает о `x.subscribe(...)`
@@ -255,6 +366,47 @@ Id начинается с буквы и соединяет сегменты и
255
366
  Передача функции без вызова подпиской здесь не является:
256
367
  `useSyncExternalStore(source.subscribe, source.getSnapshot)` передаёт ссылку, и читатель владеет тем, что начал.
257
368
 
369
+ Не является ею и подписка, написанная там, где объявленный узел открывает свою работу и сам её освобождает.
370
+ Disposer, который возвращает такой колбэк, принадлежит этому узлу и сливается вместе с поколением, которое его
371
+ открыло, — поэтому правило там молчит: это ровно та форма, которую предписывает его собственное сообщение (D299):
372
+
373
+ | Колбэк | Где | Ингресс |
374
+ | ---------------------------------------------- | --------------------------- | ------- |
375
+ | `stream(from, target, connect)` | третий аргумент | да |
376
+ | `event(from, subscribe, run)` | второй аргумент | да |
377
+ | `scope.while` / `scope.switch` / `scope.keyed` | `open`, второй аргумент | да |
378
+ | `attach(source, { open })` | член `open` | да |
379
+ | `attach(source, { open, close })` | член `close` | нет |
380
+ | `consume`, `run`, `load`, `when`, `key` | модификаторы тех же вызовов | нет |
381
+
382
+ ```ts
383
+ own: ({ imports, stream }) => ({
384
+ book: stream(
385
+ imports.exchange,
386
+ () => 'BTCUSD',
387
+ async ({ emit, signal }) => {
388
+ return imports.exchange.repository.subscribe('BTCUSD', emit, signal);
389
+ },
390
+ ),
391
+ });
392
+ ```
393
+
394
+ «Внутри» считается до самого низа: вложенная стрелка, тело `function`, `await` на самой подписке, disposer,
395
+ которому сначала дали имя. Контекст разрешается импортом, а не написанием: секция `own`, написанная прямо там, где
396
+ её принимает `defineFeature`/`defineFeature.body`, взятый **из самого `@opetope/runtime`**, и фабрика модели —
397
+ второй аргумент `model(Declaration, factory)` на том же `own` либо функция, первый параметр которой аннотирован
398
+ `ModelContext` из `@opetope/core`. В отличие от `no-command-in-deps`, эти два якоря принимают только имя пакета:
399
+ относительный импорт здесь позволил бы собственному `./my-own-context` хоста заглушить правило.
400
+ `own.stream(...)` и деструктурированный `stream(...)` читаются одинаково, алиас отвечает тем ключом, из которого его
401
+ достали (`{ stream: openStream }` — это по-прежнему `stream`), а `own.scope.while(...)` читается как
402
+ деструктурированный `scope.while(...)`.
403
+
404
+ Поэтому сообщение остаётся в четырёх формах, и каждая — граница того, что доказывает синтаксис: `stream`, который
405
+ правило не проследило ни до одного якоря (импортированный откуда-то ещё, неразрешимый или принадлежащий чужому
406
+ `defineFeature`); колбэк, написанный в другом месте и переданный сюда именем, — он не стоит лексически внутри;
407
+ секция `own`, вынесенная в собственную привязку (`const own = ({ stream }) => …; defineFeature(id, { own })`); и
408
+ `model(Decl, createOrderModel)`, где фабрика — именованная функция без аннотации `ModelContext`.
409
+
258
410
  ### `prefer-model-selection`
259
411
 
260
412
  Компонент, который берёт одну выданную модель и читает её поля по хуку на поле, повторяет одно и то же
@@ -329,16 +481,43 @@ Oxlint читает ESLint-плагины через `jsPlugins`: импорти
329
481
 
330
482
  ```json
331
483
  {
332
- "jsPlugins": [{ "name": "opetope", "specifier": "./node_modules/@opetope/lint/dist/index.js" }],
333
- "rules": { "opetope/when-predicate": "error" }
484
+ "extends": ["./node_modules/@opetope/lint/oxlintrc.json"],
485
+ "jsPlugins": [{ "name": "opetope", "specifier": "./node_modules/@opetope/lint/dist/index.js" }]
334
486
  }
335
487
  ```
336
488
 
489
+ Это две строки, и ни одна из них не транскрипция. `@opetope/lint/oxlintrc.json` сгенерирован из
490
+ `configs.recommended.rules` и поставляется в пакете, поэтому правило, которое пакет добавил, приходит вместе с
491
+ обновлением, а не теряется в написанной руками severity-карте. `extends` в `.oxlintrc.json` принимает **путь
492
+ файла**, разрешаемый относительно конфига, который его назвал, — не specifier пакета; поэтому фрагмент лежит в корне
493
+ пакета, и путь, который пишет хост, совпадает с именем его экспорта. Конфигурации сливаются от первой к последней,
494
+ поэтому правило, которое проект хочет строже, он пишет в собственных `rules` после `extends`: три `off` выше — ровно
495
+ те, которые проект включает по своим каталогам.
496
+
497
+ Фрагмент несёт правила и ничего больше. Oxlint действительно сливает через `extends` и запись `jsPlugins`, но хост,
498
+ который объявляет плагин сам — под пакетом-обёрткой, как того требует изолированная установка peer-ов, — объявит имя
499
+ `opetope` второй раз, и это отвергнет всю конфигурацию. Откуда берётся плагин — деплой хоста; severities — контракт
500
+ этого пакета.
501
+
337
502
  Алиас `name` фиксирует namespace, поэтому id правила одинаков в обоих линтерах. `jsPlugins` находится в alpha и вне
338
- semver: `npm run ci:test` запускает собранный плагин под Oxlint на валидной и невалидной фикстуре и проверяет
339
- фикс, который тот пишет, — поломка этого моста падает здесь, а не у хоста.
503
+ semver: `npm run ci:test` запускает собранный плагин под Oxlint на валидной и невалидной фикстуре — через конфиг,
504
+ который `extends`-ит поставляемый фрагмент, — и проверяет фикс, который тот пишет: поломка этого моста падает здесь,
505
+ а не у хоста.
506
+
507
+ `--fix-suggestions` применяет первую suggestion сообщения, ничего не спрашивая, поэтому suggestion здесь — только
508
+ такая правка, которую оправдывает сам прочитанный код. `no-command-in-deps` предлагает те пути, которые читает сам
509
+ колбэк: статус, за которым он следит, раньше `run`, который он вызывает. Там, где правило не видит использование
510
+ насквозь — объект передан дальше, разобран или прочитан за пределами известных ему членов, — оно сообщает и не
511
+ предлагает ничего, и зависимость остаётся написанной так, как её написали, пока её не изменит автор.
512
+
513
+ `no-snapshot-in-update` держит ту же линию с другой стороны: его suggestion — это тот самый фикс, который правило
514
+ не стало писать без спроса: параметр, затеняющий имя, которое в файле уже есть, или именованное чтение, которое
515
+ остаётся ради другого читателя. Обе правки оставляют код делать то же, что он делал. Там, где значение вообще не
516
+ может стать телом апдейтера, предлагать нечего, и сообщение стоит одно.
340
517
 
341
518
  ## Checks
342
519
 
343
520
  Из этого пакета: `npm run ci:test`, `npm run ci:type`, `npm run ci:eslint`, `npm run build`. Тест моста Oxlint
344
- читает `dist`, поэтому `npm run build` идёт первым.
521
+ читает `dist`, поэтому `npm run build` идёт первым. `ci:test` заодно проверяет, что закоммиченный `oxlintrc.json`
522
+ всё ещё равен `configs.recommended.rules`; после изменения этого конфига выполните `npm run oxlintrc:generate` и
523
+ закоммитьте результат — именно поэтому файл не пишет сборка.
@@ -0,0 +1,34 @@
1
+ import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
2
+ /** What a name holds: one hook, a record of them, a selection whose fields may be hooks, or one such field. */
3
+ type HookKind = 'candidate' | 'hook' | 'hooks' | 'selection';
4
+ type HookKinds = Map<TSESLint.Scope.Variable, HookKind>;
5
+ /**
6
+ * What a dependency element names: one hook (no key, not a record), one key of a record, or the record itself,
7
+ * which is a fresh object on every render.
8
+ */
9
+ type Binding = {
10
+ readonly key: string | undefined;
11
+ readonly proved: boolean;
12
+ readonly record: boolean;
13
+ readonly variable: TSESLint.Scope.Variable;
14
+ };
15
+ /** One read of the binding: the member it ends in, and the text the author wrote to reach it. */
16
+ type Access = {
17
+ readonly member: string | undefined;
18
+ readonly optional: boolean;
19
+ readonly text: string;
20
+ };
21
+ /** `opaque` is a use no member replaces: the object passed on, destructured, or read past a member we know. */
22
+ type Usage = {
23
+ readonly accesses: readonly Access[];
24
+ readonly opaque: boolean;
25
+ };
26
+ declare const STATUS_MEMBERS: string[];
27
+ /** Every read of the binding inside one callback, and whether any of them takes the object as a whole. */
28
+ declare function usageIn(source: TSESLint.SourceCode, binding: Binding, callback: TSESTree.Node | undefined): Usage;
29
+ /** The binding a dependency element names as a whole; a member of one is a member, and this returns nothing. */
30
+ declare function bindingAt(source: TSESLint.SourceCode, kinds: HookKinds, element: TSESTree.Node): Binding | undefined;
31
+ /** Records what one declaration binds, so a later dependency array is read against names and not against spelling. */
32
+ declare function classifyDeclaration(source: TSESLint.SourceCode, declarator: TSESTree.VariableDeclarator, kinds: HookKinds): void;
33
+ export type { Access, Binding, HookKinds, Usage };
34
+ export { bindingAt, classifyDeclaration, STATUS_MEMBERS, usageIn };