Repository navigation
feat(experimental): remove unread Effect service methods (base case) - #1
Closed
effect-bot wants to merge 61 commits into
Closed
effect-bot wants to merge 61 commits into
effect-bot wants to merge 61 commits into
Conversation
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.
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. |
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.
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.
Native proof of concept: Rolldown removes Effect service methods that nothing in the bundle reads. It's behind
experimental.effectServiceShakingand off by default. The base is Rolldownv1.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:
effect4.1.2 package, and their output still runs.@effect/ai-openai4.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:httpClientand the schemas only they use are removed;retrieveModelandRetrieveModel200stay;A third program shows where the pass doesn't apply:
@distilled.cloud/stripe1.0.0-rc.18 calls one of its 589 operations against a mock HTTP client./*@__PURE__*/module-level export, not as a method of one service object, so Rolldown's ordinary tree shaking already keepsCreateCustomerand removes the other 588.Function(...)and loads modules throughgetBuiltinModule("node:module").createRequire(...).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.const K = Context.Service("literal"), orclass K extends Context.Service<..>()("literal") {}.Context.Serviceprotocol (of,key,use,pipe,toString,~…ids, and so on).thisorsuper.const s = yield* K,const { a } = yield* Kand(yield* K).ainsideEffect.gen/Effect.fn, read with static member names.Layer.succeed(K, {...})andLayer.effect(K, e), whereeis built fromEffect.gen,Effect.sync,Effect.succeedandEffect.acquireRelease, and returnsK.of({...})or{...}.Layer.effect(K, make(args))with a module-levelEffect.fn/fnUntracedconstructor of the same module is followed throughreturnandreturn yield*, into a module-level function in any module that returns only object literals.Effect.updateService,provideService,withSpanorannotateLogs.layer.pipe(Layer.provide(a), ...)like the plain call.thiskeeps every property only if it's kept itself.constfunctions are deleted. Rolldown's shaker then drops helpers only they used, and construction-time code keeps running in order.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_SHAKINGwarning per site, in these cases:eval,Function,require, dynamicimport(), a.constructorread by a constant name,vm/module/inspector/replbindings, andReflect.get/getOwnPropertyDescriptorwith a constant loader key;typeof;Context.Serviceused other than to declare a key;ROLLDOWN_EFFECT_SERVICE_SHAKING_REPORT=1prints the bailouts, the demand per key and the removed items.docs/in-depth/effect-service-shaking.mdhas 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.
(() => 0)[["con", "structor"].join("")].effect.Other limitations:
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.getOwnPropertyDescriptor, and aliasing or destructuringprocess/globalThis, turn the pass off;effect4.1.2's public API.Results at
8434b35bThe test binary was compiled separately (
effect-verifyprofile), and every bundling and runtime command ran undertimeout 10.cargo test -- effect_service: 69 of 69 pass in 3.5 s.-- 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.91403544. The one failure istest262_module_code, because its submodule isn't checked out.7f8a8e04is a behavior-preserving refactor reviewed against these results, and the later commits change only docs.Bundle measurements, from a release
examples/effect_service_shaking.rs: median of 5 interleaved builds, and both outputs of every program run with Node.lifecyclecross_modulerun_optionsretained_fiberprovenance_success_channelprovenance_numeric_indexThe 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.ymlruns one Linux / Node 24 job on pull requests and manual dispatch:effect_servicefixtures under a 10-second limit, compiled in a separate step;@effect/ai-openai4.1.2 and@distilled.cloud/stripe1.0.0-rc.18 (pinned) are root devDependencies for the generated-client fixtures. Both resolve to the single bundledeffect4.1.2.docs/in-depth/effect-service-shaking.mddocuments the handled shapes, the bailouts, the A1–A4 contract and the report.Try it