From 3b47f3b4ab123b052c2ca53b2c4e8c585549505c Mon Sep 17 00:00:00 2001 From: Viktor Pasynok Date: Tue, 15 Sep 2026 00:25:07 +0700 Subject: [PATCH 1/3] feat(eslint-plugin): add no-scope-get rule --- .../src/content/docs/learn/linting.mdx | 29 ++++++ packages/eslint-plugin/README.md | 1 + packages/eslint-plugin/src/index.ts | 2 + .../rules/no-scope-get/no-scope-get.test.ts | 88 +++++++++++++++++++ .../src/rules/no-scope-get/no-scope-get.ts | 36 ++++++++ packages/eslint-plugin/src/shared/is.ts | 10 +++ 6 files changed, 166 insertions(+) create mode 100644 packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.test.ts create mode 100644 packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.ts create mode 100644 packages/eslint-plugin/src/shared/is.ts diff --git a/documentation/src/content/docs/learn/linting.mdx b/documentation/src/content/docs/learn/linting.mdx index e9643faf..3b081558 100644 --- a/documentation/src/content/docs/learn/linting.mdx +++ b/documentation/src/content/docs/learn/linting.mdx @@ -109,6 +109,35 @@ compose().step(login).step(debug(login)).run() compose().step(login).run() ``` +### no-scope-get + +This rule is not enabled in the `recommended` config. Enable it explicitly when `scope.get()` should be treated as +debug-only code. + +💭 This rule requires [typed linting](https://typescript-eslint.io/getting-started/typed-linting/). + +```js +{ + rules: { + 'app-compose/no-scope-get': 'warn', + }, +} +``` + +
+ +The plugin reports every call to `scope.get()` on an App-Compose Scope, including Scopes returned by `run()` and +Scopes received by observers. Other objects with a method named `get` are not reported. + +```ts +// ❌ Wrong +const scope = await compose().step(login).run() +scope.get(login.result) + +// ✅ Correct +await compose().step(login).run() +``` + ### task-options-order ⚠️ This rule warns in the `recommended` config. diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 99da1fed..3a0dd8ab 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -48,5 +48,6 @@ Or wire the plugin manually: | ----------------------------------------------------------------------------------------- | ------------------------------------------------- | --- | --- | --- | | [no-async-shape-callback](https://app-compose.dev/learn/linting/#no-async-shape-callback) | Disallow async callbacks in `shape()` | | ❗ | | | [no-coda-debug](https://app-compose.dev/learn/linting/#no-coda-debug) | Disallow `debug()` calls from `@app-compose/coda` | | ❗ | | +| [no-scope-get](https://app-compose.dev/learn/linting/#no-scope-get) | Disallow `scope.get()` calls | | | | | [task-options-order](https://app-compose.dev/learn/linting/#task-options-order) | Enforce options order for `createTask` | ⚠️ | | 🔧 | | [wire-options-order](https://app-compose.dev/learn/linting/#wire-options-order) | Enforce options order for `createWire` | ⚠️ | | 🔧 | diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index 5ce3b5e3..58b70d53 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -2,6 +2,7 @@ import type { TSESLint } from "@typescript-eslint/utils" import { name, version } from "../package.json" import noAsyncShapeCallback from "./rules/no-async-shape-callback/no-async-shape-callback" import noCodaDebug from "./rules/no-coda-debug/no-coda-debug" +import noScopeGet from "./rules/no-scope-get/no-scope-get" import taskOptionsOrder from "./rules/task-options-order/task-options-order" import wireOptionsOrder from "./rules/wire-options-order/wire-options-order" import { ruleset } from "./ruleset" @@ -11,6 +12,7 @@ const base = { rules: { "no-async-shape-callback": noAsyncShapeCallback, "no-coda-debug": noCodaDebug, + "no-scope-get": noScopeGet, "task-options-order": taskOptionsOrder, "wire-options-order": wireOptionsOrder, }, diff --git a/packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.test.ts b/packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.test.ts new file mode 100644 index 00000000..b4bff734 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.test.ts @@ -0,0 +1,88 @@ +import { RuleTester } from "@typescript-eslint/rule-tester" +import { ts } from "@/shared/tag" +import rule from "./no-scope-get" + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: ["*.ts*"], + }, + tsconfigRootDir: import.meta.dirname, + }, + }, +}) + +ruleTester.run("no-scope-get", rule, { + valid: [ + { + name: "unrelated get method", + code: ts` + const registry = new Map() + registry.get("value") + `, + }, + { + name: "unrelated type named Scope", + code: ts` + type Scope = { get: () => unknown } + declare const scope: Scope + scope.get() + `, + }, + { + name: "run without reading the scope", + code: ts` + import { compose } from "@app-compose/core" + await compose().run() + `, + }, + ], + invalid: [ + { + name: "scope returned by run", + code: ts` + import { compose, type Spot } from "@app-compose/core" + declare const spot: Spot + + const scope = await compose().run() + scope.get(spot) + `, + errors: [{ messageId: "unexpectedScopeGet" }], + }, + { + name: "composer without a local compose call", + code: ts` + import type { Composer, Spot } from "@app-compose/core" + declare const composer: Composer + declare const spot: Spot + + const scope = await composer.run() + scope.get(spot) + `, + errors: [{ messageId: "unexpectedScopeGet" }], + }, + { + name: "scope in a promise callback", + code: ts` + import { compose, type Spot } from "@app-compose/core" + declare const spot: Spot + + compose().run().then((scope) => scope.get(spot)) + `, + errors: [{ messageId: "unexpectedScopeGet" }], + }, + { + name: "scope from an observer event", + code: ts` + import type { ComposeObserver, Spot } from "@app-compose/core" + declare const spot: Spot + + const observe: ComposeObserver = (event) => { + event.scope.get(spot) + } + `, + errors: [{ messageId: "unexpectedScopeGet" }], + }, + ], +}) diff --git a/packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.ts b/packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.ts new file mode 100644 index 00000000..6a64d9df --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.ts @@ -0,0 +1,36 @@ +import { ESLintUtils, type TSESTree as Node } from "@typescript-eslint/utils" +import { createRule } from "@/shared/create" +import { isType } from "@/shared/is" + +export default createRule({ + name: "no-scope-get", + meta: { + type: "suggestion", + docs: { + description: "Disallow `scope.get()` calls", + }, + messages: { + unexpectedScopeGet: "Unexpected `scope.get()` call.", + }, + schema: [], + hasSuggestions: false, + }, + defaultOptions: [], + create: (context) => { + const services = ESLintUtils.getParserServices(context) + + type ScopeGetCall = Node.CallExpression & { callee: Node.MemberExpression & { property: Node.Identifier } } + + return { + [`CallExpression[callee.type="MemberExpression"][callee.property.type="Identifier"][callee.property.name="get"]`]: + (node: ScopeGetCall) => { + const type = services.getTypeAtLocation(node.callee.object) + + const isScope = isType.scope(type, services.program) + if (!isScope) return + + context.report({ node, messageId: "unexpectedScopeGet" }) + }, + } + }, +}) diff --git a/packages/eslint-plugin/src/shared/is.ts b/packages/eslint-plugin/src/shared/is.ts new file mode 100644 index 00000000..68ff23b2 --- /dev/null +++ b/packages/eslint-plugin/src/shared/is.ts @@ -0,0 +1,10 @@ +import type { Program, Type } from "typescript" +import { typeMatchesSpecifier } from "@typescript-eslint/type-utils" +import { PACKAGE_NAME } from "@/shared/constants" + +const isType = { + scope: (type: Type, program: Program) => + typeMatchesSpecifier(type, { from: "package", package: PACKAGE_NAME.CORE, name: "Scope" }, program), +} + +export { isType } From 27df20ce6e5a6a86abf38a1dbeade4ae8d89c9fb Mon Sep 17 00:00:00 2001 From: Viktor Pasynok Date: Tue, 15 Sep 2026 00:40:16 +0700 Subject: [PATCH 2/3] docs(eslint-plugin): explain no-scope-get rationale --- .../src/content/docs/learn/linting.mdx | 23 ++++++++++++++----- packages/eslint-plugin/README.md | 2 +- .../src/rules/no-scope-get/no-scope-get.ts | 5 ++-- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/documentation/src/content/docs/learn/linting.mdx b/documentation/src/content/docs/learn/linting.mdx index 3b081558..08e76d27 100644 --- a/documentation/src/content/docs/learn/linting.mdx +++ b/documentation/src/content/docs/learn/linting.mdx @@ -111,8 +111,8 @@ compose().step(login).run() ### no-scope-get -This rule is not enabled in the `recommended` config. Enable it explicitly when `scope.get()` should be treated as -debug-only code. +This rule is not enabled in the `recommended` config. Enable it explicitly to keep application work inside the +composition. 💭 This rule requires [typed linting](https://typescript-eslint.io/getting-started/typed-linting/). @@ -126,16 +126,27 @@ debug-only code.
-The plugin reports every call to `scope.get()` on an App-Compose Scope, including Scopes returned by `run()` and -Scopes received by observers. Other objects with a method named `get` are not reported. +`scope.get()` can lead to imperative work outside the composition: surrounding code reads a value and decides what +runs next. Put the work in a Task and pass the values it needs through the Task's `context`. + +The plugin reports every call to `scope.get()` on the App-Compose `Scope` type, including values returned by `run()` +and values received by observers. Other objects with a method named `get` are not reported. ```ts // ❌ Wrong const scope = await compose().step(login).run() -scope.get(login.result) +renderUser(scope.get(login.result)) // ✅ Correct -await compose().step(login).run() +const showUser = createTask({ + name: "show-user", + run: { + context: login.result, + fn: (user) => renderUser(user), + }, +}) + +await compose().step(login).step(showUser).run() ``` ### task-options-order diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 3a0dd8ab..12780a9f 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -48,6 +48,6 @@ Or wire the plugin manually: | ----------------------------------------------------------------------------------------- | ------------------------------------------------- | --- | --- | --- | | [no-async-shape-callback](https://app-compose.dev/learn/linting/#no-async-shape-callback) | Disallow async callbacks in `shape()` | | ❗ | | | [no-coda-debug](https://app-compose.dev/learn/linting/#no-coda-debug) | Disallow `debug()` calls from `@app-compose/coda` | | ❗ | | -| [no-scope-get](https://app-compose.dev/learn/linting/#no-scope-get) | Disallow `scope.get()` calls | | | | +| [no-scope-get](https://app-compose.dev/learn/linting/#no-scope-get) | Disallow imperative `scope.get()` calls | | | | | [task-options-order](https://app-compose.dev/learn/linting/#task-options-order) | Enforce options order for `createTask` | ⚠️ | | 🔧 | | [wire-options-order](https://app-compose.dev/learn/linting/#wire-options-order) | Enforce options order for `createWire` | ⚠️ | | 🔧 | diff --git a/packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.ts b/packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.ts index 6a64d9df..e9e4a1eb 100644 --- a/packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.ts +++ b/packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.ts @@ -7,10 +7,11 @@ export default createRule({ meta: { type: "suggestion", docs: { - description: "Disallow `scope.get()` calls", + description: "Disallow imperative `scope.get()` calls", }, messages: { - unexpectedScopeGet: "Unexpected `scope.get()` call.", + unexpectedScopeGet: + "`scope.get()` can lead to imperative work outside the composition. Put the work in a Task and pass values through its context.", }, schema: [], hasSuggestions: false, From ce8de3f738157131e213aa2a63f99ae5b172a309 Mon Sep 17 00:00:00 2001 From: Viktor Pasynok Date: Tue, 15 Sep 2026 00:45:49 +0700 Subject: [PATCH 3/3] docs(eslint-plugin): simplify no-scope-get description --- packages/eslint-plugin/README.md | 2 +- packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 12780a9f..3a0dd8ab 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -48,6 +48,6 @@ Or wire the plugin manually: | ----------------------------------------------------------------------------------------- | ------------------------------------------------- | --- | --- | --- | | [no-async-shape-callback](https://app-compose.dev/learn/linting/#no-async-shape-callback) | Disallow async callbacks in `shape()` | | ❗ | | | [no-coda-debug](https://app-compose.dev/learn/linting/#no-coda-debug) | Disallow `debug()` calls from `@app-compose/coda` | | ❗ | | -| [no-scope-get](https://app-compose.dev/learn/linting/#no-scope-get) | Disallow imperative `scope.get()` calls | | | | +| [no-scope-get](https://app-compose.dev/learn/linting/#no-scope-get) | Disallow `scope.get()` calls | | | | | [task-options-order](https://app-compose.dev/learn/linting/#task-options-order) | Enforce options order for `createTask` | ⚠️ | | 🔧 | | [wire-options-order](https://app-compose.dev/learn/linting/#wire-options-order) | Enforce options order for `createWire` | ⚠️ | | 🔧 | diff --git a/packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.ts b/packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.ts index e9e4a1eb..de305e55 100644 --- a/packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.ts +++ b/packages/eslint-plugin/src/rules/no-scope-get/no-scope-get.ts @@ -7,7 +7,7 @@ export default createRule({ meta: { type: "suggestion", docs: { - description: "Disallow imperative `scope.get()` calls", + description: "Disallow `scope.get()` calls", }, messages: { unexpectedScopeGet: