@opetope/lint 0.9.6 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +83 -16
- package/README.ru.md +85 -18
- package/dist/declaration-ingress.d.ts +6 -1
- package/dist/declaration-ingress.js +1 -1
- package/dist/declaration-ingress.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/rules/capture-command-cleanup.d.ts +6 -0
- package/dist/rules/capture-command-cleanup.js +2 -0
- package/dist/rules/capture-command-cleanup.js.map +1 -0
- package/dist/rules/no-subscribe-outside-models.js +1 -1
- package/dist/rules/no-subscribe-outside-models.js.map +1 -1
- package/oxlintrc.json +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# @opetope/lint
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 12abb62: **One Resource family: `state` observes, `acquire` holds (D349-D361).** `ctx.resource(from, target, load, options)`
|
|
8
|
+
and `ctx.stream(...)` are replaced by `ctx.resource.load({ ... })` and `ctx.resource.live({ ... })`, and `own.resource`
|
|
9
|
+
carries the same two forms with an import in `source`. The answer is a `Resource<Data, Key>` that is not a `Readable`:
|
|
10
|
+
`state` is a passive `Readable<ResourceState<Data, Key>>`, `acquire()` takes a lease and returns its release, and
|
|
11
|
+
`refresh()`, `retry()` and `invalidate()` are the verbs. `ResourceSnapshot`, `retain()`, `retention`,
|
|
12
|
+
`StreamBackpressure` (now `EventBackpressure`) and Kit's `projectResourceState` are removed; `derive(resource, ...)`
|
|
13
|
+
no longer compiles, because reading data and asking for it are two rights and one of them is `acquire`.
|
|
14
|
+
|
|
15
|
+
- **The declaration is a record.** `lifetime: 'lease' | 'owner'` is required and there is no default; `identity` names
|
|
16
|
+
`key` and `scope`, and a request that is not an atom must write `key`; `enabled` and `cache` are data on the same
|
|
17
|
+
record. A live connection writes `connect`, positional `apply` reducers and `delivery`, and a one-shot writes
|
|
18
|
+
`load`. `pagination` is a capability of the declaration, so a paginated Resource answers `pagination` with its own
|
|
19
|
+
`state` and `loadNext`.
|
|
20
|
+
- **No automatic recovery (D351).** `retry`/`reconnect` budgets are gone: a rejection is the terminal failure of that
|
|
21
|
+
logical attempt, and a transient one belongs to the source adapter. `delivery` conflates instead of holding a
|
|
22
|
+
backlog, and `ResourceState` carries one `activity` over both channels, so a failed channel behind a live one is
|
|
23
|
+
visible instead of hidden.
|
|
24
|
+
- **React (D359).** `useResource(resource)` answers `{ state, refresh, retry, invalidate, pagination }` and takes the
|
|
25
|
+
lease in a commit effect; a Resource field in a `useModel` selection takes the same lease. `useReadable(resource)`
|
|
26
|
+
is a compile error; `useReadable(resource.state)` stays passive.
|
|
27
|
+
- **Devtools (D358).** The inspection protocol moves to `opetope.runtime-activity/4` with no compatibility branch:
|
|
28
|
+
recreate exports from a matching runtime.
|
|
29
|
+
- **Budgets.** Two ceilings ratchet, both recorded in D361. `public feature consumer` measures 38.63 kB brotli and
|
|
30
|
+
moves 36.5 kB to 39 kB: the pagination capability is 0.82 kB of it, measured by building the same fixture with the
|
|
31
|
+
capability unreachable. Devtools' `react panel consumer` measures 27.29 kB and moves 27.2 kB to 27.3 kB for the
|
|
32
|
+
seven Resource facts of protocol `/4`. Everything else holds: `application graph consumer` 47.34 of 48 kB,
|
|
33
|
+
`@opetope/react` 12.08 of 18 kB, `react coverage help surface` 1.49 of 1.5 kB. Public types ratchet 44 to 55
|
|
34
|
+
against the target of 60, values stay 39, and the Kit bucket ratchets 4/4 to 3/3. No allocation or timing row
|
|
35
|
+
keeps its name and gets a larger number.
|
|
36
|
+
|
|
37
|
+
Migration is written down step by step in `docs/resource-migration.md`; `docs/spec.md` section 2.3 is the normative
|
|
38
|
+
declaration.
|
|
39
|
+
|
|
40
|
+
## 0.9.7
|
|
41
|
+
|
|
42
|
+
### Patch Changes
|
|
43
|
+
|
|
44
|
+
- 29615cd: **`opetope/capture-command-cleanup` (D330).** A write through the writer of a model command inside the `finally` or
|
|
45
|
+
`catch` of a `try` that awaits is dropped once the call is cancelled. The rule reports it and names both ways out: a
|
|
46
|
+
cleanup that must run for a cancelled call goes through `capture(state)` taken before the `await`, and an answer that
|
|
47
|
+
must not land after cancellation belongs after `rethrowIfCancelled(cause)`. On a command declared with
|
|
48
|
+
`{ policy: 'parallel' }` it does not advise a commit, because the cleanup of a cancelled call would clear state another
|
|
49
|
+
call still holds. A write in a `catch` after `rethrowIfCancelled(cause)`, `signal.throwIfAborted()` or an
|
|
50
|
+
`if (signal.aborted)` that throws or returns — in its block or an enclosing one — is not reported, and neither are the
|
|
51
|
+
reactions (`effect`, `event`), whose dropped write is the law. The rule is a syntactic heuristic with no autofix; its
|
|
52
|
+
README lists what it does not follow. It is `error` in `configs.recommended` and in the shipped `oxlintrc.json`.
|
|
53
|
+
|
|
3
54
|
## 0.9.6
|
|
4
55
|
|
|
5
56
|
No changes in this release.
|
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ both it and `eslint` are peer dependencies.
|
|
|
44
44
|
|
|
45
45
|
Every rule that holds wherever Opetope is written: `when-predicate`, `define-feature-property-order`,
|
|
46
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` and `require-declared-models` as errors. A rule that needs to know where a host keeps its files is off here and arrives
|
|
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
48
|
through `layers`; `prefer-model-selection` is off because the number of hooks a component may keep is a taste a
|
|
49
49
|
project settles for itself.
|
|
50
50
|
|
|
@@ -52,6 +52,7 @@ project settles for itself.
|
|
|
52
52
|
|
|
53
53
|
| Rule | `recommended` | Turned on by |
|
|
54
54
|
| ------------------------------- | ------------- | ------------------------------------------- |
|
|
55
|
+
| `capture-command-cleanup` | `error` | |
|
|
55
56
|
| `define-feature-property-order` | `error` | |
|
|
56
57
|
| `id-naming` | `error` | |
|
|
57
58
|
| `layer-placement` | `off` | `layers({ integration, models, ui })` |
|
|
@@ -389,7 +390,8 @@ that is not one of the six hooks belongs to whoever declared it.
|
|
|
389
390
|
|
|
390
391
|
A subscription written by hand owns a cleanup that nothing around it can see. The rule reports `x.subscribe(...)`
|
|
391
392
|
and stays off until `configs.layers` names the model layer that is allowed to hold one; in a component the reader
|
|
392
|
-
is `useReadable` or a model selection, and in a feature or a model it is `effect`, `event` or `
|
|
393
|
+
is `useReadable` or a model selection, and in a feature or a model it is `effect`, `event` or the `connect` of
|
|
394
|
+
`resource.live`.
|
|
393
395
|
|
|
394
396
|
Handing the function over without calling it is not a subscription written here:
|
|
395
397
|
`useSyncExternalStore(source.subscribe, source.getSnapshot)` passes a reference, and the reader owns what it starts.
|
|
@@ -400,22 +402,24 @@ silent there — this is the shape its own message asks for (D299):
|
|
|
400
402
|
|
|
401
403
|
| Callback | Where | Ingress |
|
|
402
404
|
| ---------------------------------------------- | --------------------------- | ------- |
|
|
403
|
-
| `
|
|
405
|
+
| `resource.live({ connect })` | the `connect` member | yes |
|
|
404
406
|
| `event(from, subscribe, run)` | second argument | yes |
|
|
405
407
|
| `scope.while` / `scope.switch` / `scope.keyed` | `open`, second argument | yes |
|
|
406
408
|
| `attach(source, { open })` | the `open` member | yes |
|
|
407
409
|
| `attach(source, { open, close })` | the `close` member | no |
|
|
408
|
-
| `
|
|
410
|
+
| `apply`, `load`, `run`, `when`, `key` | modifiers of the same calls | no |
|
|
411
|
+
|
|
412
|
+
`resource.load` opens no subscription and has no ingress at all, so a `subscribe` written in its `load` is reported
|
|
413
|
+
like any other hand-written one (D349).
|
|
409
414
|
|
|
410
415
|
```ts
|
|
411
|
-
own: ({ imports,
|
|
412
|
-
book:
|
|
413
|
-
|
|
414
|
-
() => 'BTCUSD',
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
),
|
|
416
|
+
own: ({ imports, resource }) => ({
|
|
417
|
+
book: resource.live({
|
|
418
|
+
apply: { change: (_current: ResourceData<Quote>, change: Quote) => change },
|
|
419
|
+
connect: ({ emit, signal, source }) => source.repository.subscribe('BTCUSD', emit, signal),
|
|
420
|
+
lifetime: 'owner',
|
|
421
|
+
source: imports.exchange,
|
|
422
|
+
}),
|
|
419
423
|
});
|
|
420
424
|
```
|
|
421
425
|
|
|
@@ -425,14 +429,14 @@ right where a `defineFeature`/`defineFeature.body` **taken from `@opetope/runtim
|
|
|
425
429
|
factory — the second argument of `model(Declaration, factory)` on that same `own`, or a function whose first
|
|
426
430
|
parameter is annotated `ModelContext` from `@opetope/core`. Unlike `no-command-in-deps`, these two anchors take the
|
|
427
431
|
package name and nothing else: a relative import here would let a host's own `./my-own-context` silence the rule.
|
|
428
|
-
`own.
|
|
429
|
-
(`{
|
|
432
|
+
`own.resource.live(...)` and a destructured `resource.live(...)` read alike, an alias answers with the key it was
|
|
433
|
+
taken from (`{ resource: materialize }` is still `resource`), and `own.scope.while(...)` reads like the destructured
|
|
430
434
|
`scope.while(...)`.
|
|
431
435
|
|
|
432
|
-
Four shapes therefore keep the report, and each is a limit of what syntax proves: a `
|
|
436
|
+
Four shapes therefore keep the report, and each is a limit of what syntax proves: a `resource.live` traced to none of
|
|
433
437
|
those anchors (imported from elsewhere, unresolvable, or belonging to another `defineFeature`); a callback written
|
|
434
438
|
elsewhere and passed in by name, which is not lexically inside; an `own` section hoisted to its own binding
|
|
435
|
-
(`const own = ({
|
|
439
|
+
(`const own = ({ resource }) => …; defineFeature(id, { own })`); and `model(Decl, createOrderModel)` where the factory
|
|
436
440
|
is a named function without the `ModelContext` annotation.
|
|
437
441
|
|
|
438
442
|
### `prefer-effect-current`
|
|
@@ -463,6 +467,69 @@ runs later than the run does, and the value of that moment is the one it wants.
|
|
|
463
467
|
this one resolves its context exactly: the `effect` it acts on is the one declared on a `ModelContext` parameter or
|
|
464
468
|
on the context of a `model(Declaration, factory)` written inside a `defineFeature` of `@opetope/runtime` (D309).
|
|
465
469
|
|
|
470
|
+
### `capture-command-cleanup`
|
|
471
|
+
|
|
472
|
+
The writer of a command lives as long as its caller (D288). A write in the `finally` or the `catch` of a `try` that
|
|
473
|
+
awaits runs after the `await`, and once the call is cancelled it is dropped without a record — so the cleanup that
|
|
474
|
+
was supposed to clear a busy flag or a pending mark is exactly the write that never lands. The rule reports that write
|
|
475
|
+
and names the word that does land, a commit taken before the `await` (D319, D330):
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
// reported: once the caller is cancelled, `busy` stays `true`
|
|
479
|
+
ctx.call(async (input, { signal, update }) => {
|
|
480
|
+
update(busy, true);
|
|
481
|
+
try {
|
|
482
|
+
await host.send(input, signal);
|
|
483
|
+
} finally {
|
|
484
|
+
update(busy, false);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// the cleanup lands while the model lives
|
|
489
|
+
ctx.call(async (input, { capture, signal, update }) => {
|
|
490
|
+
update(busy, true);
|
|
491
|
+
const done = capture(busy);
|
|
492
|
+
try {
|
|
493
|
+
await host.send(input, signal);
|
|
494
|
+
} finally {
|
|
495
|
+
done.update(() => false);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
There is no fix: whether a cleanup must land after its caller left is the author's decision, and the drop is the law
|
|
501
|
+
for a reason — a late write of a cancelled call must not overwrite what a newer call wrote. The report therefore names
|
|
502
|
+
both ways out. A cleanup that must run for a cancelled call goes through `capture(state)` taken before the `await`; an
|
|
503
|
+
answer that must not land after cancellation — a failure, a result — belongs in a `catch` after
|
|
504
|
+
`rethrowIfCancelled(cause)`. Where a caller cancels one run to start the next — a search its consumer issues again, a
|
|
505
|
+
Call an effect run invokes and a newer value cancels — the older run's write is exactly the one that should not land,
|
|
506
|
+
and a disable comment says so.
|
|
507
|
+
|
|
508
|
+
Under `policy: 'parallel'` the report does not advise a commit. Calls of such a command overlap, so a cancelled call's
|
|
509
|
+
captured cleanup would clear a flag another call still holds — a flag shared by parallel calls is racy either way —
|
|
510
|
+
and the report says to keep that state per call, or to leave progress to the consumer's `inFlight`. The rule sees the
|
|
511
|
+
policy only when `{ policy: 'parallel' }` is written on the `call` itself; a modifier record assembled elsewhere reads
|
|
512
|
+
as the default queue, where the lane waits for the physical body and a captured cleanup lands in order.
|
|
513
|
+
|
|
514
|
+
The rule is a heuristic over syntax, and it proves nothing about a write it does not report. It reads the writer of a
|
|
515
|
+
command body under these names: `execution.update(…)`; `({ update })` or `({ update: write })` in the parameter list;
|
|
516
|
+
`const { update } = execution` inside the body; and a `const` that gives one of those a second name —
|
|
517
|
+
`const write = update` or `const write = execution.update`. It follows a body passed as a local function. It misses a
|
|
518
|
+
closure that holds the writer (`const clear = () => update(busy, false)` called in `finally`), a writer stored anywhere
|
|
519
|
+
but a `const`, and a context handed to a helper. A `try` counts when its protected block awaits in the body's own
|
|
520
|
+
function: an `await` or a `for await` inside a nested callback suspends that callback, not the command.
|
|
521
|
+
|
|
522
|
+
In a `catch`, a write that follows `rethrowIfCancelled(cause)`, `signal.throwIfAborted()` or an `if (signal.aborted)`
|
|
523
|
+
that throws or returns — in the block that holds the write or in any block around it — is not reported. Such a guard
|
|
524
|
+
is the author's statement that what follows must not run for a cancelled call, and the rule takes it at its word: in
|
|
525
|
+
`catch (cause) { rethrowIfCancelled(cause); update(busy, false); throw cause }` the flag stays `true` on cancellation
|
|
526
|
+
and nothing is reported. A flag that must clear on cancellation is therefore written in `finally` through
|
|
527
|
+
`capture(state)`. A guard in a `finally` saves nothing and is still reported, and so is a check of `signal.aborted`
|
|
528
|
+
that neither throws nor returns. `effect`, `event` and the other reactions are left alone: a newer value is what
|
|
529
|
+
cancelled their run, and dropping its write is the point (D288). The context is resolved exactly: the `call` of a
|
|
530
|
+
`ModelContext` parameter or of the context of `model(Declaration, factory)` inside a `defineFeature` of
|
|
531
|
+
`@opetope/runtime` (D309); a feature's own `call` hands its body no writer.
|
|
532
|
+
|
|
466
533
|
### `prefer-model-selection`
|
|
467
534
|
|
|
468
535
|
A component that takes one granted model and reads its fields through a hook each repeats the same wiring. From
|
package/README.ru.md
CHANGED
|
@@ -44,7 +44,7 @@ export default [
|
|
|
44
44
|
|
|
45
45
|
Все правила, которые действуют везде, где пишут на Opetope: `when-predicate`, `define-feature-property-order`,
|
|
46
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` и `require-declared-models` как error. Правило, которому нужно знать, где хост держит свои файлы, здесь выключено и
|
|
47
|
+
`no-redundant-const-tuple`, `no-command-in-deps`, `prefer-effect-current`, `capture-command-cleanup` и `require-declared-models` как error. Правило, которому нужно знать, где хост держит свои файлы, здесь выключено и
|
|
48
48
|
приходит через `layers`; `prefer-model-selection` выключено потому, что число hooks, которое компонент вправе
|
|
49
49
|
держать, каждый проект решает для себя.
|
|
50
50
|
|
|
@@ -52,6 +52,7 @@ export default [
|
|
|
52
52
|
|
|
53
53
|
| Правило | `recommended` | Включает |
|
|
54
54
|
| ------------------------------- | ------------- | ----------------------------------------------- |
|
|
55
|
+
| `capture-command-cleanup` | `error` | |
|
|
55
56
|
| `define-feature-property-order` | `error` | |
|
|
56
57
|
| `id-naming` | `error` | |
|
|
57
58
|
| `layer-placement` | `off` | `layers({ integration, models, ui })` |
|
|
@@ -391,7 +392,7 @@ namespace. Только `const` держит тот хук, которым ег
|
|
|
391
392
|
|
|
392
393
|
Подписка, написанная руками, владеет уборкой, которой не видит ничто вокруг. Правило сообщает о `x.subscribe(...)`
|
|
393
394
|
и молчит, пока `configs.layers` не назвал слой моделей, которому такая подписка разрешена; в компоненте читатель —
|
|
394
|
-
это `useReadable` или выбор модели, а в фиче и в модели — `effect`, `event` или `
|
|
395
|
+
это `useReadable` или выбор модели, а в фиче и в модели — `effect`, `event` или `connect` у `resource.live`.
|
|
395
396
|
|
|
396
397
|
Передача функции без вызова подпиской здесь не является:
|
|
397
398
|
`useSyncExternalStore(source.subscribe, source.getSnapshot)` передаёт ссылку, и читатель владеет тем, что начал.
|
|
@@ -402,22 +403,24 @@ Disposer, который возвращает такой колбэк, прин
|
|
|
402
403
|
|
|
403
404
|
| Колбэк | Где | Ингресс |
|
|
404
405
|
| ---------------------------------------------- | --------------------------- | ------- |
|
|
405
|
-
| `
|
|
406
|
+
| `resource.live({ connect })` | член `connect` | да |
|
|
406
407
|
| `event(from, subscribe, run)` | второй аргумент | да |
|
|
407
408
|
| `scope.while` / `scope.switch` / `scope.keyed` | `open`, второй аргумент | да |
|
|
408
409
|
| `attach(source, { open })` | член `open` | да |
|
|
409
410
|
| `attach(source, { open, close })` | член `close` | нет |
|
|
410
|
-
| `
|
|
411
|
+
| `apply`, `load`, `run`, `when`, `key` | модификаторы тех же вызовов | нет |
|
|
412
|
+
|
|
413
|
+
`resource.load` не открывает подписки и ингресса не имеет вовсе, поэтому `subscribe`, написанный в его `load`,
|
|
414
|
+
сообщается как любая другая подписка руками (D349).
|
|
411
415
|
|
|
412
416
|
```ts
|
|
413
|
-
own: ({ imports,
|
|
414
|
-
book:
|
|
415
|
-
|
|
416
|
-
() => 'BTCUSD',
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
),
|
|
417
|
+
own: ({ imports, resource }) => ({
|
|
418
|
+
book: resource.live({
|
|
419
|
+
apply: { change: (_current: ResourceData<Quote>, change: Quote) => change },
|
|
420
|
+
connect: ({ emit, signal, source }) => source.repository.subscribe('BTCUSD', emit, signal),
|
|
421
|
+
lifetime: 'owner',
|
|
422
|
+
source: imports.exchange,
|
|
423
|
+
}),
|
|
421
424
|
});
|
|
422
425
|
```
|
|
423
426
|
|
|
@@ -427,14 +430,14 @@ own: ({ imports, stream }) => ({
|
|
|
427
430
|
второй аргумент `model(Declaration, factory)` на том же `own` либо функция, первый параметр которой аннотирован
|
|
428
431
|
`ModelContext` из `@opetope/core`. В отличие от `no-command-in-deps`, эти два якоря принимают только имя пакета:
|
|
429
432
|
относительный импорт здесь позволил бы собственному `./my-own-context` хоста заглушить правило.
|
|
430
|
-
`own.
|
|
431
|
-
достали (`{
|
|
432
|
-
деструктурированный `scope.while(...)`.
|
|
433
|
+
`own.resource.live(...)` и деструктурированный `resource.live(...)` читаются одинаково, алиас отвечает тем ключом,
|
|
434
|
+
из которого его достали (`{ resource: materialize }` — это по-прежнему `resource`), а `own.scope.while(...)`
|
|
435
|
+
читается как деструктурированный `scope.while(...)`.
|
|
433
436
|
|
|
434
|
-
Поэтому сообщение остаётся в четырёх формах, и каждая — граница того, что доказывает синтаксис: `
|
|
435
|
-
правило не проследило ни до одного якоря (импортированный откуда-то ещё, неразрешимый или принадлежащий чужому
|
|
437
|
+
Поэтому сообщение остаётся в четырёх формах, и каждая — граница того, что доказывает синтаксис: `resource.live`,
|
|
438
|
+
который правило не проследило ни до одного якоря (импортированный откуда-то ещё, неразрешимый или принадлежащий чужому
|
|
436
439
|
`defineFeature`); колбэк, написанный в другом месте и переданный сюда именем, — он не стоит лексически внутри;
|
|
437
|
-
секция `own`, вынесенная в собственную привязку (`const own = ({
|
|
440
|
+
секция `own`, вынесенная в собственную привязку (`const own = ({ resource }) => …; defineFeature(id, { own })`); и
|
|
438
441
|
`model(Decl, createOrderModel)`, где фабрика — именованная функция без аннотации `ModelContext`.
|
|
439
442
|
|
|
440
443
|
### `prefer-effect-current`
|
|
@@ -465,6 +468,70 @@ ctx.effect(quotes, execution => publish(execution.current));
|
|
|
465
468
|
разрешает свой контекст точно: `effect`, о котором идёт речь, объявлен на параметре типа `ModelContext` либо на
|
|
466
469
|
контексте `model(Declaration, factory)`, написанном внутри `defineFeature` из `@opetope/runtime` (D309).
|
|
467
470
|
|
|
471
|
+
### `capture-command-cleanup`
|
|
472
|
+
|
|
473
|
+
Писатель команды живёт столько же, сколько её вызывающий (D288). Запись в `finally` или `catch` у `try`, который
|
|
474
|
+
ждёт `await`, выполняется после этого `await`, и после отмены вызова она отбрасывается без записи в reporter — так что
|
|
475
|
+
уборка, которая должна была снять флаг занятости или метку ожидания, и есть та запись, которая не ляжет никогда.
|
|
476
|
+
Правило сообщает о такой записи и называет слово, которое ляжет, — commit, взятый до `await` (D319, D330):
|
|
477
|
+
|
|
478
|
+
```ts
|
|
479
|
+
// сообщается: после отмены вызывающего `busy` остаётся `true`
|
|
480
|
+
ctx.call(async (input, { signal, update }) => {
|
|
481
|
+
update(busy, true);
|
|
482
|
+
try {
|
|
483
|
+
await host.send(input, signal);
|
|
484
|
+
} finally {
|
|
485
|
+
update(busy, false);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// уборка ложится, пока жива модель
|
|
490
|
+
ctx.call(async (input, { capture, signal, update }) => {
|
|
491
|
+
update(busy, true);
|
|
492
|
+
const done = capture(busy);
|
|
493
|
+
try {
|
|
494
|
+
await host.send(input, signal);
|
|
495
|
+
} finally {
|
|
496
|
+
done.update(() => false);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Фикса нет: должна ли уборка лечь после ухода вызывающего — решение автора, а сброс — закон не без причины: поздняя
|
|
502
|
+
запись отменённого вызова не должна затирать то, что написал более новый вызов. Поэтому сообщение называет оба выхода.
|
|
503
|
+
Уборка, которая обязана выполниться для отменённого вызова, идёт через `capture(state)`, взятый до `await`; ответ,
|
|
504
|
+
который не должен лечь после отмены, — отказ, результат, — стоит в `catch` после `rethrowIfCancelled(cause)`. Там, где
|
|
505
|
+
вызывающий отменяет один прогон, чтобы начать следующий, — поиск, который потребитель запускает заново, Call,
|
|
506
|
+
вызванный прогоном эффекта и отменённый более новым значением, — запись старого прогона и есть та, что лечь не должна,
|
|
507
|
+
и об этом говорит комментарий-отключение.
|
|
508
|
+
|
|
509
|
+
При `policy: 'parallel'` сообщение commit не советует. Вызовы такой команды перекрываются, поэтому захваченная уборка
|
|
510
|
+
отменённого вызова сняла бы флаг, который ещё держит другой вызов, — флаг, общий для параллельных вызовов, гонится в
|
|
511
|
+
любом случае, — и сообщение говорит держать такое состояние на вызов или оставить ход потребителю через `inFlight`.
|
|
512
|
+
Правило видит политику, только когда `{ policy: 'parallel' }` написан на самом `call`; запись модификаторов,
|
|
513
|
+
собранная в другом месте, читается как очередь по умолчанию, где lane ждёт физического тела и захваченная уборка
|
|
514
|
+
ложится по порядку.
|
|
515
|
+
|
|
516
|
+
Правило — эвристика над синтаксисом и ничего не доказывает о записи, о которой молчит. Писателя тела команды оно
|
|
517
|
+
читает под такими именами: `execution.update(…)`; `({ update })` или `({ update: write })` в списке параметров;
|
|
518
|
+
`const { update } = execution` внутри тела; и `const`, дающий одному из них второе имя, — `const write = update` или
|
|
519
|
+
`const write = execution.update`. Тело, переданное локальной функцией, правило находит. Оно пропускает замыкание,
|
|
520
|
+
держащее писателя (`const clear = () => update(busy, false)`, вызванное в `finally`), писателя, сохранённого не в
|
|
521
|
+
`const`, и контекст, переданный helper-у. `try` считается, когда его защищённый блок ждёт в собственной функции тела:
|
|
522
|
+
`await` или `for await` внутри вложенного колбэка приостанавливает этот колбэк, а не команду.
|
|
523
|
+
|
|
524
|
+
В `catch` запись после `rethrowIfCancelled(cause)`, `signal.throwIfAborted()` или `if (signal.aborted)`, который
|
|
525
|
+
бросает или возвращает, — в блоке, где стоит запись, или в любом охватывающем его блоке, — не сообщается. Такая
|
|
526
|
+
проверка — заявление автора, что следующее за ней не должно выполняться для отменённого вызова, и правило верит ему на
|
|
527
|
+
слово: в `catch (cause) { rethrowIfCancelled(cause); update(busy, false); throw cause }` флаг после отмены остаётся
|
|
528
|
+
`true`, и сообщения нет. Поэтому флаг, который обязан сняться при отмене, пишется в `finally` через `capture(state)`.
|
|
529
|
+
Проверка в `finally` ничего не спасает и сообщается по-прежнему, как и проверка `signal.aborted`, которая не бросает и
|
|
530
|
+
не возвращает. `effect`, `event` и остальные реакции правило не трогает: их прогон отменило более новое значение, и
|
|
531
|
+
сброс его записи — ровно то, что нужно (D288). Контекст разрешается точно: `call` параметра типа `ModelContext` либо
|
|
532
|
+
контекста `model(Declaration, factory)` внутри `defineFeature` из `@opetope/runtime` (D309); собственный `call` фичи
|
|
533
|
+
писателя телу не даёт.
|
|
534
|
+
|
|
468
535
|
### `prefer-model-selection`
|
|
469
536
|
|
|
470
537
|
Компонент, который берёт одну выданную модель и читает её поля по хуку на поле, повторяет одно и то же
|
|
@@ -17,6 +17,11 @@ declare function methodPath(source: TSESLint.SourceCode, callee: TSESTree.Node):
|
|
|
17
17
|
* `model(…)` at a time, and the set of functions already visited ends a chain that would come back to its start.
|
|
18
18
|
*/
|
|
19
19
|
declare function isContextFunction(source: TSESLint.SourceCode, fn: FunctionNode): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* D330: the context of a model and nothing else. The `own` section of a feature is a declaring context too, but its
|
|
22
|
+
* `call` takes the body second and hands it no writer (D139), so a rule about the writer of a command asks this.
|
|
23
|
+
*/
|
|
24
|
+
declare function isModelContextFunction(source: TSESLint.SourceCode, fn: FunctionNode): boolean;
|
|
20
25
|
/**
|
|
21
26
|
* Whether this node stands lexically inside a callback whose opened work the declared node releases. The walk goes
|
|
22
27
|
* all the way out, so a nested function body counts; only the resolved context and the position decide.
|
|
@@ -26,4 +31,4 @@ declare function isDeclaredIngress(source: TSESLint.SourceCode, node: TSESTree.N
|
|
|
26
31
|
* D323: a rule that rewrites code has to resolve its package exactly (D309), and this pair is that resolution: the
|
|
27
32
|
* method a callee names on a declaring context, and whether that parameter really is one.
|
|
28
33
|
*/
|
|
29
|
-
export { isContextFunction, isDeclaredIngress, methodPath };
|
|
34
|
+
export { isContextFunction, isDeclaredIngress, isModelContextFunction, methodPath };
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{AST_NODE_TYPES as i,TSESLint as
|
|
1
|
+
import{AST_NODE_TYPES as i,TSESLint as m}from"@typescript-eslint/utils";import{propertyName as s}from"./ast.js";import{isFeatureSection as l,isApi as y,definitionOf as g,isFunction as h}from"./model-bindings.js";const x=new Map([["event",1],["scope.keyed",1],["scope.switch",1],["scope.while",1]]),I=new Map([["attach",{argument:1,property:"open"}],["resource.live",{argument:0,property:"connect"}]]);function S(n){const[t]=n.params;if(t?.type===i.Identifier||t?.type===i.ObjectPattern)return t}function p(n,t){const e=S(t)?.typeAnnotation?.typeAnnotation;if(e?.type!==i.TSTypeReference)return!1;const{typeName:r}=e;return r.type===i.Identifier&&y(n,r,"@opetope/core","ModelContext",!0)}function a(n,t){const e=g(n,t);if(!(e?.type!==m.Scope.DefinitionType.Parameter||!h(e.node)))return{fn:e.node,name:e.name}}function w(n,t){const e=a(n,t);return e!==void 0&&e.name===e.fn.params[0]?e.fn:void 0}function P(n,t){const e=a(n,t),r=e?.name.parent;if(e===void 0||r?.type!==i.Property)return;const o=r.parent===e.fn.params[0]?s(r):void 0;return o===void 0?void 0:{fn:e.fn,method:o}}function C(n){const t=[];let e=n;for(;e.type===i.MemberExpression;){if(e.computed||e.property.type!==i.Identifier)return;t.unshift(e.property.name),e=e.object}return{root:e,steps:t}}function d(n,t){const e=C(t);if(e?.root.type!==i.Identifier)return;const r=e.steps.length===0?void 0:w(n,e.root);if(r!==void 0)return{fn:r,method:e.steps.join(".")};const o=P(n,e.root);return o===void 0?void 0:{fn:o.fn,method:[o.method,...e.steps].join(".")}}function c(n,t){const e=t.parent;if(e.type!==i.CallExpression||e.arguments[1]!==t)return;const r=d(n,e.callee);return r?.method==="model"?r.fn:void 0}function u(n,t){const e=new Set;let r=t;for(;r!==void 0&&!e.has(r);){if(e.add(r),l(n,r,"own",!0)||p(n,r))return!0;r=c(n,r)}return!1}function E(n,t){if(p(n,t))return!0;const e=c(n,t);return e!==void 0&&u(n,e)}function M(n,t,e){if(x.get(n)===t)return!0;const r=I.get(n);return r?.argument===t&&r.property===e}function b(n,t,e,r){const o=d(n,t.callee);return o!==void 0&&M(o.method,e,r)&&u(n,o.fn)}function A(n,t,e){return n.type!==i.ObjectExpression||t.type!==i.Property?e:s(t)}function O(n,t){let e=t,r;for(const o of n.getAncestors(t).reverse()){if(o.type===i.CallExpression){const f=o.arguments.indexOf(e);if(f!==-1&&b(n,o,f,r))return!0;r=void 0}else r=A(o,e,r);e=o}return!1}export{u as isContextFunction,O as isDeclaredIngress,E as isModelContextFunction,d as methodPath};
|
|
2
2
|
//# sourceMappingURL=declaration-ingress.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"declaration-ingress.js","sources":["../src/declaration-ingress.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport { AST_NODE_TYPES, TSESLint } from '@typescript-eslint/utils';\n\nimport { propertyName } from './ast';\nimport type { FunctionNode } from './model-bindings';\nimport { definitionOf, isApi, isFeatureSection, isFunction } from './model-bindings';\n\n/**\n * The callbacks a declared node opens its work in and owns the release of: the disposer they return is run by the\n * node, in the drain of the generation that opened it. The argument index is part of the contract — `consume`,\n * `run`, `load`, `when` and `key` sit in the same calls and are not ingress.\n */\nconst positionalIngress = new Map([\n ['event', 1],\n ['scope.keyed', 1],\n ['scope.switch', 1],\n ['scope.while', 1],\n ['stream', 2],\n]);\n\n/** The one such callback written as a record member. `attach`'s `close` is a release, not an opening. */\nconst recordIngress = new Map([['attach', { argument: 1, property: 'open' }]]);\n\ntype Annotated = TSESTree.Identifier | TSESTree.ObjectPattern;\ntype NamedMethod = { readonly fn: FunctionNode; readonly method: string };\ntype ParameterOf = { readonly fn: FunctionNode; readonly name: TSESTree.BindingName };\n\nfunction annotatedParameter(fn: FunctionNode): Annotated | undefined {\n const [parameter] = fn.params;\n\n if (parameter?.type === AST_NODE_TYPES.Identifier || parameter?.type === AST_NODE_TYPES.ObjectPattern) {\n return parameter;\n }\n\n return undefined;\n}\n\n/** The factory of a model says so in the parameter it takes: the name `ModelContext` carries where it is exported. */\nfunction declaresModelContext(source: TSESLint.SourceCode, fn: FunctionNode): boolean {\n const annotation = annotatedParameter(fn)?.typeAnnotation?.typeAnnotation;\n\n if (annotation?.type !== AST_NODE_TYPES.TSTypeReference) return false;\n\n const { typeName } = annotation;\n\n return typeName.type === AST_NODE_TYPES.Identifier && isApi(source, typeName, '@opetope/core', 'ModelContext', true);\n}\n\n/** The function this name is a parameter of, and the pattern node that bound it there. */\nfunction parameterOf(source: TSESLint.SourceCode, node: TSESTree.Identifier): ParameterOf | undefined {\n const definition = definitionOf(source, node);\n\n if (definition?.type !== TSESLint.Scope.DefinitionType.Parameter || !isFunction(definition.node)) return undefined;\n\n return { fn: definition.node, name: definition.name };\n}\n\n/** `own` itself: the plain name a context parameter was given, whichever function that parameter belongs to. */\nfunction contextName(source: TSESLint.SourceCode, node: TSESTree.Identifier): FunctionNode | undefined {\n const parameter = parameterOf(source, node);\n\n return parameter !== undefined && parameter.name === parameter.fn.params[0] ? parameter.fn : undefined;\n}\n\n/** `({ stream }) => stream(…)`: the method under the key the destructuring took it from, alias included. */\nfunction destructuredMethod(source: TSESLint.SourceCode, node: TSESTree.Identifier): NamedMethod | undefined {\n const parameter = parameterOf(source, node);\n const property = parameter?.name.parent;\n\n if (parameter === undefined || property?.type !== AST_NODE_TYPES.Property) return undefined;\n\n const method = property.parent === parameter.fn.params[0] ? propertyName(property) : undefined;\n\n return method === undefined ? undefined : { fn: parameter.fn, method };\n}\n\n/** The dotted steps of a member chain and the name it starts from: `own.scope.while` is `own` and `scope.while`. */\nfunction memberSteps(callee: TSESTree.Node): { root: TSESTree.Node; steps: readonly string[] } | undefined {\n const steps: string[] = [];\n let current: TSESTree.Node = callee;\n\n while (current.type === AST_NODE_TYPES.MemberExpression) {\n if (current.computed || current.property.type !== AST_NODE_TYPES.Identifier) return undefined;\n\n steps.unshift(current.property.name);\n current = current.object;\n }\n\n return { root: current, steps };\n}\n\n/**\n * The method this callee names on the first parameter of some function, and that function. Whether the parameter\n * really is a declaring context is a separate question: answering it here would make the two questions circular.\n */\nfunction methodPath(source: TSESLint.SourceCode, callee: TSESTree.Node): NamedMethod | undefined {\n const chain = memberSteps(callee);\n\n if (chain?.root.type !== AST_NODE_TYPES.Identifier) return undefined;\n\n const named = chain.steps.length === 0 ? undefined : contextName(source, chain.root);\n\n if (named !== undefined) return { fn: named, method: chain.steps.join('.') };\n\n const destructured = destructuredMethod(source, chain.root);\n\n return destructured === undefined\n ? undefined\n : { fn: destructured.fn, method: [destructured.method, ...chain.steps].join('.') };\n}\n\n/** One step outward: the context whose `model(…)` call declares this factory, where that is how it was written. */\nfunction declaringContext(source: TSESLint.SourceCode, fn: FunctionNode): FunctionNode | undefined {\n const call = fn.parent;\n\n if (call.type !== AST_NODE_TYPES.CallExpression || call.arguments[1] !== fn) return undefined;\n\n const declaring = methodPath(source, call.callee);\n\n return declaring?.method === 'model' ? declaring.fn : undefined;\n}\n\n/**\n * The two functions that hand an author a declaring context: the `own` section written right where `defineFeature`\n * takes it, and the factory of a model — which is either annotated as one or written where a feature declares it,\n * as the second argument of `model(Declaration, factory)`. That second form is proved by walking outward one\n * `model(…)` at a time, and the set of functions already visited ends a chain that would come back to its start.\n */\nfunction isContextFunction(source: TSESLint.SourceCode, fn: FunctionNode): boolean {\n const seen = new Set<FunctionNode>();\n let current: FunctionNode | undefined = fn;\n\n while (current !== undefined && !seen.has(current)) {\n seen.add(current);\n\n if (isFeatureSection(source, current, 'own', true) || declaresModelContext(source, current)) return true;\n\n current = declaringContext(source, current);\n }\n\n return false;\n}\n\nfunction admitsIngress(method: string, position: number, property: string | undefined): boolean {\n if (positionalIngress.get(method) === position) return true;\n\n const record = recordIngress.get(method);\n\n return record?.argument === position && record.property === property;\n}\n\n/** The shape decides first and the import second: a call that cannot be ingress never pays for the resolution. */\nfunction isIngressArgument(\n source: TSESLint.SourceCode,\n call: TSESTree.CallExpression,\n position: number,\n property: string | undefined,\n): boolean {\n const named = methodPath(source, call.callee);\n\n return named !== undefined && admitsIngress(named.method, position, property) && isContextFunction(source, named.fn);\n}\n\n/** The record member the walk came through since the last call boundary: the `{ open }` of an `attach`. */\nfunction memberProperty(parent: TSESTree.Node, child: TSESTree.Node, current: string | undefined): string | undefined {\n if (parent.type !== AST_NODE_TYPES.ObjectExpression || child.type !== AST_NODE_TYPES.Property) return current;\n\n return propertyName(child);\n}\n\n/**\n * Whether this node stands lexically inside a callback whose opened work the declared node releases. The walk goes\n * all the way out, so a nested function body counts; only the resolved context and the position decide.\n */\nfunction isDeclaredIngress(source: TSESLint.SourceCode, node: TSESTree.Node): boolean {\n let child: TSESTree.Node = node;\n let property: string | undefined;\n\n for (const parent of source.getAncestors(node).reverse()) {\n if (parent.type === AST_NODE_TYPES.CallExpression) {\n const position = parent.arguments.indexOf(child as TSESTree.CallExpressionArgument);\n\n if (position !== -1 && isIngressArgument(source, parent, position, property)) return true;\n\n property = undefined;\n } else {\n property = memberProperty(parent, child, property);\n }\n\n child = parent;\n }\n\n return false;\n}\n\n/**\n * D323: a rule that rewrites code has to resolve its package exactly (D309), and this pair is that resolution: the\n * method a callee names on a declaring context, and whether that parameter really is one.\n */\nexport { isContextFunction, isDeclaredIngress, methodPath };\n"],"names":["positionalIngress","recordIngress","annotatedParameter","fn","parameter","AST_NODE_TYPES","declaresModelContext","source","annotation","typeName","isApi","parameterOf","node","definition","definitionOf","TSESLint","isFunction","contextName","destructuredMethod","property","method","propertyName","memberSteps","callee","steps","current","methodPath","chain","named","destructured","declaringContext","call","declaring","isContextFunction","seen","isFeatureSection","admitsIngress","position","record","isIngressArgument","memberProperty","parent","child","isDeclaredIngress"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"declaration-ingress.js","sources":["../src/declaration-ingress.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport { AST_NODE_TYPES, TSESLint } from '@typescript-eslint/utils';\n\nimport { propertyName } from './ast';\nimport type { FunctionNode } from './model-bindings';\nimport { definitionOf, isApi, isFeatureSection, isFunction } from './model-bindings';\n\n/**\n * The callbacks a declared node opens its work in and owns the release of: the disposer they return is run by the\n * node, in the drain of the generation that opened it. The argument index is part of the contract — `run`, `load`,\n * `when` and `key` sit in the same calls and are not ingress.\n */\nconst positionalIngress = new Map([\n ['event', 1],\n ['scope.keyed', 1],\n ['scope.switch', 1],\n ['scope.while', 1],\n]);\n\n/**\n * The callbacks written as a record member. `attach`'s `close` is a release, not an opening; D349 puts the connection\n * of a live Resource under `connect` in the one options record, so that is where the ingress of a subscription is.\n */\nconst recordIngress = new Map([\n ['attach', { argument: 1, property: 'open' }],\n ['resource.live', { argument: 0, property: 'connect' }],\n]);\n\ntype Annotated = TSESTree.Identifier | TSESTree.ObjectPattern;\ntype NamedMethod = { readonly fn: FunctionNode; readonly method: string };\ntype ParameterOf = { readonly fn: FunctionNode; readonly name: TSESTree.BindingName };\n\nfunction annotatedParameter(fn: FunctionNode): Annotated | undefined {\n const [parameter] = fn.params;\n\n if (parameter?.type === AST_NODE_TYPES.Identifier || parameter?.type === AST_NODE_TYPES.ObjectPattern) {\n return parameter;\n }\n\n return undefined;\n}\n\n/** The factory of a model says so in the parameter it takes: the name `ModelContext` carries where it is exported. */\nfunction declaresModelContext(source: TSESLint.SourceCode, fn: FunctionNode): boolean {\n const annotation = annotatedParameter(fn)?.typeAnnotation?.typeAnnotation;\n\n if (annotation?.type !== AST_NODE_TYPES.TSTypeReference) return false;\n\n const { typeName } = annotation;\n\n return typeName.type === AST_NODE_TYPES.Identifier && isApi(source, typeName, '@opetope/core', 'ModelContext', true);\n}\n\n/** The function this name is a parameter of, and the pattern node that bound it there. */\nfunction parameterOf(source: TSESLint.SourceCode, node: TSESTree.Identifier): ParameterOf | undefined {\n const definition = definitionOf(source, node);\n\n if (definition?.type !== TSESLint.Scope.DefinitionType.Parameter || !isFunction(definition.node)) return undefined;\n\n return { fn: definition.node, name: definition.name };\n}\n\n/** `own` itself: the plain name a context parameter was given, whichever function that parameter belongs to. */\nfunction contextName(source: TSESLint.SourceCode, node: TSESTree.Identifier): FunctionNode | undefined {\n const parameter = parameterOf(source, node);\n\n return parameter !== undefined && parameter.name === parameter.fn.params[0] ? parameter.fn : undefined;\n}\n\n/** `({ resource }) => resource.live(…)`: the method under the key the destructuring took it from, alias included. */\nfunction destructuredMethod(source: TSESLint.SourceCode, node: TSESTree.Identifier): NamedMethod | undefined {\n const parameter = parameterOf(source, node);\n const property = parameter?.name.parent;\n\n if (parameter === undefined || property?.type !== AST_NODE_TYPES.Property) return undefined;\n\n const method = property.parent === parameter.fn.params[0] ? propertyName(property) : undefined;\n\n return method === undefined ? undefined : { fn: parameter.fn, method };\n}\n\n/** The dotted steps of a member chain and the name it starts from: `own.scope.while` is `own` and `scope.while`. */\nfunction memberSteps(callee: TSESTree.Node): { root: TSESTree.Node; steps: readonly string[] } | undefined {\n const steps: string[] = [];\n let current: TSESTree.Node = callee;\n\n while (current.type === AST_NODE_TYPES.MemberExpression) {\n if (current.computed || current.property.type !== AST_NODE_TYPES.Identifier) return undefined;\n\n steps.unshift(current.property.name);\n current = current.object;\n }\n\n return { root: current, steps };\n}\n\n/**\n * The method this callee names on the first parameter of some function, and that function. Whether the parameter\n * really is a declaring context is a separate question: answering it here would make the two questions circular.\n */\nfunction methodPath(source: TSESLint.SourceCode, callee: TSESTree.Node): NamedMethod | undefined {\n const chain = memberSteps(callee);\n\n if (chain?.root.type !== AST_NODE_TYPES.Identifier) return undefined;\n\n const named = chain.steps.length === 0 ? undefined : contextName(source, chain.root);\n\n if (named !== undefined) return { fn: named, method: chain.steps.join('.') };\n\n const destructured = destructuredMethod(source, chain.root);\n\n return destructured === undefined\n ? undefined\n : { fn: destructured.fn, method: [destructured.method, ...chain.steps].join('.') };\n}\n\n/** One step outward: the context whose `model(…)` call declares this factory, where that is how it was written. */\nfunction declaringContext(source: TSESLint.SourceCode, fn: FunctionNode): FunctionNode | undefined {\n const call = fn.parent;\n\n if (call.type !== AST_NODE_TYPES.CallExpression || call.arguments[1] !== fn) return undefined;\n\n const declaring = methodPath(source, call.callee);\n\n return declaring?.method === 'model' ? declaring.fn : undefined;\n}\n\n/**\n * The two functions that hand an author a declaring context: the `own` section written right where `defineFeature`\n * takes it, and the factory of a model — which is either annotated as one or written where a feature declares it,\n * as the second argument of `model(Declaration, factory)`. That second form is proved by walking outward one\n * `model(…)` at a time, and the set of functions already visited ends a chain that would come back to its start.\n */\nfunction isContextFunction(source: TSESLint.SourceCode, fn: FunctionNode): boolean {\n const seen = new Set<FunctionNode>();\n let current: FunctionNode | undefined = fn;\n\n while (current !== undefined && !seen.has(current)) {\n seen.add(current);\n\n if (isFeatureSection(source, current, 'own', true) || declaresModelContext(source, current)) return true;\n\n current = declaringContext(source, current);\n }\n\n return false;\n}\n\n/**\n * D330: the context of a model and nothing else. The `own` section of a feature is a declaring context too, but its\n * `call` takes the body second and hands it no writer (D139), so a rule about the writer of a command asks this.\n */\nfunction isModelContextFunction(source: TSESLint.SourceCode, fn: FunctionNode): boolean {\n if (declaresModelContext(source, fn)) return true;\n\n const declaring = declaringContext(source, fn);\n\n return declaring !== undefined && isContextFunction(source, declaring);\n}\n\nfunction admitsIngress(method: string, position: number, property: string | undefined): boolean {\n if (positionalIngress.get(method) === position) return true;\n\n const record = recordIngress.get(method);\n\n return record?.argument === position && record.property === property;\n}\n\n/** The shape decides first and the import second: a call that cannot be ingress never pays for the resolution. */\nfunction isIngressArgument(\n source: TSESLint.SourceCode,\n call: TSESTree.CallExpression,\n position: number,\n property: string | undefined,\n): boolean {\n const named = methodPath(source, call.callee);\n\n return named !== undefined && admitsIngress(named.method, position, property) && isContextFunction(source, named.fn);\n}\n\n/** The record member the walk came through since the last call boundary: the `{ open }` of an `attach`. */\nfunction memberProperty(parent: TSESTree.Node, child: TSESTree.Node, current: string | undefined): string | undefined {\n if (parent.type !== AST_NODE_TYPES.ObjectExpression || child.type !== AST_NODE_TYPES.Property) return current;\n\n return propertyName(child);\n}\n\n/**\n * Whether this node stands lexically inside a callback whose opened work the declared node releases. The walk goes\n * all the way out, so a nested function body counts; only the resolved context and the position decide.\n */\nfunction isDeclaredIngress(source: TSESLint.SourceCode, node: TSESTree.Node): boolean {\n let child: TSESTree.Node = node;\n let property: string | undefined;\n\n for (const parent of source.getAncestors(node).reverse()) {\n if (parent.type === AST_NODE_TYPES.CallExpression) {\n const position = parent.arguments.indexOf(child as TSESTree.CallExpressionArgument);\n\n if (position !== -1 && isIngressArgument(source, parent, position, property)) return true;\n\n property = undefined;\n } else {\n property = memberProperty(parent, child, property);\n }\n\n child = parent;\n }\n\n return false;\n}\n\n/**\n * D323: a rule that rewrites code has to resolve its package exactly (D309), and this pair is that resolution: the\n * method a callee names on a declaring context, and whether that parameter really is one.\n */\nexport { isContextFunction, isDeclaredIngress, isModelContextFunction, methodPath };\n"],"names":["positionalIngress","recordIngress","annotatedParameter","fn","parameter","AST_NODE_TYPES","declaresModelContext","source","annotation","typeName","isApi","parameterOf","node","definition","definitionOf","TSESLint","isFunction","contextName","destructuredMethod","property","method","propertyName","memberSteps","callee","steps","current","methodPath","chain","named","destructured","declaringContext","call","declaring","isContextFunction","seen","isFeatureSection","isModelContextFunction","admitsIngress","position","record","isIngressArgument","memberProperty","parent","child","isDeclaredIngress"],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,9 @@ declare const plugin: {
|
|
|
19
19
|
version: string;
|
|
20
20
|
};
|
|
21
21
|
rules: {
|
|
22
|
+
'capture-command-cleanup': import("@typescript-eslint/utils/ts-eslint").RuleModule<"captureCommandCleanup" | "parallelCommandCleanup", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
23
|
+
name: string;
|
|
24
|
+
};
|
|
22
25
|
'define-feature-property-order': import("@typescript-eslint/utils/ts-eslint").RuleModule<"sectionOrder", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
23
26
|
name: string;
|
|
24
27
|
};
|
|
@@ -96,6 +99,7 @@ declare const configuredPlugin: {
|
|
|
96
99
|
};
|
|
97
100
|
};
|
|
98
101
|
rules: {
|
|
102
|
+
'opetope/capture-command-cleanup': "error";
|
|
99
103
|
'opetope/define-feature-property-order': "error";
|
|
100
104
|
'opetope/id-naming': "error";
|
|
101
105
|
'opetope/layer-placement': "off";
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{captureCommandCleanup as p}from"./rules/capture-command-cleanup.js";import{defineFeaturePropertyOrder as s}from"./rules/define-feature-property-order.js";import{idNaming as i}from"./rules/id-naming.js";import{layerPlacement as m}from"./rules/layer-placement.js";import{noCommandInDeps as a}from"./rules/no-command-in-deps.js";import{noInternalImports as l}from"./rules/no-internal-imports.js";import{noRedundantConstTuple as d}from"./rules/no-redundant-const-tuple.js";import{noSnapshotInUpdate as c}from"./rules/no-snapshot-in-update.js";import{noSnapshotReadInRender as u}from"./rules/no-snapshot-read-in-render.js";import{noSubscribeOutsideModels as f}from"./rules/no-subscribe-outside-models.js";import{preferEffectCurrent as g}from"./rules/prefer-effect-current.js";import{preferModelSelection as b}from"./rules/prefer-model-selection.js";import{requireDeclaredModels as h}from"./rules/require-declared-models.js";import{requireLiteralId as y}from"./rules/require-literal-id.js";import{whenPredicate as I}from"./rules/when-predicate.js";const S=["integration","models","ui"],q=["**/*.ts","**/*.tsx"],C=["**/__tests__/**","**/*.spec.ts","**/*.spec.tsx"],E={meta:{name:"@opetope/lint",version:"0.10.0"},rules:{"capture-command-cleanup":p,"define-feature-property-order":s,"id-naming":i,"layer-placement":m,"no-command-in-deps":a,"no-internal-imports":l,"no-redundant-const-tuple":d,"no-snapshot-in-update":c,"no-snapshot-read-in-render":u,"no-subscribe-outside-models":f,"prefer-effect-current":g,"prefer-model-selection":b,"require-declared-models":h,"require-literal-id":y,"when-predicate":I}},r=E,_={name:"opetope/recommended",plugins:{opetope:r},rules:{"opetope/capture-command-cleanup":"error","opetope/define-feature-property-order":"error","opetope/id-naming":"error","opetope/layer-placement":"off","opetope/no-command-in-deps":"error","opetope/no-internal-imports":"error","opetope/no-redundant-const-tuple":"error","opetope/no-snapshot-in-update":"error","opetope/no-snapshot-read-in-render":"error","opetope/no-subscribe-outside-models":"off","opetope/prefer-effect-current":"error","opetope/prefer-model-selection":"off","opetope/require-declared-models":"error","opetope/require-literal-id":"error","opetope/when-predicate":"error"}},j=e=>S.flatMap(o=>{const t=e[o]??[];return t.length===0?[]:[{files:[...t],name:`opetope/layers-${o}`,plugins:{opetope:r},rules:{"opetope/layer-placement":["error",{layer:o}]}}]}),x=({models:e=[],tests:o=C})=>e.length===0?[]:[{files:q,name:"opetope/layers-subscriptions",plugins:{opetope:r},rules:{"opetope/no-subscribe-outside-models":"error"}},{files:[...e,...o],name:"opetope/layers-subscriptions-allowed",rules:{"opetope/no-subscribe-outside-models":"off"}}],L=e=>[...j(e),...x(e)],P=({allow:e=[],files:o=["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"]}={})=>[{files:[...o],name:"opetope/internal-imports",plugins:{opetope:r},rules:{"opetope/no-internal-imports":"error"}},...e.length===0?[]:[{files:[...e],name:"opetope/internal-imports-allowed",rules:{"opetope/no-internal-imports":"off"}}]],R=Object.assign(r,{configs:{internalImports:P,layers:L,recommended:_}}),n=R;export{n as default,n as opetopeLint};
|
|
2
2
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":["../src/index.ts"],"sourcesContent":["import type { ESLint, Linter } from 'eslint';\n\nimport { defineFeaturePropertyOrder } from './rules/define-feature-property-order';\nimport { idNaming } from './rules/id-naming';\nimport { layerPlacement } from './rules/layer-placement';\nimport { noCommandInDeps } from './rules/no-command-in-deps';\nimport { noInternalImports } from './rules/no-internal-imports';\nimport { noRedundantConstTuple } from './rules/no-redundant-const-tuple';\nimport { noSnapshotInUpdate } from './rules/no-snapshot-in-update';\nimport { noSnapshotReadInRender } from './rules/no-snapshot-read-in-render';\nimport { noSubscribeOutsideModels } from './rules/no-subscribe-outside-models';\nimport { preferEffectCurrent } from './rules/prefer-effect-current';\nimport { preferModelSelection } from './rules/prefer-model-selection';\nimport { requireDeclaredModels } from './rules/require-declared-models';\nimport { requireLiteralId } from './rules/require-literal-id';\nimport { whenPredicate } from './rules/when-predicate';\n\n/**\n * The layers a host names for itself, as globs of the files that hold each one, with the tests that are allowed\n * to reach past a layer. `tests` defaults to the usual two shapes of a test path.\n */\ntype LayerOptions = {\n readonly integration?: readonly string[];\n readonly models?: readonly string[];\n readonly tests?: readonly string[];\n readonly ui?: readonly string[];\n};\n\ntype InternalImportOptions = {\n readonly allow?: readonly string[];\n readonly files?: readonly string[];\n};\n\nconst LAYERS = ['integration', 'models', 'ui'] as const;\nconst SOURCE_FILES = ['**/*.ts', '**/*.tsx'];\nconst TEST_FILES = ['**/__tests__/**', '**/*.spec.ts', '**/*.spec.tsx'];\n\nconst plugin = {\n meta: { name: '@opetope/lint', version: '0.
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../src/index.ts"],"sourcesContent":["import type { ESLint, Linter } from 'eslint';\n\nimport { captureCommandCleanup } from './rules/capture-command-cleanup';\nimport { defineFeaturePropertyOrder } from './rules/define-feature-property-order';\nimport { idNaming } from './rules/id-naming';\nimport { layerPlacement } from './rules/layer-placement';\nimport { noCommandInDeps } from './rules/no-command-in-deps';\nimport { noInternalImports } from './rules/no-internal-imports';\nimport { noRedundantConstTuple } from './rules/no-redundant-const-tuple';\nimport { noSnapshotInUpdate } from './rules/no-snapshot-in-update';\nimport { noSnapshotReadInRender } from './rules/no-snapshot-read-in-render';\nimport { noSubscribeOutsideModels } from './rules/no-subscribe-outside-models';\nimport { preferEffectCurrent } from './rules/prefer-effect-current';\nimport { preferModelSelection } from './rules/prefer-model-selection';\nimport { requireDeclaredModels } from './rules/require-declared-models';\nimport { requireLiteralId } from './rules/require-literal-id';\nimport { whenPredicate } from './rules/when-predicate';\n\n/**\n * The layers a host names for itself, as globs of the files that hold each one, with the tests that are allowed\n * to reach past a layer. `tests` defaults to the usual two shapes of a test path.\n */\ntype LayerOptions = {\n readonly integration?: readonly string[];\n readonly models?: readonly string[];\n readonly tests?: readonly string[];\n readonly ui?: readonly string[];\n};\n\ntype InternalImportOptions = {\n readonly allow?: readonly string[];\n readonly files?: readonly string[];\n};\n\nconst LAYERS = ['integration', 'models', 'ui'] as const;\nconst SOURCE_FILES = ['**/*.ts', '**/*.tsx'];\nconst TEST_FILES = ['**/__tests__/**', '**/*.spec.ts', '**/*.spec.tsx'];\n\nconst plugin = {\n meta: { name: '@opetope/lint', version: '0.10.0' },\n rules: {\n 'capture-command-cleanup': captureCommandCleanup,\n 'define-feature-property-order': defineFeaturePropertyOrder,\n 'id-naming': idNaming,\n 'layer-placement': layerPlacement,\n 'no-command-in-deps': noCommandInDeps,\n 'no-internal-imports': noInternalImports,\n 'no-redundant-const-tuple': noRedundantConstTuple,\n 'no-snapshot-in-update': noSnapshotInUpdate,\n 'no-snapshot-read-in-render': noSnapshotReadInRender,\n 'no-subscribe-outside-models': noSubscribeOutsideModels,\n 'prefer-effect-current': preferEffectCurrent,\n 'prefer-model-selection': preferModelSelection,\n 'require-declared-models': requireDeclaredModels,\n 'require-literal-id': requireLiteralId,\n 'when-predicate': whenPredicate,\n },\n};\n\n/**\n * The laws every Opetope author writes against, with no assumption about where a host keeps its files. A rule that\n * needs such an assumption is off here and arrives through `layers`. The namespace is `opetope`, so a rule reads\n * `opetope/when-predicate` wherever a host names it.\n */\n// Rules use the parser's typed AST internally. The public bridge exposes the\n// supported ESLint rule API; package smoke tests execute it under both linters.\ntype NativeRule = NonNullable<ESLint.Plugin['rules']>[string];\ntype NativeRules = {\n [Name in keyof typeof plugin.rules]: Omit<(typeof plugin.rules)[Name], 'create'> & NativeRule;\n};\nconst nativePlugin = plugin as unknown as { meta: typeof plugin.meta; rules: NativeRules };\n\nconst recommended = {\n name: 'opetope/recommended',\n plugins: { opetope: nativePlugin },\n rules: {\n 'opetope/capture-command-cleanup': 'error',\n 'opetope/define-feature-property-order': 'error',\n 'opetope/id-naming': 'error',\n 'opetope/layer-placement': 'off',\n 'opetope/no-command-in-deps': 'error',\n 'opetope/no-internal-imports': 'error',\n 'opetope/no-redundant-const-tuple': 'error',\n 'opetope/no-snapshot-in-update': 'error',\n 'opetope/no-snapshot-read-in-render': 'error',\n 'opetope/no-subscribe-outside-models': 'off',\n 'opetope/prefer-effect-current': 'error',\n 'opetope/prefer-model-selection': 'off',\n 'opetope/require-declared-models': 'error',\n 'opetope/require-literal-id': 'error',\n 'opetope/when-predicate': 'error',\n },\n} satisfies Linter.Config;\n\n/**\n * Placement is a convention of the project that names its layers, not a law of the framework: a layer without a\n * glob is a layer this host does not have, and it produces no configuration.\n */\nconst placement = (options: LayerOptions): readonly Linter.Config[] =>\n LAYERS.flatMap(layer => {\n const files = options[layer] ?? [];\n\n if (files.length === 0) return [];\n\n return [\n {\n files: [...files],\n name: `opetope/layers-${layer}`,\n plugins: { opetope: nativePlugin },\n rules: { 'opetope/layer-placement': ['error', { layer }] },\n } satisfies Linter.Config,\n ];\n });\n\n/**\n * A subscription is written where the state lives, so this pair arrives only with the model layer that owns it. The\n * restriction covers every source file and steps aside in that layer and in tests: a file no layer claims is\n * covered rather than forgotten.\n */\nconst subscriptions = ({ models = [], tests = TEST_FILES }: LayerOptions): readonly Linter.Config[] =>\n models.length === 0\n ? []\n : [\n {\n files: SOURCE_FILES,\n name: 'opetope/layers-subscriptions',\n plugins: { opetope: nativePlugin },\n rules: { 'opetope/no-subscribe-outside-models': 'error' },\n },\n {\n files: [...models, ...tests],\n name: 'opetope/layers-subscriptions-allowed',\n rules: { 'opetope/no-subscribe-outside-models': 'off' },\n },\n ];\n\nconst layers = (options: LayerOptions): readonly Linter.Config[] => [...placement(options), ...subscriptions(options)];\n\n/**\n * Only library implementation and explicitly named host integration can cross the internal ABI. A dedicated rule\n * leaves the project's `no-restricted-imports` untouched and also covers literal dynamic imports in JS tests.\n * Allowed files explicitly override `recommended`, which enables this rule as well.\n */\nconst internalImports = ({\n allow = [],\n files = ['**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}'],\n}: InternalImportOptions = {}): readonly Linter.Config[] => [\n {\n files: [...files],\n name: 'opetope/internal-imports',\n plugins: { opetope: nativePlugin },\n rules: { 'opetope/no-internal-imports': 'error' },\n },\n ...(allow.length === 0\n ? []\n : [\n {\n files: [...allow],\n name: 'opetope/internal-imports-allowed',\n rules: { 'opetope/no-internal-imports': 'off' as const },\n },\n ]),\n];\n\nconst configuredPlugin = Object.assign(nativePlugin, {\n configs: { internalImports, layers, recommended },\n});\n\n// ESLint consumes the rule map. Config factories are additional plugin helpers;\n// expose both contracts without requiring casts at native flat-config call sites.\n// oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- Required by native ESLint config typetests.\nconst opetopeLint = configuredPlugin as unknown as typeof configuredPlugin & Pick<ESLint.Plugin, 'configs'>;\n\n/*\n * Two shapes of one object. The default is what a tool reads when it loads a plugin by path — Oxlint's `jsPlugins`\n * takes `(await import(path)).default` — and what an ESLint flat config imports by convention; the named export is\n * for a config that prefers to say which binding it takes.\n */\n// oxlint-disable-next-line import/no-default-export\n// eslint-disable-next-line import-x/no-default-export\nexport default opetopeLint;\nexport { opetopeLint };\n"],"names":["LAYERS","SOURCE_FILES","TEST_FILES","plugin","captureCommandCleanup","defineFeaturePropertyOrder","idNaming","layerPlacement","noCommandInDeps","noInternalImports","noRedundantConstTuple","noSnapshotInUpdate","noSnapshotReadInRender","noSubscribeOutsideModels","preferEffectCurrent","preferModelSelection","requireDeclaredModels","requireLiteralId","whenPredicate","nativePlugin","recommended","placement","options","layer","files","subscriptions","models","tests","layers","internalImports","allow","configuredPlugin","opetopeLint"],"mappings":"yhCAkCA,MAAMA,EAAS,CAAC,cAAe,SAAU,IAAI,EACvCC,EAAe,CAAC,UAAW,UAAU,EACrCC,EAAa,CAAC,kBAAmB,eAAgB,eAAe,EAEhEC,EAAS,CACb,KAAM,CAAE,KAAM,gBAAiB,QAAS,QAAQ,EAChD,MAAO,CACL,0BAA2BC,EAC3B,gCAAiCC,EACjC,YAAaC,EACb,kBAAmBC,EACnB,qBAAsBC,EACtB,sBAAuBC,EACvB,2BAA4BC,EAC5B,wBAAyBC,EACzB,6BAA8BC,EAC9B,8BAA+BC,EAC/B,wBAAyBC,EACzB,yBAA0BC,EAC1B,0BAA2BC,EAC3B,qBAAsBC,EACtB,iBAAkBC,CACnB,GAcGC,EAAehB,EAEfiB,EAAc,CAClB,KAAM,sBACN,QAAS,CAAE,QAASD,CAAY,EAChC,MAAO,CACL,kCAAmC,QACnC,wCAAyC,QACzC,oBAAqB,QACrB,0BAA2B,MAC3B,6BAA8B,QAC9B,8BAA+B,QAC/B,mCAAoC,QACpC,gCAAiC,QACjC,qCAAsC,QACtC,sCAAuC,MACvC,gCAAiC,QACjC,iCAAkC,MAClC,kCAAmC,QACnC,6BAA8B,QAC9B,yBAA0B,OAC3B,GAOGE,EAAaC,GACjBtB,EAAO,QAAQuB,GAAQ,CACrB,MAAMC,EAAQF,EAAQC,CAAK,GAAK,CAAA,EAEhC,OAAIC,EAAM,SAAW,EAAU,CAAA,EAExB,CACL,CACE,MAAO,CAAC,GAAGA,CAAK,EAChB,KAAM,kBAAkBD,CAAK,GAC7B,QAAS,CAAE,QAASJ,CAAY,EAChC,MAAO,CAAE,0BAA2B,CAAC,QAAS,CAAE,MAAAI,CAAK,CAAE,CAAC,CACjC,EAE7B,CAAC,EAOGE,EAAgB,CAAC,CAAE,OAAAC,EAAS,CAAA,EAAI,MAAAC,EAAQzB,CAAU,IACtDwB,EAAO,SAAW,EACd,CAAA,EACA,CACE,CACE,MAAOzB,EACP,KAAM,+BACN,QAAS,CAAE,QAASkB,CAAY,EAChC,MAAO,CAAE,sCAAuC,OAAO,CACxD,EACD,CACE,MAAO,CAAC,GAAGO,EAAQ,GAAGC,CAAK,EAC3B,KAAM,uCACN,MAAO,CAAE,sCAAuC,KAAK,CACtD,GAGHC,EAAUN,GAAoD,CAAC,GAAGD,EAAUC,CAAO,EAAG,GAAGG,EAAcH,CAAO,CAAC,EAO/GO,EAAkB,CAAC,CACvB,MAAAC,EAAQ,CAAA,EACR,MAAAN,EAAQ,CAAC,sCAAsC,CAAC,EACvB,KAAiC,CAC1D,CACE,MAAO,CAAC,GAAGA,CAAK,EAChB,KAAM,2BACN,QAAS,CAAE,QAASL,CAAY,EAChC,MAAO,CAAE,8BAA+B,OAAO,CAChD,EACD,GAAIW,EAAM,SAAW,EACjB,CAAA,EACA,CACE,CACE,MAAO,CAAC,GAAGA,CAAK,EAChB,KAAM,mCACN,MAAO,CAAE,8BAA+B,KAAc,CACvD,IAIHC,EAAmB,OAAO,OAAOZ,EAAc,CACnD,QAAS,CAAE,gBAAAU,EAAiB,OAAAD,EAAQ,YAAAR,CAAW,CAChD,CAAA,EAKKY,EAAcD"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
type MessageIds = 'captureCommandCleanup' | 'parallelCommandCleanup';
|
|
3
|
+
declare const captureCommandCleanup: TSESLint.RuleModule<MessageIds, [], unknown, TSESLint.RuleListener> & {
|
|
4
|
+
name: string;
|
|
5
|
+
};
|
|
6
|
+
export { captureCommandCleanup };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{AST_NODE_TYPES as i}from"@typescript-eslint/utils";import{findProperty as I,propertyName as E}from"../ast.js";import{methodPath as x,isModelContextFunction as O}from"../declaration-ingress.js";import{localValue as k,unwrap as s,isFunction as A,variableOf as f,enclosingFunction as y}from"../model-bindings.js";import{createRule as P}from"../rule.js";const g=["update","signal","rethrowIfCancelled"];function d(t,e){return t.range[0]>=e.range[0]&&t.range[1]<=e.range[1]}function D(t,e){const[n]=e.arguments;if(n===void 0||n.type===i.SpreadElement)return;const a=k(t,s(n));return A(a)?a:void 0}function T(t){const e=t.arguments[1],n=e===void 0?void 0:s(e),a=n?.type===i.ObjectExpression?I(n,"policy"):void 0,r=a===void 0?void 0:s(a.value);return r?.type===i.Literal&&r.value==="parallel"}function m(t,e){return e==="update"?t.update:e==="signal"?t.signal:t.rethrow}function w(t){return g.some(e=>e===t)}function b(t,e,n){for(const a of e.properties){if(a.type!==i.Property||a.value.type!==i.Identifier)continue;const r=E(a),o=w(r)?f(t,a.value):null;w(r)&&o!==null&&m(n,r).add(o)}}function C(t,e,n){return n!==void 0&&e.type===i.Identifier&&f(t,e)===n}function F(t,e,n,a){return e.type!==i.MemberExpression||e.computed?!1:e.property.type===i.Identifier&&e.property.name===a&&C(t,e.object,n.parameter)}function p(t,e,n,a){if(e.type!==i.Identifier)return F(t,e,n,a);const r=f(t,e);return r!==null&&m(n,a).has(r)}function j(t,e,n){const{id:a,init:r}=e;if(r===null)return;const o=s(r);if(a.type===i.ObjectPattern){C(t,o,n.parameter)&&b(t,a,n);return}const l=a.type===i.Identifier?f(t,a):null,c=g.find(u=>p(t,o,n,u));l!==null&&c!==void 0&&m(n,c).add(l)}function M(t,e){return e?.type===i.Identifier?f(t,e)??void 0:void 0}function B(t,e,n){const a=e.params[1],r={parameter:M(t,a),rethrow:new Set,signal:new Set,update:new Set};a?.type===i.ObjectPattern&&b(t,a,r);for(const o of n.declarators)d(o,e)&&j(t,o,r);return r}function v(t,e,n,a){return e.type!==i.MemberExpression||e.computed||e.property.type!==i.Identifier||e.property.name!==a?!1:p(t,e.object,n,"signal")}function S(t){if(t.type===i.ThrowStatement||t.type===i.ReturnStatement)return!0;if(t.type!==i.BlockStatement)return!1;const e=t.body.at(-1);return e!==void 0&&S(e)}function K(t,e,n){if(e.type===i.IfStatement)return v(t,s(e.test),n,"aborted")&&S(e.consequent);if(e.type!==i.ExpressionStatement)return!1;const a=s(e.expression);return a.type!==i.CallExpression?!1:p(t,a.callee,n,"rethrowIfCancelled")||v(t,a.callee,n,"throwIfAborted")}function N(t,e,n,a){for(const r of e.body){if(d(n,r))return!1;if(K(t,r,a))return!0}return!1}function _(t,e,n,a){return t.getAncestors(n).some(r=>r.type===i.BlockStatement&&d(r,e.body)&&N(t,r,n,a))}function R(t,e,n){const a=y(t,e);return n.barriers.some(r=>d(r,e.block)&&y(t,r)===a)}function V(t,e,n,a,r,o){if(n===e.block||!R(t,e,o))return;if(n===e.finalizer)return"finally";const{handler:l}=e;if(!(l===null||n!==l))return _(t,l,a,r)?void 0:"catch"}function Y(t,e,n,a,r){let o=e;for(const l of t.getAncestors(e).reverse()){if(l===n)return;const c=l.type===i.TryStatement?V(t,l,o,e,a,r):void 0;if(c!==void 0)return c;o=l}}const q=P({create(t){const e=t.sourceCode,n={barriers:[],calls:[],declarators:[]},a=(r,o)=>{const l=B(e,r,n),c=o?"parallelCommandCleanup":"captureCommandCleanup";for(const u of n.calls){if(!d(u,r)||!p(e,u.callee,l,"update"))continue;const h=Y(e,u,r,l,n);h!==void 0&&t.report({data:{clause:h},messageId:c,node:u})}};return{AwaitExpression(r){n.barriers.push(r)},CallExpression(r){n.calls.push(r)},ForOfStatement(r){r.await&&n.barriers.push(r)},"Program:exit"(){for(const r of n.calls){const o=x(e,r.callee);if(o?.method!=="call"||!O(e,o.fn))continue;const l=D(e,r);l!==void 0&&a(l,T(r))}},VariableDeclarator(r){n.declarators.push(r)}}},meta:{docs:{description:"A command writes the cleanup it owes after an `await` through a commit, not through its writer."},messages:{captureCommandCleanup:"This write is in the `{{clause}}` of a `try` that awaits, and the writer of a command lives only as long as its caller: once the call is cancelled the write is dropped without a record. A cleanup that must run for a cancelled call \u2014 a busy flag, a pending mark \u2014 goes through `const commit = execution.capture(state)` taken before the `await` and `commit.update(\u2026)` here. An answer that must not land after cancellation \u2014 a failure, a result \u2014 belongs in a `catch` after `rethrowIfCancelled(cause)` (D288, D319, D330).",parallelCommandCleanup:"This write is in the `{{clause}}` of a `try` that awaits in a `policy: 'parallel'` command, and once the call is cancelled it is dropped without a record. A commit does not fix it: calls of this command overlap, so the cleanup of a cancelled call would clear state another call still holds, and a flag shared by parallel calls is racy either way. Keep such state per call \u2014 keyed by its input \u2014 or leave progress to the consumer's `inFlight`. An answer that must not land after cancellation belongs in a `catch` after `rethrowIfCancelled(cause)` (D288, D330)."},schema:[],type:"problem"},name:"capture-command-cleanup"});export{q as captureCommandCleanup};
|
|
2
|
+
//# sourceMappingURL=capture-command-cleanup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"capture-command-cleanup.js","sources":["../../src/rules/capture-command-cleanup.ts"],"sourcesContent":["import type { TSESLint, TSESTree } from '@typescript-eslint/utils';\nimport { AST_NODE_TYPES } from '@typescript-eslint/utils';\n\nimport { findProperty, propertyName } from '../ast';\nimport { isModelContextFunction, methodPath } from '../declaration-ingress';\nimport type { FunctionNode } from '../model-bindings';\nimport { enclosingFunction, isFunction, localValue, unwrap, variableOf } from '../model-bindings';\nimport { createRule } from '../rule';\n\ntype MessageIds = 'captureCommandCleanup' | 'parallelCommandCleanup';\ntype Clause = 'catch' | 'finally';\ntype Variable = TSESLint.Scope.Variable;\n\n/** What one file wrote, collected before any write is judged: the rule answers on `Program:exit` (D290). */\ntype Written = {\n readonly barriers: TSESTree.Node[];\n readonly calls: TSESTree.CallExpression[];\n readonly declarators: TSESTree.VariableDeclarator[];\n};\n\n/**\n * The names the body of one command reads its context through: the parameter itself, and what was taken out of it —\n * renamed in the parameter list, taken apart by a declaration inside the body, or given a second name by a `const`.\n */\ntype Context = {\n readonly parameter: Variable | undefined;\n readonly rethrow: Set<Variable>;\n readonly signal: Set<Variable>;\n readonly update: Set<Variable>;\n};\n\ntype ContextKey = 'rethrowIfCancelled' | 'signal' | 'update';\n\nconst CONTEXT_KEYS: readonly ContextKey[] = ['update', 'signal', 'rethrowIfCancelled'];\n\nfunction within(node: TSESTree.Node, outer: TSESTree.Node): boolean {\n return node.range[0] >= outer.range[0] && node.range[1] <= outer.range[1];\n}\n\n/** `context.call(run, options?)`: the body a model command is declared with, inline or bound to a local name. */\nfunction commandBody(source: TSESLint.SourceCode, call: TSESTree.CallExpression): FunctionNode | undefined {\n const [body] = call.arguments;\n\n if (body === undefined || body.type === AST_NODE_TYPES.SpreadElement) return undefined;\n\n const value = localValue(source, unwrap(body));\n\n return isFunction(value) ? value : undefined;\n}\n\n/**\n * `{ policy: 'parallel' }` written on the declaration itself. A record assembled elsewhere is not read: a policy this\n * rule cannot see is treated as the default queue, and the README says so.\n */\nfunction declaresParallel(call: TSESTree.CallExpression): boolean {\n const options = call.arguments[1];\n const record = options === undefined ? undefined : unwrap(options);\n const policy = record?.type === AST_NODE_TYPES.ObjectExpression ? findProperty(record, 'policy') : undefined;\n const value = policy === undefined ? undefined : unwrap(policy.value);\n\n return value?.type === AST_NODE_TYPES.Literal && value.value === 'parallel';\n}\n\nfunction boundOf(context: Context, key: ContextKey): Set<Variable> {\n if (key === 'update') return context.update;\n\n return key === 'signal' ? context.signal : context.rethrow;\n}\n\nfunction isContextKey(key: string | undefined): key is ContextKey {\n return CONTEXT_KEYS.some(candidate => candidate === key);\n}\n\n/** `({ update, signal: aborting })`: the variables a pattern bound under the keys this rule reads. */\nfunction takeFrom(source: TSESLint.SourceCode, pattern: TSESTree.ObjectPattern, context: Context): void {\n for (const property of pattern.properties) {\n if (property.type !== AST_NODE_TYPES.Property || property.value.type !== AST_NODE_TYPES.Identifier) continue;\n\n const key = propertyName(property);\n const variable = isContextKey(key) ? variableOf(source, property.value) : null;\n\n if (isContextKey(key) && variable !== null) boundOf(context, key).add(variable);\n }\n}\n\nfunction isParameter(source: TSESLint.SourceCode, node: TSESTree.Node, parameter: Variable | undefined): boolean {\n return parameter !== undefined && node.type === AST_NODE_TYPES.Identifier && variableOf(source, node) === parameter;\n}\n\n/** `execution.<key>`, read off the context parameter itself. */\nfunction isContextMember(source: TSESLint.SourceCode, node: TSESTree.Node, context: Context, key: ContextKey): boolean {\n if (node.type !== AST_NODE_TYPES.MemberExpression || node.computed) return false;\n\n return (\n node.property.type === AST_NODE_TYPES.Identifier &&\n node.property.name === key &&\n isParameter(source, node.object, context.parameter)\n );\n}\n\n/** A name taken from the context under this key, or the key read off the context itself. */\nfunction fromContext(source: TSESLint.SourceCode, node: TSESTree.Node, context: Context, key: ContextKey): boolean {\n if (node.type !== AST_NODE_TYPES.Identifier) return isContextMember(source, node, context, key);\n\n const variable = variableOf(source, node);\n\n return variable !== null && boundOf(context, key).has(variable);\n}\n\n/**\n * `const { update } = execution` takes the context apart one line later, and `const write = update` gives a name the\n * body already holds a second one. Declarations are read in the order they are written, so a name is known before an\n * alias of it. A closure over the writer and a helper handed the context are not followed.\n */\nfunction takeFromDeclarator(\n source: TSESLint.SourceCode,\n declarator: TSESTree.VariableDeclarator,\n context: Context,\n): void {\n const { id, init } = declarator;\n\n if (init === null) return;\n\n const value = unwrap(init);\n\n if (id.type === AST_NODE_TYPES.ObjectPattern) {\n if (isParameter(source, value, context.parameter)) takeFrom(source, id, context);\n\n return;\n }\n\n const variable = id.type === AST_NODE_TYPES.Identifier ? variableOf(source, id) : null;\n const key = CONTEXT_KEYS.find(candidate => fromContext(source, value, context, candidate));\n\n if (variable !== null && key !== undefined) boundOf(context, key).add(variable);\n}\n\n/** The context parameter under the name it arrived with, or nothing when the body took it apart at once. */\nfunction receivedParameter(\n source: TSESLint.SourceCode,\n received: TSESTree.Parameter | undefined,\n): Variable | undefined {\n return received?.type === AST_NODE_TYPES.Identifier ? (variableOf(source, received) ?? undefined) : undefined;\n}\n\n/** The context of a body, whether it arrived whole under a name or taken apart where the body receives it. */\nfunction contextOf(source: TSESLint.SourceCode, body: FunctionNode, written: Written): Context {\n const received = body.params[1];\n const context: Context = {\n parameter: receivedParameter(source, received),\n rethrow: new Set(),\n signal: new Set(),\n update: new Set(),\n };\n\n if (received?.type === AST_NODE_TYPES.ObjectPattern) takeFrom(source, received, context);\n\n for (const declarator of written.declarators) {\n if (within(declarator, body)) takeFromDeclarator(source, declarator, context);\n }\n\n return context;\n}\n\n/** A member read on the signal of this context: `signal.aborted`, `execution.signal.throwIfAborted`. */\nfunction signalMember(source: TSESLint.SourceCode, node: TSESTree.Node, context: Context, member: string): boolean {\n if (node.type !== AST_NODE_TYPES.MemberExpression || node.computed) return false;\n if (node.property.type !== AST_NODE_TYPES.Identifier || node.property.name !== member) return false;\n\n return fromContext(source, node.object, context, 'signal');\n}\n\nfunction exits(statement: TSESTree.Statement): boolean {\n if (statement.type === AST_NODE_TYPES.ThrowStatement || statement.type === AST_NODE_TYPES.ReturnStatement)\n return true;\n if (statement.type !== AST_NODE_TYPES.BlockStatement) return false;\n\n const last = statement.body.at(-1);\n\n return last !== undefined && exits(last);\n}\n\n/**\n * A statement past which a `catch` is no longer running for a cancelled caller: `rethrowIfCancelled(cause)`, a\n * `signal.throwIfAborted()`, or an `if (signal.aborted)` that throws or returns. A write after one of them is the\n * author's statement that it must not run for a cancelled call, so it is not the cleanup this rule is about.\n */\nfunction leavesOnCancellation(source: TSESLint.SourceCode, statement: TSESTree.Statement, context: Context): boolean {\n if (statement.type === AST_NODE_TYPES.IfStatement) {\n return signalMember(source, unwrap(statement.test), context, 'aborted') && exits(statement.consequent);\n }\n\n if (statement.type !== AST_NODE_TYPES.ExpressionStatement) return false;\n\n const expression = unwrap(statement.expression);\n\n if (expression.type !== AST_NODE_TYPES.CallExpression) return false;\n\n return (\n fromContext(source, expression.callee, context, 'rethrowIfCancelled') ||\n signalMember(source, expression.callee, context, 'throwIfAborted')\n );\n}\n\n/** Whether a statement written before the write in this block already left for a cancelled caller. */\nfunction guardedBlock(\n source: TSESLint.SourceCode,\n block: TSESTree.BlockStatement,\n write: TSESTree.Node,\n context: Context,\n): boolean {\n for (const statement of block.body) {\n if (within(write, statement)) return false;\n if (leavesOnCancellation(source, statement, context)) return true;\n }\n\n return false;\n}\n\n/**\n * A guard counts where it precedes the write in the block that holds it or in any block around that one, up to the\n * `catch` itself: `if (cause instanceof Refused) { rethrowIfCancelled(cause); update(failed, true) }` is guarded, and\n * a guard inside a block the write is not in guards nothing.\n */\nfunction guardedCatch(\n source: TSESLint.SourceCode,\n handler: TSESTree.CatchClause,\n write: TSESTree.Node,\n context: Context,\n): boolean {\n return source\n .getAncestors(write)\n .some(\n ancestor =>\n ancestor.type === AST_NODE_TYPES.BlockStatement &&\n within(ancestor, handler.body) &&\n guardedBlock(source, ancestor, write, context),\n );\n}\n\n/** Whether the protected block suspends the function the `try` belongs to; an `await` in a nested callback does not. */\nfunction awaits(source: TSESLint.SourceCode, statement: TSESTree.TryStatement, written: Written): boolean {\n const owner = enclosingFunction(source, statement);\n\n return written.barriers.some(\n barrier => within(barrier, statement.block) && enclosingFunction(source, barrier) === owner,\n );\n}\n\n/** Where this `try` holds the write it was reached from: a cleanup clause of an awaited block, or nowhere. */\nfunction clauseOf(\n source: TSESLint.SourceCode,\n statement: TSESTree.TryStatement,\n child: TSESTree.Node,\n write: TSESTree.CallExpression,\n context: Context,\n written: Written,\n): Clause | undefined {\n if (child === statement.block || !awaits(source, statement, written)) return undefined;\n if (child === statement.finalizer) return 'finally';\n\n const { handler } = statement;\n\n if (handler === null || child !== handler) return undefined;\n\n return guardedCatch(source, handler, write, context) ? undefined : 'catch';\n}\n\n/** The cleanup clause of the innermost awaited `try` that holds this write, walking out no further than the body. */\nfunction cleanupClause(\n source: TSESLint.SourceCode,\n write: TSESTree.CallExpression,\n body: FunctionNode,\n context: Context,\n written: Written,\n): Clause | undefined {\n let child: TSESTree.Node = write;\n\n for (const parent of source.getAncestors(write).reverse()) {\n if (parent === body) return undefined;\n\n const clause =\n parent.type === AST_NODE_TYPES.TryStatement\n ? clauseOf(source, parent, child, write, context, written)\n : undefined;\n\n if (clause !== undefined) return clause;\n\n child = parent;\n }\n\n return undefined;\n}\n\nconst captureCommandCleanup = createRule<[], MessageIds>({\n create(context) {\n const source = context.sourceCode;\n const written: Written = { barriers: [], calls: [], declarators: [] };\n\n const reportWrites = (body: FunctionNode, parallel: boolean): void => {\n const received = contextOf(source, body, written);\n const messageId: MessageIds = parallel ? 'parallelCommandCleanup' : 'captureCommandCleanup';\n\n for (const call of written.calls) {\n if (!within(call, body) || !fromContext(source, call.callee, received, 'update')) continue;\n\n const clause = cleanupClause(source, call, body, received, written);\n\n if (clause !== undefined) context.report({ data: { clause }, messageId, node: call });\n }\n };\n\n return {\n AwaitExpression(node): void {\n written.barriers.push(node);\n },\n CallExpression(node): void {\n written.calls.push(node);\n },\n ForOfStatement(node): void {\n if (node.await) written.barriers.push(node);\n },\n 'Program:exit'(): void {\n for (const call of written.calls) {\n const named = methodPath(source, call.callee);\n\n if (named?.method !== 'call' || !isModelContextFunction(source, named.fn)) continue;\n\n const body = commandBody(source, call);\n\n if (body !== undefined) reportWrites(body, declaresParallel(call));\n }\n },\n VariableDeclarator(node): void {\n written.declarators.push(node);\n },\n };\n },\n meta: {\n docs: {\n description: 'A command writes the cleanup it owes after an `await` through a commit, not through its writer.',\n },\n messages: {\n captureCommandCleanup:\n 'This write is in the `{{clause}}` of a `try` that awaits, and the writer of a command lives only as long as its caller: once the call is cancelled the write is dropped without a record. A cleanup that must run for a cancelled call — a busy flag, a pending mark — goes through `const commit = execution.capture(state)` taken before the `await` and `commit.update(…)` here. An answer that must not land after cancellation — a failure, a result — belongs in a `catch` after `rethrowIfCancelled(cause)` (D288, D319, D330).',\n parallelCommandCleanup:\n \"This write is in the `{{clause}}` of a `try` that awaits in a `policy: 'parallel'` command, and once the call is cancelled it is dropped without a record. A commit does not fix it: calls of this command overlap, so the cleanup of a cancelled call would clear state another call still holds, and a flag shared by parallel calls is racy either way. Keep such state per call — keyed by its input — or leave progress to the consumer's `inFlight`. An answer that must not land after cancellation belongs in a `catch` after `rethrowIfCancelled(cause)` (D288, D330).\",\n },\n schema: [],\n type: 'problem',\n },\n name: 'capture-command-cleanup',\n});\n\nexport { captureCommandCleanup };\n"],"names":["CONTEXT_KEYS","within","node","outer","commandBody","source","call","body","AST_NODE_TYPES","value","localValue","unwrap","isFunction","declaresParallel","options","record","policy","findProperty","boundOf","context","key","isContextKey","candidate","takeFrom","pattern","property","propertyName","variable","variableOf","isParameter","parameter","isContextMember","fromContext","takeFromDeclarator","declarator","id","init","receivedParameter","received","contextOf","written","signalMember","member","exits","statement","last","leavesOnCancellation","expression","guardedBlock","block","write","guardedCatch","handler","ancestor","awaits","owner","enclosingFunction","barrier","clauseOf","child","cleanupClause","parent","clause","captureCommandCleanup","createRule","reportWrites","parallel","messageId","named","methodPath","isModelContextFunction"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{AST_NODE_TYPES as t}from"@typescript-eslint/utils";import{isDeclaredIngress as s}from"../declaration-ingress.js";import{createRule as
|
|
1
|
+
import{AST_NODE_TYPES as t}from"@typescript-eslint/utils";import{isDeclaredIngress as s}from"../declaration-ingress.js";import{createRule as o}from"../rule.js";function n(r){const{callee:e}=r;return e.type!==t.MemberExpression||e.computed||r.arguments.length===0?!1:e.property.type===t.Identifier&&e.property.name==="subscribe"}const i=o({create(r){return{CallExpression(e){!n(e)||s(r.sourceCode,e)||r.report({messageId:"handWrittenSubscription",node:e.callee})}}},meta:{docs:{description:"A hand-written subscription stays in the layer that owns the state it observes."},messages:{handWrittenSubscription:"A subscription written by hand owns a cleanup nobody here can see. In a component read with `useReadable` or a model selection; in a feature or a model declare `effect`, `event` or the `connect` of `resource.live`."},schema:[],type:"problem"},name:"no-subscribe-outside-models"});export{i as noSubscribeOutsideModels};
|
|
2
2
|
//# sourceMappingURL=no-subscribe-outside-models.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"no-subscribe-outside-models.js","sources":["../../src/rules/no-subscribe-outside-models.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport { AST_NODE_TYPES } from '@typescript-eslint/utils';\n\nimport { isDeclaredIngress } from '../declaration-ingress';\nimport { createRule } from '../rule';\n\ntype MessageIds = 'handWrittenSubscription';\n\n/** A call, not a reference: `useSyncExternalStore(source.subscribe, …)` hands the function over without calling it. */\nfunction isSubscribeCall(node: TSESTree.CallExpression): boolean {\n const { callee } = node;\n\n if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed || node.arguments.length === 0) return false;\n\n return callee.property.type === AST_NODE_TYPES.Identifier && callee.property.name === 'subscribe';\n}\n\nconst noSubscribeOutsideModels = createRule<[], MessageIds>({\n create(context) {\n return {\n CallExpression(node): void {\n if (!isSubscribeCall(node) || isDeclaredIngress(context.sourceCode, node)) return;\n\n context.report({ messageId: 'handWrittenSubscription', node: node.callee });\n },\n };\n },\n meta: {\n docs: {\n description: 'A hand-written subscription stays in the layer that owns the state it observes.',\n },\n messages: {\n handWrittenSubscription:\n 'A subscription written by hand owns a cleanup nobody here can see. In a component read with `useReadable` or a model selection; in a feature or a model declare `effect`, `event` or `
|
|
1
|
+
{"version":3,"file":"no-subscribe-outside-models.js","sources":["../../src/rules/no-subscribe-outside-models.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport { AST_NODE_TYPES } from '@typescript-eslint/utils';\n\nimport { isDeclaredIngress } from '../declaration-ingress';\nimport { createRule } from '../rule';\n\ntype MessageIds = 'handWrittenSubscription';\n\n/** A call, not a reference: `useSyncExternalStore(source.subscribe, …)` hands the function over without calling it. */\nfunction isSubscribeCall(node: TSESTree.CallExpression): boolean {\n const { callee } = node;\n\n if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed || node.arguments.length === 0) return false;\n\n return callee.property.type === AST_NODE_TYPES.Identifier && callee.property.name === 'subscribe';\n}\n\nconst noSubscribeOutsideModels = createRule<[], MessageIds>({\n create(context) {\n return {\n CallExpression(node): void {\n if (!isSubscribeCall(node) || isDeclaredIngress(context.sourceCode, node)) return;\n\n context.report({ messageId: 'handWrittenSubscription', node: node.callee });\n },\n };\n },\n meta: {\n docs: {\n description: 'A hand-written subscription stays in the layer that owns the state it observes.',\n },\n messages: {\n handWrittenSubscription:\n 'A subscription written by hand owns a cleanup nobody here can see. In a component read with `useReadable` or a model selection; in a feature or a model declare `effect`, `event` or the `connect` of `resource.live`.',\n },\n schema: [],\n type: 'problem',\n },\n name: 'no-subscribe-outside-models',\n});\n\nexport { noSubscribeOutsideModels };\n"],"names":["isSubscribeCall","node","callee","AST_NODE_TYPES","noSubscribeOutsideModels","createRule","context","isDeclaredIngress"],"mappings":"gKASA,SAASA,EAAgBC,EAA6B,CACpD,KAAM,CAAE,OAAAC,CAAM,EAAKD,EAEnB,OAAIC,EAAO,OAASC,EAAe,kBAAoBD,EAAO,UAAYD,EAAK,UAAU,SAAW,EAAU,GAEvGC,EAAO,SAAS,OAASC,EAAe,YAAcD,EAAO,SAAS,OAAS,WACxF,CAEA,MAAME,EAA2BC,EAA2B,CAC1D,OAAOC,EAAO,CACZ,MAAO,CACL,eAAeL,EAAI,CACb,CAACD,EAAgBC,CAAI,GAAKM,EAAkBD,EAAQ,WAAYL,CAAI,GAExEK,EAAQ,OAAO,CAAE,UAAW,0BAA2B,KAAML,EAAK,OAAQ,CAC5E,EAEJ,EACA,KAAM,CACJ,KAAM,CACJ,YAAa,iFACd,EACD,SAAU,CACR,wBACE,wNACH,EACD,OAAQ,CAAA,EACR,KAAM,SACP,EACD,KAAM,6BACP,CAAA"}
|
package/oxlintrc.json
CHANGED