Skip to content

fix(ownts): literal-proof useEffect-парсер + строгий матчинг release для addEventListener#145

Merged
PhysShell merged 3 commits into
mainfrom
claude/ownts-parser-hardening
Jun 27, 2026
Merged

fix(ownts): literal-proof useEffect-парсер + строгий матчинг release для addEventListener#145
PhysShell merged 3 commits into
mainfrom
claude/ownts-parser-hardening

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 27, 2026

Copy link
Copy Markdown
Owner

Что и зачем

Доводка спайка OwnTS после смерженного #144 — закрывает два последних Major-замечания CodeRabbit по парсеру (они уже после мерджа, поэтому отдельным PR).

  1. Парсинг с учётом строк/скобок. _expr_end/_effect_callback/_split_cleanup считали сырую пунктуацию: строка вроде fetch("/a,b") или { внутри литерала обрезала тело/делила deps, а [items[i]] рвался по внутренней скобке → в OWN001/EFF001 шли кривые факты. Теперь исходник один раз маскируется (_mask_strings гасит содержимое литералов, сохраняя кавычки, длину и переводы строк), весь структурный разбор идёт по маске, а массив зависимостей берётся по балансу скобок с разбиением по top-level запятым. Сниппеты для сообщений по-прежнему из оригинала.
  2. addEventListener release теперь требует совпадения получателя и capture/options, а не только handler: window.addEventListener("x", h, true) больше не считается снятым через removeEventListener("x", h) (опция отброшена) или через removeEventListener на другом таргете — оба остаются утечкой.

Тип изменения

  • feat — новая возможность
  • fix — исправление бага
  • docs — документация
  • refactor / chore / test / ci — без изменения поведения

Как проверено

  • python tests/run_tests.py — зелёный (effects 31/31, ownir 194/194, explain)
  • ruff check . и mypy (--strict по ownlang) — чисто
  • селфтест: python frontend/ownts/test_ownts.py (новый кейс EffectHardening + прямые проверки _is_released на drop-options / wrong-target)
  • Демо: python frontend/ownts/ownts.py frontend/ownts/examples/EffectHardening.tsx --check

Связанные issue

Follow-up к #144 (P-020 OwnTS/EFF001). Closes — нет.

Чеклист

  • изменение покрыто тестом/селфтестом (EffectHardening.tsx, _is_released-кейсы, CI-шаг)
  • README/docs обновлены при необходимости (поведение зафиксировано тестами; публичного API не меняли)
  • коммиты в conventional-commit стиле (fix:)

🤖 Generated with Claude Code


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Added a new hardening React example to exercise stricter effect parsing and cleanup behaviors.
  • Bug Fixes

    • Improved extraction of useEffect dependencies and cleanup handling to be resilient to string literals and complex dependency expressions.
    • Strengthened addEventListener release detection to require matching handler identity and listener options, preventing false “released” results.
  • Tests

    • Extended end-to-end coverage to validate the new hardening example and stricter listener cleanup matching.
    • Added CI verification that asserts the expected hardening findings.

…match

Follow-up to the merged #144, addressing CodeRabbit's last two Major findings on
the OwnTS spike's parser.

1. String/bracket-aware parsing. _expr_end / _effect_callback / _split_cleanup
   counted raw punctuation, so a string like fetch("/a,b") or a `{` inside a
   literal truncated the body or split the deps, and the deps regex tore
   `[items[i]]` at its inner bracket — feeding garbage facts to OWN001/EFF001.
   Now the source is string-masked once (_mask_strings blanks literal CONTENT,
   keeping delimiters, length and newlines) and every structural scan runs on the
   masked copy; the dependency array is matched by balanced brackets with a
   top-level comma split. Display snippets still come from the original text.

2. addEventListener release now matches the receiver and capture/options, not
   just the handler: window.addEventListener("x", h, true) is no longer treated
   as released by removeEventListener("x", h) (option dropped) or by a
   removeEventListener on a different target — both still leak.

Coverage: new fixture EffectHardening.tsx (string commas/braces + nested dep
bracket + options-dropped listener -> OWN001 + EFF001), pinned in test_ownts.py
alongside direct _is_released discrimination cases, plus a CI step. All existing
fixtures unchanged; full suite green (effects 31/31, ownir 194/194, explain);
ruff + mypy --strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVeuchch67CtjSbizYm8gi
@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

OwnTS now masks string literals during useEffect parsing, tightens addEventListener cleanup matching for options, adds a new hardening example and tests, and extends CI with a verification step for the new diagnostics.

Changes

OwnTS effect hardening

Layer / File(s) Summary
Masked effect parsing
frontend/ownts/ownts.py
String literals are masked before structural scans, and balanced delimiter helpers are used to parse effect callbacks and dependency arrays.
Extraction wiring
frontend/ownts/ownts.py
Effect extraction uses masked source for discovery, line computation, render-binding detection, and dependency-aware parsing.
Listener options release matching
frontend/ownts/ownts.py
Listener release detection now compares receiver, handler, and capture identity between addEventListener and removeEventListener calls.
Hardening example and unit assertions
frontend/ownts/examples/EffectHardening.tsx, frontend/ownts/test_ownts.py
Adds Hardening plus assertions for interval cleanup, listener-options mismatch, nested dependency parsing, and the expected OWN001 and EFF001 results.
CI verification step
.github/workflows/ci.yml
Runs the new example through extraction and OwnIR conversion, then checks the diagnostic count and scroll listener wording.

Sequence Diagram(s)

sequenceDiagram
  participant Job as ownts-react-effects job
  participant Extractor as frontend/ownts/ownts.py
  participant Converter as python -m ownlang ownir
  participant Checks as workflow assertions
  Job->>Extractor: run on frontend/ownts/examples/EffectHardening.tsx
  Extractor->>Converter: produce OwnIR facts
  Converter-->>Job: OWN/EFF/DI diagnostics
  Job->>Checks: verify two findings and scroll listener wording
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PhysShell/Own.NET#144: Introduced the OwnTS React useEffect extraction and listener-release handling that this PR hardens with string masking, option matching, and new checks.

Poem

A rabbit hopped through brackets bright,
and masked the strings to set things right.
The scroll listener kept its options tight,
while dep arrays parsed by moonbeam light. 🐇

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: parser hardening and stricter addEventListener release matching.
Description check ✅ Passed The description follows the required template and fills all major sections with relevant implementation, testing, issue, and checklist details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/ownts-parser-hardening

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc7ba94947

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread frontend/ownts/ownts.py Outdated
Comment on lines +108 to +110
if opts:
pat += rf"[^)]*{re.escape(opts)}"
return bool(re.search(pat, cleanup))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat omitted listener options as false

When addEventListener omits the third argument, the listener is registered with capture false, but this branch skips option comparison because opts is empty. A cleanup such as window.removeEventListener("x", onX, true) is therefore accepted as a release even though it targets the capture-true listener and leaves the original listener installed, suppressing the OWN001 leak. Normalize omitted options to the default capture value and compare it in both directions.

Useful? React with 👍 / 👎.

…dex)

addEventListener's third argument is identified for removal by its capture flag,
which defaults to false when omitted. The previous check only required matching
options when the acquire HAD a third arg, so add("x", h) (capture false) was
wrongly accepted as released by remove("x", h, true) (capture true), suppressing
the OWN001 leak. Now _capture_flag normalizes each side (omitted/object-without-
capture -> false; true/false; {capture:…}; non-literal -> verbatim) and the
listener is released only when receiver, handler AND capture flag all match — in
both directions. passive/once/signal don't affect removal identity.

test_ownts.py adds the symmetric case (omitted vs remove(...,true) -> leak),
{capture:true}==true, and passive-option normalization. Full suite green; ruff +
mypy --strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVeuchch67CtjSbizYm8gi

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/ownts/ownts.py`:
- Around line 91-110: The listener-release check in ownts.py is matching on only
parts of the add/removeEventListener call, which can incorrectly treat different
listeners as cleaned up. Update the subscription branch in the matching logic to
parse both the acquire and cleanup calls into a full listener key using the
existing addEventListener/removeEventListener handling, then compare receiver,
event name, handler, and options together. Ensure the code in the subscription
path fails closed and returns False unless all fields match exactly, including
cases where the receiver is indexed or nested.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e64a1af3-f92a-454e-b7b1-62dcb85ef31a

📥 Commits

Reviewing files that changed from the base of the PR and between 11db18a and dc7ba94.

📒 Files selected for processing (4)
  • .github/workflows/ci.yml
  • frontend/ownts/examples/EffectHardening.tsx
  • frontend/ownts/ownts.py
  • frontend/ownts/test_ownts.py

Comment thread frontend/ownts/ownts.py Outdated
…key, fail closed

The listener-release check compared only handler + capture, so a real leak could
be suppressed: addEventListener("scroll", h) was "released" by
removeEventListener("resize", h) (different event), and an indexed/nested
receiver like nodes[i].addEventListener(...) collapsed the receiver to empty so a
cleanup on any target matched.

Now both the acquire and each cleanup removeEventListener are parsed into the full
listener key (receiver, event, handler, capture) and a listener is released only
when ALL fields match exactly; otherwise it fails closed (leak). The receiver
parser accepts member/index chains (this.ref, nodes[i]). The cleanup scan uses a
literal regex + Python comparison (no interpolated pattern), which also clears the
ast-grep ReDoS note.

To compare event-name string literals, release is now decided on the ORIGINAL
(unmasked) setup/cleanup while acquires are still located on the masked copy
(_cleanup_span cuts the same span out of both). test_ownts.py adds the
different-event and indexed-receiver-mismatch cases. Full suite green; ruff +
mypy --strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVeuchch67CtjSbizYm8gi

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/ownts/ownts.py`:
- Around line 393-398: The cleanup-span detection in the expression-bodied
branch is too permissive and mistakenly treats an options object inside a call
as the cleanup block. In the span logic around mbody.find("{", m.end()) and
_match_block, only classify a cleanup as block-bodied when the first
non-whitespace character after => is {; otherwise keep the expression-body range
ending at the line break. This preserves calls like removeEventListener(...,
{capture: true}) so _listener_call and _is_released continue to parse them
correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3d006d8e-ab78-4454-9772-8b8b68274da9

📥 Commits

Reviewing files that changed from the base of the PR and between 60a5404 and 943c3fb.

📒 Files selected for processing (2)
  • frontend/ownts/ownts.py
  • frontend/ownts/test_ownts.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/ownts/test_ownts.py

Comment thread frontend/ownts/ownts.py
Comment on lines +393 to +398
brace = mbody.find("{", m.end())
if brace == -1:
# `return () => clearInterval(id)` — single-expression cleanup, no block.
nl = body.find("\n", m.end())
tail = body[m.end(): nl if nl != -1 else len(body)]
return body[: m.start()], tail
end = _match_block(body, brace)
return body[: m.start()] + body[end:], body[brace:end]
return body, ""
nl = mbody.find("\n", m.end())
return (m.start(), nl if nl != -1 else len(mbody))
return (m.start(), _match_block(mbody, brace))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Expression-bodied cleanup with an options object is mis-spanned, causing false-positive leaks.

brace = mbody.find("{", m.end()) searches arbitrarily far for the cleanup block's {. For a single-expression cleanup whose removeEventListener passes an options object — exactly the case this PR targets — the { of the options literal is found instead:

return () => el.removeEventListener("scroll", onScroll, {capture: true})

_match_block then ends the span at the options }, truncating the trailing ). The resulting cleanup_o no longer parses via _LISTENER (which requires a closing )), so _listener_call returns None, _is_released returns False, and a correctly-released listener is reported as a leak. Block-bodied cleanups are unaffected; only expression bodies containing braces break.

Detect a block only when the first non-whitespace char after => is {:

🐛 Proposed fix
-        brace = mbody.find("{", m.end())
-        if brace == -1:
-            # `return () => clearInterval(id)` — single-expression cleanup, no block.
-            nl = mbody.find("\n", m.end())
-            return (m.start(), nl if nl != -1 else len(mbody))
-        return (m.start(), _match_block(mbody, brace))
+        rest = mbody[m.end():]
+        stripped = rest.lstrip()
+        if stripped.startswith("{"):  # block-bodied cleanup `=> { ... }`
+            brace = m.end() + (len(rest) - len(stripped))
+            return (m.start(), _match_block(mbody, brace))
+        # single-expression cleanup, e.g. `return () => clearInterval(id)` or
+        # `return () => el.removeEventListener("x", h, {capture: true})`
+        nl = mbody.find("\n", m.end())
+        return (m.start(), nl if nl != -1 else len(mbody))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
brace = mbody.find("{", m.end())
if brace == -1:
# `return () => clearInterval(id)` — single-expression cleanup, no block.
nl = body.find("\n", m.end())
tail = body[m.end(): nl if nl != -1 else len(body)]
return body[: m.start()], tail
end = _match_block(body, brace)
return body[: m.start()] + body[end:], body[brace:end]
return body, ""
nl = mbody.find("\n", m.end())
return (m.start(), nl if nl != -1 else len(mbody))
return (m.start(), _match_block(mbody, brace))
rest = mbody[m.end():]
stripped = rest.lstrip()
if stripped.startswith("{"): # block-bodied cleanup `=> { ... }`
brace = m.end() + (len(rest) - len(stripped))
return (m.start(), _match_block(mbody, brace))
# single-expression cleanup, e.g. `return () => clearInterval(id)` or
# `return () => el.removeEventListener("x", h, {capture: true})`
nl = mbody.find("\n", m.end())
return (m.start(), nl if nl != -1 else len(mbody))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/ownts/ownts.py` around lines 393 - 398, The cleanup-span detection
in the expression-bodied branch is too permissive and mistakenly treats an
options object inside a call as the cleanup block. In the span logic around
mbody.find("{", m.end()) and _match_block, only classify a cleanup as
block-bodied when the first non-whitespace character after => is {; otherwise
keep the expression-body range ending at the line break. This preserves calls
like removeEventListener(..., {capture: true}) so _listener_call and
_is_released continue to parse them correctly.

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.

2 participants