Skip to content

Fix all open issues: option-map-nullable rule (#69), PostToolUse lint hook (#70), AI-UX closure (#74) - #94

Merged
aviadshiber merged 5 commits into
mainfrom
claude/open-issues-scan-fixes-pjtofe
Jun 14, 2026
Merged

Fix all open issues: option-map-nullable rule (#69), PostToolUse lint hook (#70), AI-UX closure (#74)#94
aviadshiber merged 5 commits into
mainfrom
claude/open-issues-scan-fixes-pjtofe

Conversation

@aviadshiber

@aviadshiber aviadshiber commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Summary

One PR addressing all three open issues, planned via parallel design passes per issue.

#69 — New rule: option-map-nullable

Detects Vavr Option chains where .map() can wrap a null mapper result as Some(null) (unlike java.util.Optional) and a chained .filter()/.map()/.forEach()/etc. then NPEs on the value — the exact bug from the DEV-219412 incident:

Option.of(metadata)
    .map(m -> m.get("author"))          // Map.get can return null -> Some(null)
    .filter(s -> !s.trim().isEmpty());  // NPE

Detection is deliberately conservative (three gates) to keep false positives at zero:

  1. a value-consuming follower (filter, map, flatMap, forEach, peek, exists, forAll) is chained after .map(...) — terminal extractors like getOrElse are excluded
  2. the receiver chain is rooted in a literal Option.of() / Option.ofOptional() (qualified io.vavr.control.Option.of also matched); bare variables and java.util.Optional never fire
  3. the lambda body matches a known possibly-null shape: x.get(key) (Map.get / JsonNode.get). Zero-arg get() (Vavr/Supplier) and integer-index List.get(0) are excluded — all four Java integer-literal radixes (decimal/hex/octal/binary)

Severity: Warning. The data payload carries fixType: USE_FLATMAP_OPTION_OF and a paste-able snippet built from the real AST names, e.g. .flatMap(m -> Option.of(m.get("author"))). Broader heuristics from the issue (getters without @NonNull, @Nonnull analysis) are left as future work — tree-sitter has no type/annotation resolution across files.

#70 — PostToolUse hook: real-time linting on Edit/Write

New hooks/post_tool_lint.py, wired in hooks.json with matcher Edit|MultiEdit|Write:

  • lints only the edited .java file (in-process import, single-file analysis ~ms; CLI fallback when the package isn't importable under the hook's python3)
  • emits violations as hookSpecificOutput.additionalContext, so the agent actually sees them in context (plain stdout on PostToolUse is transcript-only)
  • silent on clean files, diagnostics capped at 25, honors .java-functional-lsp.json excludes
  • failure-safe: every path exits 0, with SIGALRM + subprocess timeouts — a linter problem can never break the editing session

The existing reminder hook is narrowed from Read|Edit|Write to Read: the new hook embeds its own fix instruction for edits, so keeping the reminder there would duplicate context. Flagging this as the one intentional behavior change — happy to revert the narrowing if you prefer zero change to the reminder.

Manual (non-plugin) install instructions added to README. A future strict mode could use decision: block / exit 2; left out as it would fight multi-step edits and the repo's non-blocking philosophy.

#74 — AI-agent UX (closure)

PR #88 already shipped the core of #74 (variant-aware impure-method messages + offending-line ranges, recommendedApi/suggestedSnippet payloads, imperative-option-unwrap and mutable-dto quick fixes — the tests cite the issue). This PR closes the remaining gaps:

  • Snippet fidelity (sub-issue 2): imperative-option-unwrap's suggestedSnippet now derives the lambda body from the real then-branch expression — return opt.get().toUpperCase();.map(value -> value.toUpperCase()) instead of the value -> value placeholder, sharing the rewrite helper with the quick fix
  • Doc drift: README/SKILL.md now show ✅ Quick Fix for mutable-dto and imperative-option-unwrap, document both code actions, the recommendedApi/suggestedSnippet data fields, and the previously-undocumented autoImportLombok config key
  • Rule counts synced to 17 everywhere (vscode README still said 12)

Not included (follow-up candidates): a forEach quick fix for statement-shape unwraps, and a field-injection quick fix (intentionally unregistered in fixes.py — constructor synthesis risks clobbering user code).

Post-review hardening (ensemble review, commits 4–5)

A 7-angle ensemble review of this PR produced findings that were verified empirically before acting:

  • Fixed (confirmed bug): octal/binary index literals (xs.get(010), xs.get(0b101)) falsely fired option-map-nullable — tree-sitter emits per-radix literal node types
  • Fixed (majority-agreement refactor): consolidated the three copies of single-return extraction and the duplicated var.get() regex rewrite into shared base.py helpers (single_return_stmt, return_expr_node/text, single_return_expr_text, rewrite_var_get_call, rewrite_var_references); functional_checker, mutation_checker, and fixes.py now share one implementation. The shared rewrite also fixes two confirmed defects of the old copies: \b regex boundaries silently failing on $-prefixed Java identifiers, and no tolerance for whitespace around .get ()
  • Refuted by measurement: the suggested gate reorder in option-map-nullable (lambda-shape check before chain walk) — benchmarked at ~3× slower (chain walks terminate after one step at a bare identifier in fluent code); the measured-cheapest order is documented in the gate comments
  • Remaining follow-ups (single-agent suggestions, not blocking): single-pass collect_nodes_by_type for FunctionalChecker (the new rule costs ~64ms only on a pathological 307KB/2000-method file; negligible on real files), a cli.lint_file_for_hook() entry point, and a _NULLABLE_GETTER_METHODS config set

Closes #69, closes #70, closes #74.

Testing

  • 36 new tests (rule, hooks, snippet fidelity, shared base helpers incl. $-identifier and whitespace regressions); full suite 716 passed, 7 skipped, coverage 84.58% (≥80% gate)
  • ruff check, ruff format --check, mypy all clean
  • CLI end-to-end: the issue Add rule: Option.map() producing Some(null) before .filter()/.trim() #69 reproduction case fires option-map-nullable at the .map token; the flatMap fix pattern stays silent
  • CI green on all 14 checks (8-job test matrix, jdtls integration on ubuntu+macos, CodeQL)

https://claude.ai/code/session_018SmBpTQU3hqtw8EbWMcZ5M


Generated by Claude Code

claude added 3 commits June 12, 2026 14:07
Detect Vavr Option chains where .map() can wrap a null mapper result as
Some(null) — unlike java.util.Optional — and a chained .filter()/.map()/
.forEach()/etc. then NPEs on the value.

Conservative three-gate detection to avoid false positives:
1. a value-consuming follower is chained after .map(...)
2. the receiver chain is rooted in a literal Option.of()/Option.ofOptional()
3. the lambda body matches a known possibly-null shape (x.get(key) —
   Map.get / JsonNode.get; zero-arg get() and integer-index get(0) excluded)

The data payload carries a paste-able .flatMap(x -> Option.of(...)) snippet
built from the real lambda parameter and body text.

https://claude.ai/code/session_018SmBpTQU3hqtw8EbWMcZ5M
Add hooks/post_tool_lint.py: runs java-functional-lsp on the edited file
after Edit/MultiEdit/Write and surfaces violations to Claude as
hookSpecificOutput.additionalContext, so Vavr-specific rules are checked
in the editing flow instead of only via manual /lint-java runs.

- only fires on .java files; lints just the changed file (in-process
  import with CLI fallback when the package isn't importable under the
  hook's python3)
- silent on clean files; diagnostics capped at 25 to bound context
- failure-safe: every path exits 0, SIGALRM + subprocess timeout cap
  runtime, so a linter problem can never break the editing session

The existing reminder hook is narrowed to Read — the new hook embeds its
own fix instruction for edits, so keeping the reminder on Edit|Write
would duplicate context.

https://claude.ai/code/session_018SmBpTQU3hqtw8EbWMcZ5M
Complete issue #74 (PR #88 shipped the core — variant-aware impure-method,
recommendedApi/suggestedSnippet payloads, and two new quick fixes):

- imperative-option-unwrap's suggested_snippet now derives the lambda body
  from the real then-branch expression (return opt.get().toUpperCase()
  becomes .map(value -> value.toUpperCase())) instead of the placeholder
  value -> value, mirroring the quick-fix rewrite in fixes.py
- README/SKILL.md: flip Quick Fix column for mutable-dto and
  imperative-option-unwrap, document the two code actions, the
  recommendedApi/suggestedSnippet data fields, and autoImportLombok
- README/SKILL.md/editor READMEs: rule count 16 -> 17 for the new
  option-map-nullable rule (vscode README still said 12)
- uv.lock: refresh stale package version (0.10.0 -> 0.11.1)

https://claude.ai/code/session_018SmBpTQU3hqtw8EbWMcZ5M
@aviadshiber
aviadshiber marked this pull request as ready for review June 12, 2026 14:17
claude added 2 commits June 12, 2026 14:25
…use IGNORED_CHILDREN

Ensemble review findings on PR #94:

- xs.get(010) / xs.get(0b101) falsely fired option-map-nullable because
  tree-sitter emits octal_integer_literal / binary_integer_literal node
  types that were missing from _INTEGER_LITERAL_TYPES; index access throws
  rather than returning null regardless of radix. Verified empirically.
- mutation_checker's new helpers re-declared the comment-filter tuple
  locally; use the shared IGNORED_CHILDREN constant from base instead.

https://claude.ai/code/session_018SmBpTQU3hqtw8EbWMcZ5M
…se.py

Majority-agreement finding from the ensemble review (Reuse + Altitude
agents independently flagged it): three copies of single-return-statement
extraction lived in functional_checker, mutation_checker, and fixes.py,
and the var.get()->param regex rewrite was duplicated between
mutation_checker and fixes.py. All now live in base.py (the leaf module,
so no import cycle): single_return_stmt, return_expr_node,
return_expr_text, single_return_expr_text, rewrite_var_get_call,
rewrite_var_references.

The shared rewrite also fixes two confirmed defects in the old copies:
- regex \b never matches before $-prefixed Java identifiers (and falsely
  matches inside them), silently dropping the rewrite
- no tolerance for whitespace around '.'/parens (opt .get () is valid Java)

Gate order in option-map-nullable kept as-is: benchmarked the suggested
lambda-shape-first reorder and the chain walk is ~3x cheaper per .map()
on fluent-heavy code (4.4ms vs 12.2ms per 4000-call pass), so the claim
was refuted by measurement; documented in the gate comment.

https://claude.ai/code/session_018SmBpTQU3hqtw8EbWMcZ5M
@aviadshiber
aviadshiber merged commit 4354b3a into main Jun 14, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants