diff --git a/.oxlintrc.json b/.oxlintrc.json index e7130eb1..5f1bfc8a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -17,6 +17,7 @@ "local/no-module-scoped-registry": "error", "local/no-section-divider-comments": "error", "local/no-yield-in-finally": "error", + "local/prefer-effection-operation": "error", "local/prefer-effection-result": "error" }, diff --git a/packages/durable-streams/types.ts b/packages/durable-streams/types.ts index 58f8643e..2e79846b 100644 --- a/packages/durable-streams/types.ts +++ b/packages/durable-streams/types.ts @@ -138,4 +138,5 @@ export interface DurableEffect { * at compile time — yielding a plain Effect inside a Workflow generator * is a type error. */ +// oxlint-disable-next-line local/prefer-effection-operation export type Workflow = Generator, T, unknown>; diff --git a/scripts/oxlint-plugin.js b/scripts/oxlint-plugin.js index 08edcf56..ed509c47 100644 --- a/scripts/oxlint-plugin.js +++ b/scripts/oxlint-plugin.js @@ -2,6 +2,7 @@ import { noModuleScopedRegistry } from "./oxlint-rules/no-module-scoped-registry import { noRedundantTestScope } from "./oxlint-rules/no-redundant-test-scope.js"; import { noSectionDividerComments } from "./oxlint-rules/no-section-divider-comments.js"; import { noYieldInFinally } from "./oxlint-rules/no-yield-in-finally.js"; +import { preferEffectionOperation } from "./oxlint-rules/prefer-effection-operation.js"; import { preferEffectionResult } from "./oxlint-rules/prefer-effection-result.js"; export default { @@ -11,6 +12,7 @@ export default { "no-section-divider-comments": noSectionDividerComments, "no-redundant-test-scope": noRedundantTestScope, "no-yield-in-finally": noYieldInFinally, + "prefer-effection-operation": preferEffectionOperation, "prefer-effection-result": preferEffectionResult, }, }; diff --git a/scripts/oxlint-rules/prefer-effection-operation.js b/scripts/oxlint-rules/prefer-effection-operation.js new file mode 100644 index 00000000..bfd0fded --- /dev/null +++ b/scripts/oxlint-rules/prefer-effection-operation.js @@ -0,0 +1,434 @@ +/** + * `local/prefer-effection-operation` — work is an Operation, not a Generator. + * + * `Generator` and `AsyncGenerator` are the types of the object a `function*` + * produces. Naming one is right when a declaration really is handing a consumer + * a sequence — `Generator` says what arrives on each + * `next()`, and this rule leaves it alone. What it reports is the concrete type + * standing in for Effection work: + * + * type EvalBlock = (env: Record) => + * Generator; + * + * Nobody consumes that. A caller only runs the result with `yield*`, and this + * type does not even let it: `Generator` yields `unknown` where an + * `Operation` yields `Effect`, so every call site needs a cast to get back the + * contract it already had. `Operation` is that contract, and a + * `function*` remains a perfectly good way to implement it. + * + * ## What the yield type settles + * + * The first type argument is the whole test, because it is where the two + * meanings differ. A generator that serves a consumer names what it yields. One + * that yields `unknown` — or nothing, or `any` — offers a consumer nothing it + * can use, which is the shape of work waiting for a runner. So a reference is + * reported when its yield type is: + * + * - absent, `unknown`, or `any` — no consumer is being served; or + * - an effect, which Effection work is what yields. + * + * Anything else is iteration and passes. That line is syntactic, so it is drawn + * conservatively: a domain type this rule has never seen is taken at its word + * as a value, and a `function*` whose generator type is inferred is never + * examined at all. This rule reads declarations, not implementations. + * + * ## Which type is an effect + * + * A name is not evidence. `SoundEffect` is a sound, and a generator yielding + * one is ordinary iteration, so an effect has to be recognized from something + * the source actually says: + * + * - `Effect` imported from `effection`, however it is spelled at the import — + * directly, renamed, or reached through a namespace; + * - a type this module declares by extending or intersecting one of those; or + * - a type this module declares with Effection's effect contract itself: + * + * description: string; + * enter(resolve, routine): (resolve) => void; + * + * `description` annotated `string`, and `enter` a two-parameter signature + * whose result is itself callable — entering an effect hands back the + * operation that leaves it. Both shapes are checked, not just the two names, + * because names are cheap: `{ description: number; enter: boolean }` is a + * doorway, and a generator yielding one is iteration. + * + * This branch is what recognizes `DurableEffect`. It restates the contract + * rather than extending it, deliberately, to keep `enter`'s variance under + * its own control, so nothing in its declaration names Effection at all. + * `Workflow` yields it, which is why that declaration keeps its concrete + * type behind a suppression of its own. + * + * A union or intersection counts when any member does. Everything else is a + * value — including a type imported from another module of this repository, + * whose declaration this rule cannot see and will not guess at. + * + * ## Which `Generator` is the built-in + * + * Only the global one. A module with a `Generator` of its own is talking about + * that type, and shadowing is lexical: a module-level import or declaration + * covers the whole module, while a nested declaration or a type parameter + * covers only the scope that owns it. A reference outside that scope is still + * the built-in — + * + * function local(value: Generator): Generator { return value; } + * + * type EvalBlock = () => Generator; // reported + * + * — and `globalThis.Generator` reaches past every shadow, so it is never taken + * for a local name. + * + * ## No fix + * + * Whether a reported declaration should become `Operation` or should keep a + * generator and name what it yields depends on what the author meant. A + * syntactic rule choosing between them would rewrite the contract rather than + * the annotation, so the diagnostic names both and stops there. + */ +const BUILT_INS = new Set(["Generator", "AsyncGenerator"]); + +const IMPORTS = new Set(["ImportSpecifier", "ImportDefaultSpecifier", "ImportNamespaceSpecifier"]); + +const EFFECTION = new Set(["effection", "effection/experimental"]); + +/** `enter(resolve, routine)` — the two Effection passes an effect on the way in. */ +const ENTER_ARITY = 2; + +/** Nodes that open a lexical scope a type declaration belongs to. */ +const SCOPES = new Set(["BlockStatement", "StaticBlock", "TSModuleBlock", "Program"]); + +/** Yield types that hand a consumer nothing it can name. */ +const OPAQUE = new Map([ + ["TSUnknownKeyword", "unknown"], + ["TSAnyKeyword", "any"], +]); + +/** The name a type parameter binds, whichever shape the AST gives it. */ +function parameterName(node) { + if (typeof node.name === "string") { + return node.name; + } + + return node.name && node.name.type === "Identifier" ? node.name.name : undefined; +} + +/** The trailing identifier of a type name, qualified or not. */ +function referenceName(typeName) { + if (typeName.type === "Identifier") { + return typeName.name; + } + + if (typeName.type === "TSQualifiedName" && typeName.right.type === "Identifier") { + return typeName.right.name; + } + + return undefined; +} + +/** Whether a type name reaches the global object explicitly. */ +function isGlobal(typeName) { + return ( + typeName.type === "TSQualifiedName" && + typeName.left.type === "Identifier" && + typeName.left.name === "globalThis" + ); +} + +/** The built-in a type reference names, or undefined for anything else. */ +function builtIn(typeName) { + const name = referenceName(typeName); + + if (!BUILT_INS.has(name) || !(typeName.type === "Identifier" || isGlobal(typeName))) { + return undefined; + } + + return name; +} + +/** What the generator hands its consumer, or undefined when it is left off. */ +function yielded(node) { + const args = node.typeArguments ?? node.typeParameters; + + return args && args.params.length > 0 ? args.params[0] : undefined; +} + +/** The name a member signs, or undefined when it is not a plain one. */ +function memberName(member) { + return member.key && member.key.type === "Identifier" ? member.key.name : undefined; +} + +/** The type a member is annotated with, or undefined. */ +function annotation(member) { + return member.typeAnnotation ? member.typeAnnotation.typeAnnotation : undefined; +} + +/** `description: string` — the effect's own annotation, not merely its name. */ +function isDescription(member) { + const declared = annotation(member); + + return ( + memberName(member) === "description" && + declared !== undefined && + declared.type === "TSStringKeyword" + ); +} + +/** + * `enter(resolve, routine): (resolve) => void` — a two-parameter signature whose + * result is itself callable. That return is what makes an effect an effect: + * entering one hands back the operation that leaves it. + */ +function isEnter(member) { + if (memberName(member) !== "enter") { + return false; + } + + const declared = annotation(member); + const signature = + member.type === "TSMethodSignature" + ? member + : declared && declared.type === "TSFunctionType" + ? declared + : undefined; + + if (!signature) { + return false; + } + + const parameters = signature.params ?? signature.parameters ?? []; + const returns = signature.returnType ? signature.returnType.typeAnnotation : undefined; + + return ( + parameters.length === ENTER_ARITY && returns !== undefined && returns.type === "TSFunctionType" + ); +} + +/** Whether a body declares Effection's effect contract itself. */ +function declaresContract(members) { + return members.some(isDescription) && members.some(isEnter); +} + +/** The type names a declaration builds itself out of. */ +function ancestry(type, found) { + if (!type) { + return found; + } + + if (type.type === "TSUnionType" || type.type === "TSIntersectionType") { + for (const member of type.types) { + ancestry(member, found); + } + } + + if (type.type === "TSTypeReference") { + found.push(type.typeName); + } + + return found; +} + +/** + * The type names an interface extends. A heritage clause carries an expression + * rather than a type name, so a qualified one arrives as a member expression. + */ +function heritage(node) { + return (node.extends ?? []).flatMap((entry) => { + const expression = entry.expression ?? entry; + + if (expression.type === "Identifier" || expression.type === "TSQualifiedName") { + return [expression]; + } + + if ( + expression.type === "MemberExpression" && + !expression.computed && + expression.object.type === "Identifier" && + expression.property.type === "Identifier" + ) { + return [{ type: "TSQualifiedName", left: expression.object, right: expression.property }]; + } + + return []; + }); +} + +/** The innermost scope a declaration belongs to, or null at module level. */ +function enclosingScope(node) { + for (let parent = node.parent; parent; parent = parent.parent) { + if (SCOPES.has(parent.type)) { + return parent.type === "Program" ? null : parent.range; + } + } + + return null; +} + +export const preferEffectionOperation = { + meta: { + type: "problem", + messages: { + opaque: + "{{name}} yielding {{yields}} offers a consumer nothing to consume: this is work for a runner. Declare Effection work as Operation and run it with yield*, or, if it really is iteration, name what it yields — Iterator, IterableIterator, AsyncIterator, and AsyncIterableIterator say what arrives without fixing how it is produced.", + effects: + "A {{name}} that yields effects is Effection work, and its concrete type is the machinery producing it. Declare it as Operation and run it with yield*.", + }, + }, + + create(context) { + const shadows = []; + const candidates = []; + const imported = new Set(); + const namespaces = new Set(); + const declared = []; + + function shadow(name, range) { + if (typeof name === "string" && BUILT_INS.has(name)) { + shadows.push({ name, range }); + } + } + + function declaring(node) { + if (node.id && node.id.type === "Identifier") { + shadow(node.id.name, enclosingScope(node)); + } + } + + /** Whether a declaration of `name` covers this position. */ + function shadowed(name, at) { + return shadows.some( + (entry) => + entry.name === name && + (entry.range === null || (entry.range[0] <= at && at < entry.range[1])), + ); + } + + /** + * The names this module knows to be effects. Declarations are resolved to a + * fixed point so a chain of them settles however it is ordered. + */ + function effectNames() { + const names = new Set(imported); + + for (let settling = true; settling; ) { + settling = false; + + for (const entry of declared) { + const inherited = entry.ancestry.some((typeName) => isEffect(typeName, names)); + + if (!names.has(entry.name) && (entry.contract || inherited)) { + names.add(entry.name); + settling = true; + } + } + } + + return names; + } + + /** Whether a type name is one of Effection's effects. */ + function isEffect(typeName, names) { + if (typeName.type === "TSQualifiedName") { + return ( + typeName.left.type === "Identifier" && + namespaces.has(typeName.left.name) && + referenceName(typeName) === "Effect" + ); + } + + return typeName.type === "Identifier" && names.has(typeName.name); + } + + function yieldsEffects(type, names) { + return ancestry(type, []).some((typeName) => isEffect(typeName, names)); + } + + return { + ImportDeclaration(node) { + for (const specifier of node.specifiers) { + if (IMPORTS.has(specifier.type)) { + shadow(specifier.local.name, enclosingScope(node)); + } + + if (!EFFECTION.has(node.source.value)) { + continue; + } + + if (specifier.type === "ImportSpecifier" && specifier.imported.name === "Effect") { + imported.add(specifier.local.name); + } + + if (specifier.type === "ImportNamespaceSpecifier") { + namespaces.add(specifier.local.name); + } + } + }, + + // A type parameter is visible in the declaration that introduced it and + // nowhere else, so that declaration's extent is its scope. + TSTypeParameter(node) { + const owner = node.parent && node.parent.parent; + + if (owner) { + shadow(parameterName(node), owner.range); + } + }, + + ClassDeclaration: declaring, + TSEnumDeclaration: declaring, + + TSInterfaceDeclaration(node) { + declaring(node); + + declared.push({ + name: node.id.name, + contract: declaresContract(node.body.body), + ancestry: heritage(node), + }); + }, + + TSTypeAliasDeclaration(node) { + declaring(node); + + declared.push({ + name: node.id.name, + contract: + node.typeAnnotation.type === "TSTypeLiteral" && + declaresContract(node.typeAnnotation.members), + ancestry: ancestry(node.typeAnnotation, []), + }); + }, + + TSTypeReference(node) { + const name = builtIn(node.typeName); + + if (name !== undefined) { + candidates.push({ node, name }); + } + }, + + "Program:exit"() { + const names = effectNames(); + + for (const candidate of candidates) { + const global = isGlobal(candidate.node.typeName); + + if (!global && shadowed(candidate.name, candidate.node.range[0])) { + continue; + } + + const type = yielded(candidate.node); + const opaque = type ? OPAQUE.get(type.type) : "nothing"; + + if (!opaque && !yieldsEffects(type, names)) { + continue; + } + + context.report({ + node: candidate.node, + messageId: opaque ? "opaque" : "effects", + data: { name: candidate.name, yields: opaque ?? "" }, + }); + } + }, + }; + }, +}; diff --git a/scripts/tests/fixtures/domain-effect.ts b/scripts/tests/fixtures/domain-effect.ts new file mode 100644 index 00000000..24093d31 --- /dev/null +++ b/scripts/tests/fixtures/domain-effect.ts @@ -0,0 +1,57 @@ +/** + * Fixture for `local/prefer-effection-operation` — a domain type is a value + * however it is named. + * + * `SoundEffect` is a sound. It comes from nowhere near Effection and declares + * none of an effect's contract, so a generator yielding one is iteration and + * every spelling of it passes. + * + * `Doorway`, `Portal` and `Threshold` go further: each signs both of the + * contract's member names. None signs both of its shapes — `Portal` describes + * itself with a number, `Threshold` enters and returns nothing — so none is + * assignable to `Effect`, and each one fails on a different half of the + * check. + */ + +interface SoundEffect { + name: string; +} + +interface VisualEffect { + frames: number; +} + +interface Doorway { + description: number; + enter: boolean; +} + +/** Enters exactly as an effect does, and describes itself as nothing like one. */ +interface Portal { + description: number; + enter( + resolve: (result: unknown) => void, + routine: { scope: unknown }, + ): (resolve: (result: void) => void) => void; +} + +type Threshold = { + description: string; + enter(guest: string): void; +}; + +export function* sounds(): Generator { + yield { name: "bell" }; +} + +export type SoundSource = () => Generator; + +export type QualifiedSoundSource = () => globalThis.Generator; + +export type Overlaid = () => AsyncGenerator; + +export type DoorwaySource = () => Generator; + +export type ThresholdSource = () => Generator; + +export type PortalSource = () => Generator; diff --git a/scripts/tests/fixtures/generator-contract.ts b/scripts/tests/fixtures/generator-contract.ts new file mode 100644 index 00000000..6dfc6fd5 --- /dev/null +++ b/scripts/tests/fixtures/generator-contract.ts @@ -0,0 +1,64 @@ +/** + * Fixture for `local/prefer-effection-operation` — the shapes it reports. + * + * Every declaration here stands in for Effection work: it yields nothing a + * consumer could use, or it yields effects. They appear directly, nested inside + * a callable an operation hands back, as the annotation on an implementation, + * and through `globalThis`. The effects are Effection's own `Effect` — under + * its own name, renamed at the import, and restated as a contract the way + * `DurableEffect` restates it. + */ +import type { Effect, Effect as Performed, Operation } from "effection"; + +/** The contract restated, the way `packages/durable-streams` restates it. */ +interface DurableEffect { + description: string; + effectDescription: { type: string; name: string }; + enter( + resolve: (result: T) => void, + routine: { scope: unknown }, + ): (resolve: (result: void) => void) => void; +} + +interface Retried extends Effect { + attempts: number; +} + +export type EvalBlock = (env: Record) => Generator; + +export type CompileBlock = ( + source: string, +) => Operation<(env: Record) => Generator>; + +export function* evaluate(): Generator { + yield undefined; + return "evaluated"; +} + +export type Untyped = (env: Record) => Generator; + +export type Loose = (env: Record) => Generator; + +export type ReadBlock = (path: string) => AsyncGenerator; + +export type OpenBlock = ( + path: string, +) => Operation<(chunk: string) => AsyncGenerator>; + +export async function* read(): AsyncGenerator { + yield undefined; +} + +export type Effects = Generator, T, unknown>; + +export type Renamed = Generator, T, unknown>; + +export type Inherited = Generator, T, unknown>; + +export type Mixed = Generator | string, T, unknown>; + +export type Workflow = Generator, T, unknown>; + +export type Qualified = () => globalThis.Generator; + +export type QualifiedAsync = () => globalThis.AsyncGenerator; diff --git a/scripts/tests/fixtures/generator-source.ts b/scripts/tests/fixtures/generator-source.ts new file mode 100644 index 00000000..df1a938b --- /dev/null +++ b/scripts/tests/fixtures/generator-source.ts @@ -0,0 +1,9 @@ +/** The module `imported-generator.ts` takes its own `Generator` names from. */ + +export interface Generator { + render(template: string): string; +} + +export interface AsyncGenerator { + render(template: string): Promise; +} diff --git a/scripts/tests/fixtures/imported-generator.ts b/scripts/tests/fixtures/imported-generator.ts new file mode 100644 index 00000000..64827e83 --- /dev/null +++ b/scripts/tests/fixtures/imported-generator.ts @@ -0,0 +1,9 @@ +/** + * Fixture for `local/prefer-effection-operation` — an imported name that is not + * the built-in. + */ +import type { AsyncGenerator, Generator } from "./generator-source.ts"; + +export type Render = (template: string) => Generator; + +export type RenderAsync = (template: string) => AsyncGenerator; diff --git a/scripts/tests/fixtures/namespaced-effect.ts b/scripts/tests/fixtures/namespaced-effect.ts new file mode 100644 index 00000000..9b384000 --- /dev/null +++ b/scripts/tests/fixtures/namespaced-effect.ts @@ -0,0 +1,9 @@ +/** + * Fixture for `local/prefer-effection-operation` — an effect reached through a + * namespace import of `effection`. + */ +import type * as effection from "effection"; + +export type Effects = Generator, T, unknown>; + +export type Scoped = (scope: effection.Scope) => Generator; diff --git a/scripts/tests/fixtures/nested-shadow.ts b/scripts/tests/fixtures/nested-shadow.ts new file mode 100644 index 00000000..23d6b280 --- /dev/null +++ b/scripts/tests/fixtures/nested-shadow.ts @@ -0,0 +1,27 @@ +/** + * Fixture for `local/prefer-effection-operation` — shadowing is lexical. + * + * A type parameter and a nested declaration each cover their own scope and + * nothing beyond it, so the contracts declared after them still name the + * built-in and are still reported. + */ + +export function identity(value: Generator): Generator { + return value; +} + +export type EvalBlock = (env: Record) => Generator; + +export function pull(): string { + interface AsyncGenerator { + chunk(): string; + } + + const source: AsyncGenerator = { chunk: () => "chunk" }; + + return source.chunk(); +} + +export type ReadBlock = (path: string) => AsyncGenerator; + +export type Wrap = (value: AsyncGenerator) => AsyncGenerator; diff --git a/scripts/tests/fixtures/operation-contract.ts b/scripts/tests/fixtures/operation-contract.ts new file mode 100644 index 00000000..9557739c --- /dev/null +++ b/scripts/tests/fixtures/operation-contract.ts @@ -0,0 +1,41 @@ +/** + * Fixture for `local/prefer-effection-operation` — the shapes it accepts. + * + * Effection work is declared as `Operation` however it is implemented. A + * generator that really does serve a consumer names what it yields, and saying + * so is enough — whether it is spelled as a concrete generator or as one of the + * iterator interfaces. + */ +import type { Operation } from "effection"; + +export type EvalBlock = (env: Record) => Operation; + +export type CompileBlock = ( + source: string, +) => Operation<(env: Record) => Operation>; + +export function* inferred() { + yield undefined; + return "evaluated"; +} + +export function* annotated(): Operation { + yield undefined; + return "evaluated"; +} + +export function* numbers(): Generator { + yield 1; +} + +export type NumberSource = () => Generator; + +export type ChunkSource = (path: string) => AsyncGenerator; + +export type Walk = (root: string) => IterableIterator; + +export type Step = (root: string) => Iterator; + +export type Stream = (root: string) => AsyncIterableIterator; + +export type Pull = (root: string) => AsyncIterator; diff --git a/scripts/tests/fixtures/shadowed-generator.ts b/scripts/tests/fixtures/shadowed-generator.ts new file mode 100644 index 00000000..4a49eeff --- /dev/null +++ b/scripts/tests/fixtures/shadowed-generator.ts @@ -0,0 +1,22 @@ +/** + * Fixture for `local/prefer-effection-operation` — names that are not the + * built-ins. + * + * A module that declares its own `Generator` at module level is talking about + * that type throughout. The `globalThis` form reaches past the shadow, so it is + * still reported. + */ + +export interface Generator { + render(template: string): string; +} + +export interface AsyncGenerator { + render(template: string): Promise; +} + +export type Render = (template: string) => Generator; + +export type RenderAsync = (template: string) => AsyncGenerator; + +export type Qualified = () => globalThis.Generator; diff --git a/scripts/tests/oxlint-policy.test.ts b/scripts/tests/oxlint-policy.test.ts index e64ffc32..266ab936 100644 --- a/scripts/tests/oxlint-policy.test.ts +++ b/scripts/tests/oxlint-policy.test.ts @@ -56,6 +56,7 @@ const GATE_RULES = [ "local/no-module-scoped-registry", "local/no-section-divider-comments", "local/no-yield-in-finally", + "local/prefer-effection-operation", "local/prefer-effection-result", ]; diff --git a/scripts/tests/prefer-effection-operation.test.ts b/scripts/tests/prefer-effection-operation.test.ts new file mode 100644 index 00000000..75fdf192 --- /dev/null +++ b/scripts/tests/prefer-effection-operation.test.ts @@ -0,0 +1,151 @@ +/** + * Rule tests for `local/prefer-effection-operation` (scripts/oxlint-rules). + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { readTextFile } from "@effectionx/fs"; +import type { Operation } from "effection"; +import path from "node:path"; +import { ROOT, runOxlint, violations } from "./oxlint.ts"; + +const RULE = "prefer-effection-operation"; + +const WORKFLOW = path.join("packages", "durable-streams", "types.ts"); + +const DIRECTIVE = `// oxlint-disable-next-line local/${RULE}`; + +function reported(fixture: string): Operation { + return violations(`scripts/tests/fixtures/${fixture}`, RULE); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function lintScript(source: string): string { + const manifest: unknown = JSON.parse(source); + const scripts = isRecord(manifest) ? manifest.scripts : undefined; + const lint = isRecord(scripts) ? scripts.lint : undefined; + + if (typeof lint !== "string") { + throw new Error("package.json no longer defines a lint script"); + } + + return lint; +} + +/** + * The ignores and targets the lint gate passes on oxlint's command line, taken + * from the gate itself so this sweep covers exactly the files it covers. The + * config is dropped because the harness supplies it. + */ +function gateArguments(script: string): string[] { + const tokens = script.split("&&")[0].match(/'[^']*'|\S+/gu) ?? []; + const bare = tokens.map((token) => token.replaceAll("'", "")); + const start = bare.indexOf("oxlint"); + const config = bare.indexOf("-c"); + + if (start < 0 || config < start) { + throw new Error(`the lint script no longer invokes oxlint with a config: ${script}`); + } + + return [...bare.slice(start + 1, config), ...bare.slice(config + 2)]; +} + +function reportedFiles(source: string): string[] { + const value: unknown = JSON.parse(source); + const diagnostics = isRecord(value) && Array.isArray(value.diagnostics) ? value.diagnostics : []; + + return diagnostics + .filter(isRecord) + .filter((entry) => entry.code === `local(${RULE})`) + .map((entry) => (typeof entry.filename === "string" ? entry.filename : "")); +} + +describe("local/prefer-effection-operation", () => { + /** + * In fixture order: a callable alias, the same shape nested inside a callable + * an operation returns, an annotated implementation, a reference with no type + * arguments, an `any` yield, those first three again as `AsyncGenerator`, + * Effection's `Effect` under its own name, renamed at the import, inherited + * through an `extends`, and in a union, the durable `Workflow` shape — whose + * effect restates the contract instead of naming Effection — without its + * suppression, and both `globalThis` forms. + */ + it("reports every declaration that stands in for Effection work", function* () { + expect(yield* reported("generator-contract.ts")).toEqual([ + 27, 31, 33, 38, 40, 42, 46, 48, 52, 54, 56, 58, 60, 62, 64, + ]); + }); + + /** + * Operation contracts, an inferred `function*`, and — the boundary this rule + * does not cross — ordinary generators and iterators that name what they + * yield, concrete `Generator` included. + */ + it("accepts operation contracts and generators that name what they yield", function* () { + expect(yield* reported("operation-contract.ts")).toEqual([]); + }); + + /** + * A name is not evidence. `SoundEffect` declares none of an effect's contract + * and comes from nowhere near Effection, so the annotated generator, the + * callable alias, the `globalThis` form and a union of two such types all + * pass. `Doorway` and `Threshold` sign both of the contract's member names + * with incompatible types — an interface and a type-literal alias — and are + * values too, because the shapes are what is checked. + */ + it("accepts a domain type that only resembles an effect", function* () { + expect(yield* reported("domain-effect.ts")).toEqual([]); + }); + + /** Reached through a namespace import; `effection.Scope` is still a value. */ + it("reports an effect qualified by an effection namespace, and nothing else", function* () { + expect(yield* reported("namespaced-effect.ts")).toEqual([7]); + }); + + /** + * A type parameter covers its own declaration and a nested interface its own + * block, so the two contracts declared after them still name the built-in. + */ + it("shadows lexically, so a contract outside the scope is still reported", function* () { + expect(yield* reported("nested-shadow.ts")).toEqual([13, 25]); + }); + + it("leaves a module-level Generator alone and still reports the globalThis form", function* () { + expect(yield* reported("shadowed-generator.ts")).toEqual([22]); + }); + + it("leaves an imported Generator alone", function* () { + expect(yield* reported("imported-generator.ts")).toEqual([]); + }); + + it("names the operation and the iterator destinations, and says why", function* () { + const run = yield* runOxlint(".oxlintrc.json", [ + "scripts/tests/fixtures/generator-contract.ts", + ]); + + expect(run.stdout).toContain("Declare Effection work as Operation and run it with yield*"); + expect(run.stdout).toContain("IterableIterator"); + expect(run.stdout).toContain("yields effects is Effection work"); + }); + + it("reports nothing across the sources the lint gate covers", function* () { + const gate = gateArguments(lintScript(yield* readTextFile(path.join(ROOT, "package.json")))); + expect(gate).toContain("packages"); + + const run = yield* runOxlint(".oxlintrc.json", ["--format=json", ...gate]); + + expect(reportedFiles(run.stdout)).toEqual([]); + }); + + it("suppresses the durable Workflow declaration at that line alone", function* () { + const source = yield* readTextFile(path.join(ROOT, WORKFLOW)); + const lines = source.split("\n"); + const declaration = lines.findIndex((line) => line.startsWith("export type Workflow =")); + + expect(declaration).toBeGreaterThan(0); + expect(lines[declaration - 1]).toBe(DIRECTIVE); + expect(lines.filter((line) => line.includes(RULE))).toEqual([DIRECTIVE]); + }); +});