@opetope/lint 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +460 -4
- package/README.md +312 -61
- package/README.ru.md +251 -61
- package/dist/ast.d.ts +3 -1
- package/dist/ast.js +1 -1
- package/dist/ast.js.map +1 -1
- package/dist/command-hooks.d.ts +2 -2
- package/dist/command-hooks.js +1 -1
- package/dist/command-hooks.js.map +1 -1
- package/dist/context-members.d.ts +22 -0
- package/dist/context-members.js +2 -0
- package/dist/context-members.js.map +1 -0
- package/dist/declaration-ingress.d.ts +10 -2
- package/dist/declaration-ingress.js +1 -1
- package/dist/declaration-ingress.js.map +1 -1
- package/dist/effect-declarations.d.ts +20 -0
- package/dist/effect-declarations.js +2 -0
- package/dist/effect-declarations.js.map +1 -0
- package/dist/index.d.ts +11 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/retired-vocabulary.d.ts +31 -0
- package/dist/retired-vocabulary.js +2 -0
- package/dist/retired-vocabulary.js.map +1 -0
- package/dist/rules/capture-command-cleanup.js +1 -1
- package/dist/rules/capture-command-cleanup.js.map +1 -1
- package/dist/rules/define-feature-property-order.js +1 -1
- package/dist/rules/define-feature-property-order.js.map +1 -1
- package/dist/rules/enabled-predicate.d.ts +6 -0
- package/dist/rules/enabled-predicate.js +2 -0
- package/dist/rules/enabled-predicate.js.map +1 -0
- package/dist/rules/no-command-in-deps.js +1 -1
- package/dist/rules/no-command-in-deps.js.map +1 -1
- package/dist/rules/no-internal-imports.js +1 -1
- package/dist/rules/no-internal-imports.js.map +1 -1
- package/dist/rules/no-retired-vocabulary.d.ts +5 -0
- package/dist/rules/no-retired-vocabulary.js +2 -0
- package/dist/rules/no-retired-vocabulary.js.map +1 -0
- package/dist/rules/no-write-after-source-write.d.ts +6 -0
- package/dist/rules/no-write-after-source-write.js +2 -0
- package/dist/rules/no-write-after-source-write.js.map +1 -0
- package/dist/rules/prefer-effect-current.js +1 -1
- package/dist/rules/prefer-effect-current.js.map +1 -1
- package/oxlintrc.json +3 -1
- package/package.json +1 -1
- package/dist/rules/when-predicate.d.ts +0 -6
- package/dist/rules/when-predicate.js +0 -2
- package/dist/rules/when-predicate.js.map +0 -1
package/README.md
CHANGED
|
@@ -7,6 +7,47 @@ checker, the runtime and dead-code analysis already answer stays with them (see
|
|
|
7
7
|
The package depends on no other `@opetope/*` package and reads no types, so a host can lint sources it has not
|
|
8
8
|
built yet.
|
|
9
9
|
|
|
10
|
+
<!--examples
|
|
11
|
+
import { derive } from '@opetope/core';
|
|
12
|
+
import type {
|
|
13
|
+
Command,
|
|
14
|
+
ModelCommandContext,
|
|
15
|
+
ModelContext,
|
|
16
|
+
ModelStateWriter,
|
|
17
|
+
OwnedState,
|
|
18
|
+
Readable,
|
|
19
|
+
ResourceData,
|
|
20
|
+
} from '@opetope/core';
|
|
21
|
+
import { defineModel } from '@opetope/core';
|
|
22
|
+
import { defineSlot, requiresModels, useCommand, useModel } from '@opetope/react';
|
|
23
|
+
import { useEffect } from 'react';
|
|
24
|
+
|
|
25
|
+
type Quote = Readonly<{ bid: number; pair: string }>;
|
|
26
|
+
type Payload = Readonly<{ amount: number }>;
|
|
27
|
+
/**
|
|
28
|
+
* `kind` is a plain string on purpose: an updater that spreads the previous value and writes a string literal into a
|
|
29
|
+
* field typed as a union of literals widens that literal and stops compiling, which is a defect of the writer's own
|
|
30
|
+
* signature rather than of this example (D419).
|
|
31
|
+
*/
|
|
32
|
+
type Sessions = Readonly<{ items: readonly string[]; kind: string }>;
|
|
33
|
+
|
|
34
|
+
declare const amount: number;
|
|
35
|
+
declare const busy: OwnedState<boolean>;
|
|
36
|
+
declare const context: ModelContext;
|
|
37
|
+
declare const ctx: ModelContext;
|
|
38
|
+
declare const frames: Readable<string>;
|
|
39
|
+
declare const host: Readonly<{ send(input: Payload, signal: AbortSignal): Promise<void> }>;
|
|
40
|
+
declare const label: Readable<string>;
|
|
41
|
+
declare const sessions: OwnedState<Sessions>;
|
|
42
|
+
declare const total: OwnedState<number>;
|
|
43
|
+
declare const update: ModelStateWriter['update'];
|
|
44
|
+
|
|
45
|
+
const FormActions = defineModel<{ readonly submit: Command<void, void> }>('example.form.actions');
|
|
46
|
+
const FormSlot = defineSlot('example.form.slot');
|
|
47
|
+
declare const createActions: (context: ModelContext) => import('@opetope/core').ModelOf<typeof FormActions>;
|
|
48
|
+
declare const Screen: Readonly<{ load: Command<void, void>; unload: Command<void, void> }>;
|
|
49
|
+
-->
|
|
50
|
+
|
|
10
51
|
## Installation
|
|
11
52
|
|
|
12
53
|
```sh
|
|
@@ -35,18 +76,19 @@ export default [
|
|
|
35
76
|
```
|
|
36
77
|
|
|
37
78
|
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/
|
|
79
|
+
namespace `opetope`, so a rule is named `opetope/enabled-predicate`. Parsing requires `@typescript-eslint/parser`;
|
|
39
80
|
both it and `eslint` are peer dependencies.
|
|
40
81
|
|
|
41
82
|
## Configs
|
|
42
83
|
|
|
43
84
|
### `recommended`
|
|
44
85
|
|
|
45
|
-
Every rule that holds wherever Opetope is written: `
|
|
86
|
+
Every rule that holds wherever Opetope is written: `enabled-predicate`, `define-feature-property-order`,
|
|
46
87
|
`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`, `
|
|
48
|
-
|
|
49
|
-
|
|
88
|
+
`no-redundant-const-tuple`, `no-command-in-deps`, `prefer-effect-current`, `no-write-after-source-write`,
|
|
89
|
+
`capture-command-cleanup`, `require-declared-models` and `no-retired-vocabulary` as errors. A rule that needs to know where a host keeps its
|
|
90
|
+
files is off here and arrives through `layers`; `prefer-model-selection` is off because the number of hooks a
|
|
91
|
+
component may keep is a taste a project settles for itself.
|
|
50
92
|
|
|
51
93
|
`configs.recommended.rules` in full, which is also what `@opetope/lint/oxlintrc.json` ships:
|
|
52
94
|
|
|
@@ -59,14 +101,16 @@ project settles for itself.
|
|
|
59
101
|
| `no-command-in-deps` | `error` | |
|
|
60
102
|
| `no-internal-imports` | `error` | also scoped by `internalImports({ allow })` |
|
|
61
103
|
| `no-redundant-const-tuple` | `error` | |
|
|
104
|
+
| `no-retired-vocabulary` | `error` | |
|
|
62
105
|
| `no-snapshot-in-update` | `error` | |
|
|
63
106
|
| `no-snapshot-read-in-render` | `error` | |
|
|
64
107
|
| `no-subscribe-outside-models` | `off` | `layers({ models })` |
|
|
108
|
+
| `no-write-after-source-write` | `error` | |
|
|
65
109
|
| `prefer-effect-current` | `error` | |
|
|
66
110
|
| `prefer-model-selection` | `off` | the project, with its own `threshold` |
|
|
67
111
|
| `require-declared-models` | `error` | |
|
|
68
112
|
| `require-literal-id` | `error` | |
|
|
69
|
-
| `
|
|
113
|
+
| `enabled-predicate` | `error` | |
|
|
70
114
|
|
|
71
115
|
The three `off` rules are the three that need something only the project knows: which directories are which layer,
|
|
72
116
|
and how many hooks one component may keep.
|
|
@@ -115,7 +159,7 @@ enable this rule or use the helper instead of merging an Opetope pattern into th
|
|
|
115
159
|
Application code and tests import public Opetope entries. The rule rejects `@opetope/*/internal` and its deeper
|
|
116
160
|
paths in imports, re-exports, literal `import()` calls, unshadowed `require()` calls, TypeScript import types and
|
|
117
161
|
`import = require`. It includes type-only imports; an internal type is still coupled to the private ABI.
|
|
118
|
-
Use `
|
|
162
|
+
Use `runCommand` from `@opetope/core/testing` to run a real model Command, `openModel` from `@opetope/runtime/testing` to open one without a feature, and `command` from `@opetope/react/testing` to construct a fixture Command.
|
|
119
163
|
Tests do not receive an automatic exception. Trusted library implementation or host integration can use an explicit
|
|
120
164
|
file override; the rule has no autofix because the correct public replacement depends on the imported operation.
|
|
121
165
|
|
|
@@ -123,17 +167,17 @@ file override; the rule has no autofix because the correct public replacement de
|
|
|
123
167
|
not resolve aliases, computed import paths, re-export graphs or custom loaders. A locally shadowed `require` is not
|
|
124
168
|
treated as Node's loader. Apply the config to every JavaScript/TypeScript source and test glob the project uses.
|
|
125
169
|
|
|
126
|
-
### `
|
|
170
|
+
### `enabled-predicate`
|
|
127
171
|
|
|
128
|
-
A contribution's `
|
|
129
|
-
rule reads the `
|
|
130
|
-
evaluation context `{ exports, imports, own, read }`, and reports three shapes:
|
|
172
|
+
A contribution's `enabled` answers the visibility fact of this instance, not the source that carries it (D220). The
|
|
173
|
+
rule reads the `enabled` of a `slot`, `pipe` or `register` contribution, and any `enabled` whose function
|
|
174
|
+
destructures the evaluation context `{ exports, imports, own, read }`, and reports three shapes:
|
|
131
175
|
|
|
132
|
-
| Written
|
|
133
|
-
|
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
136
|
-
| `
|
|
176
|
+
| Written | Reported |
|
|
177
|
+
| --------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
178
|
+
| `enabled: ({ imports }) => imports.x.allowed` | returns a source; fixed to `({ imports, read }) => read(imports.x.allowed)` |
|
|
179
|
+
| `enabled: async ({ read }) => read(x)` | a predicate answers synchronously; no fix |
|
|
180
|
+
| `enabled: () => allowed` | a `Readable<boolean>` is passed as `enabled` directly, without a wrapper; no fix |
|
|
137
181
|
|
|
138
182
|
The fix wraps the returned member expression in `read(...)` and adds `read` to the destructured context when it is
|
|
139
183
|
absent. A named context is read through itself: `context => context.imports.x.allowed` becomes
|
|
@@ -142,8 +186,9 @@ absent. A named context is read through itself: `context => context.imports.x.al
|
|
|
142
186
|
**The boundary.** The rule reads shapes, not types. `({ read }) => read(counter)` over a non-boolean source stays a
|
|
143
187
|
type error, and so does a `read` of something that is not a `Readable`. Where a member access of the evaluation
|
|
144
188
|
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 `
|
|
146
|
-
|
|
189
|
+
one written for a source; hoist that decision out of `enabled`, or silence the line. A `when` is a different field
|
|
190
|
+
and is left alone wherever it stands — the source value of `scope.while` — and the change filter of an effect is not
|
|
191
|
+
a `when` at all: it is `filter`, and D379 gave it that name so one word would not answer two questions.
|
|
147
192
|
|
|
148
193
|
### `define-feature-property-order`
|
|
149
194
|
|
|
@@ -188,11 +233,15 @@ available to the contribution subtree without this declaration (D158, D250).
|
|
|
188
233
|
|
|
189
234
|
```tsx
|
|
190
235
|
import { defineFeature } from '@opetope/runtime';
|
|
191
|
-
import { requiresModels, useModel } from '@opetope/react';
|
|
236
|
+
import { requiresModels, useCommand, useModel } from '@opetope/react';
|
|
192
237
|
|
|
193
238
|
const Form = () => {
|
|
194
|
-
const
|
|
195
|
-
return
|
|
239
|
+
const submit = useCommand(useModel(FormActions).submit);
|
|
240
|
+
return (
|
|
241
|
+
<button disabled={submit.inFlight} onClick={() => void submit.run()}>
|
|
242
|
+
Submit
|
|
243
|
+
</button>
|
|
244
|
+
);
|
|
196
245
|
};
|
|
197
246
|
const DeclaredForm = requiresModels([FormActions])(Form);
|
|
198
247
|
|
|
@@ -358,13 +407,13 @@ useEffect(() => {
|
|
|
358
407
|
|
|
359
408
|
The rule reads the dependency array of `useEffect`, `useLayoutEffect`, `useInsertionEffect`, `useCallback`,
|
|
360
409
|
`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 `
|
|
362
|
-
|
|
363
|
-
|
|
410
|
+
variable of a `useCommand`, and a key or a destructured name of a `useModel` selection the same code reads `run` on.
|
|
411
|
+
`x.run` and the statuses `x.inFlight` and `x.outcome` are members of that object rather than the object, and they
|
|
412
|
+
stay.
|
|
364
413
|
|
|
365
|
-
The record itself is the worse shape and carries its own report: `
|
|
366
|
-
|
|
367
|
-
|
|
414
|
+
The record itself is the worse shape and carries its own report: `useModel` with a selection builds a new object on
|
|
415
|
+
every render, so `[hooks]` and `[order]` change on every render rather than on every status. The answer is the field
|
|
416
|
+
the callback reads — `hooks.save.run`.
|
|
368
417
|
|
|
369
418
|
The fix is written where the answer is one: a callback that reaches the binding through a single `run` gets that
|
|
370
419
|
path in place of the element, whatever the element was written past — an `as`, a `satisfies` or a `!` is replaced
|
|
@@ -374,8 +423,8 @@ because which of them the dependency means is the author's to say. A callback th
|
|
|
374
423
|
written elsewhere, one that keeps the object itself, takes it apart, or reads a member outside those four — gets the
|
|
375
424
|
report and no suggestion at all.
|
|
376
425
|
|
|
377
|
-
**The boundary.** Names are resolved by import within one module, not by spelling: `useCommand
|
|
378
|
-
|
|
426
|
+
**The boundary.** Names are resolved by import within one module, not by spelling: `useCommand` and `useModel` are
|
|
427
|
+
the imports of `@opetope/react`, the six hooks are React's own, and a local alias of either —
|
|
379
428
|
`import { useEffect as effect }` — is the same import. React is the module named `react` exactly; an `@opetope/*`
|
|
380
429
|
name is also taken from a relative import, which is how a package re-exports its own entry through a barrel, so a
|
|
381
430
|
neighbouring `./scheduling` is React's no more than `@host/scheduling` is. React's hooks are read through a
|
|
@@ -400,18 +449,33 @@ Neither is a subscription written where a declared node opens its work and owns
|
|
|
400
449
|
such a callback returns belongs to that node and is drained with the generation that opened it, so the rule stays
|
|
401
450
|
silent there — this is the shape its own message asks for (D299):
|
|
402
451
|
|
|
403
|
-
| Callback
|
|
404
|
-
|
|
|
405
|
-
| `resource.live({ connect })`
|
|
406
|
-
| `event(
|
|
407
|
-
| `scope.while` / `scope.switch`
|
|
408
|
-
| `
|
|
409
|
-
| `attach(source, { open
|
|
410
|
-
| `
|
|
452
|
+
| Callback | Where | Ingress |
|
|
453
|
+
| --------------------------------------- | --------------------------- | ------- |
|
|
454
|
+
| `resource.live({ connect })` | the `connect` member | yes |
|
|
455
|
+
| `event(source, subscribe, run)` | second argument | yes |
|
|
456
|
+
| `scope.while` / `scope.switch` | `open`, second argument | yes |
|
|
457
|
+
| `scope.each(source, key, open)` | `open`, third argument | yes |
|
|
458
|
+
| `attach(source, { open })` | the `open` member | yes |
|
|
459
|
+
| `attach(source, { open, close })` | the `close` member | no |
|
|
460
|
+
| `apply`, `load`, `run`, `filter`, `key` | modifiers of the same calls | no |
|
|
411
461
|
|
|
412
462
|
`resource.load` opens no subscription and has no ingress at all, so a `subscribe` written in its `load` is reported
|
|
413
463
|
like any other hand-written one (D349).
|
|
414
464
|
|
|
465
|
+
<!--example
|
|
466
|
+
import { defineFeature, defineHostContract } from '@opetope/runtime';
|
|
467
|
+
|
|
468
|
+
type Exchange = Readonly<{
|
|
469
|
+
repository: Readonly<{ subscribe(pair: string, emit: (quote: Quote) => boolean, signal: AbortSignal): () => void }>;
|
|
470
|
+
}>;
|
|
471
|
+
|
|
472
|
+
const exchangeContract = defineHostContract<Exchange>('example.exchange.platform');
|
|
473
|
+
const bookFeature = defineFeature('example.book', {
|
|
474
|
+
imports: { exchange: exchangeContract },
|
|
475
|
+
/* … */
|
|
476
|
+
});
|
|
477
|
+
-->
|
|
478
|
+
|
|
415
479
|
```ts
|
|
416
480
|
own: ({ imports, resource }) => ({
|
|
417
481
|
book: resource.live({
|
|
@@ -420,7 +484,7 @@ own: ({ imports, resource }) => ({
|
|
|
420
484
|
lifetime: 'owner',
|
|
421
485
|
source: imports.exchange,
|
|
422
486
|
}),
|
|
423
|
-
})
|
|
487
|
+
}),
|
|
424
488
|
```
|
|
425
489
|
|
|
426
490
|
Inside counts all the way down — a nested arrow, a `function` body, an `await` on the subscription itself, a
|
|
@@ -441,32 +505,139 @@ is a named function without the `ModelContext` annotation.
|
|
|
441
505
|
|
|
442
506
|
### `prefer-effect-current`
|
|
443
507
|
|
|
444
|
-
The `run` of an effect is already given the value it was started for
|
|
445
|
-
`source.getSnapshot()` inside that run reads it a second time and says nothing about which
|
|
446
|
-
rewrites it to
|
|
508
|
+
The `run` of an effect is already given the value it was started for — as its first parameter, since D379. Reading
|
|
509
|
+
the same source through `source.getSnapshot()` inside that run reads it a second time and says nothing about which
|
|
510
|
+
run this is, so the rule rewrites it to that parameter (D296, D323, D379):
|
|
447
511
|
|
|
448
512
|
```js
|
|
449
513
|
// before
|
|
450
|
-
ctx.effect(quotes,
|
|
514
|
+
ctx.effect(quotes, current => publish(quotes.getSnapshot()));
|
|
451
515
|
// after
|
|
452
|
-
ctx.effect(quotes,
|
|
516
|
+
ctx.effect(quotes, current => publish(current));
|
|
453
517
|
```
|
|
454
518
|
|
|
455
|
-
The fix writes the word the run already has: the name
|
|
456
|
-
|
|
457
|
-
|
|
519
|
+
The fix writes the word the run already has: the name the author bound the first parameter under, whatever it is. A
|
|
520
|
+
run that destructured the value itself, or took no parameter at all, has no word for the whole of it, so the report
|
|
521
|
+
stands alone.
|
|
458
522
|
|
|
459
|
-
**The two cases it reports without rewriting.** Past the first `await` of the run,
|
|
460
|
-
started with and the snapshot is the value of the moment; a change of the source normally restarts the run, so
|
|
461
|
-
two normally agree — and «normally» is not a licence to rewrite. Under `
|
|
462
|
-
admits some values and skips others, so the source moves without restarting the run (D168, D296). A
|
|
463
|
-
this rule cannot read — one assembled elsewhere — is treated as if it carried the predicate.
|
|
523
|
+
**The two cases it reports without rewriting.** Past the first `await` of the run, the parameter is the value this
|
|
524
|
+
run started with and the snapshot is the value of the moment; a change of the source normally restarts the run, so
|
|
525
|
+
the two normally agree — and «normally» is not a licence to rewrite. Under `filter` they legitimately differ: the
|
|
526
|
+
predicate admits some values and skips others, so the source moves without restarting the run (D168, D296, D379). A
|
|
527
|
+
modifier record this rule cannot read — one assembled elsewhere — is treated as if it carried the predicate.
|
|
464
528
|
|
|
465
529
|
A read inside a nested callback of the run — a `timers.delay`, a subscription handler — is left alone: that code
|
|
466
530
|
runs later than the run does, and the value of that moment is the one it wants. Like every rule that rewrites code,
|
|
467
531
|
this one resolves its context exactly: the `effect` it acts on is the one declared on a `ModelContext` parameter or
|
|
468
532
|
on the context of a `model(Declaration, factory)` written inside a `defineFeature` of `@opetope/runtime` (D309).
|
|
469
533
|
|
|
534
|
+
### `no-write-after-source-write`
|
|
535
|
+
|
|
536
|
+
A write of an effect run into a cell its own source reads changes the value that source publishes, and the
|
|
537
|
+
notification is synchronous: the run is aborted inside that write, so every write of the run after it is dropped and
|
|
538
|
+
an `invoke` after it is refused. The first of those dropped writes reaches the reporter as `LostWrite`, once per run
|
|
539
|
+
and saying the rest went with it (D432); the refused `invoke` is still silent, and so is every other way a write is
|
|
540
|
+
lost. The rule reports the write that moves the source when another write of the same run can follow it
|
|
541
|
+
(spec §2.11, D288, D365, D432, D447):
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
// reported: the deposit is never written down, because releasing the key ended the run
|
|
545
|
+
const key = context.state<string | null>('idempotency-1');
|
|
546
|
+
const deposit = context.state<string | null>(null);
|
|
547
|
+
const source = derive([frames, key], (frame, held) => `${frame}:${String(held)}`);
|
|
548
|
+
|
|
549
|
+
context.effect(source, (_current, { update }) => {
|
|
550
|
+
update(key, null);
|
|
551
|
+
update(deposit, 'deposit-1');
|
|
552
|
+
});
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
A command the run `invoke`s writes under the same authority. `invoke` hands the command the cancellation of the run,
|
|
556
|
+
so one abort ends them both, and a write of that command into a cell the run's source reads ends the run exactly as
|
|
557
|
+
the run's own write does. A write the run reaches after that is dropped and recorded as `LostWrite` like any other
|
|
558
|
+
(D447) — but the run is usually _awaiting_ that call when the abort lands, and then the promise it awaits is
|
|
559
|
+
cancelled and the body reaches no write at all. Nothing is dropped there for the runtime to record, so this rule is
|
|
560
|
+
the only finder of that shape, and it reports the call where it is written:
|
|
561
|
+
|
|
562
|
+
```ts
|
|
563
|
+
// reported: the write that releases the key is the command's, and the deposit below the call is never reached
|
|
564
|
+
const key = context.state<string | null>('idempotency-1');
|
|
565
|
+
const deposit = context.state<string | null>(null);
|
|
566
|
+
const source = derive([frames, key], (frame, held) => `${frame}:${String(held)}`);
|
|
567
|
+
const release = context.command(({ update }: ModelCommandContext<void>) => {
|
|
568
|
+
update(key, null);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
context.effect(source, async (_current, { invoke, update }) => {
|
|
572
|
+
await invoke(release);
|
|
573
|
+
update(deposit, 'deposit-1');
|
|
574
|
+
});
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
Whether the drop shows in the data is a second question, and it is why such a defect used to be found in production
|
|
578
|
+
rather than in a test: the loop opens a successor run for the new value where `filter` admits it, and a successor
|
|
579
|
+
that repeats the write hides the loss — the cell ends up correct through the second run. The loss stands whole where
|
|
580
|
+
the successor does not repeat that write, which is what a guard does (`if (held === null) return`), and where no
|
|
581
|
+
successor opens at all, which is what a `filter` that rejects the new value does (D329). The record arrives in every
|
|
582
|
+
one of those shapes, the hidden one included: it speaks about the write that was dropped, not about the value the
|
|
583
|
+
cell ended up with (D432).
|
|
584
|
+
|
|
585
|
+
There is no fix: which of the two writes the author meant to keep is the author's to say, and the message names three
|
|
586
|
+
ways out — make the write into the source the run's last write, or keep what the run accumulates out of its source and
|
|
587
|
+
read it with `getSnapshot()`, which also saves the second run, or take `execution.capture(state)` before the moving
|
|
588
|
+
write, which is the one that saves this write whole although it saves nothing written after it. It names them in the
|
|
589
|
+
words the `LostWrite` record prints, and `ci:source` holds the two texts equal, because the author reads the rule
|
|
590
|
+
before any run reports (D319, D432, D442, D447).
|
|
591
|
+
|
|
592
|
+
**What it proves before it reports.** Three things, and a fourth where the moving write is a command's; it says
|
|
593
|
+
nothing where it cannot prove one of them.
|
|
594
|
+
|
|
595
|
+
_The declaration._ The `effect` is declared on a context of this library — the `effect` of a `ModelContext`
|
|
596
|
+
parameter, or of the context of `model(Declaration, factory)` written inside a `defineFeature` of `@opetope/runtime`
|
|
597
|
+
(D309). The run is the function the declaration takes, written there or bound to a name in this module.
|
|
598
|
+
|
|
599
|
+
_The cell is part of the source._ Either the source is that cell, or it is a `derive` of `@opetope/core` — written
|
|
600
|
+
where the declaration takes it, or held by a name in this scope — whose dependencies name the cell, and whose
|
|
601
|
+
selector publishes the value of that dependency. That last clause is the carve-out of the law itself: a write that
|
|
602
|
+
leaves the published value equal ends nothing, so a selector that only asks a question about its dependency —
|
|
603
|
+
`held === null ? 'none' : 'some'`, `!held`, `typeof held`, `held ? a : b` — publishes the same word for one key as
|
|
604
|
+
for another, and the rule stays silent over it. So does a selector that drops the dependency, and a selector this
|
|
605
|
+
file cannot read.
|
|
606
|
+
|
|
607
|
+
_The write can run after it._ The writes are the writer of this run, read under the same names as
|
|
608
|
+
`capture-command-cleanup` reads a command's: `execution.update(…)`, `({ update })` or `({ update: write })` in the
|
|
609
|
+
parameter list, and `const { update } = execution` in the body. The later write has to stand in the same block, in a
|
|
610
|
+
later statement, with no unconditional exit of the run between the two: `if (held === null) { update(key, null);
|
|
611
|
+
return; }` has a write below the `if` that cannot run after this one, and it is not reported. A `break` or a
|
|
612
|
+
`continue` is not such an exit, because the code below it runs.
|
|
613
|
+
|
|
614
|
+
_The command makes that write._ Where the moving write is a call rather than a write, the call is an `invoke` of the
|
|
615
|
+
run, read under the same names as the writer, and its first argument is a name this module holds a
|
|
616
|
+
`context.command(body)` in — declared on the context of a model, because the `command` of a feature's `own` takes its
|
|
617
|
+
body second and is handed no writer (D385, D386). The body is the function that declaration takes, written there or
|
|
618
|
+
bound to a name in this module, and at least one write through the context of that body names a cell of this source.
|
|
619
|
+
A later write of the run has to follow the call by the same rule as it follows a write.
|
|
620
|
+
|
|
621
|
+
**The boundary.** Silence is not proof that a write lands. Beyond the shapes above, the rule does not read: a
|
|
622
|
+
source assembled in another function, a source read off an object (`deps.sources`, the shape of the latch recipe), a
|
|
623
|
+
cell that arrived as a parameter, a `derive` over a `derive`, and a writer held by a closure. It reads only `update`
|
|
624
|
+
as the moving write, so a commit into the source — the same law, `update` or commit alike — is not reported; neither
|
|
625
|
+
is the third form of that loss, a `capture` taken _after_ the moving write, which throws the run's own cancellation
|
|
626
|
+
and is reported nowhere. Two writes of one statement are not a sequence — the branches of an `if`, the arms of a
|
|
627
|
+
ternary, the halves of a `try`, the sides of a comma — and neither are two writes of one `switch` case. A write in a
|
|
628
|
+
nested callback of the run is left alone, because the run is already over when a timer or a disposer runs and its
|
|
629
|
+
dropped write has its own reason. A loop whose write into the source goes last loses the first write of the next turn,
|
|
630
|
+
and the rule misses that one.
|
|
631
|
+
|
|
632
|
+
The call has a boundary of its own, and it is the boundary of one step. A command that arrived as a dependency
|
|
633
|
+
(`invoke(deps.release)`), one whose declaration is in another module, one declared on a context this file cannot
|
|
634
|
+
resolve, and one whose body is assembled elsewhere name nothing this rule can read. It reads one step only: a command
|
|
635
|
+
that moves the source through a second command it invokes, or through a `capture` of a cell of the source, is not
|
|
636
|
+
reported, and neither is a command whose body writes the source through a writer it took from somewhere other than
|
|
637
|
+
its own context. And the `invoke` of a run whose loss is the call itself — a run with no write after that call —
|
|
638
|
+
is not reported either, because nothing there is lost: the `await` does not return, and the run had nothing left to
|
|
639
|
+
do (D447).
|
|
640
|
+
|
|
470
641
|
### `capture-command-cleanup`
|
|
471
642
|
|
|
472
643
|
The writer of a command lives as long as its caller (D288). A write in the `finally` or the `catch` of a `try` that
|
|
@@ -476,7 +647,7 @@ and names the word that does land, a commit taken before the `await` (D319, D330
|
|
|
476
647
|
|
|
477
648
|
```ts
|
|
478
649
|
// reported: once the caller is cancelled, `busy` stays `true`
|
|
479
|
-
ctx.
|
|
650
|
+
ctx.command(async ({ input, signal, update }: ModelCommandContext<Payload>) => {
|
|
480
651
|
update(busy, true);
|
|
481
652
|
try {
|
|
482
653
|
await host.send(input, signal);
|
|
@@ -486,7 +657,7 @@ ctx.call(async (input, { signal, update }) => {
|
|
|
486
657
|
});
|
|
487
658
|
|
|
488
659
|
// the cleanup lands while the model lives
|
|
489
|
-
ctx.
|
|
660
|
+
ctx.command(async ({ capture, input, signal, update }: ModelCommandContext<Payload>) => {
|
|
490
661
|
update(busy, true);
|
|
491
662
|
const done = capture(busy);
|
|
492
663
|
try {
|
|
@@ -502,14 +673,15 @@ for a reason — a late write of a cancelled call must not overwrite what a newe
|
|
|
502
673
|
both ways out. A cleanup that must run for a cancelled call goes through `capture(state)` taken before the `await`; an
|
|
503
674
|
answer that must not land after cancellation — a failure, a result — belongs in a `catch` after
|
|
504
675
|
`rethrowIfCancelled(cause)`. Where a caller cancels one run to start the next — a search its consumer issues again, a
|
|
505
|
-
|
|
676
|
+
Command an effect run invokes and a newer value cancels — the older run's write is exactly the one that should not land,
|
|
506
677
|
and a disable comment says so.
|
|
507
678
|
|
|
508
|
-
Under `
|
|
509
|
-
captured cleanup would clear a flag another call still holds — a flag shared by parallel calls is racy either
|
|
510
|
-
and the report says to keep that state per call, or to leave progress to the consumer's `inFlight`. The rule
|
|
511
|
-
|
|
512
|
-
as the default queue, where the lane waits for the physical body and a captured cleanup lands in
|
|
679
|
+
Under `concurrency: 'parallel'` the report does not advise a commit. Runs of such a command overlap, so a cancelled
|
|
680
|
+
call's captured cleanup would clear a flag another call still holds — a flag shared by parallel calls is racy either
|
|
681
|
+
way — and the report says to keep that state per call, or to leave progress to the consumer's `inFlight`. The rule
|
|
682
|
+
sees the order only when `{ concurrency: 'parallel' }` is written on the `command` itself; a modifier record assembled
|
|
683
|
+
elsewhere reads as the default queue, where the lane waits for the physical body and a captured cleanup lands in
|
|
684
|
+
order (D387).
|
|
513
685
|
|
|
514
686
|
The rule is a heuristic over syntax, and it proves nothing about a write it does not report. It reads the writer of a
|
|
515
687
|
command body under these names: `execution.update(…)`; `({ update })` or `({ update: write })` in the parameter list;
|
|
@@ -526,9 +698,9 @@ is the author's statement that what follows must not run for a cancelled call, a
|
|
|
526
698
|
and nothing is reported. A flag that must clear on cancellation is therefore written in `finally` through
|
|
527
699
|
`capture(state)`. A guard in a `finally` saves nothing and is still reported, and so is a check of `signal.aborted`
|
|
528
700
|
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 `
|
|
701
|
+
cancelled their run, and dropping its write is the point (D288). The context is resolved exactly: the `command` of a
|
|
530
702
|
`ModelContext` parameter or of the context of `model(Declaration, factory)` inside a `defineFeature` of
|
|
531
|
-
`@opetope/runtime` (D309); a feature's own `
|
|
703
|
+
`@opetope/runtime` (D309); a feature's own `command` hands its body no writer.
|
|
532
704
|
|
|
533
705
|
### `prefer-model-selection`
|
|
534
706
|
|
|
@@ -544,6 +716,85 @@ without a selection, and it counts each granted model separately: two models in
|
|
|
544
716
|
the same variable name in two components is two counts. There is no fix — the shape of the selection is the
|
|
545
717
|
author's to write.
|
|
546
718
|
|
|
719
|
+
### `no-retired-vocabulary`
|
|
720
|
+
|
|
721
|
+
The words the D365–D411 wave retired, each named together with the word that replaced it. The rule is written for
|
|
722
|
+
one migration pass: this release ships no alias for any of them, so a source that still writes one has not been
|
|
723
|
+
migrated, and once it has been the rule never fires again.
|
|
724
|
+
|
|
725
|
+
| Retired | Is now | Decision |
|
|
726
|
+
| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------- |
|
|
727
|
+
| `invalidate()` of a Resource | `reset()` | D373 |
|
|
728
|
+
| `backpressure` of an event | `delivery` | D374 |
|
|
729
|
+
| `kind` inside a delivery record | `pending` | D374 |
|
|
730
|
+
| `key` inside a delivery record | `by` | D374 |
|
|
731
|
+
| `activity` of a `loading` state | `failure` | D375 |
|
|
732
|
+
| `scope.keyed(source, open, { key })` | `scope.each(source, key, open)` | D378 |
|
|
733
|
+
| `skipInitial` | `initial`, with the polarity inverted | D379 |
|
|
734
|
+
| `when` of an effect | `filter` **or** `initial` | D379 |
|
|
735
|
+
| `onDispose` | `finalize` | D379 |
|
|
736
|
+
| `externalReadable` | `fromExternal` | D380 |
|
|
737
|
+
| `Call`, `ctx.call`, `runCall` | `Command`, `ctx.command`, `runCommand` | D385 |
|
|
738
|
+
| `policy`, `queueBy`, `lane` | `concurrency` | D387 |
|
|
739
|
+
| `ctx.calls`, `own.calls`, `useCommands` | `ctx.select`, `own.select`, `useModel(Declaration, select)` | D388 |
|
|
740
|
+
| `once` | `memoize` | D389 |
|
|
741
|
+
| `from` of `defineCondition` | `source` | D411 |
|
|
742
|
+
| `when` of a contribution | `enabled` | D411 |
|
|
743
|
+
| `overflow: 'reject'` of a live Resource | `overflow: 'drop'` | D411 |
|
|
744
|
+
| `status` of a `CommandOutcome` | `kind` | D411 |
|
|
745
|
+
| `ResourceActivity` of `@opetope/devtools` | `ResourceNodeActivity` | D411 |
|
|
746
|
+
| `ResourceDisposer` | `Disposer` | D411 |
|
|
747
|
+
| `NavigatorPolicy`, `surface.policy` | `NavigatorCatalogue`, `surface.catalogue` | D411 |
|
|
748
|
+
| `ApplicationConditionBinding`, `ApplicationExecution` and `ApplicationImportBinding` of `@opetope/runtime/internal` | the same names, from `@opetope/runtime` | D411 |
|
|
749
|
+
|
|
750
|
+
**One row cannot be executed from the word alone, and the report says the rest of it.** `ctx.call` becomes
|
|
751
|
+
`ctx.command`, and with it a body of `(input, context) => …` becomes a body that takes its context alone — which
|
|
752
|
+
leaves the input with nowhere to be named, because TypeScript has no partial inference and `command<Deposit>(…)` is
|
|
753
|
+
not a route. The one place is the annotation of the destructured context,
|
|
754
|
+
`({ input, update }: ModelCommandContext<Deposit>)` in a model and
|
|
755
|
+
`({ input, source }: FeatureCommandContext<typeof within, Deposit>)` in a feature, so the report names that
|
|
756
|
+
annotation beside the word. A rename carried out to the letter without it leaves this rule silent and the build red,
|
|
757
|
+
with every refusal standing on code the author wrote correctly (D444).
|
|
758
|
+
|
|
759
|
+
**There is no autofix, and that is the decision rather than an omission (D404).** Several of these rows are not one
|
|
760
|
+
to one. `policy`, `queueBy` and `lane` collapse into a single `concurrency` whose shape depends on which of the
|
|
761
|
+
three a record wrote together: `{ by }`, `{ by, lane }` and `{ lane, pending: 'latest' }` are three different values
|
|
762
|
+
built from the same three words. `when` at an effect becomes `filter` **or** `initial`, and which one is a question
|
|
763
|
+
about what the predicate asks — `filter: (_current, previous) => previous !== undefined` is not a filter at all, it
|
|
764
|
+
is `initial: false`, and the authoring guide says so. `skipInitial` becomes `initial` with the polarity inverted, so
|
|
765
|
+
the one row that looks purely mechanical is the one that compiles and means the opposite. A fix that guessed wrong
|
|
766
|
+
would rewrite a consumer's source silently, which is worse than refusing, so every report here names the retired
|
|
767
|
+
word, names its replacement, and says outright where the replacement is a choice.
|
|
768
|
+
|
|
769
|
+
**The boundary.** Ownership is proved by symbol and never by spelling. A retired member is reported only on a
|
|
770
|
+
declaring context of this library — a parameter annotated `ModelContext` of `@opetope/core`, or the `own` section
|
|
771
|
+
written where a `defineFeature` of `@opetope/runtime` takes it — resolved through an alias or a destructured member
|
|
772
|
+
the same way `no-subscribe-outside-models` resolves the ingress of a declared node. A retired name is reported only
|
|
773
|
+
where an `@opetope/*` module is the one that exports it, and where the name is retired at one entry and live at
|
|
774
|
+
another the module is asked exactly: `ResourceActivity` is still what `@opetope/core` calls the background work of
|
|
775
|
+
a `ready` state, and the three names of `@opetope/runtime/internal` are still names on the entry an author takes
|
|
776
|
+
them from. A
|
|
777
|
+
contribution's gate is reported on the `provides` section a `defineFeature` of `@opetope/runtime` takes, the way a
|
|
778
|
+
declared node is reported on `own`; the `status` of a settled answer only on the `outcome` of a `useCommand` of
|
|
779
|
+
`@opetope/react`, and the `policy` of a surface only on the value a `defineSurface` of `@opetope/navigation/react`
|
|
780
|
+
declared. A Resource is the value a `resource.load` or
|
|
781
|
+
`resource.live` of such a context declared, or the record `useResource` of `@opetope/react` answers; its state is
|
|
782
|
+
that record's `state`, or a `getSnapshot()` of it. `activity` is reported only inside a
|
|
783
|
+
narrowing that proves the state is `loading` — an `if`, a ternary or the left half of an `&&`, written about the
|
|
784
|
+
same expression — because a `ready` state still carries the field.
|
|
785
|
+
|
|
786
|
+
The silence is the point. `Call` inside `'Margin Call'` and inside the translation keys of a catalogue, the
|
|
787
|
+
`{ once: true }` of `addEventListener`, a host's own `policy` option or `policy` of somebody else's object, `Navigator.reset()` and a
|
|
788
|
+
`delivery` field of
|
|
789
|
+
somebody else's record are all left alone, as is `when` at a feature and at `scope.while`, where the word is not
|
|
790
|
+
retired at all, and a `status` of somebody else's record. So is what the rule cannot see: a Resource reached through a model record instead
|
|
791
|
+
of through the declaration that built it, a state narrowed in a `switch` or inside a helper, and an options record
|
|
792
|
+
assembled in another module. Its silence is therefore not proof that a migration is complete — the compiler, which
|
|
793
|
+
now prints the replacement for every word it can name, is the other half (D401, D444). Six of the rows above are
|
|
794
|
+
words an options record used to carry, and each of those records names its retired word so the refusal carries the
|
|
795
|
+
replacement: `skipInitial`, `when` and `onDispose` at an effect, `backpressure` at an event, `when` at a
|
|
796
|
+
contribution and `from` at `defineCondition`.
|
|
797
|
+
|
|
547
798
|
## Coexistence with key sorting
|
|
548
799
|
|
|
549
800
|
A host that sorts object keys alphabetically will disagree with `define-feature-property-order`, because the
|