From 70cbb5fb05c0a50068661162577c73c9a85cd8a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 06:36:19 +0000 Subject: [PATCH 1/4] feat(ownts): parse `function` callbacks/cleanups; catch a real OSS leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook libraries clean up by design, so the real-leak hunt moved to app-shaped component code — and found one: react-scroll-to-bottom@4.2.0 Composer.js:574 adds a `focus` listener with { capture: true } but removes it with the default capture (false), so per the DOM spec it is NEVER removed and piles up on every `target` change. That code ships transpiled ES5 (`function () {}` callbacks + `return function () {}` cleanups), which the arrow-tuned parser could not read. This teaches the frontend those shapes: - _effect_callback: detect a `function (...) {}` callback (body brace after the params via _body_brace), alongside arrow block / arrow expression. - _cleanup_span: a cleanup may be `return function () {}` (not only `return () =>`). With both, the Composer bug is caught for the RIGHT reason — the cleanup parses, and the listener key rejects the capture:true-vs-false removal — instead of by the coarse "no cleanup" path. EffectFunctionCallback.tsx pins it: a correctly-matched ES5 cleanup stays silent; the capture mismatch is the single OWN001. CI step added. The ES5 corpus (ahooks + react-use, ~93 useEffect) now parses; its 11 findings triage as FPs from the known patterns plus one new transpilation artifact (optional-chaining desugars the cleanup receiver to a temp `_a` ≠ `ref.current`), documented as residual in docs/notes/ownts-oss-benchmark.md (which also records the first confirmed true positive). Arrow corpus unchanged at 11; full suite green; ruff + mypy --strict clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PVeuchch67CtjSbizYm8gi --- .github/workflows/ci.yml | 12 ++++++ docs/notes/ownts-oss-benchmark.md | 33 +++++++++++++++ .../ownts/examples/EffectFunctionCallback.tsx | 28 +++++++++++++ frontend/ownts/ownts.py | 40 ++++++++++++++----- frontend/ownts/test_ownts.py | 7 ++++ 5 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 frontend/ownts/examples/EffectFunctionCallback.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d77bf577..4f7b68c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1142,6 +1142,18 @@ jobs: # over-suppressed — all three controls stay OWN001 [ "$(echo "$leak" | grep -c 'OWN001')" -eq 3 ] \ || { echo "FAIL: broadened matchers must not over-suppress real leaks"; exit 1; } + - name: ES5 `function` callbacks parse; capture-mismatch leak caught (real bug shape) + run: | + python frontend/ownts/ownts.py frontend/ownts/examples/EffectFunctionCallback.tsx \ + -o "$RUNNER_TEMP/fn.facts.json" + fn=$(python -m ownlang ownir "$RUNNER_TEMP/fn.facts.json" || true) + echo "$fn" + # the matched ES5 cleanup is silent; the capture:true-vs-default mismatch + # (react-scroll-to-bottom@4.2.0 shape) is the one OWN001 + [ "$(echo "$fn" | grep -c 'OWN001')" -eq 1 ] \ + || { echo "FAIL: expected exactly the capture-mismatch leak"; exit 1; } + echo "$fn" | grep -q "focus" \ + || { echo "FAIL: the leak must be the capture-mismatched focus listener"; exit 1; } - name: The clean fixture (cleanups + useMemo'd dep) is silent run: | python frontend/ownts/ownts.py frontend/ownts/examples/DashboardClean.tsx \ diff --git a/docs/notes/ownts-oss-benchmark.md b/docs/notes/ownts-oss-benchmark.md index e60627b1..bb1aaa61 100644 --- a/docs/notes/ownts-oss-benchmark.md +++ b/docs/notes/ownts-oss-benchmark.md @@ -123,6 +123,39 @@ The honest takeaway: OwnTS is now quiet on idiomatic cleanup, with its remaining false alarms confined to four named, understood patterns — a real step toward the "low false positives" bar P-020 set, without a single guessed release. +## First confirmed TRUE positive — a real hanging effect + +Hook *libraries* clean up by design, so the honest hunt for a real leak moved to +*application-shaped* component code. In **`react-scroll-to-bottom@4.2.0`** +(`ScrollToBottom/Composer.js:574`): + +```js +target.addEventListener('focus', handleFocus, { capture: true, passive: true }); +return () => target.removeEventListener('focus', handleFocus); // default capture (false) +``` + +`removeEventListener` must match the capture flag; `capture: true` is added but the +removal uses the default `false`, so the listener is **never removed** — a new one +piles up every time `target` changes. This is exactly the capture-mismatch class the +listener-key matching (P-148/#145) models, and OwnTS flags it. A second real one: +**`@reactuses/core@6.4.0`** passes `onPressed('mouse')` (a freshly *returned* +function) to both add and remove, so the drag/touch listeners can never be removed. + +## ES5 (`function () {}`) build coverage + +The benchmark above is arrow-ESM only. To reach the capture-mismatch bug for the +*right* reason — and to widen the corpus to transpiled output (`ahooks`, +`react-use`, and the Composer file above ship ES5 `function(){}`) — the frontend now +parses `function` callbacks and `return function () {}` cleanups +(`EffectFunctionCallback.tsx` pins it: a matched ES5 cleanup is silent, the +capture-mismatch is the one finding). The ES5 corpus (`ahooks` + `react-use`, ~93 +`useEffect`) then yields 11 findings, again triaged as FPs from the known patterns +plus one new transpilation artifact: **optional-chaining desugaring** +(`ref.current?.removeEventListener` → `(_a = ref.current) ? … : _a.removeEventListener`) +makes the cleanup receiver a temp `_a ≠ ref.current`, which the exact-receiver match +rejects. Documented as residual; alias-resolving `_a` back to `ref.current` is the +next increment. + ### Reproduce ```bash diff --git a/frontend/ownts/examples/EffectFunctionCallback.tsx b/frontend/ownts/examples/EffectFunctionCallback.tsx new file mode 100644 index 00000000..43acf533 --- /dev/null +++ b/frontend/ownts/examples/EffectFunctionCallback.tsx @@ -0,0 +1,28 @@ +// Transpiled-ES5 shape: `useEffect(function () { … return function () { … } }, …)`. +// Proves the parser handles `function` callbacks AND `return function` cleanups — +// a correctly-matched ES5 cleanup stays silent, while a real capture-flag mismatch +// (the react-scroll-to-bottom@4.2.0 bug) is caught for the right reason. Run: +// python frontend/ownts/ownts.py frontend/ownts/examples/EffectFunctionCallback.tsx --check +// Expect exactly ONE OWN001 (the capture-mismatched focus listener). +import { useEffect } from "react"; + +export function Composer({ target }: { target: HTMLElement }) { + // Correctly cleaned ES5 effect: same handler, same (default) capture -> silent. + useEffect(function () { + window.addEventListener("resize", onResize); + return function () { + window.removeEventListener("resize", onResize); + }; + }, []); + + // Real leak: added with { capture: true } but removed with the default (false) + // capture, so the listener is never actually removed (react-scroll-to-bottom bug). + useEffect(function () { + target.addEventListener("focus", handleFocus, { capture: true, passive: true }); + return function () { + return target.removeEventListener("focus", handleFocus); + }; + }, [target]); + + return
composer
; +} diff --git a/frontend/ownts/ownts.py b/frontend/ownts/ownts.py index 2494a6b6..89c03ce5 100644 --- a/frontend/ownts/ownts.py +++ b/frontend/ownts/ownts.py @@ -412,19 +412,30 @@ def _expr_end(text: str, i: int) -> int: def _effect_callback(masked: str, after_open: int) -> tuple[str, int, list[str] | None, int]: """Parse `useEffect(, [deps])` from just past the `(`, over the STRING-MASKED source (so literals never truncate a body or split a dep). Returns - (body, body_start, deps, end). Handles BOTH a block `() => { ... }` and an - expression `() => fetch(url)` callback — calling `_match_block` blindly on the - latter would jump to an unrelated `{` or run off the end. `deps` is None when no + (body, body_start, deps, end). Handles a block arrow `() => { ... }`, an + expression arrow `() => fetch(url)`, AND a `function () { ... }` expression + (transpiled ES5 output) — calling `_match_block` blindly, or keying on `=>`, + would jump to an unrelated `{`/arrow or run off the end. `deps` is None when no dependency array is present; the array is matched by BALANCED brackets so a dep like `items[i]` survives. `body` includes the braces for a block callback.""" - arrow = masked.find("=>", after_open) - i = (arrow + 2) if arrow != -1 else after_open - while i < len(masked) and masked[i] in " \t\r\n": - i += 1 - if i < len(masked) and masked[i] == "{": - end = _match_block(masked, i) + j0 = after_open + while j0 < len(masked) and masked[j0] in " \t\r\n": + j0 += 1 + if re.match(r"(?:async\s+)?function\b", masked[j0:]): + # function-expression callback: the body `{` follows the parameter list. + i = _body_brace(masked, after_open) + end = _match_block(masked, i) if i != -1 else len(masked) + if i == -1: + i = after_open else: - end = _expr_end(masked, i) + arrow = masked.find("=>", after_open) + i = (arrow + 2) if arrow != -1 else after_open + while i < len(masked) and masked[i] in " \t\r\n": + i += 1 + if i < len(masked) and masked[i] == "{": + end = _match_block(masked, i) + else: + end = _expr_end(masked, i) body = masked[i:end] # the dependency array is the balanced `[ ... ]` after an optional `, ` deps: list[str] | None = None @@ -462,7 +473,8 @@ def _cleanup_span(mbody: str) -> tuple[int, int, int] | None: fn_braces.add(fm.end() - 1) for fm in re.finditer(r"\bfunction\b[^{;]*?\)\s*\{", mbody): fn_braces.add(fm.end() - 1) - ret_re = re.compile(r"return\s*(?:\(\s*\)|\w+)\s*=>") + # a cleanup is `return ` or `return ` (ES5 output). + ret_re = re.compile(r"return\s*(?:(?:\(\s*\)|\w+)\s*=>|(?:async\s+)?function\b)") stack: list[tuple[bool, int]] = [] # (is_function_body, open_index) per open brace fdepth = 0 i, n = 0, len(mbody) @@ -487,6 +499,12 @@ def _cleanup_span(mbody: str) -> tuple[int, int, int] | None: mbody.rfind("}", 0, i)) if re.search(r"\b(?:if|else|for|while)\b", mbody[b + 1:i]): cov_start = m.start() # braceless conditional guard -> covers nothing + if "function" in m.group(): # `return function () { ... }` (ES5 cleanup) + brace = _body_brace(mbody, m.end()) # body brace after the params + if brace != -1: + return (m.start(), _match_block(mbody, brace), cov_start) + i += 1 + continue rest = mbody[m.end():] stripped = rest.lstrip() if stripped.startswith("{"): # block-bodied cleanup: `=> { ... }` diff --git a/frontend/ownts/test_ownts.py b/frontend/ownts/test_ownts.py index 63a32df2..ec4a1ef9 100644 --- a/frontend/ownts/test_ownts.py +++ b/frontend/ownts/test_ownts.py @@ -70,6 +70,13 @@ def main() -> int: leaks = codes("EffectLeakControl.tsx") assert leaks == ["OWN001", "OWN001", "OWN001"], f"EffectLeakControl -> {leaks}" + # Transpiled-ES5 shape: `function () { … return function () { … } }`. The parser + # handles `function` callbacks + `return function` cleanups — a matched cleanup is + # silent, and a real capture-flag mismatch (the react-scroll-to-bottom@4.2.0 bug) + # is caught precisely via the listener key. + fn_cb = codes("EffectFunctionCallback.tsx") + assert fn_cb == ["OWN001"], f"EffectFunctionCallback -> {fn_cb}" + # an expression-bodied cleanup whose removeEventListener carries an options # object must parse (the `{` belongs to the call, not the cleanup block) — the # listener is released, so no false-positive leak. From 627f44a991b4f9cf773bff32437e6796dc9860dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 06:41:48 +0000 Subject: [PATCH 2/4] fix(ownts): don't credit cleanups from async effect callbacks (Codex) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `async` effect callback returns a Promise, so React never runs its returned function as cleanup. With `function`-callback support now accepting `useEffect(async () => …)` / `useEffect(async function () …)`, a `return () => …` inside an async effect was wrongly treated as a real cleanup, masking the leak in JS/transpiled inputs that aren't type-checked. extract() now detects an async outer callback and credits no cleanup for it (an inner sync IIFE keeps its own cleanup — only the OUTER callback's async-ness matters). EffectLeakControl.tsx gains a fourth control (async effect whose dead cleanup must still leak); test + CI updated to 4. Full suite green; ruff + mypy --strict clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PVeuchch67CtjSbizYm8gi --- .github/workflows/ci.yml | 9 +++++---- frontend/ownts/examples/EffectLeakControl.tsx | 9 ++++++++- frontend/ownts/ownts.py | 6 +++++- frontend/ownts/test_ownts.py | 2 +- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f7b68c9..a730c29f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1132,15 +1132,16 @@ jobs: # cleanup, observer.subscribe/unsubscribe — all released, zero findings. [ "$(echo "$rw" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 0 ] \ || { echo "FAIL: real-world cleanup patterns must not be reported"; exit 1; } - - name: False-negative controls (wrong controller / args / conditional cleanup) still leak + - name: False-negative controls (wrong controller / args / conditional / async) still leak run: | python frontend/ownts/ownts.py frontend/ownts/examples/EffectLeakControl.tsx \ -o "$RUNNER_TEMP/leak.facts.json" leak=$(python -m ownlang ownir "$RUNNER_TEMP/leak.facts.json" || true) echo "$leak" - # a release-shaped cleanup that does not release THIS resource must not be - # over-suppressed — all three controls stay OWN001 - [ "$(echo "$leak" | grep -c 'OWN001')" -eq 3 ] \ + # a release-shaped cleanup that does not release THIS resource (wrong + # controller / mismatched args / conditional return / async effect) must + # not be over-suppressed — all four controls stay OWN001 + [ "$(echo "$leak" | grep -c 'OWN001')" -eq 4 ] \ || { echo "FAIL: broadened matchers must not over-suppress real leaks"; exit 1; } - name: ES5 `function` callbacks parse; capture-mismatch leak caught (real bug shape) run: | diff --git a/frontend/ownts/examples/EffectLeakControl.tsx b/frontend/ownts/examples/EffectLeakControl.tsx index d75e08b6..3a822dff 100644 --- a/frontend/ownts/examples/EffectLeakControl.tsx +++ b/frontend/ownts/examples/EffectLeakControl.tsx @@ -2,7 +2,7 @@ // each uses a release-shaped cleanup that does NOT actually release THIS resource, // so it must STILL report a leak. Run: // python frontend/ownts/ownts.py frontend/ownts/examples/EffectLeakControl.tsx --check -// Expect exactly three OWN001 findings. +// Expect exactly four OWN001 findings. import { useEffect } from "react"; export function LeakControl({ enabled }: { enabled: boolean }) { @@ -29,5 +29,12 @@ export function LeakControl({ enabled }: { enabled: boolean }) { if (enabled) return () => clearInterval(id); }, [enabled]); + // (4) async effect: React receives a Promise and never runs the returned + // function as cleanup, so the listener leaks despite the release-shaped return. + useEffect(async () => { + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + return
leak control
; } diff --git a/frontend/ownts/ownts.py b/frontend/ownts/ownts.py index 89c03ce5..a93a92cd 100644 --- a/frontend/ownts/ownts.py +++ b/frontend/ownts/ownts.py @@ -536,7 +536,11 @@ def extract(path: str) -> list[Component]: for eff in _USE_EFFECT.finditer(masked): body_m, body_start, _deps, end = _effect_callback(masked, eff.end()) body_o = text[body_start:end] # original (unmasked) body — same positions - span = _cleanup_span(body_m) + # An ASYNC effect callback returns a Promise, so React never runs its + # returned function as cleanup — credit no cleanup for it (a `return () => …` + # inside `useEffect(async () => …)` is dead, so the resource still leaks). + is_async = bool(re.match(r"\s*async\b", masked[eff.end():])) + span = None if is_async else _cleanup_span(body_m) if span: cs, ce, cov_start = span setup_m = body_m[:cs] + body_m[ce:] diff --git a/frontend/ownts/test_ownts.py b/frontend/ownts/test_ownts.py index ec4a1ef9..3b8c4079 100644 --- a/frontend/ownts/test_ownts.py +++ b/frontend/ownts/test_ownts.py @@ -68,7 +68,7 @@ def main() -> int: # returned cleanup over an unconditional acquire) must STILL report the leak — # the broadened matchers must not over-suppress. leaks = codes("EffectLeakControl.tsx") - assert leaks == ["OWN001", "OWN001", "OWN001"], f"EffectLeakControl -> {leaks}" + assert leaks == ["OWN001"] * 4, f"EffectLeakControl -> {leaks}" # Transpiled-ES5 shape: `function () { … return function () { … } }`. The parser # handles `function` callbacks + `return function` cleanups — a matched cleanup is From f180ea52f7175c456326f920dd5fc1622d214437 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 06:42:49 +0000 Subject: [PATCH 3/4] docs(ownts): show the react-scroll-to-bottom leak in its real ES5 form (CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The benchmark note printed the Composer cleanup as an arrow, which made the leak look detectable by the old arrow-only path and undercut the rationale for parsing `function () {}` cleanups. Show it as the published ES5 `return function () {}` and note it's only reachable after the parser change — the bug is then the capture-mismatch class, not a missing cleanup. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PVeuchch67CtjSbizYm8gi --- docs/notes/ownts-oss-benchmark.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/notes/ownts-oss-benchmark.md b/docs/notes/ownts-oss-benchmark.md index bb1aaa61..1af156fd 100644 --- a/docs/notes/ownts-oss-benchmark.md +++ b/docs/notes/ownts-oss-benchmark.md @@ -130,14 +130,19 @@ Hook *libraries* clean up by design, so the honest hunt for a real leak moved to (`ScrollToBottom/Composer.js:574`): ```js +// as published — transpiled ES5: a `function () {}` cleanup, not an arrow target.addEventListener('focus', handleFocus, { capture: true, passive: true }); -return () => target.removeEventListener('focus', handleFocus); // default capture (false) +return function () { + return target.removeEventListener('focus', handleFocus); // default capture (false) +}; ``` `removeEventListener` must match the capture flag; `capture: true` is added but the removal uses the default `false`, so the listener is **never removed** — a new one -piles up every time `target` changes. This is exactly the capture-mismatch class the -listener-key matching (P-148/#145) models, and OwnTS flags it. A second real one: +piles up every time `target` changes. (The cleanup is an ES5 `function () {}`, so it +is only reachable after the parser change below; the bug is then the capture-mismatch +class the listener-key matching (P-148/#145) models, not a missing cleanup.) A second +real one: **`@reactuses/core@6.4.0`** passes `onPressed('mouse')` (a freshly *returned* function) to both add and remove, so the drag/touch listeners can never be removed. From 3e24fd068f6cdc1f09d61d76377035db60cacb8f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 06:48:09 +0000 Subject: [PATCH 4/4] test(ownts): cover async ES5 `function` effect in leak controls (CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The async-cleanup suppression path was only exercised via `async () =>` in EffectLeakControl.tsx. Add a fifth control — `useEffect(async function () { … return function () {} }, [])` — so the ES5 transpiled async shape is regressed too: React still ignores the returned cleanup, so the listener must stay OWN001. Bumps the expected count to 5 in the spike test and the CI step. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PVeuchch67CtjSbizYm8gi --- .github/workflows/ci.yml | 9 +++++---- frontend/ownts/examples/EffectLeakControl.tsx | 10 ++++++++++ frontend/ownts/test_ownts.py | 7 ++++--- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a730c29f..9e1dedd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1132,16 +1132,17 @@ jobs: # cleanup, observer.subscribe/unsubscribe — all released, zero findings. [ "$(echo "$rw" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 0 ] \ || { echo "FAIL: real-world cleanup patterns must not be reported"; exit 1; } - - name: False-negative controls (wrong controller / args / conditional / async) still leak + - name: False-negative controls (wrong controller / args / conditional / async arrow+ES5) still leak run: | python frontend/ownts/ownts.py frontend/ownts/examples/EffectLeakControl.tsx \ -o "$RUNNER_TEMP/leak.facts.json" leak=$(python -m ownlang ownir "$RUNNER_TEMP/leak.facts.json" || true) echo "$leak" # a release-shaped cleanup that does not release THIS resource (wrong - # controller / mismatched args / conditional return / async effect) must - # not be over-suppressed — all four controls stay OWN001 - [ "$(echo "$leak" | grep -c 'OWN001')" -eq 4 ] \ + # controller / mismatched args / conditional return / async arrow effect / + # async ES5 `function` effect) must not be over-suppressed — all five + # controls stay OWN001 + [ "$(echo "$leak" | grep -c 'OWN001')" -eq 5 ] \ || { echo "FAIL: broadened matchers must not over-suppress real leaks"; exit 1; } - name: ES5 `function` callbacks parse; capture-mismatch leak caught (real bug shape) run: | diff --git a/frontend/ownts/examples/EffectLeakControl.tsx b/frontend/ownts/examples/EffectLeakControl.tsx index 3a822dff..4d2563ea 100644 --- a/frontend/ownts/examples/EffectLeakControl.tsx +++ b/frontend/ownts/examples/EffectLeakControl.tsx @@ -36,5 +36,15 @@ export function LeakControl({ enabled }: { enabled: boolean }) { return () => window.removeEventListener("message", onMessage); }, []); + // (5) async ES5 effect: same async suppression, but the transpiled + // `async function () { … }` shape — React still ignores the returned + // `function () {}` cleanup, so the listener leaks. + useEffect(async function () { + window.addEventListener("online", onOnline); + return function () { + window.removeEventListener("online", onOnline); + }; + }, []); + return
leak control
; } diff --git a/frontend/ownts/test_ownts.py b/frontend/ownts/test_ownts.py index 3b8c4079..496ec1cc 100644 --- a/frontend/ownts/test_ownts.py +++ b/frontend/ownts/test_ownts.py @@ -65,10 +65,11 @@ def main() -> int: # False-negative controls: a release-shaped cleanup that does NOT release THIS # resource (wrong AbortController, mismatched unsubscribe args, a conditionally - # returned cleanup over an unconditional acquire) must STILL report the leak — - # the broadened matchers must not over-suppress. + # returned cleanup over an unconditional acquire, an async arrow effect, and an + # async ES5 `function` effect) must STILL report the leak — the broadened + # matchers and async suppression must not over-suppress. leaks = codes("EffectLeakControl.tsx") - assert leaks == ["OWN001"] * 4, f"EffectLeakControl -> {leaks}" + assert leaks == ["OWN001"] * 5, f"EffectLeakControl -> {leaks}" # Transpiled-ES5 shape: `function () { … return function () { … } }`. The parser # handles `function` callbacks + `return function` cleanups — a matched cleanup is