java-functional-lsp 0.12.0__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.claude-plugin/plugin.json +1 -1
  2. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/PKG-INFO +29 -1
  3. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/README.md +28 -0
  4. java_functional_lsp-0.13.0/hooks/post_tool_lint.py +184 -0
  5. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/pyproject.toml +3 -1
  6. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/__init__.py +1 -1
  7. java_functional_lsp-0.13.0/src/java_functional_lsp/freshness_marker.py +78 -0
  8. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/proxy.py +47 -6
  9. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/server.py +424 -30
  10. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/conftest.py +16 -0
  11. java_functional_lsp-0.13.0/tests/test_diagnostics_freshness.py +785 -0
  12. java_functional_lsp-0.13.0/tests/test_e2e_cross_file.py +170 -0
  13. java_functional_lsp-0.13.0/tests/test_e2e_hold.py +294 -0
  14. java_functional_lsp-0.13.0/tests/test_freshness_marker.py +243 -0
  15. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_server.py +2 -2
  16. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/uv.lock +1 -1
  17. java_functional_lsp-0.12.0/hooks/post_tool_lint.py +0 -89
  18. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.githooks/pre-commit +0 -0
  19. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.githooks/pre-push +0 -0
  20. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/CODEOWNERS +0 -0
  21. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  22. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  23. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  24. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/SECURITY.md +0 -0
  25. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/dependabot.yml +0 -0
  26. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/release-drafter.yml +0 -0
  27. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/workflows/publish.yml +0 -0
  28. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/workflows/release-drafter.yml +0 -0
  29. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/workflows/stale.yml +0 -0
  30. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/workflows/test.yml +0 -0
  31. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/workflows/update-homebrew.yml +0 -0
  32. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/workflows/vscode-ext.yml +0 -0
  33. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.gitignore +0 -0
  34. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/CONTRIBUTING.md +0 -0
  35. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/LICENSE +0 -0
  36. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/SKILL.md +0 -0
  37. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/commands/lint-java.md +0 -0
  38. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/intellij/README.md +0 -0
  39. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/intellij/lsp4ij-template.json +0 -0
  40. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/vscode/.vscodeignore +0 -0
  41. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/vscode/README.md +0 -0
  42. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/vscode/package-lock.json +0 -0
  43. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/vscode/package.json +0 -0
  44. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/vscode/src/extension.ts +0 -0
  45. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/vscode/tsconfig.json +0 -0
  46. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/hooks/hooks.json +0 -0
  47. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/hooks/java_linter_reminder.py +0 -0
  48. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/scripts/ensure-lsp.sh +0 -0
  49. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/scripts/generate-formula.py +0 -0
  50. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/__main__.py +0 -0
  51. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  52. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/analyzers/base.py +0 -0
  53. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  54. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  55. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  56. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  57. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  58. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/capabilities/__init__.py +0 -0
  59. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/capabilities/handler_wiring.py +0 -0
  60. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/capabilities/negotiator.py +0 -0
  61. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/capabilities/probe.py +0 -0
  62. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/capabilities/registry.py +0 -0
  63. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/capabilities/static_builder.py +0 -0
  64. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/cli.py +0 -0
  65. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/fixes.py +0 -0
  66. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/merkle.py +0 -0
  67. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/__init__.py +0 -0
  68. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_base.py +0 -0
  69. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_capabilities/__init__.py +0 -0
  70. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_capabilities/test_handler_wiring.py +0 -0
  71. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_capabilities/test_negotiator.py +0 -0
  72. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_capabilities/test_probe.py +0 -0
  73. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_capabilities/test_registry.py +0 -0
  74. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_capabilities/test_static_builder.py +0 -0
  75. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_cli.py +0 -0
  76. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_config.py +0 -0
  77. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_e2e.py +0 -0
  78. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_e2e_jdtls.py +0 -0
  79. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_exception_checker.py +0 -0
  80. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_fixes.py +0 -0
  81. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_functional_checker.py +0 -0
  82. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_hooks.py +0 -0
  83. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_merkle.py +0 -0
  84. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_merkle_proxy.py +0 -0
  85. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_mutation_checker.py +0 -0
  86. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_null_checker.py +0 -0
  87. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_plugin_manifest.py +0 -0
  88. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_proxy.py +0 -0
  89. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_spring_checker.py +0 -0
  90. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_suppress.py +0 -0
  91. {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_version.py +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "java-functional-lsp",
3
3
  "description": "Java LSP with functional programming rules enforcement — null safety, immutability, no exceptions, Spring best practices. Wraps jdtls for full Java language support.",
4
- "version": "0.4.3",
4
+ "version": "0.4.4",
5
5
  "lspServers": {
6
6
  "java-functional": {
7
7
  "command": "java-functional-lsp",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: java-functional-lsp
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions
5
5
  Project-URL: Homepage, https://github.com/aviadshiber/java-functional-lsp
6
6
  Project-URL: Repository, https://github.com/aviadshiber/java-functional-lsp
@@ -229,6 +229,7 @@ moving on. Fix any type errors or missing imports immediately.
229
229
  | "java-functional-lsp not found" | Run `brew install aviadshiber/tap/java-functional-lsp` |
230
230
  | Plugin not active | Run `claude plugin list` to verify, then `/reload-plugins` |
231
231
  | Diagnostics slow on first open | Normal — tree-sitter parses on first load, then incremental |
232
+ | Java errors show up one tool call after the edit | Claude Code doesn't wait for LSP diagnostics after Edit/Write ([anthropics/claude-code#93321](https://github.com/anthropics/claude-code/issues/93321)). The plugin's hook waits up to 3s for them; if jdtls is slower (large projects), raise `JAVA_FUNCTIONAL_LSP_HOOK_WAIT` (max 4). See [Fresh jdtls diagnostics after edits](#fresh-jdtls-diagnostics-after-edits) |
232
233
 
233
234
  ### Other Editors
234
235
 
@@ -334,6 +335,33 @@ The jdtls Eclipse workspace index is cached in `~/.cache/jdtls-data/`. Warm star
334
335
 
335
336
  To force a clean rebuild: `rm -rf ~/.cache/jdtls-data/`
336
337
 
338
+ ### Fresh jdtls diagnostics after edits
339
+
340
+ jdtls re-validates a file 0.4–2.4s after the last edit to *any* file, and its results carry no document version. So after an edit, java-functional-lsp holds back jdtls diagnostics for that file until jdtls publishes again, rather than re-sending the previous edit's results as if they were current ([#109](https://github.com/aviadshiber/java-functional-lsp/issues/109)):
341
+
342
+ | Mode | Default for | After an edit |
343
+ |------|-------------|---------------|
344
+ | `custom-first` | Claude Code | Custom diagnostics publish at once, without jdtls errors; the full set follows when jdtls has re-validated |
345
+ | `hold-all` | Other editors | Nothing is published for the file until jdtls has re-validated (no flickering squiggles) |
346
+ | `off` | — | Previous behavior: custom diagnostics plus the last known jdtls diagnostics after 150ms |
347
+
348
+ With Claude Code two more things happen:
349
+
350
+ - **The plugin's PostToolUse hook waits for fresh results.** Claude Code attaches diagnostics as soon as its PostToolUse hooks finish, without waiting for the server ([anthropics/claude-code#93321](https://github.com/anthropics/claude-code/issues/93321)). The server records when it last published final diagnostics for each file, and `hooks/post_tool_lint.py` waits until that is newer than the edit (at most `JAVA_FUNCTIONAL_LSP_HOOK_WAIT` seconds, default 3, max 4), so jdtls's result for the edit arrives in the same tool result. The markers live in a private per-user temp directory and contain only a timestamp.
351
+ - **jdtls gets a didSave after each edit.** Claude Code writes the file itself; when the edited buffer matches the file on disk, the server forwards a didSave. Without it, jdtls can re-check dependent files against the old version of the edited one (e.g. a caller keeps reporting a constructor's old arity) and never correct them.
352
+
353
+ If jdtls doesn't publish in time (3s after the last edit, stretched when jdtls is slow, 10s at most), the last known jdtls diagnostics are used. Files whose module is still being imported, and files matched by `java.diagnostic.filter`, are never held. While you edit the same file in quick succession, its diagnostics update only once the burst settles.
354
+
355
+ Environment variables:
356
+
357
+ | Variable | Values | Effect |
358
+ |----------|--------|--------|
359
+ | `JAVA_FUNCTIONAL_LSP_DIAG_HOLD` | `custom-first`, `hold-all`, `off` | Overrides the mode above; `off` restores the previous behavior |
360
+ | `JAVA_FUNCTIONAL_LSP_HOOK_WAIT` | seconds, `0`–`4` (default `3`) | How long the Claude Code PostToolUse hook waits for fresh jdtls diagnostics; `0` disables the wait |
361
+ | `JAVA_FUNCTIONAL_LSP_LOG_LEVEL` | `DEBUG`, `INFO` (default), `WARNING` | Verbosity of java-functional-lsp's own logs; `DEBUG` adds one line per publish decision (file name, trigger, counts). Library logging (pygls) is unaffected — note that pygls already logs the JSON it sends, including diagnostic messages, at `INFO` |
362
+
363
+ The log reports `jdtls freshness: released=… too_early=… timeout=… late_correction=…` every 100 decisions and when jdtls stops. Repeated `timeout` lines mean jdtls is slow or stuck on that module. A growing `late_correction` count means jdtls results were released too early.
364
+
337
365
  ### Suppressing jdtls diagnostics
338
366
 
339
367
  For project-specific jdtls false positives (e.g., annotation processor methods, MapStruct mappers), use `suppressJdtlsPatterns` to add custom regex filters:
@@ -201,6 +201,7 @@ moving on. Fix any type errors or missing imports immediately.
201
201
  | "java-functional-lsp not found" | Run `brew install aviadshiber/tap/java-functional-lsp` |
202
202
  | Plugin not active | Run `claude plugin list` to verify, then `/reload-plugins` |
203
203
  | Diagnostics slow on first open | Normal — tree-sitter parses on first load, then incremental |
204
+ | Java errors show up one tool call after the edit | Claude Code doesn't wait for LSP diagnostics after Edit/Write ([anthropics/claude-code#93321](https://github.com/anthropics/claude-code/issues/93321)). The plugin's hook waits up to 3s for them; if jdtls is slower (large projects), raise `JAVA_FUNCTIONAL_LSP_HOOK_WAIT` (max 4). See [Fresh jdtls diagnostics after edits](#fresh-jdtls-diagnostics-after-edits) |
204
205
 
205
206
  ### Other Editors
206
207
 
@@ -306,6 +307,33 @@ The jdtls Eclipse workspace index is cached in `~/.cache/jdtls-data/`. Warm star
306
307
 
307
308
  To force a clean rebuild: `rm -rf ~/.cache/jdtls-data/`
308
309
 
310
+ ### Fresh jdtls diagnostics after edits
311
+
312
+ jdtls re-validates a file 0.4–2.4s after the last edit to *any* file, and its results carry no document version. So after an edit, java-functional-lsp holds back jdtls diagnostics for that file until jdtls publishes again, rather than re-sending the previous edit's results as if they were current ([#109](https://github.com/aviadshiber/java-functional-lsp/issues/109)):
313
+
314
+ | Mode | Default for | After an edit |
315
+ |------|-------------|---------------|
316
+ | `custom-first` | Claude Code | Custom diagnostics publish at once, without jdtls errors; the full set follows when jdtls has re-validated |
317
+ | `hold-all` | Other editors | Nothing is published for the file until jdtls has re-validated (no flickering squiggles) |
318
+ | `off` | — | Previous behavior: custom diagnostics plus the last known jdtls diagnostics after 150ms |
319
+
320
+ With Claude Code two more things happen:
321
+
322
+ - **The plugin's PostToolUse hook waits for fresh results.** Claude Code attaches diagnostics as soon as its PostToolUse hooks finish, without waiting for the server ([anthropics/claude-code#93321](https://github.com/anthropics/claude-code/issues/93321)). The server records when it last published final diagnostics for each file, and `hooks/post_tool_lint.py` waits until that is newer than the edit (at most `JAVA_FUNCTIONAL_LSP_HOOK_WAIT` seconds, default 3, max 4), so jdtls's result for the edit arrives in the same tool result. The markers live in a private per-user temp directory and contain only a timestamp.
323
+ - **jdtls gets a didSave after each edit.** Claude Code writes the file itself; when the edited buffer matches the file on disk, the server forwards a didSave. Without it, jdtls can re-check dependent files against the old version of the edited one (e.g. a caller keeps reporting a constructor's old arity) and never correct them.
324
+
325
+ If jdtls doesn't publish in time (3s after the last edit, stretched when jdtls is slow, 10s at most), the last known jdtls diagnostics are used. Files whose module is still being imported, and files matched by `java.diagnostic.filter`, are never held. While you edit the same file in quick succession, its diagnostics update only once the burst settles.
326
+
327
+ Environment variables:
328
+
329
+ | Variable | Values | Effect |
330
+ |----------|--------|--------|
331
+ | `JAVA_FUNCTIONAL_LSP_DIAG_HOLD` | `custom-first`, `hold-all`, `off` | Overrides the mode above; `off` restores the previous behavior |
332
+ | `JAVA_FUNCTIONAL_LSP_HOOK_WAIT` | seconds, `0`–`4` (default `3`) | How long the Claude Code PostToolUse hook waits for fresh jdtls diagnostics; `0` disables the wait |
333
+ | `JAVA_FUNCTIONAL_LSP_LOG_LEVEL` | `DEBUG`, `INFO` (default), `WARNING` | Verbosity of java-functional-lsp's own logs; `DEBUG` adds one line per publish decision (file name, trigger, counts). Library logging (pygls) is unaffected — note that pygls already logs the JSON it sends, including diagnostic messages, at `INFO` |
334
+
335
+ The log reports `jdtls freshness: released=… too_early=… timeout=… late_correction=…` every 100 decisions and when jdtls stops. Repeated `timeout` lines mean jdtls is slow or stuck on that module. A growing `late_correction` count means jdtls results were released too early.
336
+
309
337
  ### Suppressing jdtls diagnostics
310
338
 
311
339
  For project-specific jdtls false positives (e.g., annotation processor methods, MapStruct mappers), use `suppressJdtlsPatterns` to add custom regex filters:
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse hook: lint a .java file after Edit/Write and surface violations to Claude.
3
+
4
+ Reads the Claude Code PostToolUse JSON payload on stdin, runs java-functional-lsp
5
+ on the edited file, and emits diagnostics as ``hookSpecificOutput.additionalContext``
6
+ so the agent sees them in context and can fix them immediately (issue #70). Before
7
+ returning it waits (bounded) for the running language server to publish fresh jdtls
8
+ diagnostics for the file, so Claude Code attaches current ones to this edit (#109).
9
+
10
+ Failure-safe by design: every path exits 0 — a linter problem must never break the
11
+ editing session. Prefers the fast in-process import; falls back to the CLI when the
12
+ package is not importable under this interpreter.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import getpass
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import signal
22
+ import stat
23
+ import subprocess
24
+ import sys
25
+ import tempfile
26
+ import time
27
+ from pathlib import Path
28
+
29
+ TIMEOUT_SECONDS = 5 # hard cap; single-file analysis is typically <200ms
30
+ MAX_DIAGNOSTICS = 25 # keep additionalContext bounded
31
+ POLL_SECONDS = 0.05
32
+ DEFAULT_WAIT_SECONDS = 3.0
33
+ MAX_WAIT_SECONDS = 4.0
34
+ EXIT_MARGIN_SECONDS = 0.5 # finish well before SIGALRM so the lint output is printed
35
+ MARKER_MAX_BYTES = 128
36
+
37
+
38
+ def _fresh_wait_seconds() -> float:
39
+ try:
40
+ wait = float(os.environ.get("JAVA_FUNCTIONAL_LSP_HOOK_WAIT", DEFAULT_WAIT_SECONDS))
41
+ except ValueError:
42
+ return DEFAULT_WAIT_SECONDS
43
+ return max(0.0, min(wait, MAX_WAIT_SECONDS))
44
+
45
+
46
+ def _user() -> str:
47
+ try:
48
+ return getpass.getuser()
49
+ except Exception:
50
+ return str(os.getuid()) if hasattr(os, "getuid") else "user"
51
+
52
+
53
+ def _marker_path(file_path: str) -> Path:
54
+ """Must match java_functional_lsp.freshness_marker.marker_path (a test checks)."""
55
+ digest = hashlib.sha256(os.path.realpath(file_path).encode()).hexdigest()[:32]
56
+ return Path(tempfile.gettempdir()) / f"java-functional-lsp-{_user()}" / "fresh" / digest
57
+
58
+
59
+ def _owned(st: os.stat_result) -> bool:
60
+ return not hasattr(os, "getuid") or st.st_uid == os.getuid()
61
+
62
+
63
+ def _read_marker(marker: Path) -> tuple[int, str] | None:
64
+ """(server pid, content digest), or None unless the marker and its dirs are private and ours."""
65
+ try:
66
+ for level in (marker.parent.parent, marker.parent):
67
+ st = os.lstat(level)
68
+ if not stat.S_ISDIR(st.st_mode) or not _owned(st) or st.st_mode & 0o077:
69
+ return None
70
+ fd = os.open(marker, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
71
+ try:
72
+ st = os.fstat(fd)
73
+ if not stat.S_ISREG(st.st_mode) or not _owned(st):
74
+ return None
75
+ pid, digest = os.read(fd, MARKER_MAX_BYTES).decode().split()
76
+ finally:
77
+ os.close(fd)
78
+ server_pid = int(pid)
79
+ return (server_pid, digest) if server_pid > 0 else None # kill(0|-n, 0) targets groups
80
+ except (OSError, ValueError):
81
+ return None
82
+
83
+
84
+ def _server_alive(pid: int) -> bool:
85
+ try:
86
+ os.kill(pid, 0)
87
+ except PermissionError:
88
+ return True
89
+ except OSError:
90
+ return False
91
+ return True
92
+
93
+
94
+ def _wait_for_fresh_diagnostics(path: Path, budget: float) -> None:
95
+ """Wait until the language server has published final diagnostics for this edit.
96
+
97
+ Claude Code attaches LSP diagnostics right after PostToolUse hooks finish, but jdtls
98
+ needs 0.4-2.4s to re-validate (#109). On each final publish the server records its pid
99
+ and a hash of the content it analyzed; waiting until that hash matches the file puts
100
+ fresh results in this tool result. No marker, or a dead server, means nobody will
101
+ publish — don't wait.
102
+ """
103
+ marker = _marker_path(str(path))
104
+ current = _read_marker(marker)
105
+ if current is None or not _server_alive(current[0]):
106
+ return
107
+ want = hashlib.sha256(path.read_bytes()).hexdigest()
108
+ deadline = time.monotonic() + min(_fresh_wait_seconds(), budget)
109
+ while current is not None and current[1] != want and time.monotonic() < deadline:
110
+ time.sleep(POLL_SECONDS)
111
+ current = _read_marker(marker)
112
+
113
+
114
+ def _lint_in_process(path: Path) -> list[str] | None:
115
+ """Lint via direct import. Returns None if the package isn't importable here."""
116
+ try:
117
+ from java_functional_lsp.analyzers.base import is_excluded
118
+ from java_functional_lsp.cli import check_file, format_diagnostic, load_config
119
+ except ImportError:
120
+ return None
121
+ config = load_config(path)
122
+ if is_excluded(path.as_posix(), config.get("excludes", [])):
123
+ return []
124
+ return [format_diagnostic(path, d) for d in check_file(path, config)]
125
+
126
+
127
+ def _lint_via_cli(path: Path) -> list[str]:
128
+ """Fallback: shell out to the installed CLI (exit 1 + stdout lines on violations)."""
129
+ proc = subprocess.run(
130
+ ["java-functional-lsp", "check", str(path)],
131
+ capture_output=True,
132
+ text=True,
133
+ timeout=TIMEOUT_SECONDS,
134
+ check=False, # exit 1 just means violations were found
135
+ )
136
+ return [ln for ln in proc.stdout.splitlines() if ln.strip()]
137
+
138
+
139
+ def main() -> None:
140
+ started = time.monotonic()
141
+ hook_input = json.load(sys.stdin)
142
+ file_path = (hook_input.get("tool_input") or {}).get("file_path", "")
143
+ if not file_path.endswith(".java"):
144
+ return # silent no-op
145
+ path = Path(file_path)
146
+ if not path.is_file():
147
+ return # tool call may have failed or the file was deleted
148
+
149
+ lines = _lint_in_process(path)
150
+ if lines is None:
151
+ lines = _lint_via_cli(path)
152
+ try:
153
+ budget = TIMEOUT_SECONDS - EXIT_MARGIN_SECONDS - (time.monotonic() - started)
154
+ _wait_for_fresh_diagnostics(path, budget=budget)
155
+ except Exception:
156
+ pass # a marker problem must never suppress the lint output below
157
+ if not lines:
158
+ return # clean file: stay silent, no per-edit context noise
159
+
160
+ if len(lines) > MAX_DIAGNOSTICS:
161
+ lines = [*lines[:MAX_DIAGNOSTICS], f"... and {len(lines) - MAX_DIAGNOSTICS} more"]
162
+ json.dump(
163
+ {
164
+ "hookSpecificOutput": {
165
+ "hookEventName": "PostToolUse",
166
+ "additionalContext": (
167
+ "java-functional-lsp found violations in the file you just edited:\n"
168
+ + "\n".join(lines)
169
+ + "\nFix each violation now with your next Edit. Do not explain or list them."
170
+ ),
171
+ }
172
+ },
173
+ sys.stdout,
174
+ )
175
+
176
+
177
+ if __name__ == "__main__":
178
+ if hasattr(signal, "SIGALRM"): # POSIX hard runtime cap
179
+ signal.signal(signal.SIGALRM, lambda *_: sys.exit(0))
180
+ signal.alarm(TIMEOUT_SECONDS)
181
+ try:
182
+ main()
183
+ except Exception:
184
+ sys.exit(0) # hooks must never break the session
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "java-functional-lsp"
7
- version = "0.12.0"
7
+ version = "0.13.0"
8
8
  description = "Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -65,6 +65,8 @@ python_classes = ["Test*"]
65
65
  python_functions = ["test_*"]
66
66
  addopts = "--cov=java_functional_lsp --cov-report=term-missing --cov-fail-under=80"
67
67
  asyncio_mode = "auto"
68
+ # Safety net so a hung await fails the test instead of the CI job; e2e tests set their own.
69
+ timeout = 120
68
70
  markers = [
69
71
  "e2e: end-to-end tests that spawn a real jdtls subprocess (require jdtls + Java 21+; skipped when unavailable)",
70
72
  ]
@@ -1,3 +1,3 @@
1
1
  """java-functional-lsp: A Java LSP server enforcing functional programming best practices."""
2
2
 
3
- __version__ = "0.12.0"
3
+ __version__ = "0.13.0"
@@ -0,0 +1,78 @@
1
+ """Per-file "diagnostics are final" markers for the Claude Code PostToolUse hook (#109).
2
+
3
+ Claude Code collects LSP diagnostics right after its PostToolUse hooks finish, without
4
+ waiting for the server. When the server publishes final diagnostics for a file (fresh
5
+ jdtls results merged in) it records the server pid and a hash of the content it analyzed;
6
+ ``hooks/post_tool_lint.py`` waits until the hash matches the file on disk, so the fresh
7
+ set lands in the same tool result. A hash (not a time) means a publish for an older
8
+ version of the file can never satisfy the hook, and the pid lets the hook skip waiting
9
+ when the server that wrote the marker is gone.
10
+
11
+ The hook keeps its own copy of :func:`marker_path` and of the directory checks (it may run
12
+ without this package importable); tests keep the two in sync.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import getpass
18
+ import hashlib
19
+ import os
20
+ import stat
21
+ import tempfile
22
+ from pathlib import Path
23
+
24
+ #: Written at didOpen: never equals a content hash, so the hook waits for jdtls's first result.
25
+ OPENING = "opening"
26
+
27
+ _validated: set[Path] = set()
28
+
29
+
30
+ def _user() -> str:
31
+ try:
32
+ return getpass.getuser()
33
+ except Exception: # containers without a passwd entry: KeyError (<3.13) / OSError (3.13+)
34
+ return str(os.getuid()) if hasattr(os, "getuid") else "user"
35
+
36
+
37
+ def marker_dir() -> Path:
38
+ return Path(tempfile.gettempdir()) / f"java-functional-lsp-{_user()}" / "fresh"
39
+
40
+
41
+ def marker_path(file_path: str) -> Path:
42
+ digest = hashlib.sha256(os.path.realpath(file_path).encode()).hexdigest()[:32]
43
+ return marker_dir() / digest
44
+
45
+
46
+ def content_digest(content: bytes) -> str:
47
+ return hashlib.sha256(content).hexdigest()
48
+
49
+
50
+ def _ensure_private_dir(path: Path) -> bool:
51
+ """Create *path* if needed; True only for a real directory we own with no group/other access."""
52
+ path.mkdir(mode=0o700, exist_ok=True)
53
+ st = os.lstat(path) # lstat: a symlink is never S_ISDIR
54
+ if not stat.S_ISDIR(st.st_mode) or (hasattr(os, "getuid") and st.st_uid != os.getuid()):
55
+ return False
56
+ if st.st_mode & 0o077:
57
+ os.chmod(path, 0o700)
58
+ return True
59
+
60
+
61
+ def write_marker(file_path: str, digest: str) -> None:
62
+ """Record that final diagnostics for *file_path* were published for content *digest*. Never raises."""
63
+ try:
64
+ directory = marker_dir()
65
+ if directory not in _validated:
66
+ # Check each level before creating below it, so nothing lands behind a
67
+ # symlink planted in a shared temp dir.
68
+ if not (_ensure_private_dir(directory.parent) and _ensure_private_dir(directory)):
69
+ return
70
+ _validated.add(directory)
71
+ target = marker_path(file_path)
72
+ tmp = target.with_name(f"{target.name}.{os.getpid()}.tmp")
73
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
74
+ with os.fdopen(os.open(tmp, flags, 0o600), "w") as f:
75
+ f.write(f"{os.getpid()} {digest}")
76
+ os.replace(tmp, target)
77
+ except Exception:
78
+ _validated.clear()
@@ -57,6 +57,9 @@ _DEFAULT_JDTLS_SETTINGS: dict[str, Any] = {
57
57
  #: Java 21"`` before the server even starts.
58
58
  _MIN_JDTLS_JAVA_MAJOR = 21
59
59
 
60
+ #: Distinct jdtls→client request methods tracked individually; the rest count as "<other>".
61
+ _MAX_DROPPED_METHODS = 50
62
+
60
63
  #: Matches the first version token in ``java -version`` output. Handles:
61
64
  #: - modern format: ``openjdk version "21.0.10" 2026-01-20``
62
65
  #: - legacy Java 8: ``openjdk version "1.8.0_452"`` (captures ``1``; caller
@@ -788,14 +791,24 @@ def _build_effective_params(
788
791
  class JdtlsProxy:
789
792
  """Manages a jdtls subprocess and provides async request/notification forwarding."""
790
793
 
791
- def __init__(self, on_diagnostics: Callable[[str, list[Any]], None] | None = None) -> None:
794
+ def __init__(
795
+ self,
796
+ on_diagnostics: Callable[[str, list[Any]], None] | None = None,
797
+ uri_key: Callable[[str], str] = lambda uri: uri,
798
+ on_stopped: Callable[[], None] | None = None,
799
+ ) -> None:
792
800
  self._process: asyncio.subprocess.Process | None = None
793
801
  self._reader_task: asyncio.Task[None] | None = None
794
802
  self._stderr_task: asyncio.Task[None] | None = None
795
803
  self._next_id: int = 1
796
804
  self._pending: dict[int, asyncio.Future[Any]] = {}
805
+ # Keyed by uri_key(uri): jdtls may echo a URI in a different encoding than
806
+ # the client sent, so lookups must go through the same canonical key.
797
807
  self._diagnostics_cache: dict[str, list[Any]] = {}
808
+ self._uri_key = uri_key
798
809
  self._on_diagnostics = on_diagnostics
810
+ self._on_stopped = on_stopped
811
+ self._dropped_request_counts: dict[str, int] = {}
799
812
  self._available = False
800
813
  self._jdtls_capabilities: dict[str, Any] = {}
801
814
  # Lazy-start state
@@ -834,7 +847,19 @@ class JdtlsProxy:
834
847
 
835
848
  def get_cached_diagnostics(self, uri: str) -> list[Any]:
836
849
  """Get the latest jdtls diagnostics for a URI."""
837
- return list(self._diagnostics_cache.get(uri, []))
850
+ return list(self._diagnostics_cache.get(self._uri_key(uri), []))
851
+
852
+ def _mark_stopped(self) -> None:
853
+ """Drop state that belongs to the dead jdtls process and notify the owner."""
854
+ if not self._available and not self._diagnostics_cache:
855
+ return # already handled (reader EOF, then stop())
856
+ self._available = False
857
+ cached = len(self._diagnostics_cache)
858
+ self._diagnostics_cache.clear()
859
+ if cached:
860
+ logger.info("jdtls stopped: cleared cached diagnostics for %d files", cached)
861
+ if self._on_stopped:
862
+ self._on_stopped()
838
863
 
839
864
  def check_available(self) -> bool:
840
865
  """Check if jdtls is on PATH (lightweight, no subprocess started)."""
@@ -929,6 +954,8 @@ class JdtlsProxy:
929
954
 
930
955
  self._jdtls_capabilities = result.get("capabilities", {})
931
956
  logger.info("jdtls initialized (capabilities: %s)", list(self._jdtls_capabilities.keys()))
957
+ server_info = result.get("serverInfo") or {}
958
+ logger.info("jdtls server: %s %s", server_info.get("name", "?"), server_info.get("version", "?"))
932
959
 
933
960
  await self.send_notification("initialized", {})
934
961
  self._available = True
@@ -1170,7 +1197,7 @@ class JdtlsProxy:
1170
1197
 
1171
1198
  async def stop(self) -> None:
1172
1199
  """Shutdown jdtls subprocess gracefully."""
1173
- self._available = False
1200
+ self._mark_stopped()
1174
1201
 
1175
1202
  if self._reader_task and not self._reader_task.done():
1176
1203
  self._reader_task.cancel()
@@ -1258,7 +1285,7 @@ class JdtlsProxy:
1258
1285
  msg = await read_message(reader)
1259
1286
  if msg is None:
1260
1287
  logger.warning("jdtls stdout closed — subprocess may have exited")
1261
- self._available = False
1288
+ self._mark_stopped()
1262
1289
  break
1263
1290
 
1264
1291
  self._dispatch_message(msg)
@@ -1266,7 +1293,7 @@ class JdtlsProxy:
1266
1293
  pass
1267
1294
  except Exception as e:
1268
1295
  logger.error("jdtls reader loop error: %s", e)
1269
- self._available = False
1296
+ self._mark_stopped()
1270
1297
 
1271
1298
  async def _stderr_reader(self, stderr: asyncio.StreamReader) -> None:
1272
1299
  """Background task: log jdtls stderr output for debugging.
@@ -1311,6 +1338,20 @@ class JdtlsProxy:
1311
1338
  elif "method" in msg and "id" not in msg:
1312
1339
  # Notification from jdtls
1313
1340
  self._handle_notification(msg)
1341
+ elif "method" in msg:
1342
+ self._note_dropped_request(str(msg["method"]))
1343
+
1344
+ def _note_dropped_request(self, method: str) -> None:
1345
+ """Count jdtls→client requests the proxy leaves unanswered (method names only, never params)."""
1346
+ method = method[:100]
1347
+ if method not in self._dropped_request_counts and len(self._dropped_request_counts) >= _MAX_DROPPED_METHODS:
1348
+ method = "<other>"
1349
+ count = self._dropped_request_counts.get(method, 0) + 1
1350
+ self._dropped_request_counts[method] = count
1351
+ if count == 1:
1352
+ logger.info("jdtls request %r dropped (no client-side handler)", method)
1353
+ else:
1354
+ logger.debug("jdtls request %r dropped (%d times)", method, count)
1314
1355
 
1315
1356
  def _handle_notification(self, msg: dict[str, Any]) -> None:
1316
1357
  """Handle a notification from jdtls."""
@@ -1320,7 +1361,7 @@ class JdtlsProxy:
1320
1361
  if method == "textDocument/publishDiagnostics":
1321
1362
  uri = params.get("uri", "")
1322
1363
  diagnostics = params.get("diagnostics", [])
1323
- self._diagnostics_cache[uri] = diagnostics
1364
+ self._diagnostics_cache[self._uri_key(uri)] = diagnostics
1324
1365
  if self._on_diagnostics:
1325
1366
  self._on_diagnostics(uri, diagnostics)
1326
1367
  # Other notifications (window/logMessage, etc.) are silently ignored