@opetope/lint 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @opetope/lint
2
2
 
3
+ ## 0.9.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 29615cd: **`opetope/capture-command-cleanup` (D330).** A write through the writer of a model command inside the `finally` or
8
+ `catch` of a `try` that awaits is dropped once the call is cancelled. The rule reports it and names both ways out: a
9
+ cleanup that must run for a cancelled call goes through `capture(state)` taken before the `await`, and an answer that
10
+ must not land after cancellation belongs after `rethrowIfCancelled(cause)`. On a command declared with
11
+ `{ policy: 'parallel' }` it does not advise a commit, because the cleanup of a cancelled call would clear state another
12
+ call still holds. A write in a `catch` after `rethrowIfCancelled(cause)`, `signal.throwIfAborted()` or an
13
+ `if (signal.aborted)` that throws or returns — in its block or an enclosing one — is not reported, and neither are the
14
+ reactions (`effect`, `event`), whose dropped write is the law. The rule is a syntactic heuristic with no autofix; its
15
+ README lists what it does not follow. It is `error` in `configs.recommended` and in the shipped `oxlintrc.json`.
16
+
17
+ ## 0.9.6
18
+
19
+ No changes in this release.
20
+
3
21
  ## 0.9.5
4
22
 
5
23
  ### Patch Changes
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 })` |
@@ -463,6 +464,69 @@ runs later than the run does, and the value of that moment is the one it wants.
463
464
  this one resolves its context exactly: the `effect` it acts on is the one declared on a `ModelContext` parameter or
464
465
  on the context of a `model(Declaration, factory)` written inside a `defineFeature` of `@opetope/runtime` (D309).
465
466
 
467
+ ### `capture-command-cleanup`
468
+
469
+ The writer of a command lives as long as its caller (D288). A write in the `finally` or the `catch` of a `try` that
470
+ awaits runs after the `await`, and once the call is cancelled it is dropped without a record — so the cleanup that
471
+ was supposed to clear a busy flag or a pending mark is exactly the write that never lands. The rule reports that write
472
+ and names the word that does land, a commit taken before the `await` (D319, D330):
473
+
474
+ ```ts
475
+ // reported: once the caller is cancelled, `busy` stays `true`
476
+ ctx.call(async (input, { signal, update }) => {
477
+ update(busy, true);
478
+ try {
479
+ await host.send(input, signal);
480
+ } finally {
481
+ update(busy, false);
482
+ }
483
+ });
484
+
485
+ // the cleanup lands while the model lives
486
+ ctx.call(async (input, { capture, signal, update }) => {
487
+ update(busy, true);
488
+ const done = capture(busy);
489
+ try {
490
+ await host.send(input, signal);
491
+ } finally {
492
+ done.update(() => false);
493
+ }
494
+ });
495
+ ```
496
+
497
+ There is no fix: whether a cleanup must land after its caller left is the author's decision, and the drop is the law
498
+ for a reason — a late write of a cancelled call must not overwrite what a newer call wrote. The report therefore names
499
+ both ways out. A cleanup that must run for a cancelled call goes through `capture(state)` taken before the `await`; an
500
+ answer that must not land after cancellation — a failure, a result — belongs in a `catch` after
501
+ `rethrowIfCancelled(cause)`. Where a caller cancels one run to start the next — a search its consumer issues again, a
502
+ Call an effect run invokes and a newer value cancels — the older run's write is exactly the one that should not land,
503
+ and a disable comment says so.
504
+
505
+ Under `policy: 'parallel'` the report does not advise a commit. Calls of such a command overlap, so a cancelled call's
506
+ captured cleanup would clear a flag another call still holds — a flag shared by parallel calls is racy either way —
507
+ and the report says to keep that state per call, or to leave progress to the consumer's `inFlight`. The rule sees the
508
+ policy only when `{ policy: 'parallel' }` is written on the `call` itself; a modifier record assembled elsewhere reads
509
+ as the default queue, where the lane waits for the physical body and a captured cleanup lands in order.
510
+
511
+ The rule is a heuristic over syntax, and it proves nothing about a write it does not report. It reads the writer of a
512
+ command body under these names: `execution.update(…)`; `({ update })` or `({ update: write })` in the parameter list;
513
+ `const { update } = execution` inside the body; and a `const` that gives one of those a second name —
514
+ `const write = update` or `const write = execution.update`. It follows a body passed as a local function. It misses a
515
+ closure that holds the writer (`const clear = () => update(busy, false)` called in `finally`), a writer stored anywhere
516
+ but a `const`, and a context handed to a helper. A `try` counts when its protected block awaits in the body's own
517
+ function: an `await` or a `for await` inside a nested callback suspends that callback, not the command.
518
+
519
+ In a `catch`, a write that follows `rethrowIfCancelled(cause)`, `signal.throwIfAborted()` or an `if (signal.aborted)`
520
+ that throws or returns — in the block that holds the write or in any block around it — is not reported. Such a guard
521
+ is the author's statement that what follows must not run for a cancelled call, and the rule takes it at its word: in
522
+ `catch (cause) { rethrowIfCancelled(cause); update(busy, false); throw cause }` the flag stays `true` on cancellation
523
+ and nothing is reported. A flag that must clear on cancellation is therefore written in `finally` through
524
+ `capture(state)`. A guard in a `finally` saves nothing and is still reported, and so is a check of `signal.aborted`
525
+ that neither throws nor returns. `effect`, `event` and the other reactions are left alone: a newer value is what
526
+ cancelled their run, and dropping its write is the point (D288). The context is resolved exactly: the `call` of a
527
+ `ModelContext` parameter or of the context of `model(Declaration, factory)` inside a `defineFeature` of
528
+ `@opetope/runtime` (D309); a feature's own `call` hands its body no writer.
529
+
466
530
  ### `prefer-model-selection`
467
531
 
468
532
  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 })` |
@@ -465,6 +466,70 @@ ctx.effect(quotes, execution => publish(execution.current));
465
466
  разрешает свой контекст точно: `effect`, о котором идёт речь, объявлен на параметре типа `ModelContext` либо на
466
467
  контексте `model(Declaration, factory)`, написанном внутри `defineFeature` из `@opetope/runtime` (D309).
467
468
 
469
+ ### `capture-command-cleanup`
470
+
471
+ Писатель команды живёт столько же, сколько её вызывающий (D288). Запись в `finally` или `catch` у `try`, который
472
+ ждёт `await`, выполняется после этого `await`, и после отмены вызова она отбрасывается без записи в reporter — так что
473
+ уборка, которая должна была снять флаг занятости или метку ожидания, и есть та запись, которая не ляжет никогда.
474
+ Правило сообщает о такой записи и называет слово, которое ляжет, — commit, взятый до `await` (D319, D330):
475
+
476
+ ```ts
477
+ // сообщается: после отмены вызывающего `busy` остаётся `true`
478
+ ctx.call(async (input, { signal, update }) => {
479
+ update(busy, true);
480
+ try {
481
+ await host.send(input, signal);
482
+ } finally {
483
+ update(busy, false);
484
+ }
485
+ });
486
+
487
+ // уборка ложится, пока жива модель
488
+ ctx.call(async (input, { capture, signal, update }) => {
489
+ update(busy, true);
490
+ const done = capture(busy);
491
+ try {
492
+ await host.send(input, signal);
493
+ } finally {
494
+ done.update(() => false);
495
+ }
496
+ });
497
+ ```
498
+
499
+ Фикса нет: должна ли уборка лечь после ухода вызывающего — решение автора, а сброс — закон не без причины: поздняя
500
+ запись отменённого вызова не должна затирать то, что написал более новый вызов. Поэтому сообщение называет оба выхода.
501
+ Уборка, которая обязана выполниться для отменённого вызова, идёт через `capture(state)`, взятый до `await`; ответ,
502
+ который не должен лечь после отмены, — отказ, результат, — стоит в `catch` после `rethrowIfCancelled(cause)`. Там, где
503
+ вызывающий отменяет один прогон, чтобы начать следующий, — поиск, который потребитель запускает заново, Call,
504
+ вызванный прогоном эффекта и отменённый более новым значением, — запись старого прогона и есть та, что лечь не должна,
505
+ и об этом говорит комментарий-отключение.
506
+
507
+ При `policy: 'parallel'` сообщение commit не советует. Вызовы такой команды перекрываются, поэтому захваченная уборка
508
+ отменённого вызова сняла бы флаг, который ещё держит другой вызов, — флаг, общий для параллельных вызовов, гонится в
509
+ любом случае, — и сообщение говорит держать такое состояние на вызов или оставить ход потребителю через `inFlight`.
510
+ Правило видит политику, только когда `{ policy: 'parallel' }` написан на самом `call`; запись модификаторов,
511
+ собранная в другом месте, читается как очередь по умолчанию, где lane ждёт физического тела и захваченная уборка
512
+ ложится по порядку.
513
+
514
+ Правило — эвристика над синтаксисом и ничего не доказывает о записи, о которой молчит. Писателя тела команды оно
515
+ читает под такими именами: `execution.update(…)`; `({ update })` или `({ update: write })` в списке параметров;
516
+ `const { update } = execution` внутри тела; и `const`, дающий одному из них второе имя, — `const write = update` или
517
+ `const write = execution.update`. Тело, переданное локальной функцией, правило находит. Оно пропускает замыкание,
518
+ держащее писателя (`const clear = () => update(busy, false)`, вызванное в `finally`), писателя, сохранённого не в
519
+ `const`, и контекст, переданный helper-у. `try` считается, когда его защищённый блок ждёт в собственной функции тела:
520
+ `await` или `for await` внутри вложенного колбэка приостанавливает этот колбэк, а не команду.
521
+
522
+ В `catch` запись после `rethrowIfCancelled(cause)`, `signal.throwIfAborted()` или `if (signal.aborted)`, который
523
+ бросает или возвращает, — в блоке, где стоит запись, или в любом охватывающем его блоке, — не сообщается. Такая
524
+ проверка — заявление автора, что следующее за ней не должно выполняться для отменённого вызова, и правило верит ему на
525
+ слово: в `catch (cause) { rethrowIfCancelled(cause); update(busy, false); throw cause }` флаг после отмены остаётся
526
+ `true`, и сообщения нет. Поэтому флаг, который обязан сняться при отмене, пишется в `finally` через `capture(state)`.
527
+ Проверка в `finally` ничего не спасает и сообщается по-прежнему, как и проверка `signal.aborted`, которая не бросает и
528
+ не возвращает. `effect`, `event` и остальные реакции правило не трогает: их прогон отменило более новое значение, и
529
+ сброс его записи — ровно то, что нужно (D288). Контекст разрешается точно: `call` параметра типа `ModelContext` либо
530
+ контекста `model(Declaration, factory)` внутри `defineFeature` из `@opetope/runtime` (D309); собственный `call` фичи
531
+ писателя телу не даёт.
532
+
468
533
  ### `prefer-model-selection`
469
534
 
470
535
  Компонент, который берёт одну выданную модель и читает её поля по хуку на поле, повторяет одно и то же
@@ -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 a}from"@typescript-eslint/utils";import{propertyName as s}from"./ast.js";import{isFeatureSection as c,isApi as m,definitionOf as l,isFunction as y}from"./model-bindings.js";const h=new Map([["event",1],["scope.keyed",1],["scope.switch",1],["scope.while",1],["stream",2]]),g=new Map([["attach",{argument:1,property:"open"}]]);function x(t){const[n]=t.params;if(n?.type===i.Identifier||n?.type===i.ObjectPattern)return n}function I(t,n){const e=x(n)?.typeAnnotation?.typeAnnotation;if(e?.type!==i.TSTypeReference)return!1;const{typeName:r}=e;return r.type===i.Identifier&&m(t,r,"@opetope/core","ModelContext",!0)}function u(t,n){const e=l(t,n);if(!(e?.type!==a.Scope.DefinitionType.Parameter||!y(e.node)))return{fn:e.node,name:e.name}}function S(t,n){const e=u(t,n);return e!==void 0&&e.name===e.fn.params[0]?e.fn:void 0}function w(t,n){const e=u(t,n),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 P(t){const n=[];let e=t;for(;e.type===i.MemberExpression;){if(e.computed||e.property.type!==i.Identifier)return;n.unshift(e.property.name),e=e.object}return{root:e,steps:n}}function d(t,n){const e=P(n);if(e?.root.type!==i.Identifier)return;const r=e.steps.length===0?void 0:S(t,e.root);if(r!==void 0)return{fn:r,method:e.steps.join(".")};const o=w(t,e.root);return o===void 0?void 0:{fn:o.fn,method:[o.method,...e.steps].join(".")}}function E(t,n){const e=n.parent;if(e.type!==i.CallExpression||e.arguments[1]!==n)return;const r=d(t,e.callee);return r?.method==="model"?r.fn:void 0}function p(t,n){const e=new Set;let r=n;for(;r!==void 0&&!e.has(r);){if(e.add(r),c(t,r,"own",!0)||I(t,r))return!0;r=E(t,r)}return!1}function b(t,n,e){if(h.get(t)===n)return!0;const r=g.get(t);return r?.argument===n&&r.property===e}function A(t,n,e,r){const o=d(t,n.callee);return o!==void 0&&b(o.method,e,r)&&p(t,o.fn)}function C(t,n,e){return t.type!==i.ObjectExpression||n.type!==i.Property?e:s(n)}function M(t,n){let e=n,r;for(const o of t.getAncestors(n).reverse()){if(o.type===i.CallExpression){const f=o.arguments.indexOf(e);if(f!==-1&&A(t,o,f,r))return!0;r=void 0}else r=C(o,e,r);e=o}return!1}export{p as isContextFunction,M as isDeclaredIngress,d as methodPath};
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],["stream",2]]),I=new Map([["attach",{argument:1,property:"open"}]]);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 f(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&&f(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)&&f(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 u=o.arguments.indexOf(e);if(u!==-1&&b(n,o,u,r))return!0;r=void 0}else r=A(o,e,r);e=o}return!1}export{f 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 — `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\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{defineFeaturePropertyOrder as p}from"./rules/define-feature-property-order.js";import{idNaming as s}from"./rules/id-naming.js";import{layerPlacement as i}from"./rules/layer-placement.js";import{noCommandInDeps as l}from"./rules/no-command-in-deps.js";import{noInternalImports as m}from"./rules/no-internal-imports.js";import{noRedundantConstTuple as a}from"./rules/no-redundant-const-tuple.js";import{noSnapshotInUpdate as d}from"./rules/no-snapshot-in-update.js";import{noSnapshotReadInRender as c}from"./rules/no-snapshot-read-in-render.js";import{noSubscribeOutsideModels as f}from"./rules/no-subscribe-outside-models.js";import{preferEffectCurrent as u}from"./rules/prefer-effect-current.js";import{preferModelSelection as g}from"./rules/prefer-model-selection.js";import{requireDeclaredModels as b}from"./rules/require-declared-models.js";import{requireLiteralId as h}from"./rules/require-literal-id.js";import{whenPredicate as y}from"./rules/when-predicate.js";const I=["integration","models","ui"],S=["**/*.ts","**/*.tsx"],q=["**/__tests__/**","**/*.spec.ts","**/*.spec.tsx"],E={meta:{name:"@opetope/lint",version:"0.9.5"},rules:{"define-feature-property-order":p,"id-naming":s,"layer-placement":i,"no-command-in-deps":l,"no-internal-imports":m,"no-redundant-const-tuple":a,"no-snapshot-in-update":d,"no-snapshot-read-in-render":c,"no-subscribe-outside-models":f,"prefer-effect-current":u,"prefer-model-selection":g,"require-declared-models":b,"require-literal-id":h,"when-predicate":y}},r=E,_={name:"opetope/recommended",plugins:{opetope:r},rules:{"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=>I.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=q})=>e.length===0?[]:[{files:S,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};
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.9.7"},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.9.5' },\n rules: {\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/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","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":"88BAiCA,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,OAAO,EAC/C,MAAO,CACL,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,EAAef,EAEfgB,EAAc,CAClB,KAAM,sBACN,QAAS,CAAE,QAASD,CAAY,EAChC,MAAO,CACL,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,GACjBrB,EAAO,QAAQsB,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,EAAQxB,CAAU,IACtDuB,EAAO,SAAW,EACd,CAAA,EACA,CACE,CACE,MAAOxB,EACP,KAAM,+BACN,QAAS,CAAE,QAASiB,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"}
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.9.7' },\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,OAAO,EAC/C,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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"}
package/oxlintrc.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "rules": {
3
+ "opetope/capture-command-cleanup": "error",
3
4
  "opetope/define-feature-property-order": "error",
4
5
  "opetope/id-naming": "error",
5
6
  "opetope/layer-placement": "off",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opetope/lint",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "engines": {
5
5
  "node": ">=20.19.0"
6
6
  },