🔧 Prevent contracts from exposing concrete generator types - #413
Merged
Conversation
PR #413: 🔧 Prevent contracts from exposing concrete generator types14 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.
Slop✅ Slop indicators look low. Static Analysis✅ Oxlint found no issues. CorrectnessNo extraneous code patterns detected. |
taras
marked this pull request as ready for review
August 9, 2026 12:41
taras
enabled auto-merge (squash)
August 9, 2026 12:41
taras
disabled auto-merge
August 9, 2026 12:41
taras
force-pushed
the
prefer-effection-operation
branch
3 times, most recently
from
August 9, 2026 13:33
60d0312 to
04352ce
Compare
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
force-pushed
the
prefer-effection-operation
branch
from
August 9, 2026 14:02
04352ce to
7be5ba0
Compare
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
GeneratorandAsyncGeneratorare the types of the object afunction*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, …>yieldsunknownwhere anOperationyieldsEffect, soevery call site needs a cast to get back the contract it already had.
#184 already completed the cleanup this issue's Cleanup section describes —
EvalBlockreturnsOperation<unknown>,compileBlockreturnsOperation<EvalBlock>,eval-handler.tsruns compiled blocks withyield*, andthe casts and manual
.next()tests are gone. Nothing enforced it. This PRcloses 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 thecasts would come back with it.
After:
deno task lintfails on a concrete generator type that stands in forEffection work, and says which of the two destinations the declaration belongs
in. Ordinary iteration is untouched.
How it works
The rule reports a reference to the built-in
GeneratororAsyncGeneratorwhenits yield type says the declaration describes work rather than a sequence.
That is the whole boundary, and it is where the two meanings differ:
unknown,anynumber,string, a domain typeSo
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 anOperationreturns, theAsyncGeneratorcounterparts, andGenerator<DurableEffect<unknown>, T, unknown>are all reported. The line is notdrawn by file, declaration kind, or "any concrete yield type" —
DurableEffectis concrete and is still reported.
A name is not evidence.
SoundEffectis a sound, and a generator yieldingone is ordinary iteration. An effect is recognized only from something the
source actually says:
Effectimported fromeffection, 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:
Both member shapes are validated, not just the two names:
descriptionannotated
string, andentera two-parameter signature whose return type isitself a function type — entering an effect hands back the operation that
leaves it.
{ description: number; enter: boolean }is a doorway, not aneffect, and a generator yielding one is iteration.
This branch is what recognizes
DurableEffect. It restates the contractrather than extending it, deliberately, to keep
enter's variance under itsown control, so nothing in its declaration names Effection at all — an
import-only rule would silently drop it and make the
Workflowsuppressiondead.
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
Generatorcovers the module; a nested declaration or a type parameter covers only the
scope that owns it, so this still reports:
globalThis.Generatorreaches past every shadow and is never taken for alocal 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 isinferred is out of scope; the rule reads declarations.
No autofix. Whether a reported declaration should become
Operation<T>orshould 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.jsThen review:
scripts/tests/fixtures/generator-contract.tsandoperation-contract.ts— the boundary in both directionsscripts/tests/fixtures/nested-shadow.ts— lexical scopescripts/tests/prefer-effection-operation.test.ts— exact diagnostic lines in source orderpackages/durable-streams/types.ts— the one suppression.oxlintrc.json,scripts/oxlint-plugin.js,scripts/tests/oxlint-policy.test.ts— gate wiringLook carefully at:
isDescriptionandisEnter. They are the whole contractbranch, they are what the real
DurableEffectmatches, and each is separatelymutation-checked — a fixture fails when either stops validating its shape.
What must stay true
Workflow<T> = Generator<DurableEffect<unknown>, T, unknown>keeps its concreteyield type — that is what makes yielding a plain
Effectinside a workflow acompile error (DEC-009). It carries a single-line
oxlint-disable-next-line local/prefer-effection-operation, not a file ordirectory exclusion — checked by suppresses the durable Workflow declaration
at that line alone.
contracts and generators that name what they yield.
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.jsonso thesweep 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[27, 31, 33, 38, 40, 42, 46, 48, 52, 54, 56, 58, 60, 62, 64]ofgenerator-contract.ts: a callable alias, that shape nested inside a callablean
Operationreturns, an annotatedfunction*, a reference with no typearguments, an
anyyield, the threeAsyncGeneratorcounterparts, thenEffection's real
Effectunder its own name, renamed at the import, inheritedthrough an
extends, and inside a union; the durableWorkflowshape — whoseeffect restates the contract instead of naming Effection, which is the
reporting coverage for that mechanism — without its suppression; and both
globalThisforms.Operation<T>, an inferredfunction*,function* numbers(): Generator<number, void, unknown>,type NumberSource = () => Generator<number, void, unknown>, anAsyncGenerator<string, …>source, and all four iterator interfaces. It failsif the rule widens back to every concrete generator type.
function* sounds(): Generator<SoundEffect, void, unknown>, the callablealias, the
globalThis-qualified form, and aSoundEffect | VisualEffectunion — plus three types that sign both contract member names and fail on
its shapes:
Doorway(description: number; enter: boolean),Portal(aninterface entering exactly as an effect does but describing itself with a
number), and
Threshold(a type-literal alias whoseentertakes oneparameter and returns nothing). It fails if recognition goes back to matching
names, and separately if either member's shape check is dropped.
asserts line
7ofnamespaced-effect.ts:effection.Effectis an effect,effection.Scopeis a value.[13, 25]ofnested-shadow.ts: the built-incontracts declared after a type parameter and after a nested interface are
reported, while the references inside those scopes are not.
alone fail if the rule reports a name that is not the built-in; the first also
asserts the
globalThisform is still reported in that same file.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
diffagainst a snapshot).description'sstringannotation no longer checkedPortalenter's arity and function return no longer checkedThresholdWorkflowsuppression removedendsWith("Effect")heuristic restoredEffectimport removedAsyncGeneratorrecognition removedOperationtraversal disabledscripts/oxlint-plugin.js.oxlintrc.jsonRow four is the one that proves the contract branch reaches the real
DurableEffectinpackages/durable-streams/types.ts, not just the fixture's.Verification
Head
7be5ba04581f164c32dbf4cc6fdffe158d7846e8, baseaa4744285aae46883f533f5676b5decdd343698f.deno task test scripts/tests/prefer-effection-operation.test.ts scripts/tests/oxlint-policy.test.tsdeno task lintdeno task checkdeno task check:jsrSuccess Dry run completedeno task test --changed=origin/maingit diff --checkThe 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 itsGATE_RULESentryWorkflow<T>suppressionIntentionally unchanged
EvalBlock/compileBlockcleanup, which 💥 feat(runtime): resolve the xmd command through API.Env #184 already mergedWorkflow<T>'s type, which the durable protocol depends onAGENTS.md,architecture.md, language specifications, package manifests,dependencies, and runtime behavior
Generatorcasts and broader Effection cleanupRisks and limitations
effectionimport, anddeclarations 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
SoundEffector ban the ordinary iterators lint: prevent Effection contracts from exposing Generator #185 requires accepting.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 repositorydoes, and the three
domain-effect.tsnear-misses pin the boundary.function*whose generator type isinferred and then re-exported is out of reach, by design.
Scope confirmation
Closes #185