Skip to content

🔧 Prevent contracts from exposing concrete generator types - #413

Merged
taras merged 1 commit into
mainfrom
prefer-effection-operation
Aug 9, 2026
Merged

🔧 Prevent contracts from exposing concrete generator types#413
taras merged 1 commit into
mainfrom
prefer-effection-operation

Conversation

@taras

@taras taras commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Why

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. Naming one for Effection work is not: a caller only runs the result
with yield*, and the concrete type does not even let it —
Generator<unknown, …> yields unknown where an Operation yields Effect, so
every call site needs a cast to get back the contract it already had.

#184 already completed the cleanup this issue's Cleanup section describes —
EvalBlock returns Operation<unknown>, compileBlock returns
Operation<EvalBlock>, eval-handler.ts runs compiled blocks with yield*, and
the casts and manual .next() tests are gone. Nothing enforced it. This PR
closes that remaining enforcement gap and nothing else.

What changes

Before: nothing stopped the next contract from being written as
(env: Record<string, unknown>) => Generator<unknown, unknown, unknown>, and the
casts would come back with it.

After: deno task lint fails on a concrete generator type that stands in for
Effection work, and says which of the two destinations the declaration belongs
in. Ordinary iteration is untouched.

oxlint gate → local/prefer-effection-operation → built-in Generator/AsyncGenerator → yield type → error

How it works

The rule reports a reference to the built-in Generator or AsyncGenerator when
its yield type says the declaration describes work rather than a sequence.
That is the whole boundary, and it is where the two meanings differ:

Yield type Verdict
absent, unknown, any reported — a consumer is offered nothing it can use, so this is work waiting for a runner
an effect (see below) reported — Effection work is what yields effects
anything else — number, string, a domain type accepted — the declaration names what arrives, which is iteration

So Generator<number, void, unknown> and () => Generator<number, void, unknown>
pass, per #185's requirement that ordinary synchronous iterators are accepted,
while Generator<unknown, …>, the same shape nested inside a callable an
Operation returns, the AsyncGenerator counterparts, and
Generator<DurableEffect<unknown>, T, unknown> are all reported. The line is not
drawn by file, declaration kind, or "any concrete yield type" — DurableEffect
is concrete and is still reported.

A name is not evidence. SoundEffect is a sound, and a generator yielding
one is ordinary iteration. An effect is recognized only from something the
source actually says:

  • Effect imported from effection, however it is spelled at the import —
    directly, renamed (Effect as Performed), or reached through a namespace
    (effection.Effect);

  • 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;

    Both member shapes are validated, not just the two names: description
    annotated string, and enter a two-parameter signature whose return type is
    itself a function type — entering an effect hands back the operation that
    leaves it. { description: number; enter: boolean } is a doorway, not an
    effect, 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 — an
    import-only rule would silently drop it and make the Workflow suppression
    dead.

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 the rule cannot see and does not guess at.

Oxlint's JavaScript plugins are syntactic
(docs), so the rule is
conservative in the places a syntactic answer could be wrong:

  • Shadowing is lexical. A module-level import or declaration of Generator
    covers the module; a nested declaration or a type parameter covers only the
    scope that owns it, so this still reports:

    function identity<Generator>(value: Generator): Generator { return value; }
    
    type EvalBlock = () => Generator<unknown, unknown, unknown>;  // reported

    globalThis.Generator reaches past every shadow and is never taken for a
    local name.

  • A domain type is taken at its word. A yield type this rule has never seen
    is a value, not an effect.

  • Implementations are never examined. A function* whose generator type is
    inferred is out of scope; the rule reads declarations.

  • No autofix. Whether a reported declaration should become Operation<T> 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.

Review guide

Start with: scripts/oxlint-rules/prefer-effection-operation.js

Then review:

  1. scripts/tests/fixtures/generator-contract.ts and operation-contract.ts — the boundary in both directions
  2. scripts/tests/fixtures/nested-shadow.ts — lexical scope
  3. scripts/tests/prefer-effection-operation.test.ts — exact diagnostic lines in source order
  4. packages/durable-streams/types.ts — the one suppression
  5. .oxlintrc.json, scripts/oxlint-plugin.js, scripts/tests/oxlint-policy.test.ts — gate wiring

Look carefully at: isDescription and isEnter. They are the whole contract
branch, they are what the real DurableEffect matches, and each is separately
mutation-checked — a fixture fails when either stops validating its shape.

What must stay true

  • Workflow<T> = Generator<DurableEffect<unknown>, T, unknown> keeps its concrete
    yield type — that is what makes yielding a plain Effect inside a workflow a
    compile error (DEC-009). It carries a single-line
    oxlint-disable-next-line local/prefer-effection-operation, not a file or
    directory exclusion — checked by suppresses the durable Workflow declaration
    at that line alone
    .
  • Ordinary iteration stays lintable code — checked by accepts operation
    contracts and generators that name what they yield
    .
  • No other repository source needs a suppression — checked by reports nothing
    across the sources the lint gate covers
    , which runs the rule over the lint
    script's own targets and ignore patterns, read out of package.json so the
    sweep cannot drift from the gate.

How to verify it

deno task test scripts/tests/prefer-effection-operation.test.ts scripts/tests/oxlint-policy.test.ts
  • reports every declaration that stands in for Effection work asserts lines
    [27, 31, 33, 38, 40, 42, 46, 48, 52, 54, 56, 58, 60, 62, 64] of
    generator-contract.ts: a callable alias, that shape nested inside a callable
    an Operation returns, an annotated function*, a reference with no type
    arguments, an any yield, the three AsyncGenerator counterparts, then
    Effection's real Effect under its own name, renamed at the import, inherited
    through an extends, and inside a union; the durable Workflow shape — whose
    effect restates the contract instead of naming Effection, which is the
    reporting coverage for that mechanism — without its suppression; and both
    globalThis forms.
  • accepts operation contracts and generators that name what they yield covers
    Operation<T>, an inferred function*, function* numbers(): Generator<number, void, unknown>,
    type NumberSource = () => Generator<number, void, unknown>, an
    AsyncGenerator<string, …> source, and all four iterator interfaces. It fails
    if the rule widens back to every concrete generator type.
  • accepts a domain type that only resembles an effect covers
    function* sounds(): Generator<SoundEffect, void, unknown>, the callable
    alias, the globalThis-qualified form, and a SoundEffect | VisualEffect
    union — plus three types that sign both contract member names and fail on
    its shapes: Doorway (description: number; enter: boolean), Portal (an
    interface entering exactly as an effect does but describing itself with a
    number), and Threshold (a type-literal alias whose enter takes one
    parameter and returns nothing). It fails if recognition goes back to matching
    names, and separately if either member's shape check is dropped.
  • reports an effect qualified by an effection namespace, and nothing else
    asserts line 7 of namespaced-effect.ts: effection.Effect is an effect,
    effection.Scope is a value.
  • shadows lexically … asserts [13, 25] of nested-shadow.ts: the built-in
    contracts declared after a type parameter and after a nested interface are
    reported, while the references inside those scopes are not.
  • leaves a module-level Generator alone … and leaves an imported Generator
    alone
    fail if the rule reports a name that is not the built-in; the first also
    asserts the globalThis form is still reported in that same file.
  • names the operation and the iterator destinations, and says why fails if
    either message stops naming its destination.

Mutation results

Each mutation was applied to the working tree, the focused tests were run, and
the file was restored byte-identically (verified with diff against a snapshot).

Mutation Result
contract recognized by member names only FAIL — accepts a domain type that only resembles an effect
description's string annotation no longer checked FAIL — same test, via Portal
enter's arity and function return no longer checked FAIL — same test, via Threshold
Workflow suppression removed FAIL — the gate sweep and the suppression test
endsWith("Effect") heuristic restored FAIL — the domain-type acceptance test
recognition of effection's Effect import removed FAIL — reports every declaration that stands in for Effection work
ordinary-iterator acceptance removed FAIL — the two acceptance tests and the namespace test
lexical shadowing reverted to file-wide FAIL — shadows lexically
AsyncGenerator recognition removed FAIL — the reporting test and shadows lexically
nested Operation traversal disabled FAIL — reports every declaration that stands in for Effection work
rule removed from scripts/oxlint-plugin.js FAIL — 15 steps across both test files
rule removed from .oxlintrc.json FAIL — 6 steps across both test files

Row four is the one that proves the contract branch reaches the real
DurableEffect in packages/durable-streams/types.ts, not just the fixture's.

Verification

Head 7be5ba04581f164c32dbf4cc6fdffe158d7846e8, base
aa4744285aae46883f533f5676b5decdd343698f.

Command Result
deno task test scripts/tests/prefer-effection-operation.test.ts scripts/tests/oxlint-policy.test.ts 2 passed (24 steps), 0 failed
deno task lint exit 0 — 0 errors, formatting clean
deno task check no errors
deno task check:jsr Success Dry run complete
deno task test --changed=origin/main 307 passed (2321 steps), 0 failed
git diff --check clean

The explicit rule tests are run by name because the fixtures and the oxlint
subprocess are boundaries changed-test import selection cannot see.

Scope

Included

  • local/prefer-effection-operation, its plugin export, its gate entry, and its
    GATE_RULES entry
  • Fixtures and a focused rule test
  • The narrow Workflow<T> suppression

Intentionally unchanged

  • The EvalBlock/compileBlock cleanup, which 💥 feat(runtime): resolve the xmd command through API.Env #184 already merged
  • Workflow<T>'s type, which the durable protocol depends on
  • AGENTS.md, architecture.md, language specifications, package manifests,
    dependencies, and runtime behavior
  • Unrelated Generator casts and broader Effection cleanup

Risks and limitations

  • Effect recognition reaches what one module can see: an effection import, and
    declarations in that same file. A generator yielding an effect type imported
    from a sibling module of this repository passes. Type awareness is not
    available to Oxlint JavaScript plugins, and the alternatives — matching names,
    or reporting every concrete yield type — either misclassify domain types like
    SoundEffect or ban the ordinary iterators lint: prevent Effection contracts from exposing Generator #185 requires accepting.
  • The contract branch reads two member shapes, not the full assignability
    question a type checker would answer. A declaration that matched both shapes
    and still was not an Effect<T> would be reported; nothing in the repository
    does, and the three domain-effect.ts near-misses pin the boundary.
  • The rule sees explicit annotations only. A function* whose generator type is
    inferred and then re-exported is out of reach, by design.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

Closes #185

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

PR #413: 🔧 Prevent contracts from exposing concrete generator types

14 files, +828 / -0

Scope

🔴 PR has 828 lines changed. Split into focused PRs.

🟡 828 lines changed. PRs under 400 receive more thorough review.

🟡 PR mixes config and source changes.

Structural

🟡 Type declarations with no consumers: SoundSource, QualifiedSoundSource, Overlaid, DoorwaySource, ThresholdSource, PortalSource, Untyped, Loose, OpenBlock, Renamed, Inherited, Mixed, QualifiedAsync, Scoped, Wrap, NumberSource, ChunkSource, Walk, Step, Stream, Pull.
Symbol Declared at Refs in diff Why flagged
SoundSource scripts/tests/fixtures/domain-effect.ts:47 1 referenced ≤1× within the added diff (pre-existing usages not counted)
QualifiedSoundSource scripts/tests/fixtures/domain-effect.ts:49 1 referenced ≤1× within the added diff (pre-existing usages not counted)
Overlaid scripts/tests/fixtures/domain-effect.ts:51 1 referenced ≤1× within the added diff (pre-existing usages not counted)
DoorwaySource scripts/tests/fixtures/domain-effect.ts:53 1 referenced ≤1× within the added diff (pre-existing usages not counted)
ThresholdSource scripts/tests/fixtures/domain-effect.ts:55 1 referenced ≤1× within the added diff (pre-existing usages not counted)
PortalSource scripts/tests/fixtures/domain-effect.ts:57 1 referenced ≤1× within the added diff (pre-existing usages not counted)
Untyped scripts/tests/fixtures/generator-contract.ts:38 1 referenced ≤1× within the added diff (pre-existing usages not counted)
Loose scripts/tests/fixtures/generator-contract.ts:40 1 referenced ≤1× within the added diff (pre-existing usages not counted)
OpenBlock scripts/tests/fixtures/generator-contract.ts:44 1 referenced ≤1× within the added diff (pre-existing usages not counted)
Renamed scripts/tests/fixtures/generator-contract.ts:54 1 referenced ≤1× within the added diff (pre-existing usages not counted)
Inherited scripts/tests/fixtures/generator-contract.ts:56 1 referenced ≤1× within the added diff (pre-existing usages not counted)
Mixed scripts/tests/fixtures/generator-contract.ts:58 1 referenced ≤1× within the added diff (pre-existing usages not counted)
QualifiedAsync scripts/tests/fixtures/generator-contract.ts:64 1 referenced ≤1× within the added diff (pre-existing usages not counted)
Scoped scripts/tests/fixtures/namespaced-effect.ts:9 1 referenced ≤1× within the added diff (pre-existing usages not counted)
Wrap scripts/tests/fixtures/nested-shadow.ts:27 1 referenced ≤1× within the added diff (pre-existing usages not counted)
NumberSource scripts/tests/fixtures/operation-contract.ts:31 1 referenced ≤1× within the added diff (pre-existing usages not counted)
ChunkSource scripts/tests/fixtures/operation-contract.ts:33 1 referenced ≤1× within the added diff (pre-existing usages not counted)
Walk scripts/tests/fixtures/operation-contract.ts:35 1 referenced ≤1× within the added diff (pre-existing usages not counted)
Step scripts/tests/fixtures/operation-contract.ts:37 1 referenced ≤1× within the added diff (pre-existing usages not counted)
Stream scripts/tests/fixtures/operation-contract.ts:39 1 referenced ≤1× within the added diff (pre-existing usages not counted)
Pull scripts/tests/fixtures/operation-contract.ts:41 1 referenced ≤1× within the added diff (pre-existing usages not counted)

Slop

✅ Slop indicators look low.

Static Analysis

✅ Oxlint found no issues.

Correctness

No extraneous code patterns detected.

@taras
taras marked this pull request as ready for review August 9, 2026 12:41
@taras
taras enabled auto-merge (squash) August 9, 2026 12:41
@taras
taras disabled auto-merge August 9, 2026 12:41
@taras
taras force-pushed the prefer-effection-operation branch 3 times, most recently from 60d0312 to 04352ce Compare August 9, 2026 13:33
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, so local/prefer-effection-operation reports the
narrower thing: the concrete type standing in for Effection work.

The yield type settles which is which. 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 work waiting for a runner.
Effection work yields effects. Everything else is iteration and passes,
Generator<number, void, unknown> included.

A name is not evidence that a type is an effect: SoundEffect is a sound.
An effect is Effect imported from effection, however it is spelled at the
import; a type this module declares out of one; or a type this module
declares with the effect contract itself — description annotated string,
and enter a two-parameter signature returning a function type. Both
shapes are checked, because names are cheap: { description: number;
enter: boolean } is a doorway. That branch is how DurableEffect is
recognized: it restates the contract rather than extending it, to keep
enter's variance under its own control, so nothing in its declaration
names Effection at all.

Shadowing is lexical. A module-level import or declaration covers the
module; a nested declaration or a type parameter covers only its own
scope, so a contract declared after one still names the built-in.
globalThis.Generator reaches past every shadow.

Choosing between Operation<T> and naming what a generator yields is a
decision about what the declaration means, so there is no fix.

Workflow<T> keeps its concrete yield type — that is what stops a workflow
yielding a non-durable effect — behind a suppression at that one line.

Closes #185
@taras
taras force-pushed the prefer-effection-operation branch from 04352ce to 7be5ba0 Compare August 9, 2026 14:02
@taras
taras merged commit 30dc295 into main Aug 9, 2026
16 checks passed
@taras
taras deleted the prefer-effection-operation branch August 9, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

lint: prevent Effection contracts from exposing Generator

1 participant