@opetope/lint 0.12.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +207 -0
- package/README.md +70 -848
- package/dist/declaration-ingress.js +1 -1
- package/dist/declaration-ingress.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/rules/no-retired-vocabulary.d.ts +1 -1
- package/dist/rules/no-retired-vocabulary.js +1 -1
- package/dist/rules/no-retired-vocabulary.js.map +1 -1
- package/package.json +1 -2
- package/README.ru.md +0 -838
package/README.ru.md
DELETED
|
@@ -1,838 +0,0 @@
|
|
|
1
|
-
# @opetope/lint
|
|
2
|
-
|
|
3
|
-
Правила ESLint для кода, который пишет автор на Opetope. Плагин держит только то, что синтаксическая проверка может
|
|
4
|
-
доказать об объявлении: формы, которые компилируются и работают, но говорят не то, что имел в виду автор. То, на что
|
|
5
|
-
уже отвечают проверка типов, рантайм и анализ мёртвого кода, остаётся за ними (см. [`../docs/decisions.md`](https://www.npmjs.com/package/@opetope/runtime), D227).
|
|
6
|
-
|
|
7
|
-
Пакет не зависит ни от одного другого пакета `@opetope/*` и не читает типы, поэтому хост может линтовать исходники,
|
|
8
|
-
которые ещё не собирал.
|
|
9
|
-
|
|
10
|
-
## Установка
|
|
11
|
-
|
|
12
|
-
```sh
|
|
13
|
-
npm install --save-dev @opetope/lint 'eslint@^9' '@typescript-eslint/parser@^8'
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
Используйте согласованные версии Opetope. Для release candidate добавьте `@next` каждому пакету `@opetope/*` в команде.
|
|
17
|
-
API поставляется только в ESM; требуется Node 20.19+. Команды разработки ниже относятся к contributor checkout.
|
|
18
|
-
|
|
19
|
-
Нормативные руководства EN/RU поставляются в `@opetope/runtime`: после его установки откройте
|
|
20
|
-
`node_modules/@opetope/runtime/docs/spec.md` или `spec.ru.md`; рецепты находятся в `cookbook.md` и `cookbook.ru.md`.
|
|
21
|
-
Для чтения установленных руководств доступ к GitHub не нужен.
|
|
22
|
-
|
|
23
|
-
## Usage
|
|
24
|
-
|
|
25
|
-
```js
|
|
26
|
-
// eslint.config.mjs
|
|
27
|
-
import opetope from '@opetope/lint';
|
|
28
|
-
|
|
29
|
-
export default [
|
|
30
|
-
{
|
|
31
|
-
files: ['src/**/*.{ts,tsx}'],
|
|
32
|
-
...opetope.configs.recommended,
|
|
33
|
-
},
|
|
34
|
-
];
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
Плагин — это и default export, и именованный `opetopeLint`. Конфигурация регистрирует его в namespace `opetope`,
|
|
38
|
-
поэтому правило называется `opetope/enabled-predicate`. Для разбора нужен `@typescript-eslint/parser`; он и `eslint`
|
|
39
|
-
объявлены peer dependencies.
|
|
40
|
-
|
|
41
|
-
## Configs
|
|
42
|
-
|
|
43
|
-
### `recommended`
|
|
44
|
-
|
|
45
|
-
Все правила, которые действуют везде, где пишут на Opetope: `enabled-predicate`, `define-feature-property-order`,
|
|
46
|
-
`require-literal-id`, `id-naming`, `no-internal-imports`, `no-snapshot-read-in-render`, `no-snapshot-in-update`,
|
|
47
|
-
`no-redundant-const-tuple`, `no-command-in-deps`, `prefer-effect-current`, `no-write-after-source-write`,
|
|
48
|
-
`capture-command-cleanup`, `require-declared-models` и `no-retired-vocabulary` как error. Правило, которому нужно знать, где хост держит
|
|
49
|
-
свои файлы, здесь выключено и приходит через `layers`; `prefer-model-selection` выключено потому, что число hooks,
|
|
50
|
-
которое компонент вправе держать, каждый проект решает для себя.
|
|
51
|
-
|
|
52
|
-
`configs.recommended.rules` целиком — и ровно это поставляется как `@opetope/lint/oxlintrc.json`:
|
|
53
|
-
|
|
54
|
-
| Правило | `recommended` | Включает |
|
|
55
|
-
| ------------------------------- | ------------- | ----------------------------------------------- |
|
|
56
|
-
| `capture-command-cleanup` | `error` | |
|
|
57
|
-
| `define-feature-property-order` | `error` | |
|
|
58
|
-
| `id-naming` | `error` | |
|
|
59
|
-
| `layer-placement` | `off` | `layers({ integration, models, ui })` |
|
|
60
|
-
| `no-command-in-deps` | `error` | |
|
|
61
|
-
| `no-internal-imports` | `error` | плюс сужение через `internalImports({ allow })` |
|
|
62
|
-
| `no-redundant-const-tuple` | `error` | |
|
|
63
|
-
| `no-retired-vocabulary` | `error` | |
|
|
64
|
-
| `no-snapshot-in-update` | `error` | |
|
|
65
|
-
| `no-snapshot-read-in-render` | `error` | |
|
|
66
|
-
| `no-subscribe-outside-models` | `off` | `layers({ models })` |
|
|
67
|
-
| `no-write-after-source-write` | `error` | |
|
|
68
|
-
| `prefer-effect-current` | `error` | |
|
|
69
|
-
| `prefer-model-selection` | `off` | проект, со своим `threshold` |
|
|
70
|
-
| `require-declared-models` | `error` | |
|
|
71
|
-
| `require-literal-id` | `error` | |
|
|
72
|
-
| `enabled-predicate` | `error` | |
|
|
73
|
-
|
|
74
|
-
Три `off` — это ровно те три, которым нужно знание, которое есть только у проекта: какие каталоги каким слоем
|
|
75
|
-
являются и сколько hooks вправе держать один компонент.
|
|
76
|
-
|
|
77
|
-
### `layers`
|
|
78
|
-
|
|
79
|
-
`opetope.configs.layers({ integration, models, ui, tests })` принимает глобы собственных слоёв проекта и
|
|
80
|
-
возвращает ту конфигурацию, которую каждый из них заслужил. Слой без глобов — это слой, которого у проекта нет, и
|
|
81
|
-
он не даёт ничего; файл, который не заявил ни один глоб, не размещает никто.
|
|
82
|
-
|
|
83
|
-
```js
|
|
84
|
-
...opetope.configs.layers({
|
|
85
|
-
integration: ['src/features/*/integration/**/*.{ts,tsx}'],
|
|
86
|
-
models: ['src/features/*/models/**/*.ts'],
|
|
87
|
-
ui: ['src/features/*/ui/**/*.{ts,tsx}'],
|
|
88
|
-
}),
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
`integration`, `models` и `ui` включают `layer-placement` для своих файлов. `models` вместе с этим включает
|
|
92
|
-
`no-subscribe-outside-models` для всех исходников и отступает в самом слое моделей и в `tests`, у которых значение
|
|
93
|
-
по умолчанию `['**/__tests__/**', '**/*.spec.ts', '**/*.spec.tsx']`. Подписка покрыта и в файле, который не заявил
|
|
94
|
-
ни один слой: забытая подписка — это ровно то, как она переживает своего читателя.
|
|
95
|
-
|
|
96
|
-
### `internalImports`
|
|
97
|
-
|
|
98
|
-
`opetope.configs.internalImports({ allow, files })` настраивает `opetope/no-internal-imports` для JavaScript и
|
|
99
|
-
TypeScript, включая `.mjs`-тесты моделей. Правило уже включено в `recommended`; helper полезен для ограничения
|
|
100
|
-
области проверки или явного перечисления доверенных файлов реализации библиотеки и интеграции хоста.
|
|
101
|
-
`allow` выключает только это правило в указанных файлах. Размещайте блоки helper **после** `recommended`:
|
|
102
|
-
последующий блок `recommended` снова включает правило, в том числе для разрешённых файлов. Остальные ограничения
|
|
103
|
-
проекта продолжают действовать.
|
|
104
|
-
|
|
105
|
-
```js
|
|
106
|
-
export default [
|
|
107
|
-
opetope.configs.recommended,
|
|
108
|
-
...opetope.configs.internalImports({ allow: ['src/bootstrap/**/devtools.ts'] }),
|
|
109
|
-
];
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
Отдельное правило не изменяет `no-restricted-imports`. Прежний экспорт `internalImportPattern` удалён:
|
|
113
|
-
включайте это правило или helper вместо добавления паттерна Opetope в ограничения хоста (D261).
|
|
114
|
-
|
|
115
|
-
## Rules
|
|
116
|
-
|
|
117
|
-
### `no-internal-imports`
|
|
118
|
-
|
|
119
|
-
Код приложения и тесты импортируют публичные входы Opetope. Правило запрещает `@opetope/*/internal` и вложенные
|
|
120
|
-
пути в imports, re-exports, литеральных `import()`, незатенённых `require()`, TypeScript import types и
|
|
121
|
-
`import = require`. Type-only импорты тоже проверяются: внутренний тип связывает потребителя с приватным ABI.
|
|
122
|
-
Для запуска настоящего Command модели есть `runCommand` из `@opetope/core/testing`, для открытия модели без фичи — `openModel` из `@opetope/runtime/testing`, для создания фикстуры — `command` из `@opetope/react/testing`.
|
|
123
|
-
Автоматического исключения для тестов нет. Доверенные файлы реализации библиотеки или интеграции хоста можно
|
|
124
|
-
разрешить явным override; autofix отсутствует, потому что публичная замена зависит от импортированной операции.
|
|
125
|
-
|
|
126
|
-
**Граница.** Проверка синтаксическая: строковые пути модулей и шаблоны без подстановок. Она не разрешает алиасы,
|
|
127
|
-
вычисляемые пути, граф re-exports или пользовательские загрузчики. Локальный `require` не считается загрузчиком
|
|
128
|
-
Node. Применяйте конфигурацию ко всем используемым JavaScript/TypeScript исходникам и тестам.
|
|
129
|
-
|
|
130
|
-
### `enabled-predicate`
|
|
131
|
-
|
|
132
|
-
`enabled` вклада отвечает фактом видимости этого экземпляра, а не источником, который этот факт несёт (D220).
|
|
133
|
-
Правило читает `enabled` у вкладов `slot`, `pipe` и `register`, а также любой `enabled`, чья функция деструктурирует
|
|
134
|
-
контекст вычисления `{ exports, imports, own, read }`, и сообщает о трёх формах:
|
|
135
|
-
|
|
136
|
-
| Написано | О чём сообщает |
|
|
137
|
-
| --------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
138
|
-
| `enabled: ({ imports }) => imports.x.allowed` | возвращает источник; фикс приводит к `({ imports, read }) => read(imports.x.allowed)` |
|
|
139
|
-
| `enabled: async ({ read }) => read(x)` | предикат отвечает синхронно; без фикса |
|
|
140
|
-
| `enabled: () => allowed` | `Readable<boolean>` передаётся в `enabled` как есть, без обёртки; без фикса |
|
|
141
|
-
|
|
142
|
-
Фикс оборачивает возвращённое обращение к полю в `read(...)` и добавляет `read` в деструктуризацию контекста, если
|
|
143
|
-
его там нет. Именованный контекст читается через себя: `context => context.imports.x.allowed` становится
|
|
144
|
-
`context => context.read(context.imports.x.allowed)`.
|
|
145
|
-
|
|
146
|
-
**Граница.** Правило читает формы, а не типы. `({ read }) => read(counter)` над не-boolean источником остаётся
|
|
147
|
-
ошибкой типов, как и `read` над тем, что не является `Readable`. Если обращение к полю контекста вычисления — это
|
|
148
|
-
обычное значение, а не источник, предикат не может изменить свой ответ, и правило сообщает о нём как о написанном
|
|
149
|
-
для источника; вынеси такое решение из `enabled` или заглуши строку. `when` — другое поле, и правило не трогает его,
|
|
150
|
-
где бы он ни стоял, — значение источника у `scope.while`; а фильтр изменений у эффекта — вовсе не `when`: это
|
|
151
|
-
`filter`, и D379 дал ему это имя, чтобы одно слово не отвечало на два вопроса.
|
|
152
|
-
|
|
153
|
-
### `define-feature-property-order`
|
|
154
|
-
|
|
155
|
-
Фича объявляет свои секции в одном порядке: `imports`, `requires`, `own`, `exports`, `provides`, `when` и
|
|
156
|
-
загрузчик `body`, который заменяет три последние стадии (spec §2.1, D186). Идентичности среди них нет — фича
|
|
157
|
-
называет себя первым аргументом (D280). Порядок совпадает с порядком чтения объявления — рёбра, стадии, затем
|
|
158
|
-
время жизни и загрузчик, — поэтому читатель находит секцию по её месту, а сортировщик ключей можно настроить на
|
|
159
|
-
тот же порядок вместо второго.
|
|
160
|
-
|
|
161
|
-
Фикс переставляет свойства. Он отступает, если между двумя секциями стоит комментарий: такой комментарий не
|
|
162
|
-
принадлежит ни одной из них, и перестановка увела бы его от строки, которую он объясняет; комментарий внутри секции
|
|
163
|
-
переезжает вместе с ней. Объект с ключом, который не является секцией, или со spread остаётся проверке типов.
|
|
164
|
-
|
|
165
|
-
### `no-redundant-const-tuple`
|
|
166
|
-
|
|
167
|
-
`derive` принимает кортеж источников `const`-параметром типа, поэтому `[left, right]` выводит собственный кортеж, и
|
|
168
|
-
селектор уже видит точные значения. Написанный рядом `as const` утверждает то, что и так утверждает сигнатура
|
|
169
|
-
(D279, D309):
|
|
170
|
-
|
|
171
|
-
```ts
|
|
172
|
-
const summary = derive([total, label], (value, suffix) => `${value} ${suffix}`);
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
Правило читает первый аргумент `derive`, разрешённого к `@opetope/core`, и только там, где этот аргумент —
|
|
176
|
-
литерал массива с `as const`. Фикс убирает утверждение и ничего больше. Он отступает, если между кортежем и
|
|
177
|
-
утверждением стоит комментарий: такой комментарий не принадлежит ни одному из них, и удаление увело бы его вместе
|
|
178
|
-
с диапазоном.
|
|
179
|
-
|
|
180
|
-
**Граница.** Утверждение, написанное в другом месте, остаётся нетронутым: на одном элементе кортежа, где оно
|
|
181
|
-
говорит об этом элементе; на результате селектора; на записи опций; и на форме с одним источником, аргумент
|
|
182
|
-
которой вообще не кортеж. `as Sources` — другое утверждение и говорит другое. Импорт разрешается в пределах одного
|
|
183
|
-
модуля: локальный alias, `const`, который держит импорт, и namespace-привязка достигают одного и того же экспорта,
|
|
184
|
-
— а в отличие от `no-command-in-deps` правило узнаёт только импорт из `@opetope/core` и ничего больше: реэкспорт
|
|
185
|
-
через собственный barrel хоста оно не видит. Это осознанная цена за безопасный фикс. Правило правит код, а у
|
|
186
|
-
собственного `derive` хоста нет `const`-параметра типа, который делал бы утверждение лишним, — снятие `as const`
|
|
187
|
-
там молча изменило бы выведенные типы.
|
|
188
|
-
|
|
189
|
-
### `require-declared-models`
|
|
190
|
-
|
|
191
|
-
Компонент, читающий UI-модель отдельного mount, объявляет её через `requiresModels`. Owner-модели остаются
|
|
192
|
-
доступны всему subtree вклада без такого объявления (D158, D250).
|
|
193
|
-
|
|
194
|
-
```tsx
|
|
195
|
-
import { defineFeature } from '@opetope/runtime';
|
|
196
|
-
import { requiresModels, useCommand, useModel } from '@opetope/react';
|
|
197
|
-
|
|
198
|
-
const Form = () => {
|
|
199
|
-
const submit = useCommand(useModel(FormActions).submit);
|
|
200
|
-
return (
|
|
201
|
-
<button disabled={submit.inFlight} onClick={() => void submit.run()}>
|
|
202
|
-
Отправить
|
|
203
|
-
</button>
|
|
204
|
-
);
|
|
205
|
-
};
|
|
206
|
-
const DeclaredForm = requiresModels([FormActions])(Form);
|
|
207
|
-
|
|
208
|
-
defineFeature('example.form', {
|
|
209
|
-
provides: ({ slot }) => ({
|
|
210
|
-
form: slot(FormSlot, ({ model }) => ({
|
|
211
|
-
Component: DeclaredForm,
|
|
212
|
-
models: [model(FormActions, createActions)],
|
|
213
|
-
})),
|
|
214
|
-
}),
|
|
215
|
-
});
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
Правило проверяет вклады, переданные capability `slot` из видимого callback `provides` в `defineFeature` или
|
|
219
|
-
`defineFeature.body`. Локальный компонент, получающий UI-модели, объявляет свои требования; каждый видимый
|
|
220
|
-
компонент или hook, читающий одну из этих моделей, объявляет собственный список. Обёртка родителя не объявляет
|
|
221
|
-
требования вложенной функции.
|
|
222
|
-
|
|
223
|
-
Поддерживаются named и namespace imports, aliases импортов и локальных значений, именованный контекст `provides`,
|
|
224
|
-
inline и именованные компоненты. Bindings сравниваются в своих лексических scope: посторонняя локальная функция
|
|
225
|
-
`useModel`, `requiresModels` или `slot` не становится API Opetope из-за совпадения имени. Named API из относительных
|
|
226
|
-
реэкспортов распознаётся по экспортированным именам без чтения другого файла.
|
|
227
|
-
|
|
228
|
-
**Граница.** Анализ ограничен одним модулем и следует видимым локальным объявлениям и возвратам фабрик.
|
|
229
|
-
Произвольные объекты с `Component` и `models` не считаются вкладом. Реализации импортированных компонентов,
|
|
230
|
-
динамические списки моделей и непрозрачные helpers остаются непроверенными: отсутствие диагностики не доказывает
|
|
231
|
-
полноту их требований. Ссылки на модели должны быть видимыми локальными идентификаторами или aliases; правило не
|
|
232
|
-
читает типы и не обходит отрендеренное React-дерево. Autofix отсутствует: выбор модели для чтения принадлежит
|
|
233
|
-
автору. Проверки типов и runtime authority продолжают действовать.
|
|
234
|
-
|
|
235
|
-
### `require-literal-id`
|
|
236
|
-
|
|
237
|
-
Объявление называет себя строкой, которая написана, а не собрана там, где стоит объявление. Правило читает каждое
|
|
238
|
-
объявление публичного словаря, которое называет себя: `defineFeature`, `defineApplication`, `defineCondition`,
|
|
239
|
-
`defineHostContract`, `defineModel`, `definePort`, `defineSlot`, `defineSwitchSlot`, `definePipe` и
|
|
240
|
-
`defineRegistry`, — и четыре из необязательного пакета Navigation: `defineScreen`, `defineSurface`, `defineLink` и
|
|
241
|
-
`defineLinkHandlers`. D280 даёт им одну форму: id — первый аргумент у каждого.
|
|
242
|
-
|
|
243
|
-
Написана — это строковый литерал, шаблон без выражений, имя, которое разрешается в импорт или в `const` того же
|
|
244
|
-
модуля, и чтение поля у такого имени. Собрана — всё, что складывает место вызова: шаблон с выражением, конкатенация,
|
|
245
|
-
вызов функции или имя, которое разрешается в параметр.
|
|
246
|
-
|
|
247
|
-
Проект, который порождает набор объявлений по имени, говорит об этом один раз — называя такую фабрику:
|
|
248
|
-
|
|
249
|
-
```js
|
|
250
|
-
'opetope/require-literal-id': ['error', { allowInCallees: ['createSlots'] }],
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
Имя сверяется и с функцией, внутри которой написано объявление, и с вызовом, в который оно передано, поэтому
|
|
254
|
-
покрыты обе формы фабрики.
|
|
255
|
-
|
|
256
|
-
**Граница.** Имена правило отслеживает только внутри одного модуля: id, импортированный из другого файла, написан
|
|
257
|
-
там, и там же проверяется его собственный текст. `defineModule`, `defineCallTarget` и `defineCallLane` из ядра оно
|
|
258
|
-
не читает: их автор не пишет.
|
|
259
|
-
|
|
260
|
-
### `id-naming`
|
|
261
|
-
|
|
262
|
-
Id начинается с буквы и соединяет сегменты из букв и цифр одним из `.`, `/`, `:` или `-`, не длиннее 160 символов.
|
|
263
|
-
Это грамматика, которую принимает `declarationId` в `@opetope/core`; id вне неё — это `DeclarationError` в момент
|
|
264
|
-
выполнения объявления, а правило говорит об этом до запуска кода. Экран, поверхность и registry обработчиков ссылок
|
|
265
|
-
проходят через тот же `declarationId`, поэтому правило читает и их; `defineLink` из него исключён: id ссылки — это
|
|
266
|
-
канонический сегмент пути URL в нижнем регистре, который пакет проверяет сам и к которому зарезервированный префикс
|
|
267
|
-
неприменим (D313).
|
|
268
|
-
|
|
269
|
-
Проект, который зарезервировал первый сегмент, называет его, и каждое объявление таких файлов обязано с него
|
|
270
|
-
начинаться:
|
|
271
|
-
|
|
272
|
-
```js
|
|
273
|
-
'opetope/id-naming': ['error', { prefix: 'workspace' }],
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
**Граница.** Правило проверяет те id, чей текст видно в файле: литерал или имя, которое разрешается в литерал того
|
|
277
|
-
же модуля. Id, пришедший из другого модуля, проверяется там, где он написан.
|
|
278
|
-
|
|
279
|
-
### `layer-placement`
|
|
280
|
-
|
|
281
|
-
Каждое объявление написано в слое, который им владеет. Правило не сообщает ничего, пока проект не назвал свои слои
|
|
282
|
-
через `configs.layers`: имена каталогов не являются законом фреймворка.
|
|
283
|
-
|
|
284
|
-
| Объявление | Слой |
|
|
285
|
-
| ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
|
286
|
-
| `defineFeature` | integration — фича сочленяет два других слоя |
|
|
287
|
-
| `defineModel` | models или контракты UI, которые объявляют модель монтирования |
|
|
288
|
-
| `defineSlot`, `defineSwitchSlot`, `defineSurface`, `definePipe`, `defineRegistry`, `definePort`, `defineCondition`, `defineHostContract` | integration или контракты UI рядом с компонентом |
|
|
289
|
-
| `defineApplication` | ни один из них: им владеет bootstrap хоста |
|
|
290
|
-
|
|
291
|
-
**Это конвенция проекта, а не закон библиотеки.** Фреймворк говорит, что UI фичи импортирует только её собственные
|
|
292
|
-
контракты, а модель не знает о фиче; где лежат эти файлы — выбор проекта, и правило держит тот выбор, который он
|
|
293
|
-
объявил.
|
|
294
|
-
|
|
295
|
-
### `no-snapshot-read-in-render`
|
|
296
|
-
|
|
297
|
-
`getSnapshot()`, вызванный во время рендера компонента или хука, читает значение один раз и никогда не услышит
|
|
298
|
-
следующее. Читайте через `useReadable` или выбирайте вместе с командами той же модели:
|
|
299
|
-
`useModel(Declaration, (model, { read }) => ...)` (D205, D214).
|
|
300
|
-
|
|
301
|
-
Область рендера — это функция с именем `use…` либо функция с заглавной буквы, возвращающая элементы. Правило
|
|
302
|
-
смотрит на ту функцию, внутри которой написан вызов, поэтому снимок, прочитанный в обработчике события, эффекте
|
|
303
|
-
или любом другом вложенном колбэке, остаётся: они выполняются после рендера, и им нужно значение того момента.
|
|
304
|
-
|
|
305
|
-
**Граница.** Правило читает имя получателя, а не его тип: сообщается о каждом `getSnapshot()` в области рендера,
|
|
306
|
-
какому бы объекту он ни принадлежал. Функция с заглавной буквы, не возвращающая элементов, — это фабрика, и её
|
|
307
|
-
чтения остаются, включая фабрику модели вклада, которая читает props своего монтирования.
|
|
308
|
-
|
|
309
|
-
### `no-snapshot-in-update`
|
|
310
|
-
|
|
311
|
-
Значение, переданное в `update`, читает то состояние, которое собирается заменить, — и составное,
|
|
312
|
-
`update(sessions, { items: sessions.getSnapshot().items, kind: 'loading' })`, и скалярное,
|
|
313
|
-
`update(total, total.getSnapshot() + amount)`. Чтение выполняется там, где написан аргумент, — до записи и на
|
|
314
|
-
состоянии, которое могло уже остановиться, где `getSnapshot()` отвечает значением, которое больше никто не
|
|
315
|
-
сдвинет, — а вычисленное значение ложится на состояние, которое с тех пор могло уйти вперёд. У формы с апдейтером нет ни одной из этих бед: она
|
|
316
|
-
читает в момент записи, а у записи, отброшенной вместе с отменённым контекстом, апдейтер не вызывается вовсе
|
|
317
|
-
(D282, D288, D295).
|
|
318
|
-
|
|
319
|
-
```ts
|
|
320
|
-
update(sessions, previous => ({ ...previous, kind: 'loading' }));
|
|
321
|
-
update(total, previous => previous + amount);
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
Правило сообщает о вызове `update` — по этому имени или через контекст, `ctx.update`, — ровно с двумя аргументами,
|
|
325
|
-
у которого первым аргументом назван state, а значение читает `getSnapshot()` на нём же: написанный прямо в
|
|
326
|
-
значении или через имя той же функции, которое держит это чтение, — `const current = s.getSnapshot()`,
|
|
327
|
-
`const { items } = s.getSnapshot()` или `let`, который больше никто не переписывает. `getSnapshot()` другого
|
|
328
|
-
readable внутри значения — чтение другого состояния, и оно остаётся; остаются и значение, которое уже является
|
|
329
|
-
апдейтером, и чтение, написанное где угодно, кроме этого значения, и второй аргумент-spread, чьи аргументы
|
|
330
|
-
собраны в другом месте.
|
|
331
|
-
|
|
332
|
-
Фикс пишет апдейтер: значение становится `previous => ...`, где каждое чтение этого состояния заменено параметром —
|
|
333
|
-
а разобранное поле тем же полем параметра, `previous.items`, — и `const`, державший чтение, уходит вместе с ним, с
|
|
334
|
-
комментарием на той же строке, там, где это значение было его единственным читателем. Параметр называется
|
|
335
|
-
`previous`, а где `previous` уже занят объемлющей областью — `snapshot`. Там, где параметр затенил бы имя, которое
|
|
336
|
-
в файле есть, там, где у именованного чтения остаётся другой читатель, и везде, где чтение разобрано или открыто
|
|
337
|
-
через `let`, та же правка приходит suggestion'ом: это ровно те правки, на которые стоит посмотреть. Значение,
|
|
338
|
-
которое вообще нельзя перенести в тело функции — написанные в нём `await` или `yield`, присваивание, инкремент,
|
|
339
|
-
`delete`, — получает сообщение и ничего больше, и так же остаётся чтение, отложенное до более позднего вызова.
|
|
340
|
-
|
|
341
|
-
**Граница.** Форма `update(S, ...S.getSnapshot()...)` достаточно определённа сама по себе, поэтому правило не
|
|
342
|
-
разрешает импортов и не читает типов: собственный `update` хоста той же формы тоже получает сообщение, и фикс
|
|
343
|
-
верен для него только тогда, когда вторым аргументом он принимает апдейтер. Имя — это привязка, к которой оно
|
|
344
|
-
разрешается, а member expression — путь, который он пишет, поэтому два разных объекта, записанных как `this.state`,
|
|
345
|
-
здесь одно состояние. Правка переносит всё значение в тело функции, поэтому и всё остальное, что значение вызывало
|
|
346
|
-
— `Date.now()`, помощник, форматтер, — вычисляется в момент записи, а не там, где написано, и не вычисляется вовсе
|
|
347
|
-
для записи, которую рантайм отбросил; это и значит форма с апдейтером, и это стоит прочитать один раз до того, как
|
|
348
|
-
правка ляжет. Чтение, до которого значение дотягивается любым другим путём — помощник, который принимает state и
|
|
349
|
-
читает его сам, имя, объявленное в другой функции, имя, которое код переписывает, значение, уже являющееся
|
|
350
|
-
апдейтером, — не видно, и молчание правила не доказывает, что значение вычислено из предыдущего.
|
|
351
|
-
|
|
352
|
-
### `no-command-in-deps`
|
|
353
|
-
|
|
354
|
-
Хук команды — это снимок того рендера, в котором его прочитали: объект пересобирается на каждой смене `inFlight` и
|
|
355
|
-
`outcome`, а `run` держит одну идентичность всё время жизни привязки (D290). Массив зависимостей, который называет
|
|
356
|
-
этот объект, срабатывает поэтому на каждой такой смене: эффект перезапускается, memo обнуляется, а эффект, который
|
|
357
|
-
вызывает из него `run`, не останавливается никогда — он запускает команду, статус переключается, зависимость
|
|
358
|
-
меняется, выполняется уборка, и эффект запускается снова.
|
|
359
|
-
|
|
360
|
-
```tsx
|
|
361
|
-
const load = useCommand(Screen.load);
|
|
362
|
-
const unload = useCommand(Screen.unload);
|
|
363
|
-
|
|
364
|
-
useEffect(() => {
|
|
365
|
-
load.run();
|
|
366
|
-
return () => void unload.run();
|
|
367
|
-
}, [load.run, unload.run]);
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
Правило читает массив зависимостей `useEffect`, `useLayoutEffect`, `useInsertionEffect`, `useCallback`, `useMemo` и
|
|
371
|
-
`useImperativeHandle` и сообщает об элементе, который целиком является привязкой хука команды: о переменной
|
|
372
|
-
`useCommand` и о ключе или деструктурированном имени выбора `useModel`, на котором тот же код читает `run`.
|
|
373
|
-
`x.run` и статусы `x.inFlight` и `x.outcome` — это члены объекта, а не сам объект, и они остаются.
|
|
374
|
-
|
|
375
|
-
Сама запись — форма ещё хуже, и у неё собственное сообщение: `useModel` с выбором строит новый объект на каждом
|
|
376
|
-
рендере, поэтому `[hooks]` и `[order]` меняются на каждом рендере, а не на каждом статусе. Ответ здесь — то поле,
|
|
377
|
-
которое читает колбэк: `hooks.save.run`.
|
|
378
|
-
|
|
379
|
-
Фикс пишется там, где ответ один: колбэк, который дотягивается до привязки единственным путём через `run`, получает
|
|
380
|
-
на месте элемента этот путь — вместе со всем, через что элемент был написан: `as`, `satisfies` или `!` заменяются
|
|
381
|
-
вместе с ним, а массив, в котором этот путь уже есть, теряет элемент вместо того, чтобы удвоить его. Там, где
|
|
382
|
-
колбэк читает больше одного пути или читает статус, правило предлагает suggestions — по одной на путь, статусы
|
|
383
|
-
первыми, — потому что какой из них имеет в виду зависимость, говорит автор. Колбэк, сквозь который правило не
|
|
384
|
-
видит, — написанный в другом месте, держащий сам объект, разбирающий его или читающий член вне этих четырёх —
|
|
385
|
-
получает сообщение и ни одной suggestion.
|
|
386
|
-
|
|
387
|
-
**Граница.** Имена разрешаются импортом в пределах одного модуля, а не написанием: `useCommand` и `useModel` —
|
|
388
|
-
это импорты `@opetope/react`, шесть хуков — собственные хуки React, а локальный алиас любого из них —
|
|
389
|
-
`import { useEffect as effect }` — это тот же импорт. React — это модуль, названный ровно `react`; имя `@opetope/*`
|
|
390
|
-
берётся ещё и из относительного импорта, потому что так пакет реэкспортирует собственный вход через barrel, и
|
|
391
|
-
соседний `./scheduling` — React не больше, чем `@host/scheduling`. Хуки React читаются и через namespace, и через
|
|
392
|
-
default-привязку (`React.useEffect`); у входа `@opetope/*` default-экспорта нет, и до него дотягивается только
|
|
393
|
-
namespace. Только `const` держит тот хук, которым его открыли, поэтому `let` остаётся в стороне. Выбранное поле
|
|
394
|
-
`useModel` — хук только там, где код доказал это чтением `run` на нём: имя статуса не доказывает ничего, такое имя
|
|
395
|
-
столь же легко носят обычные данные. Поле, прочитанное как данные, остаётся полем неизвестного рода, и так же
|
|
396
|
-
остаётся `useModel(Declaration)` без выбора, который по-прежнему возвращает выданную модель. Массив зависимостей
|
|
397
|
-
вызова, который не является одним из шести хуков, принадлежит тому, кто его объявил.
|
|
398
|
-
|
|
399
|
-
### `no-subscribe-outside-models`
|
|
400
|
-
|
|
401
|
-
Подписка, написанная руками, владеет уборкой, которой не видит ничто вокруг. Правило сообщает о `x.subscribe(...)`
|
|
402
|
-
и молчит, пока `configs.layers` не назвал слой моделей, которому такая подписка разрешена; в компоненте читатель —
|
|
403
|
-
это `useReadable` или выбор модели, а в фиче и в модели — `effect`, `event` или `connect` у `resource.live`.
|
|
404
|
-
|
|
405
|
-
Передача функции без вызова подпиской здесь не является:
|
|
406
|
-
`useSyncExternalStore(source.subscribe, source.getSnapshot)` передаёт ссылку, и читатель владеет тем, что начал.
|
|
407
|
-
|
|
408
|
-
Не является ею и подписка, написанная там, где объявленный узел открывает свою работу и сам её освобождает.
|
|
409
|
-
Disposer, который возвращает такой колбэк, принадлежит этому узлу и сливается вместе с поколением, которое его
|
|
410
|
-
открыло, — поэтому правило там молчит: это ровно та форма, которую предписывает его собственное сообщение (D299):
|
|
411
|
-
|
|
412
|
-
| Колбэк | Где | Ингресс |
|
|
413
|
-
| --------------------------------------- | --------------------------- | ------- |
|
|
414
|
-
| `resource.live({ connect })` | член `connect` | да |
|
|
415
|
-
| `event(source, subscribe, run)` | второй аргумент | да |
|
|
416
|
-
| `scope.while` / `scope.switch` | `open`, второй аргумент | да |
|
|
417
|
-
| `scope.each(source, key, open)` | `open`, третий аргумент | да |
|
|
418
|
-
| `attach(source, { open })` | член `open` | да |
|
|
419
|
-
| `attach(source, { open, close })` | член `close` | нет |
|
|
420
|
-
| `apply`, `load`, `run`, `filter`, `key` | модификаторы тех же вызовов | нет |
|
|
421
|
-
|
|
422
|
-
`resource.load` не открывает подписки и ингресса не имеет вовсе, поэтому `subscribe`, написанный в его `load`,
|
|
423
|
-
сообщается как любая другая подписка руками (D349).
|
|
424
|
-
|
|
425
|
-
```ts
|
|
426
|
-
own: ({ imports, resource }) => ({
|
|
427
|
-
book: resource.live({
|
|
428
|
-
apply: { change: (_current: ResourceData<Quote>, change: Quote) => change },
|
|
429
|
-
connect: ({ emit, signal, source }) => source.repository.subscribe('BTCUSD', emit, signal),
|
|
430
|
-
lifetime: 'owner',
|
|
431
|
-
source: imports.exchange,
|
|
432
|
-
}),
|
|
433
|
-
}),
|
|
434
|
-
```
|
|
435
|
-
|
|
436
|
-
«Внутри» считается до самого низа: вложенная стрелка, тело `function`, `await` на самой подписке, disposer,
|
|
437
|
-
которому сначала дали имя. Контекст разрешается импортом, а не написанием: секция `own`, написанная прямо там, где
|
|
438
|
-
её принимает `defineFeature`/`defineFeature.body`, взятый **из самого `@opetope/runtime`**, и фабрика модели —
|
|
439
|
-
второй аргумент `model(Declaration, factory)` на том же `own` либо функция, первый параметр которой аннотирован
|
|
440
|
-
`ModelContext` из `@opetope/core`. В отличие от `no-command-in-deps`, эти два якоря принимают только имя пакета:
|
|
441
|
-
относительный импорт здесь позволил бы собственному `./my-own-context` хоста заглушить правило.
|
|
442
|
-
`own.resource.live(...)` и деструктурированный `resource.live(...)` читаются одинаково, алиас отвечает тем ключом,
|
|
443
|
-
из которого его достали (`{ resource: materialize }` — это по-прежнему `resource`), а `own.scope.while(...)`
|
|
444
|
-
читается как деструктурированный `scope.while(...)`.
|
|
445
|
-
|
|
446
|
-
Поэтому сообщение остаётся в четырёх формах, и каждая — граница того, что доказывает синтаксис: `resource.live`,
|
|
447
|
-
который правило не проследило ни до одного якоря (импортированный откуда-то ещё, неразрешимый или принадлежащий чужому
|
|
448
|
-
`defineFeature`); колбэк, написанный в другом месте и переданный сюда именем, — он не стоит лексически внутри;
|
|
449
|
-
секция `own`, вынесенная в собственную привязку (`const own = ({ resource }) => …; defineFeature(id, { own })`); и
|
|
450
|
-
`model(Decl, createOrderModel)`, где фабрика — именованная функция без аннотации `ModelContext`.
|
|
451
|
-
|
|
452
|
-
### `prefer-effect-current`
|
|
453
|
-
|
|
454
|
-
`run` эффекта уже получил то значение, ради которого его запустили, — с D379 первым параметром. Чтение того же
|
|
455
|
-
источника через `source.getSnapshot()` внутри этого прогона читает его второй раз и ничего не говорит о том, какой
|
|
456
|
-
это прогон, поэтому правило переписывает его в этот параметр (D296, D323, D379):
|
|
457
|
-
|
|
458
|
-
```js
|
|
459
|
-
// было
|
|
460
|
-
ctx.effect(quotes, current => publish(quotes.getSnapshot()));
|
|
461
|
-
// стало
|
|
462
|
-
ctx.effect(quotes, current => publish(current));
|
|
463
|
-
```
|
|
464
|
-
|
|
465
|
-
Фикс пишет то слово, которое у прогона уже есть: имя, под которым автор связал первый параметр, каким бы оно ни
|
|
466
|
-
было. Прогону, который разобрал само значение на части или не взял параметра вовсе, назвать значение целиком нечем,
|
|
467
|
-
и тогда остаётся только сообщение.
|
|
468
|
-
|
|
469
|
-
**Два случая, о которых правило сообщает, но не переписывает.** После первого `await` в прогоне параметр — это
|
|
470
|
-
значение, с которым прогон начался, а снимок — значение текущего момента; смена источника обычно перезапускает
|
|
471
|
-
прогон, поэтому обычно они совпадают, — а «обычно» не даёт права на правку. Под `filter` они расходятся законно:
|
|
472
|
-
предикат допускает одни значения и пропускает другие, поэтому источник меняется без перезапуска прогона
|
|
473
|
-
(D168, D296, D379). Запись модификаторов, которую правило прочитать не может — собранную в другом месте, — оно
|
|
474
|
-
считает несущей предикат.
|
|
475
|
-
|
|
476
|
-
Чтение внутри вложенного колбэка прогона — в `timers.delay`, в обработчике подписки — остаётся как есть: этот код
|
|
477
|
-
выполняется позже прогона, и ему нужно значение того момента. Как и всякое правило, которое правит код, это
|
|
478
|
-
разрешает свой контекст точно: `effect`, о котором идёт речь, объявлен на параметре типа `ModelContext` либо на
|
|
479
|
-
контексте `model(Declaration, factory)`, написанном внутри `defineFeature` из `@opetope/runtime` (D309).
|
|
480
|
-
|
|
481
|
-
### `no-write-after-source-write`
|
|
482
|
-
|
|
483
|
-
Запись прогона эффекта в ячейку, которую читает его собственный источник, меняет значение, публикуемое этим
|
|
484
|
-
источником, а уведомление синхронно: прогон отменяется внутри самой записи, поэтому каждая следующая запись прогона
|
|
485
|
-
отбрасывается, а `invoke` после неё отклоняется. Первая из отброшенных записей доходит до репортера как `LostWrite`
|
|
486
|
-
— одна на прогон, и в ней сказано, что остальные ушли вместе с ней (D432); отклонённый `invoke` по-прежнему молчит,
|
|
487
|
-
и молчит любая другая потеря записи. Правило сообщает о записи, сдвигающей источник, когда за ней в том же прогоне
|
|
488
|
-
может следовать ещё одна запись (spec §2.11, D288, D365, D432, D447):
|
|
489
|
-
|
|
490
|
-
```ts
|
|
491
|
-
// сообщается: депозит так и не записан, потому что освобождение ключа завершило прогон
|
|
492
|
-
const key = context.state<string | null>('idempotency-1');
|
|
493
|
-
const deposit = context.state<string | null>(null);
|
|
494
|
-
const source = derive([frames, key], (frame, held) => `${frame}:${String(held)}`);
|
|
495
|
-
|
|
496
|
-
context.effect(source, (_current, { update }) => {
|
|
497
|
-
update(key, null);
|
|
498
|
-
update(deposit, 'deposit-1');
|
|
499
|
-
});
|
|
500
|
-
```
|
|
501
|
-
|
|
502
|
-
Команда, которую прогон зовёт через `invoke`, пишет под той же властью. `invoke` отдаёт команде отмену прогона,
|
|
503
|
-
поэтому один abort кончает обоих, и запись такой команды в ячейку, которую читает источник прогона, завершает прогон
|
|
504
|
-
ровно так же, как его собственная запись. Запись, до которой прогон после этого дошёл, отбрасывается и попадает в
|
|
505
|
-
`LostWrite` наравне с любой другой (D447), — но чаще прогон в этот момент _ждёт_ этот самый вызов, и тогда обещание,
|
|
506
|
-
которого он ждёт, отменяется, а тело не доходит ни до какой записи. Отброшенной записи там нет, рантайму сообщать не
|
|
507
|
-
о чем, поэтому эту форму находит только правило — и сообщает о вызове там, где он написан:
|
|
508
|
-
|
|
509
|
-
```ts
|
|
510
|
-
// сообщается: ключ освобождает запись команды, а до депозита под вызовом прогон не доходит
|
|
511
|
-
const key = context.state<string | null>('idempotency-1');
|
|
512
|
-
const deposit = context.state<string | null>(null);
|
|
513
|
-
const source = derive([frames, key], (frame, held) => `${frame}:${String(held)}`);
|
|
514
|
-
const release = context.command(({ update }: ModelCommandContext<void>) => {
|
|
515
|
-
update(key, null);
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
context.effect(source, async (_current, { invoke, update }) => {
|
|
519
|
-
await invoke(release);
|
|
520
|
-
update(deposit, 'deposit-1');
|
|
521
|
-
});
|
|
522
|
-
```
|
|
523
|
-
|
|
524
|
-
Виден ли дроп по данным — вопрос отдельный, и именно из-за него такой дефект находили в проде, а не в тесте: цикл
|
|
525
|
-
открывает прогон-наследник для нового значения, если его допускает `filter`, и наследник, повторяющий запись, потерю
|
|
526
|
-
скрывает — ячейка оказывается правильной через второй прогон. Потеря остаётся целиком там, где наследник эту запись
|
|
527
|
-
не повторяет, — а именно так и ведёт себя гард (`if (held === null) return`), — и там, где наследник не открывается
|
|
528
|
-
вовсе: так ведёт себя `filter`, отвергающий новое значение (D329). Запись репортеру приходит в каждой из этих форм,
|
|
529
|
-
включая скрытую: она говорит об отброшенной записи, а не о значении, с которым осталась ячейка (D432).
|
|
530
|
-
|
|
531
|
-
Фикса нет: какую из двух записей автор хотел сохранить, знает только он, а сообщение называет три выхода — сделать
|
|
532
|
-
запись в источник последней записью прогона; держать накапливаемое вне источника и читать его через `getSnapshot()`,
|
|
533
|
-
что заодно снимает и второй прогон; либо взять `execution.capture(state)` до сдвигающей записи — это единственный
|
|
534
|
-
выход, который спасает саму эту запись целиком, хотя и не спасает ничего написанного после неё. Называет теми же
|
|
535
|
-
словами, какими их печатает запись `LostWrite`, и равенство этих двух текстов держит `ci:source`: правило автор
|
|
536
|
-
читает раньше, чем отчитается хоть один прогон (D319, D432, D442, D447).
|
|
537
|
-
|
|
538
|
-
**Что доказывается до сообщения.** Три вещи, и четвёртая — там, где сдвигающую запись делает команда; там, где хотя
|
|
539
|
-
бы одна не доказана, правило молчит.
|
|
540
|
-
|
|
541
|
-
_Объявление._ `effect` объявлен на контексте этой библиотеки — `effect` на параметре типа `ModelContext` либо на
|
|
542
|
-
контексте `model(Declaration, factory)`, написанном внутри `defineFeature` из `@opetope/runtime` (D309). Прогон —
|
|
543
|
-
это функция, которую берёт объявление: написанная там же или связанная с именем в этом модуле.
|
|
544
|
-
|
|
545
|
-
_Ячейка входит в источник._ Либо источник — это она сама, либо это `derive` из `@opetope/core` — написанный там, где
|
|
546
|
-
его берёт объявление, или удержанный именем в этой области, — чьи зависимости её называют и чей селектор публикует
|
|
547
|
-
значение этой зависимости. Последнее условие и есть вырезка самого закона: запись, после которой опубликованное
|
|
548
|
-
значение осталось равным, не завершает ничего, поэтому селектор, который лишь задаёт вопрос о своей зависимости —
|
|
549
|
-
`held === null ? 'none' : 'some'`, `!held`, `typeof held`, `held ? a : b`, — публикует для одного ключа то же слово,
|
|
550
|
-
что и для другого, и над ним правило молчит. Молчит и над селектором, который зависимость выбрасывает, и над
|
|
551
|
-
селектором, которого этот файл прочитать не может.
|
|
552
|
-
|
|
553
|
-
_Запись может выполниться после._ Записи сделаны писателем этого прогона, прочитанным под теми же именами, под
|
|
554
|
-
которыми `capture-command-cleanup` читает писателя команды: `execution.update(…)`, `({ update })` или
|
|
555
|
-
`({ update: write })` в списке параметров и `const { update } = execution` в теле. Более поздняя запись обязана
|
|
556
|
-
стоять в том же блоке, в более позднем операторе и без безусловного выхода из прогона между ними: у
|
|
557
|
-
`if (held === null) { update(key, null); return; }` запись под `if` после этой выполниться не может, и о ней не
|
|
558
|
-
сообщается. `break` и `continue` таким выходом не являются: код под ними выполняется.
|
|
559
|
-
|
|
560
|
-
_Эту запись делает команда._ Там, где сдвигающая запись — это вызов, а не запись, вызов сделан через `invoke` этого
|
|
561
|
-
прогона, прочитанный под теми же именами, что и писатель, а его первый аргумент — имя, за которым в этом модуле
|
|
562
|
-
стоит `context.command(body)`, объявленный на контексте модели: `command` секции `own` у фичи берёт тело вторым и
|
|
563
|
-
писателя не выдаёт (D385, D386). Тело — функция, которую берёт это объявление, написанная там же или связанная с
|
|
564
|
-
именем в этом модуле, и хотя бы одна запись через контекст этого тела называет ячейку этого источника. Более поздняя
|
|
565
|
-
запись прогона обязана следовать за вызовом по тому же правилу, по которому она следует за записью.
|
|
566
|
-
|
|
567
|
-
**Граница.** Молчание не доказывает, что запись ляжет. За пределами форм выше правило не читает: источник,
|
|
568
|
-
собранный в другой функции; источник, прочитанный с объекта (`deps.sources` — форма рецепта-защёлки); ячейку,
|
|
569
|
-
приехавшую параметром; `derive` над `derive`; писателя, удержанного замыканием. Сдвигающей записью оно считает только
|
|
570
|
-
`update`, поэтому commit в источник — тот же закон, «`update` или commit» — не сообщается; не сообщается и третья
|
|
571
|
-
форма той же потери — `capture`, взятый **после** сдвигающей записи: он бросает собственную отмену прогона и не
|
|
572
|
-
репортится нигде. Две записи одного оператора не последовательность — ветви `if`, плечи тернарного оператора,
|
|
573
|
-
половины `try`, стороны запятой, — как и две записи одного `case`. Запись во вложенном колбэке прогона остаётся как
|
|
574
|
-
есть: когда выполняется таймер или disposer, прогон уже кончился, и у её потери своя причина. Цикл, у которого запись
|
|
575
|
-
в источник стоит последней, теряет первую запись следующего витка, и этого правило не видит.
|
|
576
|
-
|
|
577
|
-
У вызова своя граница, и это граница одного шага. Команда, приехавшая зависимостью (`invoke(deps.release)`), команда,
|
|
578
|
-
объявленная в другом модуле, команда на контексте, который этот файл разрешить не может, и команда, тело которой
|
|
579
|
-
собрано в другом месте, не называют здесь ничего. Читается ровно один шаг: команда, сдвигающая источник через вторую
|
|
580
|
-
команду, которую зовёт сама, или через `capture` ячейки источника, не сообщается; не сообщается и команда, чьё тело
|
|
581
|
-
пишет в источник писателем, взятым не со своего контекста. И `invoke` прогона, у которого вся потеря — сам вызов
|
|
582
|
-
(записи после вызова нет), тоже не сообщается: там ничего не потеряно — `await` не возвращается, а делать прогону
|
|
583
|
-
больше было нечего (D447).
|
|
584
|
-
|
|
585
|
-
### `capture-command-cleanup`
|
|
586
|
-
|
|
587
|
-
Писатель команды живёт столько же, сколько её вызывающий (D288). Запись в `finally` или `catch` у `try`, который
|
|
588
|
-
ждёт `await`, выполняется после этого `await`, и после отмены вызова она отбрасывается без записи в reporter — так что
|
|
589
|
-
уборка, которая должна была снять флаг занятости или метку ожидания, и есть та запись, которая не ляжет никогда.
|
|
590
|
-
Правило сообщает о такой записи и называет слово, которое ляжет, — commit, взятый до `await` (D319, D330):
|
|
591
|
-
|
|
592
|
-
```ts
|
|
593
|
-
// сообщается: после отмены вызывающего `busy` остаётся `true`
|
|
594
|
-
ctx.command(async ({ input, signal, update }: ModelCommandContext<Payload>) => {
|
|
595
|
-
update(busy, true);
|
|
596
|
-
try {
|
|
597
|
-
await host.send(input, signal);
|
|
598
|
-
} finally {
|
|
599
|
-
update(busy, false);
|
|
600
|
-
}
|
|
601
|
-
});
|
|
602
|
-
|
|
603
|
-
// уборка ложится, пока жива модель
|
|
604
|
-
ctx.command(async ({ capture, input, signal, update }: ModelCommandContext<Payload>) => {
|
|
605
|
-
update(busy, true);
|
|
606
|
-
const done = capture(busy);
|
|
607
|
-
try {
|
|
608
|
-
await host.send(input, signal);
|
|
609
|
-
} finally {
|
|
610
|
-
done.update(() => false);
|
|
611
|
-
}
|
|
612
|
-
});
|
|
613
|
-
```
|
|
614
|
-
|
|
615
|
-
Фикса нет: должна ли уборка лечь после ухода вызывающего — решение автора, а сброс — закон не без причины: поздняя
|
|
616
|
-
запись отменённого вызова не должна затирать то, что написал более новый вызов. Поэтому сообщение называет оба выхода.
|
|
617
|
-
Уборка, которая обязана выполниться для отменённого вызова, идёт через `capture(state)`, взятый до `await`; ответ,
|
|
618
|
-
который не должен лечь после отмены, — отказ, результат, — стоит в `catch` после `rethrowIfCancelled(cause)`. Там, где
|
|
619
|
-
вызывающий отменяет один прогон, чтобы начать следующий, — поиск, который потребитель запускает заново, Command,
|
|
620
|
-
вызванный прогоном эффекта и отменённый более новым значением, — запись старого прогона и есть та, что лечь не должна,
|
|
621
|
-
и об этом говорит комментарий-отключение.
|
|
622
|
-
|
|
623
|
-
При `concurrency: 'parallel'` сообщение commit не советует. Вызовы такой команды перекрываются, поэтому захваченная
|
|
624
|
-
уборка отменённого вызова сняла бы флаг, который ещё держит другой вызов, — флаг, общий для параллельных вызовов,
|
|
625
|
-
гонится в любом случае, — и сообщение говорит держать такое состояние на вызов или оставить ход потребителю через
|
|
626
|
-
`inFlight`. Правило видит порядок, только когда `{ concurrency: 'parallel' }` написан на самом `command`; запись
|
|
627
|
-
модификаторов, собранная в другом месте, читается как очередь по умолчанию, где lane ждёт физического тела и
|
|
628
|
-
захваченная уборка ложится по порядку (D387).
|
|
629
|
-
|
|
630
|
-
Правило — эвристика над синтаксисом и ничего не доказывает о записи, о которой молчит. Писателя тела команды оно
|
|
631
|
-
читает под такими именами: `execution.update(…)`; `({ update })` или `({ update: write })` в списке параметров;
|
|
632
|
-
`const { update } = execution` внутри тела; и `const`, дающий одному из них второе имя, — `const write = update` или
|
|
633
|
-
`const write = execution.update`. Тело, переданное локальной функцией, правило находит. Оно пропускает замыкание,
|
|
634
|
-
держащее писателя (`const clear = () => update(busy, false)`, вызванное в `finally`), писателя, сохранённого не в
|
|
635
|
-
`const`, и контекст, переданный helper-у. `try` считается, когда его защищённый блок ждёт в собственной функции тела:
|
|
636
|
-
`await` или `for await` внутри вложенного колбэка приостанавливает этот колбэк, а не команду.
|
|
637
|
-
|
|
638
|
-
В `catch` запись после `rethrowIfCancelled(cause)`, `signal.throwIfAborted()` или `if (signal.aborted)`, который
|
|
639
|
-
бросает или возвращает, — в блоке, где стоит запись, или в любом охватывающем его блоке, — не сообщается. Такая
|
|
640
|
-
проверка — заявление автора, что следующее за ней не должно выполняться для отменённого вызова, и правило верит ему на
|
|
641
|
-
слово: в `catch (cause) { rethrowIfCancelled(cause); update(busy, false); throw cause }` флаг после отмены остаётся
|
|
642
|
-
`true`, и сообщения нет. Поэтому флаг, который обязан сняться при отмене, пишется в `finally` через `capture(state)`.
|
|
643
|
-
Проверка в `finally` ничего не спасает и сообщается по-прежнему, как и проверка `signal.aborted`, которая не бросает и
|
|
644
|
-
не возвращает. `effect`, `event` и остальные реакции правило не трогает: их прогон отменило более новое значение, и
|
|
645
|
-
сброс его записи — ровно то, что нужно (D288). Контекст разрешается точно: `command` параметра типа `ModelContext`
|
|
646
|
-
либо контекста `model(Declaration, factory)` внутри `defineFeature` из `@opetope/runtime` (D309); собственный
|
|
647
|
-
`command` фичи писателя телу не даёт.
|
|
648
|
-
|
|
649
|
-
### `prefer-model-selection`
|
|
650
|
-
|
|
651
|
-
Компонент, который берёт одну выданную модель и читает её поля по хуку на поле, повторяет одно и то же
|
|
652
|
-
подключение. От трёх полей и выше правило показывает на один выбор вместо них (D205, D214):
|
|
653
|
-
|
|
654
|
-
```js
|
|
655
|
-
'opetope/prefer-model-selection': ['error', { threshold: 3 }],
|
|
656
|
-
```
|
|
657
|
-
|
|
658
|
-
Оно считает `useReadable(model.field)` и `useCommand(model.field)` над переменной из `useModel(Declaration)` без
|
|
659
|
-
выбора и считает каждую выданную модель отдельно: две модели в одном компоненте — это два гранта, а одно и то же
|
|
660
|
-
имя переменной в двух компонентах — два счёта. Фикса нет: форму выбора пишет автор.
|
|
661
|
-
|
|
662
|
-
### `no-retired-vocabulary`
|
|
663
|
-
|
|
664
|
-
Слова, снятые волной D365–D411, — каждое названо вместе с тем, которое его заменило. Правило написано ровно на один
|
|
665
|
-
проход миграции: в этом релизе ни у одного из них нет алиаса, поэтому исходник, который всё ещё пишет такое слово,
|
|
666
|
-
просто не мигрирован, а после миграции правило не срабатывает никогда.
|
|
667
|
-
|
|
668
|
-
| Снято | Стало | Решение |
|
|
669
|
-
| ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------- |
|
|
670
|
-
| `invalidate()` у Resource | `reset()` | D373 |
|
|
671
|
-
| `backpressure` у события | `delivery` | D374 |
|
|
672
|
-
| `kind` внутри записи доставки | `pending` | D374 |
|
|
673
|
-
| `key` внутри записи доставки | `by` | D374 |
|
|
674
|
-
| `activity` у состояния `loading` | `failure` | D375 |
|
|
675
|
-
| `scope.keyed(source, open, { key })` | `scope.each(source, key, open)` | D378 |
|
|
676
|
-
| `skipInitial` | `initial`, с перевёрнутой полярностью | D379 |
|
|
677
|
-
| `when` у эффекта | `filter` **или** `initial` | D379 |
|
|
678
|
-
| `onDispose` | `finalize` | D379 |
|
|
679
|
-
| `externalReadable` | `fromExternal` | D380 |
|
|
680
|
-
| `Call`, `ctx.call`, `runCall` | `Command`, `ctx.command`, `runCommand` | D385 |
|
|
681
|
-
| `policy`, `queueBy`, `lane` | `concurrency` | D387 |
|
|
682
|
-
| `ctx.calls`, `own.calls`, `useCommands` | `ctx.select`, `own.select`, `useModel(Declaration, select)` | D388 |
|
|
683
|
-
| `once` | `memoize` | D389 |
|
|
684
|
-
| `from` у `defineCondition` | `source` | D411 |
|
|
685
|
-
| `when` у вклада | `enabled` | D411 |
|
|
686
|
-
| `overflow: 'reject'` у live Resource | `overflow: 'drop'` | D411 |
|
|
687
|
-
| `status` у `CommandOutcome` | `kind` | D411 |
|
|
688
|
-
| `ResourceActivity` из `@opetope/devtools` | `ResourceNodeActivity` | D411 |
|
|
689
|
-
| `ResourceDisposer` | `Disposer` | D411 |
|
|
690
|
-
| `NavigatorPolicy`, `surface.policy` | `NavigatorCatalogue`, `surface.catalogue` | D411 |
|
|
691
|
-
| `ApplicationConditionBinding`, `ApplicationExecution` и `ApplicationImportBinding` из `@opetope/runtime/internal` | те же имена, из `@opetope/runtime` | D411 |
|
|
692
|
-
|
|
693
|
-
**Одну строку по слову не выполнить, и отчёт договаривает остальное.** `ctx.call` становится `ctx.command`, а
|
|
694
|
-
вместе с ним тело `(input, context) => …` становится телом, которое принимает один свой контекст, — и входу больше
|
|
695
|
-
негде называться: частичного вывода типовых аргументов в TypeScript нет, и `command<Deposit>(…)` не маршрут.
|
|
696
|
-
Единственное место — аннотация деструктурируемого контекста: `({ input, update }: ModelCommandContext<Deposit>)` у
|
|
697
|
-
модели и `({ input, source }: FeatureCommandContext<typeof within, Deposit>)` у фичи, — поэтому отчёт называет эту
|
|
698
|
-
аннотацию рядом со словом. Переименование, выполненное дословно без неё, оставляет это правило молчащим, а сборку
|
|
699
|
-
красной, и каждый отказ при этом стоит на верно написанном коде (D444).
|
|
700
|
-
|
|
701
|
-
**Автофикса нет, и это решение, а не недоделка (D404).** Часть строк здесь не один-к-одному. `policy`, `queueBy` и
|
|
702
|
-
`lane` схлопываются в одно `concurrency`, чья форма зависит от того, какую комбинацию написала запись: `{ by }`,
|
|
703
|
-
`{ by, lane }` и `{ lane, pending: 'latest' }` — три разных значения из тех же трёх слов. `when` у эффекта
|
|
704
|
-
становится `filter` **или** `initial`, и что именно — вопрос о том, что проверяет предикат:
|
|
705
|
-
`filter: (_current, previous) => previous !== undefined` — это вообще не фильтр, а `initial: false`, и руководство
|
|
706
|
-
автора требует именно этого. А `skipInitial` становится `initial` с перевёрнутой полярностью, то есть единственная
|
|
707
|
-
строка, выглядящая чисто механической, — ровно та, которая скомпилируется и будет значить обратное. Автофикс,
|
|
708
|
-
угадавший неверно, молча перепишет исходник потребителя, а это хуже отказа, — поэтому каждое сообщение называет
|
|
709
|
-
снятое слово, называет замену и прямо говорит там, где замена является выбором.
|
|
710
|
-
|
|
711
|
-
**Граница.** Принадлежность доказывается символом, а не написанием. Снятый член репортится только на объявляющем
|
|
712
|
-
контексте этой библиотеки — параметре, аннотированном `ModelContext` из `@opetope/core`, или секции `own`,
|
|
713
|
-
написанной там, где её принимает `defineFeature` из `@opetope/runtime`, — и разрешается через алиас или
|
|
714
|
-
деструктурированный член так же, как `no-subscribe-outside-models` разрешает ingress объявленного узла. Снятое имя
|
|
715
|
-
репортится только там, где его экспортирует модуль `@opetope/*`, а там, где имя снято на одном входе и живо на
|
|
716
|
-
другом, модуль спрашивается точно: `ResourceActivity` по-прежнему то, как `@opetope/core` называет фоновую работу
|
|
717
|
-
состояния `ready`, а три имени `@opetope/runtime/internal` — по-прежнему имена, только на том входе, с которого их
|
|
718
|
-
берёт автор. Гейт вклада репортится на секции `provides`, которую принимает `defineFeature` из `@opetope/runtime`, — так
|
|
719
|
-
же, как объявленный узел репортится на `own`; `status` свершившегося ответа — только на `outcome` от `useCommand`
|
|
720
|
-
из `@opetope/react`, а `policy` поверхности — только на значении, объявленном через `defineSurface` из
|
|
721
|
-
`@opetope/navigation/react`. Resource — это значение, объявленное через
|
|
722
|
-
`resource.load` или `resource.live` такого контекста, либо запись, которую отвечает `useResource` из
|
|
723
|
-
`@opetope/react`; его состояние — `state` этой записи или `getSnapshot()` от него.
|
|
724
|
-
`activity` репортится только внутри сужения, доказывающего, что состояние — `loading`: `if`, тернарник или левая
|
|
725
|
-
половина `&&`, написанные про то же самое выражение, — потому что у `ready` это поле осталось.
|
|
726
|
-
|
|
727
|
-
Молчание здесь и есть смысл. `Call` внутри `'Margin Call'` и внутри ключей каталога переводов, `{ once: true }` у
|
|
728
|
-
`addEventListener`, собственный параметр `policy` хоста и `policy` чужого объекта, `Navigator.reset()` и поле
|
|
729
|
-
`delivery` чужой записи — всё
|
|
730
|
-
это не трогается, как и `when` у фичи и у `scope.while`, где слово не снято вовсе, и `status` чужой записи. Не трогается и то,
|
|
731
|
-
чего правило не видит: Resource, до которого дошли через запись модели, а не через объявление, которое его
|
|
732
|
-
построило; состояние, суженное в `switch` или внутри helper-а; запись опций, собранная в другом модуле. Поэтому
|
|
733
|
-
молчание правила не доказывает, что миграция закончена: вторая половина — компилятор, который теперь печатает
|
|
734
|
-
замену для каждого слова, которое может назвать (D401, D444). Шесть строк выше — слова, которые носила запись
|
|
735
|
-
опций, и каждая такая запись называет своё снятое слово, чтобы отказ нёс замену: `skipInitial`, `when` и
|
|
736
|
-
`onDispose` у эффекта, `backpressure` у события, `when` у вклада и `from` у `defineCondition`.
|
|
737
|
-
|
|
738
|
-
## Coexistence with key sorting
|
|
739
|
-
|
|
740
|
-
Хост, который сортирует ключи объектов по алфавиту, разойдётся с `define-feature-property-order`: секции фичи
|
|
741
|
-
упорядочены по смыслу, а не по имени. `recommended` не трогает ни одного правила сортировки — примирять их дело
|
|
742
|
-
хоста, и способа два.
|
|
743
|
-
|
|
744
|
-
**`perfectionist/sort-objects`** принимает именованный порядок ровно для двух callee, которые объявляют секции, и
|
|
745
|
-
сохраняет обычную конфигурацию для всех остальных объектов:
|
|
746
|
-
|
|
747
|
-
```js
|
|
748
|
-
'perfectionist/sort-objects': [
|
|
749
|
-
'error',
|
|
750
|
-
{
|
|
751
|
-
customGroups: [
|
|
752
|
-
{ elementNamePattern: '^id$', groupName: 'feature-id' },
|
|
753
|
-
{ elementNamePattern: '^imports$', groupName: 'feature-imports' },
|
|
754
|
-
{ elementNamePattern: '^requires$', groupName: 'feature-requires' },
|
|
755
|
-
{ elementNamePattern: '^own$', groupName: 'feature-own' },
|
|
756
|
-
{ elementNamePattern: '^exports$', groupName: 'feature-exports' },
|
|
757
|
-
{ elementNamePattern: '^provides$', groupName: 'feature-provides' },
|
|
758
|
-
{ elementNamePattern: '^when$', groupName: 'feature-when' },
|
|
759
|
-
{ elementNamePattern: '^body$', groupName: 'feature-body' },
|
|
760
|
-
],
|
|
761
|
-
groups: [
|
|
762
|
-
'feature-id',
|
|
763
|
-
'feature-imports',
|
|
764
|
-
'feature-requires',
|
|
765
|
-
'feature-own',
|
|
766
|
-
'feature-exports',
|
|
767
|
-
'feature-provides',
|
|
768
|
-
'feature-when',
|
|
769
|
-
'feature-body',
|
|
770
|
-
'unknown',
|
|
771
|
-
],
|
|
772
|
-
order: 'asc',
|
|
773
|
-
type: 'natural',
|
|
774
|
-
useConfigurationIf: { callingFunctionNamePattern: '^(?:defineFeature|defineFeature\\.body)$' },
|
|
775
|
-
},
|
|
776
|
-
{ order: 'asc', type: 'natural' },
|
|
777
|
-
],
|
|
778
|
-
```
|
|
779
|
-
|
|
780
|
-
**ESLint core `sort-keys` и правило с тем же id в Oxlint** не настраиваются по callee и не знают исключения на
|
|
781
|
-
объявление: они сообщают о каждом ключе, который идёт после большего, в любом объекте. Выключите правило для
|
|
782
|
-
файлов, объявляющих фичи, или оберните объявление в `/* eslint-disable sort-keys */` и
|
|
783
|
-
`/* eslint-enable sort-keys */` — `// eslint-disable-next-line sort-keys` покрывает одну строку, то есть глушит
|
|
784
|
-
одну секцию, а не многострочное объявление. Oxlint читает те же директивы под своим префиксом,
|
|
785
|
-
`// oxlint-disable-next-line sort-keys`.
|
|
786
|
-
|
|
787
|
-
Фикс этого правила переставляет ключи только внутри объекта вызова `defineFeature`, поэтому он никогда не трогает
|
|
788
|
-
то, чем правило сортировки владеет в остальном файле.
|
|
789
|
-
|
|
790
|
-
## Running the rules under Oxlint
|
|
791
|
-
|
|
792
|
-
Oxlint читает ESLint-плагины через `jsPlugins`: импортирует модуль по пути и берёт его default export. Правила
|
|
793
|
-
остаются на чистом rule API — `meta`, `create(context)` с visitors, `context.report` с `fix`, `getText` и
|
|
794
|
-
`getCommentsInside` у source code — без typed services и без ESLint-only возможностей, поэтому их выполняют оба
|
|
795
|
-
линтера, вместе с автофиксом:
|
|
796
|
-
|
|
797
|
-
```json
|
|
798
|
-
{
|
|
799
|
-
"extends": ["./node_modules/@opetope/lint/oxlintrc.json"],
|
|
800
|
-
"jsPlugins": [{ "name": "opetope", "specifier": "./node_modules/@opetope/lint/dist/index.js" }]
|
|
801
|
-
}
|
|
802
|
-
```
|
|
803
|
-
|
|
804
|
-
Это две строки, и ни одна из них не транскрипция. `@opetope/lint/oxlintrc.json` сгенерирован из
|
|
805
|
-
`configs.recommended.rules` и поставляется в пакете, поэтому правило, которое пакет добавил, приходит вместе с
|
|
806
|
-
обновлением, а не теряется в написанной руками severity-карте. `extends` в `.oxlintrc.json` принимает **путь
|
|
807
|
-
файла**, разрешаемый относительно конфига, который его назвал, — не specifier пакета; поэтому фрагмент лежит в корне
|
|
808
|
-
пакета, и путь, который пишет хост, совпадает с именем его экспорта. Конфигурации сливаются от первой к последней,
|
|
809
|
-
поэтому правило, которое проект хочет строже, он пишет в собственных `rules` после `extends`: три `off` выше — ровно
|
|
810
|
-
те, которые проект включает по своим каталогам.
|
|
811
|
-
|
|
812
|
-
Фрагмент несёт правила и ничего больше. Oxlint действительно сливает через `extends` и запись `jsPlugins`, но хост,
|
|
813
|
-
который объявляет плагин сам — под пакетом-обёрткой, как того требует изолированная установка peer-ов, — объявит имя
|
|
814
|
-
`opetope` второй раз, и это отвергнет всю конфигурацию. Откуда берётся плагин — деплой хоста; severities — контракт
|
|
815
|
-
этого пакета.
|
|
816
|
-
|
|
817
|
-
Алиас `name` фиксирует namespace, поэтому id правила одинаков в обоих линтерах. `jsPlugins` находится в alpha и вне
|
|
818
|
-
semver: `npm run ci:test` запускает собранный плагин под Oxlint на валидной и невалидной фикстуре — через конфиг,
|
|
819
|
-
который `extends`-ит поставляемый фрагмент, — и проверяет фикс, который тот пишет: поломка этого моста падает здесь,
|
|
820
|
-
а не у хоста.
|
|
821
|
-
|
|
822
|
-
`--fix-suggestions` применяет первую suggestion сообщения, ничего не спрашивая, поэтому suggestion здесь — только
|
|
823
|
-
такая правка, которую оправдывает сам прочитанный код. `no-command-in-deps` предлагает те пути, которые читает сам
|
|
824
|
-
колбэк: статус, за которым он следит, раньше `run`, который он вызывает. Там, где правило не видит использование
|
|
825
|
-
насквозь — объект передан дальше, разобран или прочитан за пределами известных ему членов, — оно сообщает и не
|
|
826
|
-
предлагает ничего, и зависимость остаётся написанной так, как её написали, пока её не изменит автор.
|
|
827
|
-
|
|
828
|
-
`no-snapshot-in-update` держит ту же линию с другой стороны: его suggestion — это тот самый фикс, который правило
|
|
829
|
-
не стало писать без спроса: параметр, затеняющий имя, которое в файле уже есть, или именованное чтение, которое
|
|
830
|
-
остаётся ради другого читателя. Обе правки оставляют код делать то же, что он делал. Там, где значение вообще не
|
|
831
|
-
может стать телом апдейтера, предлагать нечего, и сообщение стоит одно.
|
|
832
|
-
|
|
833
|
-
## Checks
|
|
834
|
-
|
|
835
|
-
Из этого пакета: `npm run ci:test`, `npm run ci:type`, `npm run ci:eslint`, `npm run build`. Тест моста Oxlint
|
|
836
|
-
читает `dist`, поэтому `npm run build` идёт первым. `ci:test` заодно проверяет, что закоммиченный `oxlintrc.json`
|
|
837
|
-
всё ещё равен `configs.recommended.rules`; после изменения этого конфига выполните `npm run oxlintrc:generate` и
|
|
838
|
-
закоммитьте результат — именно поэтому файл не пишет сборка.
|