@@ -121,6 +121,36 @@ def _receiver(text: str, end: int) -> str:
121121 return m .group (1 ) if m else ""
122122
123123
124+ def _call_args (s : str ) -> str | None :
125+ """The normalized (whitespace-stripped) argument text of the first call in `s`,
126+ e.g. `target,handler` for `.subscribe(target, handler)`. None if no `(`."""
127+ i = s .find ("(" )
128+ if i == - 1 :
129+ return None
130+ close = _match_pair (s , i , "(" , ")" )
131+ return re .sub (r"\s+" , "" , s [i + 1 :close - 1 ])
132+
133+
134+ def _signal_controller (setup : str , opts : str ) -> str | None :
135+ """The AbortController whose `signal` an addEventListener options arg uses, so a
136+ cleanup is only credited when THAT controller is aborted. `{ signal: c.signal }`
137+ → `c`; the shorthand `{ signal }` is resolved back through `const signal =
138+ c.signal` / `const { signal } = c` in setup. None when it can't be tied."""
139+ m = re .search (r"\bsignal\s*:\s*([A-Za-z_$][\w$.]*)" , opts )
140+ if m :
141+ return re .sub (r"\.signal$" , "" , m .group (1 ))
142+ if re .search (r"\bsignal\b" , opts ): # shorthand `{ signal }`
143+ m = re .search (r"(?:const|let|var)\s+signal\s*=\s*([A-Za-z_$][\w$]*)\s*\.\s*signal\b" ,
144+ setup )
145+ if m :
146+ return m .group (1 )
147+ m = re .search (r"(?:const|let|var)\s*\{\s*signal\b[^}]*\}\s*=\s*([A-Za-z_$][\w$]*)" ,
148+ setup )
149+ if m :
150+ return m .group (1 )
151+ return None
152+
153+
124154def _is_released (acq : Acquire , setup : str , pos : int , cleanup : str ) -> bool :
125155 """Whether THIS acquire (at `pos` in `setup`) is released by the effect's cleanup
126156 — matched to its own handle, so two `setInterval`s with one `clearInterval` leave
@@ -135,21 +165,33 @@ def _is_released(acq: Acquire, setup: str, pos: int, cleanup: str) -> bool:
135165 if tok and re .search (
136166 rf"\b{ re .escape (tok )} \s*\.\s*(?:unsubscribe|remove)\s*\(" , cleanup ):
137167 return True
138- # a bare `recv.subscribe(...)` (no token) is released by `recv.unsubscribe(...)`
139- # / `recv.disconnect()` on the SAME receiver in cleanup — the observer pattern.
168+ # a bare `recv.subscribe(args)` (no token) is released only by a cleanup
169+ # `recv.unsubscribe(args)` on the SAME receiver AND the same argument list
170+ # (a `disconnect()` with no args tears down everything). Iterating + comparing
171+ # in Python gives an exact receiver match (no `otherro` substring) and no
172+ # interpolated regex.
140173 recv = _receiver (setup , pos )
141- return bool (recv and re .search (
142- rf"{ re .escape (recv )} \s*\.\s*(?:unsubscribe|disconnect)\s*\(" , cleanup ))
174+ if not recv :
175+ return False
176+ sub_args = _call_args (setup [pos :])
177+ for um in re .finditer (
178+ r"([A-Za-z_$][\w$.\[\]]*)\s*\.\s*(?:unsubscribe|disconnect)\s*\(" , cleanup ):
179+ if um .group (1 ) == recv and _call_args (cleanup [um .end () - 1 :]) in ("" , sub_args ):
180+ return True
181+ return False
143182 if acq .resource == "subscription" : # target.addEventListener(event, handler[, opts])
144183 a = _listener_call (setup [pos :])
145184 if a is None :
146185 return False
147- # AbortController: a listener bound to an AbortSignal is released when the
148- # effect aborts the controller — `addEventListener(.., { signal})` torn down by
149- # ` controller.abort()` removes every signal-bound listener at once .
186+ # AbortController: a signal- bound listener is released only when THE controller
187+ # backing that signal is aborted in cleanup — tie `{ signal }` back to its
188+ # controller and require `<that controller> .abort()`, not any `.abort()` .
150189 am = _LISTENER .match (setup [pos :].lstrip ())
151190 opts = (am .group (3 ) or "" ) if am else ""
152- if re .search (r"\bsignal\b" , opts ) and re .search (r"\.abort\s*\(" , cleanup ):
191+ ctrl = _signal_controller (setup , opts )
192+ if ctrl and any (
193+ ab .group (1 ) == ctrl for ab in
194+ re .finditer (r"([A-Za-z_$][\w$.\[\]]*)\s*\.\s*abort\s*\(" , cleanup )):
153195 return True
154196 # Otherwise the full listener key is (receiver, event, handler, capture): a
155197 # listener is released ONLY by a removeEventListener whose whole key matches —
@@ -399,15 +441,20 @@ def _effect_callback(masked: str, after_open: int) -> tuple[str, int, list[str]
399441 return body , i , deps , end
400442
401443
402- def _cleanup_span (mbody : str ) -> tuple [int , int ] | None :
403- """The (start, end) span of the effect's OWN cleanup within `mbody` (the masked
404- body), or None. The cleanup is the `return () => …` directly in the effect
405- callback — possibly inside an `if`/`try`/`.then()` BLOCK, but NOT inside a nested
406- CALLBACK (an event handler defined in the effect). So the test is FUNCTION depth,
407- not brace depth: a block `{` keeps us in the effect; an arrow/`function` body `{`
408- is a new function. A return at function-depth 1 (only the effect body encloses) is
409- the cleanup; deeper is some inner callback's return. The span is returned (not
410- sliced) so the caller can cut it from BOTH the masked and the original body."""
444+ def _cleanup_span (mbody : str ) -> tuple [int , int , int ] | None :
445+ """`(start, end, cov_start)` for the effect's OWN cleanup within `mbody` (masked),
446+ or None. The cleanup is the `return () => …` directly in the effect callback —
447+ possibly inside an `if`/`try`/`.then()` BLOCK, but NOT inside a nested CALLBACK. So
448+ the test is FUNCTION depth, not brace depth: a block `{` keeps us in the effect; an
449+ arrow/`function` body `{` is a new function. A return at function-depth 1 is the
450+ cleanup; deeper is some inner callback's return.
451+
452+ `cov_start` bounds which acquires the cleanup may credit: only acquires at body
453+ position `cov_start < p < start` are co-guarded with the cleanup. For a top-level
454+ cleanup that is the whole effect body (`cov_start == 0`); for a cleanup inside a
455+ block it is that block's `{` (so an acquire OUTSIDE the branch is NOT released by a
456+ conditional cleanup); for a *braceless* guard (`if (x) return …`) it is `start`
457+ (empty), so a conditionally-returned cleanup releases nothing it does not dominate."""
411458 # function-body braces: the effect body's own `{` (index 0), plus every `=> {`
412459 # and `function (...) {`. Every other `{` is a plain block (if/try/for/object).
413460 fn_braces = {0 }
@@ -416,30 +463,35 @@ def _cleanup_span(mbody: str) -> tuple[int, int] | None:
416463 for fm in re .finditer (r"\bfunction\b[^{;]*?\)\s*\{" , mbody ):
417464 fn_braces .add (fm .end () - 1 )
418465 ret_re = re .compile (r"return\s*(?:\(\s*\)|\w+)\s*=>" )
419- stack : list [bool ] = [] # True where the open brace started a function body
466+ stack : list [tuple [ bool , int ]] = [] # (is_function_body, open_index) per open brace
420467 fdepth = 0
421468 i , n = 0 , len (mbody )
422469 while i < n :
423470 c = mbody [i ]
424471 if c == "{" :
425472 is_fn = i in fn_braces
426- stack .append (is_fn )
473+ stack .append (( is_fn , i ) )
427474 fdepth += 1 if is_fn else 0
428475 i += 1
429476 continue
430477 if c == "}" :
431- if stack and stack .pop ():
478+ if stack and stack .pop ()[ 0 ] :
432479 fdepth -= 1
433480 i += 1
434481 continue
435482 if c == "r" and fdepth == 1 :
436483 m = ret_re .match (mbody , i )
437484 if m :
485+ cov_start = stack [- 1 ][1 ] if stack else 0 # innermost enclosing block
486+ b = max (mbody .rfind (";" , 0 , i ), mbody .rfind ("{" , 0 , i ),
487+ mbody .rfind ("}" , 0 , i ))
488+ if re .search (r"\b(?:if|else|for|while)\b" , mbody [b + 1 :i ]):
489+ cov_start = m .start () # braceless conditional guard -> covers nothing
438490 rest = mbody [m .end ():]
439491 stripped = rest .lstrip ()
440492 if stripped .startswith ("{" ): # block-bodied cleanup: `=> { ... }`
441493 brace = m .end () + (len (rest ) - len (stripped ))
442- return (m .start (), _match_block (mbody , brace ))
494+ return (m .start (), _match_block (mbody , brace ), cov_start )
443495 # single-expression cleanup — consume the WHOLE expression across line
444496 # breaks, stopping at a top-level `;` or the effect body's own close.
445497 j , depth = m .end (), 0
@@ -454,7 +506,7 @@ def _cleanup_span(mbody: str) -> tuple[int, int] | None:
454506 elif cj == ";" and depth == 0 :
455507 break
456508 j += 1
457- return (m .start (), j )
509+ return (m .start (), j , cov_start )
458510 i += 1
459511 return None
460512
@@ -468,11 +520,12 @@ def extract(path: str) -> list[Component]:
468520 body_o = text [body_start :end ] # original (unmasked) body — same positions
469521 span = _cleanup_span (body_m )
470522 if span :
471- cs , ce = span
523+ cs , ce , cov_start = span
472524 setup_m = body_m [:cs ] + body_m [ce :]
473525 setup_o = body_o [:cs ] + body_o [ce :]
474526 cleanup_o = body_o [cs :ce ]
475527 else :
528+ cs = ce = cov_start = - 1
476529 setup_m , setup_o , cleanup_o = body_m , body_o , ""
477530 cname = _component_at (masked , eff .start ())
478531 comp = comps .setdefault (cname , Component (cname , path ))
@@ -481,9 +534,16 @@ def extract(path: str) -> list[Component]:
481534 # decide release on the ORIGINAL setup/cleanup (so event-name strings and
482535 # handles are intact for the full-key comparison).
483536 for hit in acq .pattern .finditer (setup_m ):
484- abs_pos = body_start + hit .start ()
537+ # map the setup position back to the body (the cleanup span was cut out)
538+ sp = hit .start ()
539+ bp = sp if (cs < 0 or sp < cs ) else sp + (ce - cs )
540+ abs_pos = body_start + bp
485541 line = masked .count ("\n " , 0 , abs_pos ) + 1
486- released = _is_released (acq , setup_o , hit .start (), cleanup_o )
542+ # a conditional cleanup only releases acquires it dominates (co-guarded
543+ # in the same block); an acquire outside that block keeps its own cleanup
544+ # empty, so a `return` guarded by a branch cannot silence it.
545+ covered = cs < 0 or cov_start < bp < cs
546+ released = _is_released (acq , setup_o , sp , cleanup_o if covered else "" )
487547 # the acquire expression for the tag — from the ORIGINAL text, so the
488548 # message shows the real call (string args intact), trimmed to one line.
489549 snippet = text [abs_pos :].splitlines ()[0 ].strip ().rstrip ("{" ).strip ()
0 commit comments