@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +460 -4
  2. package/README.md +312 -61
  3. package/README.ru.md +251 -61
  4. package/dist/ast.d.ts +3 -1
  5. package/dist/ast.js +1 -1
  6. package/dist/ast.js.map +1 -1
  7. package/dist/command-hooks.d.ts +2 -2
  8. package/dist/command-hooks.js +1 -1
  9. package/dist/command-hooks.js.map +1 -1
  10. package/dist/context-members.d.ts +22 -0
  11. package/dist/context-members.js +2 -0
  12. package/dist/context-members.js.map +1 -0
  13. package/dist/declaration-ingress.d.ts +10 -2
  14. package/dist/declaration-ingress.js +1 -1
  15. package/dist/declaration-ingress.js.map +1 -1
  16. package/dist/effect-declarations.d.ts +20 -0
  17. package/dist/effect-declarations.js +2 -0
  18. package/dist/effect-declarations.js.map +1 -0
  19. package/dist/index.d.ts +11 -3
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/retired-vocabulary.d.ts +31 -0
  23. package/dist/retired-vocabulary.js +2 -0
  24. package/dist/retired-vocabulary.js.map +1 -0
  25. package/dist/rules/capture-command-cleanup.js +1 -1
  26. package/dist/rules/capture-command-cleanup.js.map +1 -1
  27. package/dist/rules/define-feature-property-order.js +1 -1
  28. package/dist/rules/define-feature-property-order.js.map +1 -1
  29. package/dist/rules/enabled-predicate.d.ts +6 -0
  30. package/dist/rules/enabled-predicate.js +2 -0
  31. package/dist/rules/enabled-predicate.js.map +1 -0
  32. package/dist/rules/no-command-in-deps.js +1 -1
  33. package/dist/rules/no-command-in-deps.js.map +1 -1
  34. package/dist/rules/no-internal-imports.js +1 -1
  35. package/dist/rules/no-internal-imports.js.map +1 -1
  36. package/dist/rules/no-retired-vocabulary.d.ts +5 -0
  37. package/dist/rules/no-retired-vocabulary.js +2 -0
  38. package/dist/rules/no-retired-vocabulary.js.map +1 -0
  39. package/dist/rules/no-write-after-source-write.d.ts +6 -0
  40. package/dist/rules/no-write-after-source-write.js +2 -0
  41. package/dist/rules/no-write-after-source-write.js.map +1 -0
  42. package/dist/rules/prefer-effect-current.js +1 -1
  43. package/dist/rules/prefer-effect-current.js.map +1 -1
  44. package/oxlintrc.json +3 -1
  45. package/package.json +1 -1
  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
@@ -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/when-predicate`. Parsing requires `@typescript-eslint/parser`;
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: `when-predicate`, `define-feature-property-order`,
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`, `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.
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
- | `when-predicate` | `error` | |
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 `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.
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
- ### `when-predicate`
170
+ ### `enabled-predicate`
127
171
 
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:
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 | 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 |
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 `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.
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 actions = useModel(FormActions);
195
- return null;
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 `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.
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: `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`.
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`, `useCommands` and
378
- `useModel` are the imports of `@opetope/react`, the six hooks are React's own, and a local alias of either —
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 | 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 |
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. 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):
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, execution => publish(quotes.getSnapshot()));
514
+ ctx.effect(quotes, current => publish(quotes.getSnapshot()));
451
515
  // after
452
- ctx.effect(quotes, execution => publish(execution.current));
516
+ ctx.effect(quotes, current => publish(current));
453
517
  ```
454
518
 
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.
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, `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.
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.call(async (input, { signal, update }) => {
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.call(async (input, { capture, signal, update }) => {
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
- Call an effect run invokes and a newer value cancels — the older run's write is exactly the one that should not land,
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 `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.
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 `call` of a
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 `call` hands its body no writer.
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