Skip to content

feat(experimental): remove unread Effect service methods (base case) - #1

Closed
effect-bot wants to merge 61 commits into
effect-pinned-v1.2.16from
effect-service-demand-tests
Closed

effect-bot wants to merge 61 commits into
effect-pinned-v1.2.16from
effect-service-demand-tests

Conversation

@effect-bot

@effect-bot effect-bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Owner

Native proof of concept: Rolldown removes Effect service methods that nothing in the bundle reads. It's behind experimental.effectServiceShaking and off by default. The base is Rolldown v1.2.16 (de6facf), so the diff shows only this work.

Closes EFF-3380

Feasibility

It works for closed Effect applications under four approved assumptions (below). Two kinds of program were checked:

  • The generator/constructor base case. All six pruning gates remove their unread methods on the real effect 4.1.2 package, and their output still runs.
  • A generated client. The generated OpenAI client from @effect/ai-openai 4.1.2 is used through its service, calling one method against a mock HTTP client. The bundle goes from 2,068,645 B to 532,916 B (−74.2%), with identical runtime results:
    • the other 378 methods, httpClient and the schemas only they use are removed;
    • retrieveModel and RetrieveModel200 stay;
    • there's no global bailout.

A third program shows where the pass doesn't apply:

  • A Distilled client. @distilled.cloud/stripe 1.0.0-rc.18 calls one of its 589 operations against a mock HTTP client.
    • The bundles are byte-identical at 439,842 B off and on, with identical runtime results.
    • Distilled exposes each operation as a separate /*@__PURE__*/ module-level export, not as a method of one service object, so Rolldown's ordinary tree shaking already keeps CreateCustomer and removes the other 588.
    • The pass correctly turns itself off under A2, because Distilled core compiles operation specs with Function(...) and loads modules through getBuiltinModule("node:module").createRequire(...).
    • No compiler change is warranted, and nothing requires widening A1–A4.

Programs the pass can't model keep every method, or turn the pass off with a warning at the site.

What it does

A link-stage pass (crates/rolldown/src/stages/link_stage/effect_service_shaking.rs) runs after cross-module optimization. It works on the module ASTs with oxc's scoping and Rolldown's canonical symbols.

  1. Keys. Module-level const K = Context.Service("literal"), or class K extends Context.Service<..>()("literal") {}.
    • A class body may only declare static members with constant names that don't redeclare the inherited Context.Service protocol (of, key, use, pipe, toString, ~… ids, and so on).
    • The body may not use this or super.
  2. Consumers. const s = yield* K, const { a } = yield* K and (yield* K).a inside Effect.gen / Effect.fn, read with static member names.
  3. Providers. Layer.succeed(K, {...}) and Layer.effect(K, e), where e is built from Effect.gen, Effect.sync, Effect.succeed and Effect.acquireRelease, and returns K.of({...}) or {...}.
    • Generated-client shape, outside the trusted packages only. Layer.effect(K, make(args)) with a module-level Effect.fn/fnUntraced constructor of the same module is followed through return and return yield*, into a module-level function in any module that returns only object literals.
    • Those literals count as provider objects only when every reference to the constructor and the builder is a call the pass followed. Constructor pipeables must be Effect.updateService, provideService, withSpan or annotateLogs.
    • A named callback, constructor or builder function expression whose own name is referenced isn't followed.
    • A Layer returned from a module-level function is followed through each call, and layer.pipe(Layer.provide(a), ...) like the plain call.
  4. Demand is a least fixpoint. Reads inside a removed method don't count; code that runs during construction always does. A property that may read this keeps every property only if it's kept itself.
  5. Edits. Unread provider properties and dead constructor-local const functions are deleted. Rolldown's shaker then drops helpers only they used, and construction-time code keeps running in order.
    • A property that reads a binding is removed only when the read can't throw.
    • So imports, and bindings read inside their own declaration, parameter list, catch pattern, switch case or loop-head right-hand side, are kept.
  6. Lookups. A keyed lookup other than yield* (Context.get, Effect.updateService, provideService, ...) by a declared key keeps that key's methods.

The pass turns itself off for the whole build, with an EFFECT_SERVICE_SHAKING warning per site, in these cases:

  • entry exports;
  • external imports other than Node builtins;
  • code loaders the compiler can name: eval, Function, require, dynamic import(), a .constructor read by a constant name, vm/module/inspector/repl bindings, and Reflect.get/getOwnPropertyDescriptor with a constant loader key;
  • host values used other than through constant members, calls or typeof;
  • keyed lookups by an untracked key or with spread arguments, and the string-keyed Context APIs;
  • Context.Service used other than to declare a key;
  • writes to builtin prototypes.

ROLLDOWN_EFFECT_SERVICE_SHAKING_REPORT=1 prints the bailouts, the demand per key and the removed items. docs/in-depth/effect-service-shaking.md has the full rules.

Approved assumptions and limitations

Tim approved these for the POC. A program that breaks one can be miscompiled instead of keeping its methods.

  • A1. User code doesn't reflect on, serialise or mutate Effect runtime objects, and doesn't modify builtin prototypes.
  • A2. The app is closed: the entry exports only primitive constants, externals are Node builtins, and code loaders the compiler can name turn the pass off. Not checked: a loader reached through a member name built at run time, such as (() => 0)[["con", "structor"].join("")].
  • A3. Effect reads user service values only by key. Constructor/builder following and Layer factories are therefore never applied inside Effect's own modules.
  • A4. There is one bundled copy of effect.

Other limitations:

  • Unsupported shapes keep every method: combinator consumers (Key.use, Effect.map(Key, ...)), providers built by spreading a parameter (NodeFileSystem), constructors with other pipeables, and named callbacks whose name is referenced. Library builds with entry exports aren't optimized.
  • Conservative fallbacks:
    • any member named getOwnPropertyDescriptor, and aliasing or destructuring process/globalThis, turn the pass off;
    • class keys that override the protocol turn it off.
  • The keyed-lookup list is pinned to effect 4.1.2's public API.

Results at 8434b35b

The test binary was compiled separately (effect-verify profile), and every bundling and runtime command ran under timeout 10.

  • cargo test -- effect_service: 69 of 69 pass in 3.5 s.
    • 17 stub fixtures, 44 real-Effect fixtures and 8 bailout-site tests.
    • Every miscompile found in adversarial review has a regression: TDZ cases (forward, switch, import cycle, own initializer, parameters, loop heads, catch patterns), loader and host-value paths, keyed lookups, class-key overrides, builder and callback escapes, and recursive constructors.
  • -- effect_generated_client: 2 of 2 pass in 1.8 s (OpenAI and Distilled Stripe). It runs separately because bundling the generated clients takes a few seconds.
  • Full integration suite: 2254 pass at 91403544. The one failure is test262_module_code, because its submodule isn't checked out. 7f8a8e04 is a behavior-preserving refactor reviewed against these results, and the later commits change only docs.
  • Clippy and fmt are clean.

Bundle measurements, from a release examples/effect_service_shaking.rs: median of 5 interleaved builds, and both outputs of every program run with Node.

program off on
lifecycle 149,375 B 149,007 B
cross_module 137,512 B 137,426 B
run_options 137,601 B 137,512 B
retained_fiber 140,984 B 140,895 B
provenance_success_channel 139,809 B 139,720 B
provenance_numeric_index 154,834 B 154,745 B
OpenAI generated client 2,068,645 B 532,916 B (−74.2%)
Distilled Stripe client, one operation 439,842 B 439,842 B (identical; the pass bails under A2)

The gates are minimal programs with one or a few unread methods each. Build times on the gates are 15–40 ms either way. For OpenAI they were 205–350 ms off and 170–300 ms on, depending on host load; the enabled build is usually faster because it writes far less. For Distilled Stripe they were 57–71 ms off and 82–111 ms on: the extra time is the analysis before it bails.

CI and docs

  • .github/workflows/effect-service-shaking.yml runs one Linux / Node 24 job on pull requests and manual dispatch:
    • the effect_service fixtures under a 10-second limit, compiled in a separate step;
    • a snapshot check;
    • a debug NAPI binding build;
    • the real-Effect gates through the JS API.
  • @effect/ai-openai 4.1.2 and @distilled.cloud/stripe 1.0.0-rc.18 (pinned) are root devDependencies for the generated-client fixtures. Both resolve to the single bundled effect 4.1.2.
  • docs/in-depth/effect-service-shaking.md documents the handled shapes, the bailouts, the A1–A4 contract and the report.

Try it

cargo test --profile effect-verify -p rolldown --test integration -- effect_service
cargo test --profile effect-verify -p rolldown --test integration -- effect_generated_client
cargo run --release -p rolldown --example effect_service_shaking -- <entry> <out-dir> [runs]

Fixtures for a native pass that removes service methods no consumer reads,
limited to providers built with Layer.effect + Effect.gen and consumers
using yield*. They use a minimal stand-in for the effect package so they
stay hermetic.

Intended to fail until the pass exists (removal assertions in _test.mjs):
- constructor_local_methods: writeOne, a constructor-local const, and its
  helper are removed.
- transitive_demand: removing writeOne removes the dependency method only
  it called.
- lifecycle: acquisition, release, construction-time calls and a helper
  also used during construction keep running, in the same order.

Expected to pass (unsupported paths keep every method):
- bailout_unsupported_access: Effect.map, Context.get, Key.use, escape,
  computed access, and a method using this.
- bailout_entry_export, bailout_reflection: global bailouts.

Snapshots record the unmodified v1.2.16 output.
Adds experimental.effectServiceShaking (off by default). A link-stage
pass, run before referenced symbols are computed, attaches demand to
Effect service key strings and deletes provider object properties that
no consumer can read, then drops the statement references those
properties held so the existing shaker removes their helpers.

Base case: keys from module-level Context.Service declarations,
consumers `const s = yield* K` / `(yield* K).name` in Effect.gen,
providers Layer.succeed(K, {...}) and Layer.effect(K, e) with e built
from Effect.gen/sync/succeed/acquireRelease returning K.of({...}).

Demand is a least fixpoint: reads inside a removed method, or inside a
constructor-local const function only removed code referenced, do not
count; code that runs during construction always does. Only properties
and dead local const functions are deleted, so acquisition, release and
construction-time calls keep running. Anything else keeps every method
of the key, and entry exports, non-builtin externals, reflection roots
and unaudited Effect APIs disable the pass.

Uses oxc scoping and Rolldown's canonical symbols for resolution. The
effect_service fixtures now enable the option; their assertions are
unchanged.
Regressions from review, each failing on the current pass:
- nested_release: nested acquireRelease releases both read the object.
- rest_release: a release callback reads it through a rest parameter.
- namespace_layer_escape: a provider Layer built by hand through a
  namespace import (named_layer_escape is the passing control).
- receivers: default-parameter `this` and a reassigned function
  declaration.
- key_retarget: keys retargeted by a helper and by a computed write.

Positive gates against the real effect package (now a root
devDependency, like cjs-module-lexer): lifecycle and cross_module must
actually remove code, so a global bailout cannot pass them.

The stub effect package now looks services up by the key's current
`key`, as the real package does.
- Nested acquireRelease: every release level's reader is kept.
- Release callbacks with a rest or pattern parameter, or reading
  `arguments`, keep the whole provider object.
- `ns.binding` uses count as uses of the exported binding, so a Layer
  reached through a namespace import gets its escape check; a module
  namespace used as a value disables the pass.
- Receiver checks scan parameters as well as the body, and a reassigned
  function declaration is never certified `this`-free.
- An unrecognised use of a service key outside trusted modules disables
  the pass, since opaque code may change the key string.

Regression fixtures and both real-Effect gates pass; generated
snapshots updated.
One fixture per cause, each failing on the current pass:
- release_param_default: a release callback reads the object through
  `arguments` in a later parameter's default value.
- nested_namespace_key, nested_namespace_layer: a key retarget and a
  hand-built Layer reached through a namespace re-exported as a namespace
  (two hops); the pass follows one hop, so neither key use nor Layer
  escape is seen.
- class_receiver: a method reads `this` in a class heritage expression.
- key_constructor_alias: keys created through an alias of
  Context.Service share the provided key string.

Both real-Effect removal gates are unchanged.
- Release callbacks are only followed with a single plain parameter and
  no default; anything else keeps the provider object.
- Namespace member chains (`ns.a.b`) resolve through every hop in
  reference recording, the API check and key classification, so key uses
  and Layer escapes behind re-exported namespaces are seen; a namespace
  used as a value at any hop disables the pass.
- Any class inside a provider method counts as reading the receiver.
- In untrusted code, Context.Service/Context.Reference used other than as
  the callee of a tracked key declaration disables the pass.

Generated snapshots updated; tests and real-Effect gates unchanged.
A provider object sets `__proto__` to a function whose inherited `read`
reads `this.unused`. The pass keeps the `__proto__` entry but removes
`unused`, so the inherited method throws. Fails on the current pass.
A non-computed `__proto__` entry sets the object's prototype, whose
inherited methods may read any own property through `this`. Prototypes
are not modelled, so such a provider object now keeps every property.

Generated snapshot added; tests and real-Effect gates unchanged.
Remove bookkeeping the pass never read (the site span index, the
unused span parameter and completeness flag of provider), collapse the
identifier-valued property check to the this-free test it was
equivalent to, return the callee span from key_creation instead of
re-matching the call, share the key string extraction between the two
key creation checks, and apply the pruner once per edited module.

No behaviour change: the effect_service suite is 20/20 with unchanged
snapshots, and both real-Effect gates still report the same removals.
Adds a fork-owned workflow on standard runners: the effect_service
fixtures (stub and real Effect), the full integration suite with a
snapshot drift check, clippy, the NAPI debug build with JS type checks,
and both real-Effect gates bundled through the JS API and checked with
their own _test.mjs.

Documents the option, its supported patterns, the fallbacks and results
under docs/in-depth.
Positive (must remove writeOne, fail on the current allow-list):
- run_options: runPromise with an onFiberStart that ignores the Fiber.
- retained_fiber: forkChild + Fiber.join, Fiber kept after its scope.

Retention (must keep every method; pass today only through global
bailouts, and miscompile when those are disabled):
- internals_destructuring: `const { mapUnsafe } = context`.
- enumeration: Object.values(context) finds the service Map.
- enumeration_alias: the same through `const values = Object.values`.
- dynamic_name: `context[["map", "Unsafe"].join("")]`.
- json_literal: JSON.stringify({ context }, replacer) reaches the
  service through Context.toJSON, despite the inline literal.

The live Context comes from Effect.withFiber inside the provided scope.
They were generated during a local check with global bailouts disabled,
not by any real implementation; the implementation run generates them.
Replace the two-job workflow with one Linux/Node 24 job that runs only the
effect_service fixtures (with a scoped snapshot check), builds only the
NAPI binding and JS glue, and runs the lifecycle and cross_module gates
through the JS API. Drop the full integration suite, the test262
submodule checkout, clippy and the JS type check.

Run on pull requests (and manual dispatch) only, so a push to the PR
branch no longer starts a duplicate run. Later steps still run when a
fixture fails, so a failing fixture does not hide the JS API gates.
Remove the step conditions that ran the snapshot check, the NAPI build
and the JS API gates after a failed prerequisite. Any failed step now
stops the job.
Services leave a Context only through a keyed lookup, which the pass
tracks, or through a sink. Drop the audited Effect API list, the
RunOptions/runFork rules and the Context/Fiber member rules, and disable
the pass instead when user code:

- reads a Context internal by name (mapUnsafe, _flat, base, overlay,
  cacheRoot, toJSON), statically, by destructuring, by a constant
  computed name or through Object/Reflect, or calls
  Context.getOrUndefinedUnsafe / Context.makeUnsafe;
- can run code outside the module graph (eval, Function, require,
  .constructor, with, vm/module/inspector/repl);
- enumerates (Object.keys/values/entries/getOwnProperty*, Reflect,
  for...in, structuredClone), reads a member by a non-constant name, or
  JSON.stringify-s a non-constant value.

The last group is turned off by the new
experimental.effectServiceShakingAssumeNoReflection option.
…ction

Fixtures that run with the option on:
- option_internals_destructuring, option_internals_resolved_name,
  option_internals_reflect_get: Context internals read by name, by a
  computed name resolved through const bindings, and through
  Reflect.get still keep every method;
- option_positive_reflection: user code that enumerates, indexes and
  serializes its own data still gets writeOne removed;
- option_contract_violation_probe: deliberately breaks the option's
  promise on a live Context and pins that the option then removes the
  method each probe reads.
… option

Convert the option fixtures to default mode and add behavioural gates
for an option-free provenance analysis. Each program asserts its own
runtime result.

Converted:
- option_internals_resolved_name / option_internals_reflect_get ->
  internals_resolved_name / internals_reflect_get (retain);
- option_positive_reflection -> reflection_clean_data (must prune);
- option_contract_violation_probe -> reflection_live_context, with the
  assertions inverted (must retain);
- option_internals_destructuring dropped as a duplicate of
  internals_destructuring.

New prune gates: provenance_success_channel, provenance_numeric_index,
provenance_json_clean.
New retention probes: provenance_tainted_param, provenance_json_nested,
provenance_format, provenance_alias_mutation, provenance_tap,
forged_key, builtin_prototype_write.

Expected to fail before the implementation: the four prune gates
(over-retention) and provenance_format, forged_key,
builtin_prototype_write (wrongly pruned today).
Default-mode retention probes for gaps reproduced in review, each
pruned wrongly today:
- qualified_builtin: globalThis.Object.values(context);
- reflective_getter_alias: aliased Object.getOwnPropertyDescriptor on
  the Context prototype's mapUnsafe getter;
- lookup_getter: context.__lookupGetter__("mapUnsafe");
- node_prefixed_vm: compileFunction from node:vm;
- inspectable_to_json: Inspectable.toJson(context).services.
…tion option

Replace the syntax-only reflection sinks and the rejected
effectServiceShakingAssumeNoReflection option with a provenance check:
reflection, serialization and formatting disable the pass only when
their operand may hold an Effect runtime object, and static reads on
such a value demand that name on every service.

Also: builtin identity through globalThis and aliases, accessor
reflection members, node:-prefixed externals, and trusted formatting
APIs (Inspectable, Cause, logs).
…ure verification

- Only external imports of untrusted modules can disable the pass:
  Rolldown's Node runtime imports node:module, which made every Node
  build bail (the native example pruned nothing).
- Add an optimized effect-verify test profile. The real-Effect fixtures
  bundle effect four times each, which is parse-bound in a debug build:
  verification drops from ~12s to ~4s after a one-time compile.
- CI compiles the fixture runner in its own step and runs the fixtures
  under timeout 10, so an overrun fails the job.
- Docs: provenance rules replace the reflection option.
…d provenance design

Runtime retention fixtures (real Effect 4.1.2), each asserting its own result:
clean_receiver_method, builtin_callback_capture, serialization_hook,
computed_mutator, function_object_heap, host_module_loader,
accessor_install, for_each_success_channel, assert_failure_message,
method_result_tainted, cross_module_tainted_export, heap_bit_late_write,
callback_body_effect, ambient_getter, fiber_set_store, json_parse_reviver,
to_json_hook_ambient, repeat_schedule_output, catch_success_channel,
cross_module_alias_box, coercion_hook, mutated_exit_join,
completion_replacement, completion_replacement_helper,
completion_replacement_escaped_helper, hook_assignment.

Prune gates: unused_host_import, clean_builtin_callback.
Stub: version_mismatch (effect 4.0.0); the shared stub package declares
4.1.2, the API it models.

topics/effect_service_reasons: site-specific, non-exclusive checks of
EFFECT_SERVICE_SHAKING warnings for the heap-bit clauses and the version
pin.

New fixtures and the existing real-Effect retention fixtures run without
the harness's extended variants to keep verification under 10s; the four
pruning gates are unchanged.
Implements the accepted design and its amendments:
- value and success taint per expression, solved as a least fixpoint
  across keys (consumer method results over every provider site) and
  modules (exported bindings), with the build-global heap bit as input;
- function values carry their captures; builtins join their callbacks'
  returns; methods are builtins only on array, string and collection
  receivers; object-literal hooks contribute their returns;
- operation rows 1-13 by exact identity and call shape; any other
  trusted call sets the heap bit and bails on a runtime argument; host
  operations other than the node:assert rows bail, and an unused Node
  import observes nothing;
- heap bit clauses: runtime or unresolved receiver, stored runtime value
  or callable, implicitly invoked hook name, unknown write target, plus
  accessor installation, unsummarised trusted calls and unknown callees
  receiving runtime values; join, awaited run and method/property facts
  become unknown under it;
- effect version pin (4.1.2);
- EFFECT_SERVICE_SHAKING warnings, one per tag and site (generated
  check option effectServiceShaking).

Returns inside loops, switches and labels are now followed.

Snapshots regenerated for the new warnings. provenance_success_channel
and provenance_numeric_index retain under the specified rows (Effect.map
and Effect.all have no row).
Four real-Effect fixtures for rows 14 and 15, each asserting its own
runtime result, plus site-specific reason checks:
- map_success_binding: Effect.map over a forked Fiber returns its
  Context; no heap reason, no reason at the map/forkChild/sync calls,
  and the serialization sink fires at JSON.stringify;
- map_curried_fallback: one-argument Effect.map takes the fallback,
  [heap:unsummarised-call] at that call; unused kept;
- all_record_fallback: a record Effect.all takes the fallback,
  [heap:unsummarised-call] at that call; unused kept;
- all_mixed_origin_join: joining a numeric-index element of a row-15
  result joins every origin; no heap reason, no reason at the
  all/forkChild/join calls, and the sink fires at JSON.stringify.

The two internal-mutation regressions are dropped: unmodified Effect
internals are now an explicit POC assumption.
Tim approved assumptions A1-A4 (no reflection on, serialisation or
mutation of Effect runtime objects; closed applications; Effect reads
services only by key; one bundled effect). Remove the regressions whose
requirements exist only outside that contract:
- 46 real-Effect retention fixtures that enumerate, serialise, format,
  hook or mutate Contexts, Fibers, Exits or descriptions, including the
  map/all reason cases and the internal-mutation cases;
- stub bailout_reflection (reflection on globalThis, not a runtime
  object) and version_mismatch (same major version);
- the EFFECT_SERVICE_SHAKING reason-tag tests.

Kept: the four pruning gates; the user-data prune gates
(reflection_clean_data, provenance_json_clean, provenance_success_channel,
provenance_numeric_index, clean_builtin_callback, unused_host_import);
forged_key, builtin_prototype_write, node_prefixed_vm, host_module_loader;
every other stub fixture (key identity, escapes and writes, unsupported
consumer shapes including direct Context and combinators, provider
shapes, lifecycle, entry exports). No kept test changed.
…lysis

Under the approved contract user code reaches services only through
keyed lookups and does not reflect on, serialise or mutate Effect runtime
objects (A1), the program is closed (A2), Effect reads services only by
key (A3) and there is one bundled effect (A4). Remove what only enforced
reflection safety: the value/success taint solver, operation rows and
fallbacks, heap bit and its reasons, hook and builtin summaries, the
effect version pin, demand-by-name on unknown receivers, and the
Context-internals, accessor, with and global-object name checks.

Keep the key, provider, consumer and demand analysis and these
syntactic bailouts: entry exports, non-builtin externals, code loading
(eval/Function/require/.constructor/dynamic import, vm/module/inspector/
repl uses, process.getBuiltinModule, createRequire), the string-keyed
Context APIs, keyed lookups other than yield* (their key may be
untracked), Context.Service other than as a key declaration, key member
writes, and builtin prototype writes.

Snapshots differ only in their EFFECT_SERVICE_SHAKING warning lines;
bundled output is unchanged. Docs describe the new rules.
Six independent real-Effect inputs from the c8ffb45 review:
- lookup_update_service_scoped: Effect.updateServiceScoped by a
  hand-made key calls the otherwise-unused method;
- provider_tdz_reference: a provider property reading a binding in its
  temporal dead zone must keep throwing ReferenceError;
- string_key_get_or_undefined_unsafe, string_key_make_unsafe: the
  string-keyed Context APIs, each in its own entry;
- loader_reexport: compileFunction through a renamed re-export of
  node:vm;
- loader_computed_access: a code loader reached through non-constant
  names on process.

Fixtures assert their runtime result, and the bailout cases that no
method is removed. topics/effect_service_bailouts checks the bailout
warning at each site for the lookup, string-keyed and loader cases.
Existing pruning checks are unchanged.
- Keyed lookups: add Effect.updateServiceScoped and the updateService
  of Layer, Stream and Channel, the remaining public APIs that hand a
  service looked up by a caller-supplied key to user code.
- Providers: keep a property whose identifier value may be in its
  temporal dead zone where the provider object is built. Removable
  only for function declarations and `var`, or a const/let/class
  declared earlier with no hoisted function declaration between;
  imports are kept because a module cycle can leave them uninitialised.
- Code loading (A2): resolve code-loader bindings through Rolldown's
  named imports, so re-exports of vm/module/inspector/repl and of
  loader names such as compileFunction or getBuiltinModule are caught.
  Host values that lead to loaders (process, globalThis, global, self,
  window, module, node:process namespace or default imports) are usable
  only through constant member names, calls and typeof; a non-constant
  name or any other use turns the pass off.

Snapshots added for the four fixtures that now pass. Docs updated for
the changed bailout rules.
The EffectServiceShaking diagnostic doc comment still described a
provenance-era reason that marked every user object as possibly holding
a runtime value. The pass now reports only reasons that turn it off for
the whole build, once per site.
…f contract

Tim approved narrowing A2: a code loader reached through a member name
known only at run time is outside the supported contract. The app is
assumed not to build one; the pass does not detect it.

The two Function-constructor fixtures now reach `constructor` through a
constant computed name ("con" + "structor"), which is a statically
recognised loader and must still bail. They keep both roots, the
retention assertions and the bailout-site check, and their comments
record the unchecked assumption. The bailout test is renamed to match.
Adds their snapshots.
…ption

Tim approved narrowing A2: a code loader reached through a member name
built at run time is outside the contract. The docs now say which member
names count as statically recognised (and still turn the pass off), and
record the runtime-built case as an unchecked user assumption that can
miscompile. Ordinary computed reads on other values keep pruning.
Regression for the review of dfdea2b; no implementation change.

loader_reflect_get reads the Function constructor with
`Reflect.get(() => 0, "constructor")`. The name is a literal, so Tim's
exclusion of names built at run time does not cover it, and A2 requires
a bailout. The pass checks loader names only in member and destructuring
syntax, so today it reports no bailout and removes `unused`. The fixture
checks retention, and the bailout test checks the warning site.
`Reflect.get`, `Reflect.getOwnPropertyDescriptor` and
`Object.getOwnPropertyDescriptor` read the property named by their
second argument. In untrusted modules a direct call to one of them now
checks a constant key like a member name, so
`Reflect.get(fn, "constructor")` bails. Using them other than by
calling them, destructuring `getOwnPropertyDescriptor`, or using the
global `Reflect` other than through a member read or `typeof` also
bails, since the key they will read is then out of sight. A key known
only at run time stays under the approved A2 exclusion.

Adds the snapshot for loader_reflect_get, which now passes, and names
these reads in the bailout list and A2.
Regressions for the review of c4f7248; no implementation change.

- loader_reflect_get_global: `globalThis.Reflect.get(fn, "constructor")`
  and the same call through `const reflect = globalThis.Reflect`.
- loader_reflect_get_spread: `Reflect.get(...[], fn, "constructor")`,
  where the spread moves the literal key to the second argument.

Each name is a literal, so the approved exclusion of names built at run
time does not cover them. Both fixtures check retention, and the
bailout test checks all three sites; today none bails.
`Reflect.get` is now recognised on the global `Reflect` reached through
the global object by constant names (`globalThis.Reflect`,
`globalThis["Reflect"]`, `globalThis.globalThis.Reflect`), and such an
expression used other than as a member-read object or `typeof` operand
bails, like bare `Reflect`. A reflective call with a spread at or
before the key argument bails, since the key position is unknown.

The receiver-agnostic `getOwnPropertyDescriptor` match is now described
as a conservative fallback, in code and docs, not builtin recognition.

Adds the snapshots for loader_reflect_get_global and
loader_reflect_get_spread. Rolldown inlines the fixture's
`const reflect = globalThis.Reflect` alias before the pass runs, so the
alias bails at the inlined `reflect.get(` call instead of at the
declaration; the site check pinned to the declaration still fails.
…call

Rolldown inlines `const reflect = globalThis.Reflect` into its single
use before the pass runs, so the declaration is gone and the alias path
bails at the inlined `reflect.get(` call. The alias keeps its own site
check, now at that call; the other two sites and every retention and
pruning check are unchanged.
…penAI client

Adds @effect/ai-openai 4.1.2 as a root devDependency (one copy of effect
4.1.2) and tree_shaking/effect_generated_client/openai: the generated
client reached through its OpenAiClientGenerated service, calling
retrieveModel against a deterministic mock HttpClient. It asserts the
decoded model and the single request, both off and on.

The bundle is about 2 MB, so the fixture is not snapshotted, and it
sits outside the effect_service filter so the 10-second CI step keeps
its budget. With the current POC the pass turns itself off for this
program; no implementation change.
…uctor providers

Tests for the bounded extension proposed after the generated OpenAI
client measurement; no compiler or Effect changes.

Expected to fail on the current compiler:
- effect_generated_client/openai: `_test.mjs` requires `retrieveModel`
  and `RetrieveModel200` to stay and `listAssistants` and
  `ListAssistants200` to be removed.
- lookup_tracked_key: `Effect.updateService` (both dual forms) and
  `Context.getOrUndefined` (both forms) by tracked keys keep every
  method of those keys, while an unrelated key still loses its unread
  method.
- provider_constructor_function: a class-form key provided by
  `Layer.effect(Key, make(options))`, where `make` is an
  `Effect.fnUntraced` generator, with a piped argument, returning an
  object built in another module. The unread method and its helper go;
  construction-time code still runs in order.

Safety, passing now and required to keep passing:
- lookup_unresolved_key: a lookup whose key is not one declared key
  keeps every method of both candidate services.
- provider_constructor_shared: the builder is also called directly and
  its object read by a run-time name, so the shared literal keeps every
  property.
- key_class_static_member: a class-form key whose body reads the
  service through `this` keeps every method.

Snapshots are added for the three passing fixtures.
…ctor providers

Extends the pass to the generated-client shape, within A1-A4 and without
trusting more packages:

- Class-form keys: a module-level `class K extends Context.Service<..>()("key")`
  whose body never mentions `this` or `super` is a key declaration. Reads
  of its own static members are allowed.
- Keyed lookups and `provideService` by tracked keys keep every method of
  those keys instead of turning the pass off. A key argument that is not a
  declared key still turns it off.
- Constructor providers, outside the trusted packages:
  `Layer.effect(K, make(args))` with a module-level `Effect.fn` /
  `Effect.fnUntraced` constructor of the same module, through `return` and
  `return yield*`, into a module-level function returning object literals
  in any module. The literals are provider objects only when every
  reference to the constructor and builder is a followed call. Constructor
  pipeables must be `Effect.updateService`, `provideService`, `withSpan`
  or `annotateLogs`.
- Layers returned from module-level functions are followed through each
  call, and `layer.pipe(Layer.provide(a), ...)` like the plain call.
- A provider property that may read `this` keeps every property only when
  it is kept itself.

The generated OpenAI client fixture goes from 2,068,645 to 532,916
bytes. Snapshots are refreshed where warnings moved from global to per
key, and added for lookup_tracked_key. provider_constructor_function
still fails: its arrow pipeable is not followed.
…pe guard

Tests only, for the constructor-provider boundary of 8f73e74.

- provider_constructor_function now pipes `Effect.updateService(Http, f)`,
  an allow-listed pipeable that only changes the constructor's context.
  Its callback runs before the constructor body, so the expected log is
  ["piped", "make mock:"]. The removal assertions are unchanged; the
  fixture now passes and gets its snapshot.
- provider_constructor_pipeable_escape: an arbitrary pipeable that taps
  the constructed object and calls a method by a run-time name. The
  constructor is not followed, so every property stays; if it were
  followed, the program would fail at run time.
Tests only; no compiler or docs changes. Each program passes unbundled.

- key_class_overrides_of: a class-form key with its own static `of`,
  which reads a method of its argument. On removes it and throws.
- key_class_overrides_key: a class-form key whose static `key` names
  another key's string. On attributes demand to the heritage string and
  throws.
- provider_builder_named_escape: a named function expression builder
  exposes itself through its inner name. On removes the method the
  escaped call reads.
- lookup_spread_arguments: a spread moves the key argument of
  `Context.get` to a hand-made key. On keeps only the trailing tracked key
  and throws.
- provider_constructor_recursive: a constructor returning
  `yield* make(n - 1)`. On overflows the compiler stack (exit 134), which
  aborts the whole effect_service run until fixed.

The first four also require every method to stay.
- Class-form keys keep the inherited `Context.Service` protocol: every
  body member must be static with a constant name outside `of`, `key`,
  `use`, `useSync`, `context`, `pipe`, `toJSON`, `toString`, `name`,
  `length`, `prototype`, the inherited Object members and `~` ids. The
  `this`/`super` restriction stays. Other classes turn the pass off as
  before.
- Named function expressions are not builders: the inner name can hand
  the function out unseen.
- A keyed lookup or `provideService` call with a spread argument is not
  resolved per key; lookups fall back to the global bailout.
- Constructors followed into themselves stop at the cycle, and the
  provider keeps every property.

Adds the snapshots of the five regression fixtures from 0799b40.
… their name

Regression for the review of 9f0eda8; no compiler change.

provider_named_callback_escape: `Effect.sync(function syncInner() ...)`,
`Effect.gen(function* genInner() ...)` and an `Effect.fnUntraced(function*
makeInner() ...)` constructor each store their own inner name, and the
program later calls it to get another object from the same literal. The
Layer factory is declared before the constructor, so the constructor's
literal is a provider site when it is visited. On removes all three
unread methods without a bailout and the escaped call throws. The fixture
requires every method to stay.
…ence their name

A named function expression can hand itself out through its own name,
and each call builds another object from the same literal. `return_sites`
no longer follows a named function expression whose inner binding has
any reference. This covers the shared return path of `Effect.sync`,
`Effect.gen`, `acquireRelease` acquisitions and `Effect.fn` constructors.
A name nothing references (kept for stack traces) is still followed.

Adds the snapshot of provider_named_callback_escape from 96fc45d.
…nitializer

Regression for the review of 7f3c87e; no compiler change.

provider_tdz_own_initializer: a provider property reads a `const` from
inside that const's own initializer, directly and from an immediately
invoked function. The binding is in its temporal dead zone, so building
the provider throws a ReferenceError, which the program asserts. The
name precedes the site in the source, so the initialization proof
accepts it; since 8f73e74 a removed property no longer keeps the whole
object, and on removes both `unused` properties without a bailout.
…own declaration

`initialized_at` accepted a `const`/`let` whose name precedes the site
even when the site is inside that binding's own initializer, directly or
in code it runs (an immediately invoked function). It now also requires
that no `VariableDeclarator` on the site's path declares the binding and
no class on the path is the binding (a class heritage reads it before
initialisation). The kept-only `this` rule is unchanged.

Adds the snapshot of provider_tdz_own_initializer from c364cc0.
…n TDZ

Regressions for the review of 08e5226; no compiler change.

- provider_tdz_parameters_and_loop_heads (fails): a provider property
  reads a parameter inside its own default, a later parameter from an
  earlier default, a `for...of` const head while the right-hand side runs,
  and a `for...in` let head likewise. Each throws a ReferenceError the
  program asserts; on removes all four properties without a bailout.
- provider_initialized_bindings_prune (passes): controls that must keep
  pruning, with a parameter read in the function body, a loop-head const
  read in the loop body, and a `var` loop head read in the right-hand side
  (undefined, no throw). Their unread properties are removed. Adds its
  snapshot.
- Parameters share the `var` flag, so they skipped every initialisation
  check. A site inside the parameter list that declares the binding is
  now rejected first, covering own and later defaults; reads from the
  function body are unchanged.
- A lexical `for...of` / `for...in` head binding is uninitialised while
  the loop's right-hand side runs. A site in the right-hand side of a loop
  whose head declares the binding is rejected; `var` heads and body reads
  are unchanged.

The kept-only `this` rule is unchanged. Adds the snapshot of
provider_tdz_parameters_and_loop_heads from d64c06c; the control
provider_initialized_bindings_prune and its snapshot are unchanged.
Regressions for the review of af570f1; no compiler change.

- provider_tdz_catch_pattern (fails): a provider property reads a
  destructured catch binding inside its own default, in an object and an
  array pattern. Each throws a ReferenceError the program asserts; on
  removes both properties without a bailout.
- provider_initialized_catch_body_prune (passes): control in which the
  catch binding is read from the catch body, where it is initialised; the
  unread property is removed. Adds its snapshot.
…in TDZ

A destructured catch binding is lexical and uninitialised while its own
pattern, defaults included, is evaluated. `initialized_at` now rejects a
site inside the catch parameter that declares the binding; reads from the
catch body are unchanged.

Adds the snapshot of provider_tdz_catch_pattern from dcc3dea; the control
provider_initialized_catch_body_prune and its snapshot are unchanged.
…ember checks

FoundSite was a five-field tuple destructured at five sites; it is a
struct with a constructor for the plain-literal case. The static and
computed member visitors ran the same loader-name, reflective-read and
Reflect-as-value checks in two copies; one check_member does both, and a
constant_member helper gives is_global_reflect and on_global_object one
member case instead of two each. The twin for-of/for-in TDZ arms share a
closure, and module-level const collection moves out of the driver into
module_consts.

No behaviour change: effect_service 69/69, the OpenAI fixture still
prunes, no snapshot changed.
Document the two boundaries added in 9f0eda8: a class-form key may
declare only static members with constant names that leave the inherited
Context.Service protocol alone (other classes turn the pass off), and a
named function expression is not followed as a provider builder.
Document that a provider callback or Effect.fn constructor written as a
named function expression is followed only when its name is unreferenced,
that constructors are module-level consts built from a generator, and the
current initialized_at rules: parameters, catch bindings, and reads inside
a binding's own declaration, parameter list, catch pattern or loop head.
Adds @distilled.cloud/stripe 1.0.0-rc.18 (pinned, one copy of effect
4.1.2) and tree_shaking/effect_generated_client/distilled_stripe: one
operation, CreateCustomer, against a deterministic mock HttpClient. It
asserts the decoded customer and the single request, and that Rolldown's
own tree shaking keeps CreateCustomer and drops other operations.

Distilled exposes operations as separate module-level exports rather than
methods of a service object, and its core compiles operation specs with
`Function` and loads modules through `createRequire`, so the pass turns
itself off under A2. Not snapshotted; outside the effect_service filter.
No compiler change.
…lient lacks

`ListCustomersRequest` does not occur in @distilled.cloud/stripe
1.0.0-rc.18, so its absence from the output proved nothing. The
`CreateAccountRequest` and `CreateInvoiceRequest` checks name real input
schemas and remain.
@effect-bot

Copy link
Copy Markdown
Owner Author

Superseded by EFF-3430 following Tim’s design session. Closing unmerged; retaining this branch and effect-service-provenance-spec-tests for their tooling and fixtures. The feasibility result at 81230a1 works under A1–A4 and reduces the OpenAI client from 2,068,645 B to 532,916 B. The side-branch source-derivation evaluator is abandoned. Further work belongs to EFF-3430.

@effect-bot effect-bot closed this Nov 5, 2026
tim-smart pushed a commit that referenced this pull request Nov 7, 2026
…thout module-path comments (EFF-3430)

Bob's finding (01a1b69f): the 8,555 B the OpenAI switch-off bundle gained over
PR #1's baseline are module-path comments, which carry pnpm's store paths and
depend on the installation layout. Both acceptance builds and the timing
example now set experimental.attachDebugInfo to "none". Rolldown's default is
unchanged, and so is the 74% threshold.
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.

1 participant