Skip to content

feat(evaluation): local-project classpath fallback for expression eval#33

Merged
novoj merged 10 commits into
mainfrom
feat/local-project-classpath
May 28, 2026
Merged

feat(evaluation): local-project classpath fallback for expression eval#33
novoj merged 10 commits into
mainfrom
feat/local-project-classpath

Conversation

@novoj
Copy link
Copy Markdown
Contributor

@novoj novoj commented May 28, 2026

Summary

  • Adds LocalProjectClasspathProvider as an additive fallback to fill gaps the remote ClasspathDiscoverer walk cannot see (Tomcat / Spring Boot dev-tools / custom URLClassLoaders that hide JARs from getURLs()).
  • Three composable sources: JDWP_EXTRA_CLASSPATH env var, depth-5 filesystem scan for target/classes + target/test-classes, Maven dependency:build-classpath. Synchronized + memoized — Maven runs at most once per JDI connection; reset() ties cache lifecycle to the connection edge.
  • ProcessBuilderMavenRunner shells out with 180s timeout, 64KB stdout capture for diagnosable WARN logs, daemon drainer so the child never blocks on pipe buffer. InterruptedException destroys the child and restores the interrupt flag.
  • jdwp_diagnose shows a per-source breakdown so operators can inspect what was scanned. Cold-cache diagnose uses a non-blocking peek so it never triggers the 180s Maven call.

Closes #32.

File-safety invariant

  • Zero Files.delete* / Files.move* in production code (grep-verified).
  • Maven output stays under target/.jdwp-mcp-classpath (Maven-owned, gitignored, wiped by mvn clean).
  • Harvester accepts a file ONLY when its direct parent dir is named target/. A .jdwp-mcp-classpath anywhere else is ignored.
  • Symlinks not followed by either the filesystem walk or the harvester.

Resolution semantics

Remote-first union ordering means live target VM types win JDT resolution. Local entries only contribute classes the remote walk could not see, mitigating source/binary drift between the developer's checkout and the deployed target.

Tests

+23 new tests across 7 files; 4 evaluator-test setUp blocks refactored onto a shared LocalProjectClasspathProviderTestSupport helper.

Area Count
Env-var override 5
Filesystem scan (depth boundary, SKIP_DIRS, target/test-classes independence) 7
Maven invocation (flags, output-file safety, 180s timeout, empty pom.xml) 7
Memoization + concurrency + breakdown 8
Process-leak on interrupt, harvester SKIP_DIRS, mvnw.cmd, file-safety 6
Diagnose section (cold cache, env-state, pom-presence, error path) 7

Module sweep: 1059 tests, 0 failures, 3 skipped (2 pre-existing TOCTOU race tests + 1 @EnabledOnOs(WINDOWS) Maven-wrapper test).

Test plan

  • Attach the MCP server (launched from a Maven project directory) to a Spring Boot target.
  • Run jdwp_diagnose before any expression evaluation → confirm "Local project classpath" section renders with "(not yet computed)" fast-path; diagnose returns immediately.
  • Set a breakpoint, hit it, run jdwp_evaluate_expression "1+1" → confirm it succeeds even on a target VM where remote discovery is sparse.
  • Run jdwp_diagnose again → confirm full breakdown now appears with non-zero filesystem= and maven= counts.
  • Set JDWP_EXTRA_CLASSPATH=/some/jar:/another/path, reconnect, repeat → confirm env-override appears in breakdown and "Override env: JDWP_EXTRA_CLASSPATH (set)".
  • Launch the MCP server from a non-Maven directory (e.g. /tmp), reconnect → confirm local breakdown shows zeros; remote-only discovery still works for projects where the target VM's classloader walk is healthy.
  • Optional: cold-cache ~/.m2 to verify Maven timeout and WARN-log behavior on slow first run.

… eval

Expression evaluation fails with "X cannot be resolved" when the target
JVM's classloader hierarchy is opaque to JDI (Tomcat / Spring Boot
dev-tools / custom URLClassLoaders that hide JARs from getURLs()). Even
trivial expressions like `1+1` crash when the wrapper's _this type can't
be resolved.

Adds LocalProjectClasspathProvider as an additive fallback unioned into
JdiExpressionEvaluator.configureCompilerClasspath. Three composable
sources: JDWP_EXTRA_CLASSPATH env var, depth-5 filesystem scan for
target/classes + target/test-classes, and Maven dependency:build-classpath.
Synchronized memoization keeps Maven runs to at most one per JDI
connection; reset() ties cache lifetime to the connection edge that
already wipes the compiler.

ProcessBuilderMavenRunner shells out with a 180s timeout (cold-cache mvn
may download dependencies), captures 64KB of stdout for diagnosable WARN
logs on failure, and uses a daemon drainer so the child never blocks on
pipe buffer. InterruptedException destroys the child and restores the
interrupt flag.

File-safety invariant: zero Files.delete*/Files.move* in production code;
Maven output stays in target/.jdwp-mcp-classpath (Maven-owned, gitignored);
harvester gates on direct parent being target/; symlinks not followed.

jdwp_diagnose exposes a per-source breakdown so operators can inspect
what was scanned. Cold-cache diagnose uses peekCachedBreakdown() so it
never triggers the 180s Maven call — discovery happens organically on
first expression evaluation. Three-way env state: (unset) / (set, no
entries) / (set).

Closes #32
Copilot AI review requested due to automatic review settings May 28, 2026 13:28
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a local-project classpath fallback to expression evaluation so ECJ/JDT compilation can resolve types even when the target VM’s classloader walk is sparse/opaque (e.g., Tomcat / Spring Boot dev-tools). This integrates a new provider into the evaluator’s classpath configuration and surfaces diagnostics via jdwp_diagnose, with extensive accompanying tests and documentation.

Changes:

  • Introduces LocalProjectClasspathProvider (env override + filesystem scan + Maven dependency classpath) and unions it with remote-discovered classpath in JdiExpressionEvaluator.
  • Adds ProcessBuilderMavenRunner to run Maven with timeout/stdout draining and harvest target/.jdwp-mcp-classpath outputs.
  • Expands jdwp_diagnose output + docs and adds a broad test suite around the new behavior.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
skills/java-debug/references/troubleshooting.md Adds troubleshooting guidance for “X cannot be resolved” eval failures and operator steps.
README.md Documents the new LocalProjectClasspathProvider in the architecture overview.
docs/expression-evaluation.md Detailed design/behavior docs for the local-project classpath fallback.
jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java Wires provider into tools and renders a local-classpath diagnose block.
jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java Unions remote + local classpath for ECJ configuration; resets local cache on reconnect.
jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/LocalProjectClasspathProvider.java Implements the local-project classpath discovery (env/filesystem/Maven) with caching/breakdown.
jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/ProcessBuilderMavenRunner.java Implements Maven shell-out + harvesting of output files under target/.
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsTestSupport.java Updates test scaffolding for new JDWPTools constructor/provider dependency.
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsDiagnoseTest.java Adds assertions for the new diagnose “Local project classpath” section.
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/ProcessBuilderMavenRunnerTest.java Tests harvesting rules, no-delete policy, interrupt behavior, and Windows wrapper selection.
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/LocalProjectClasspathProviderTestSupport.java Adds helper for constructing provider instances in tests.
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/LocalProjectClasspathProviderMavenTest.java Tests Maven command shape, wrapper preference, timeout, and pom presence handling.
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/LocalProjectClasspathProviderFilesystemScanTest.java Tests scan depth boundary and skip directory behavior for target/classes discovery.
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/LocalProjectClasspathProviderEnvOverrideTest.java Tests env parsing (separator, trimming, blanks) and breakdown semantics.
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/LocalProjectClasspathProviderCacheTest.java Tests memoization/reset, concurrency behavior, and breakdown immutability/peek semantics.
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluatorLocalClasspathUnionTest.java Tests remote-first union ordering, dedupe, empty-remote behavior, and Windows-path heuristic.
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluatorConfigureClasspathTest.java Updates evaluator tests to inject a provider seam.
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluatorGetDeclaredTypeTest.java Updates evaluator tests to inject a provider seam.
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluatorAbsentLocalsTest.java Updates evaluator tests to inject a provider seam.
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluatorPackageFallbackTest.java Updates evaluator tests to inject a provider seam.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java Outdated
Comment thread jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java Outdated
Comment thread jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java Outdated
Comment thread jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsTestSupport.java Outdated
Symlink invariants
- Filesystem scan now passes LinkOption.NOFOLLOW_LINKS to every isDirectory
  probe and rejects symlinked children before recursing, so the documented
  "symlinks not followed" rule actually holds.
- Harvester adds Files.isRegularFile(p, NOFOLLOW_LINKS) to its filter chain,
  so a symlink at target/.jdwp-mcp-classpath cannot be used to redirect the
  read at a file outside the project tree.

Process ownership
- Removed the run()-level descendant-tracking that used
  ProcessHandle.current().descendants() PID diff; that would forcibly kill
  any unrelated subprocess another server component happened to start in
  the same window. Cleanup responsibility is now scoped to the spawned
  Process inside executeRealCommand, which knows exactly which tree it
  owns. New destroyProcessTree() destroys the Maven process and its own
  descendants (Maven can fork helpers) without touching siblings.

Concurrency
- ByteArrayOutputStream is no longer relied on for cross-thread safety.
  An explicit monitor guards both the drainer's writes and the main
  thread's toString()/size() snapshots, so the WARN log on timeout sees a
  consistent buffer.

Cold-cache UX
- Diagnose env-state now renders three-way (unset)/(set, no entries)/(set)
  on cold cache as well, via a new envOverrideHasEntries() helper that
  parses the env var without triggering discovery. Stale "may trigger
  slow discovery" comments updated to match the actual non-blocking peek.

Test hygiene
- defaultEmptyClasspathProvider() and noOpProvider() now root the
  filesystem scan at a guaranteed-non-existent path so the scan
  short-circuits at the first isDirectory probe. Previously user.dir
  /java.io.tmpdir leaked unrelated target/classes trees into tests.
- Same fix applied to stubLocalProvider() in the union test.
- shouldDestroyProcessOnInterrupt replaced by two tests that exercise
  the real invariants: run() must NOT kill unrelated subprocesses,
  executeRealCommand DOES destroy its own process tree.
- Added explicit symlink tests for both filesystem scan and harvest.

Hardening
- LocalProjectClasspathProvider default constructor null-guards
  System.getProperty("user.dir") with a "." fallback so a sandboxed JVM
  launch can still construct the bean.

Module sweep: 1062 tests, 0 failures, 3 skipped.
@novoj
Copy link
Copy Markdown
Contributor Author

novoj commented May 28, 2026

Addressed all 10 review findings in 90edc54. Mapping:

# Finding Fix
1 Path.of(System.getProperty("user.dir")) could NPE in a sandboxed JVM Objects.toString(..., ".") fallback in default constructor
2 Filesystem scan followed symlinks (contradicts documented invariant) LinkOption.NOFOLLOW_LINKS on every isDirectory probe; symlinked children rejected before recursing
3 destroyNewDescendants killed unrelated subprocesses by PID diff Removed PID-diff cleanup from run(). New destroyProcessTree(Process) in executeRealCommand destroys only the spawned Maven process and its own descendants — scope-correct
4 Harvester followed symlinks at target/.jdwp-mcp-classpath Added Files.isRegularFile(p, NOFOLLOW_LINKS) to the filter chain
5 ByteArrayOutputStream race between drainer and main thread Explicit monitor; snapshotCaptured() / snapshotSize() helpers read under the same lock the drainer writes under
6 Stale Javadoc on localClasspathProvider field Updated to describe the actual non-blocking peek behavior
7 Stale comment in buildFullDiagnosticReport Same — now documents NON-BLOCKING + cold-cache hint
8 Cold-cache env state only 2-way (set)/(unset) New envOverrideHasEntries() parses env without triggering discovery; cold path now also renders 3-way (unset) / (set, no entries) / (set). Extracted as renderEnvState(Breakdown) for both warm/cold paths
9 defaultEmptyClasspathProvider() actually DID scan user.dir Now roots scan at a guaranteed-non-existent path so the depth-5 walk short-circuits at the first isDirectory probe
10 noOpProvider() used java.io.tmpdir which can contain unrelated target/classes Same fix as #9; also applied to stubLocalProvider() in the union test (which was hitting /tmp/evita-dev-pr1195/... on the dev host)

Additional tests added:

Removed the old shouldDestroyProcessOnInterrupt test — it relied on the over-killing PID-diff behavior that #3 fixes.

Module sweep: 1062 tests, 0 failures, 3 skipped (2 pre-existing TOCTOU + 1 Windows-only conditional). Compile clean; no new NullAway warnings.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.

- Harvester fixture uses File.pathSeparator instead of hard-coded ':'
  so the test runs correctly on Windows.
- shouldNotOverkillUnrelatedSubprocessesOnInterrupt gated with
  @EnabledOnOs({LINUX, MAC}) — the sibling-process trick relies on the
  external `sleep` command which Windows does not ship by default.
- Env-override tests no longer root the provider at /tmp/no-such-project
  (not guaranteed absent on every host); use a guaranteed-non-existent
  random path so the depth-5 filesystem scan short-circuits cleanly.
@novoj
Copy link
Copy Markdown
Contributor Author

novoj commented May 28, 2026

Addressed all 3 new portability findings in the latest commit:

File:Line Finding Fix
ProcessBuilderMavenRunnerTest.java:35 Hard-coded : separator broke harvester fixture on Windows Switched to String.join(File.pathSeparator, ...)
ProcessBuilderMavenRunnerTest.java:103 shouldNotOverkillUnrelatedSubprocessesOnInterrupt used sleep (Unix-only) Added @EnabledOnOs({LINUX, MAC}) — matches the existing pattern on shouldDestroyOwnedProcessTreeOnTimeout and the symlink tests
LocalProjectClasspathProviderEnvOverrideTest.java:35 /tmp/no-such-project not guaranteed absent Replaced with NONEXISTENT_CWD constant — a UUID-based path so the depth-5 scan short-circuits at the first isDirectory probe

Module sweep: 1062 tests, 0 failures, 3 skipped.

resolveMavenExecutable:
- Skip the Unix `mvnw` probe on Windows. Files.isExecutable is ACL-based
  on Windows and may report a bare-name Unix shell script as executable,
  but ProcessBuilder can't actually invoke a Unix script on Windows. Now
  Windows only probes `mvnw.cmd`, then falls back to PATH `mvn` (which
  Windows resolves via PATHEXT).

Test paths:
- Replaced `Path.of("/nonexistent/...")` in 4 test seam helpers with
  `Path.of(System.getProperty("java.io.tmpdir"), "jdwp-mcp-...-UUID")`.
  Functionally equivalent on Linux/macOS (path that doesn't exist),
  but uses the platform-correct temp dir as the parent on every OS.
@novoj
Copy link
Copy Markdown
Contributor Author

novoj commented May 28, 2026

Cross-OS portability audit

Reviewed every production and test file touched in this PR for Linux / macOS / Windows correctness. Two real issues found and fixed; the rest is already portable.

Fixed in latest commit

Location Issue Fix
LocalProjectClasspathProvider.resolveMavenExecutable Files.isExecutable(mvnw) on Windows is ACL-based and may report a bare-name Unix shell script as executable, but ProcessBuilder cannot actually launch a Unix script on Windows Skip the Unix mvnw probe entirely on Windows — only probe mvnw.cmd, then fall back to PATH mvn (which Windows resolves via PATHEXT)
4 test seam helpers using Path.of("/nonexistent/...") Unix-flavored absolute path; on Windows it parses to \nonexistent\... (relative to current drive) — works in practice, but stylistically wrong Switched to Path.of(System.getProperty("java.io.tmpdir"), "jdwp-mcp-...-" + UUID) — uses the platform-correct temp dir as parent; UUID-named child we never create ⇒ guaranteed-absent root for the depth-5 scan to short-circuit on

Audited and already portable

  • Pattern.quote(File.pathSeparator) for env-var parsing — uses host separator on read and write
  • mergeClasspaths / splitRemoteClasspath / looksLikeSingleWindowsPath — handles ; separator, : separator, and single-entry Windows paths (C:\foo / C:/foo)
  • Path.resolve("target/classes")Path normalizes the / to the host's File.separator
  • process.descendants(), destroyForcibly(), waitFor — Java cross-platform
  • File-safety gate uses literal "target" — Maven convention is lowercase on every OS
  • SKIP_DIRS / HARVEST_SKIP_DIRS — same skip names work on all OSes (.git, .idea, node_modules, etc.)
  • Tests using sleep (shouldNotOverkillUnrelatedSubprocessesOnInterrupt, shouldDestroyOwnedProcessTreeOnTimeout) — @EnabledOnOs({LINUX, MAC})
  • Tests using Files.createSymbolicLink (shouldNotFollowSymlinkAtHarvestPath, shouldNotFollowSymlinkedDirectoriesDuringScan) — @EnabledOnOs({LINUX, MAC}) (Windows symlinks need admin / dev mode)
  • shouldPreferMvnwCmdOnWindows@EnabledOnOs(WINDOWS), so it actually runs the Windows-only path on a Windows runner

Documented tradeoffs (not fixed — accepted)

  • Hidden-dir detection via startsWith(".") works for the dot-prefixed convention every project uses (.gradle, .idea, .git). On Windows, an attribute-hidden dir without a leading dot (e.g. $RECYCLE.BIN) would NOT be skipped, but the harvester's target/ parent-name gate makes that harmless — the depth-5 walk wouldn't pick up a .jdwp-mcp-classpath file from such a dir unless it sat directly under a target/ subdir there.
  • Test-fixture content includes Unix-style /m2/foo.jar strings — they're test data representing classpath entries (strings, not on-disk paths). Portable as strings.

Module sweep: 1062 tests, 0 failures, 3 skipped.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 2 comments.

Harvester I/O
- Rewrote harvestOutputFiles using Files.walkFileTree with
  preVisitDirectory pruning. Skip-listed directories (node_modules, .git,
  .idea, .gradle, .mvn, .vscode) now return SKIP_SUBTREE on descent so
  their contents are never enumerated. On large repos the previous
  Files.walk(root, 5) + post-filter could enumerate tens of thousands of
  files inside node_modules/ alone before discarding them.
- Symlink rejection now uses attrs.isRegularFile() from the visitor's
  BasicFileAttributes (no FOLLOW_LINKS option) instead of an extra
  Files.isRegularFile call.
- Extracted readOutputFile helper so the visitor body stays focused.

Merge log accuracy
- The previous INFO line said "{} local-only entries" but reported
  localEntries.size(), which includes entries that overlap with the
  remote classpath and are deduped during merge. Now reports three
  numbers: "{} remote + {} local entries → {} merged" so an operator can
  see the overlap explicitly (merged < remote + local).
@novoj
Copy link
Copy Markdown
Contributor Author

novoj commented May 28, 2026

Addressed both new findings.

Location Finding Fix
ProcessBuilderMavenRunner.harvestOutputFiles Files.walk(root, 5) descends into node_modules/, .git/, etc. and filters them out post-traversal — wasteful on large repos Rewrote using Files.walkFileTree + preVisitDirectory returning SKIP_SUBTREE for any directory in HARVEST_SKIP_DIRS. Skip-listed trees are never enumerated. Symlink check now uses attrs.isRegularFile() from the visitor's BasicFileAttributes (no FOLLOW_LINKS), saving an extra stat call. hasSkippedAncestor removed — pruning makes it unreachable
JdiExpressionEvaluator.configureCompilerClasspath:1206 INFO log said "{} local-only entries" but reported localEntries.size() including overlaps Reports three numbers now: "{} remote + {} local entries → {} merged". An operator can see the overlap explicitly (merged < remote + local when local rebuilds duplicate live target JARs)

1062 tests / 0 failures / 3 skipped.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.

Comment thread jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsDiagnoseTest.java Outdated
- Harvester walk cap is now scan-depth + 2 (= 7) so modules at the
  filesystem-scan boundary still expose their target/.jdwp-mcp-classpath.
- Every pom.xml probe (hasPomAtRoot, Maven gate, empty-result log) now
  uses LinkOption.NOFOLLOW_LINKS for consistency with the rest of the
  provider's symlink-not-followed invariant.
- Realigned the misleading "temp dir + pom.xml fixture" comment in the
  cold-cache diagnose test.

Regression tests:
- target/.jdwp-mcp-classpath at <root>/a/b/c/d/e/target (depth 7).
- Symlinked pom.xml at the working directory must not trigger Maven.
@novoj
Copy link
Copy Markdown
Contributor Author

novoj commented May 28, 2026

Addressed all three findings from the latest Copilot review in commit 8f23 (push of feat/local-project-classpath):

# Finding Fix
1 harvestOutputFiles maxDepth of 5 misses files for reactor modules at the scan boundary (<root>/a/b/c/d/e/target/.jdwp-mcp-classpath = depth 7) Bumped to HARVEST_MAX_DEPTH = 7 (scan-depth + 2). New constant is documented alongside the provider's MAX_SCAN_DEPTH so the two limits stay coupled. Regression test: shouldHarvestOutputFileAtReactorBoundaryDepth writes the fixture at the exact depth-7 boundary.
2 hasPomAtRoot() (and other pom.xml probes) follows symlinks, inconsistent with the "no symlinks" invariant Every pom.xml and target/ probe now passes LinkOption.NOFOLLOW_LINKS: hasPomAtRoot(), the Maven-source gate in addMavenDependencies, and the empty-result diagnose log. Regression test: shouldSkipMavenWhenPomXmlIsSymlink (Linux/macOS only — Windows can't create symlinks without elevated rights) asserts both the runner is not invoked AND hasPomAtRoot() returns false.
3 Misleading comment in shouldRenderLocalClasspathSectionWithoutBlockingOnColdCache claims a pom.xml fixture is written into a temp dir, but the test uses user.dir and writes no fixture Rewrote the comment to describe what the test actually does: the CWD is the real project root (so the Maven path WOULD be reached if discovery ran), and the assertion is precisely that diagnose's cold-cache peek skips the slow runner. The 500ms budget catches a regression.

Build: 1064 tests, 0 failures, 3 skipped on Linux JDK 17.

Resolved all three review threads.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 2 comments.

- resolveMavenExecutable now uses LinkOption.NOFOLLOW_LINKS on both the
  Windows mvnw.cmd probe and the Unix mvnw probe. A symlinked wrapper
  could redirect Maven execution at a script outside the working
  directory, defeating the symlinks-never-followed invariant the rest
  of the provider enforces.
- destroyProcessTree wraps process.descendants() in try/catch and moves
  the root destroyForcibly() into finally. Under restricted security
  policies descendants() can throw; with the previous flow that would
  leave the Maven subprocess running — exactly the leak the cleanup
  exists to prevent.

Regression tests:
- Symlinked mvnw at the working directory falls back to PATH 'mvn'.
- Stub Process whose descendants() throws still gets destroyForcibly()d.
@novoj
Copy link
Copy Markdown
Contributor Author

novoj commented May 28, 2026

Addressed both findings from the latest Copilot review:

# Finding Fix
1 resolveMavenExecutable() follows symlinks on mvnw / mvnw.cmd, breaking the symlinks-not-followed invariant the rest of the provider enforces Both probes now gate on Files.isRegularFile(..., LinkOption.NOFOLLOW_LINKS). On Unix the gate runs before Files.isExecutable() so the executable bit is only consulted once we have proven the entry is a real file. A symlinked wrapper is rejected, and the provider falls back to PATH mvn. Regression test: shouldRejectSymlinkedMvnwAndFallBackToPathMvn (Linux/macOS — Windows can't create symlinks without elevated rights).
2 destroyProcessTree assumes process.descendants().toList() cannot fail — under restricted security policies it throws and the root Maven process leaks Wrapped the descendant enumeration in try/catch and moved process.destroyForcibly() into finally. Descendant cleanup remains best-effort; root cleanup is guaranteed. Made the method package-private so it's directly testable. Regression test: shouldDestroyRootEvenIfDescendantsThrows exercises the path with a stub Process whose descendants() throws SecurityException.

Build: 1066 tests, 0 failures, 3 skipped on Linux JDK 17.

Resolved both review threads.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.

Address the latest Copilot review on PR #33 (commit d02c25d):

- prewarmClasspath() ran the full configureCompilerClasspath() from the
  JDI event-listener thread, which invokes the local provider and can
  shell out to Maven (180s timeout). The first breakpoint hit could thus
  stall event processing for minutes even if the user never evaluated
  anything. Split configure into a public (full) form and a private
  (thread, includeLocal) worker; prewarm passes includeLocal=false to
  warm only remote classpath + JDK detection. A new localClasspathMerged
  flag lets the first real evaluation/logpoint/condition fall through the
  short-circuit and complete the local merge on the MCP worker thread.
  The cache-reset block is guarded on !jdkReady so the deferred-merge
  pass never wipes valid remote discovery.

- Reword the "Maven contributed 0 jars" INFO line so zero jars no longer
  implies failure (a project with no runtime deps is a valid outcome).

- Move an orphaned Javadoc block onto the empty-pom test it describes.

Tests: update shouldShortCircuitWhenAlreadyConfigured for the new
"fully configured = JDK discovered AND local merged" semantics; add
prewarmDefersLocalMergeUntilFirstEvaluation. Document the deferral in
docs/expression-evaluation.md. Full suite: 1067 passing.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

novoj added 2 commits May 28, 2026 21:39
…nits

Independent full-PR review (my own reviewer agents) found one real bug
plus a few robustness/coverage gaps:

- BUG: scanForClassDirs probed dir.resolve("target/classes") with
  NOFOLLOW_LINKS, but NOFOLLOW only guards the FINAL path component —
  the intermediate `target` segment was still resolved through symlinks.
  A symlinked target/ pointing outside the project tree would pull
  external classes onto the compile classpath, breaching the documented
  "symlinks are never followed" invariant. Now probe `target` itself with
  NOFOLLOW first and only resolve its leaves once confirmed a real dir.
  Regression test added (shouldNotFollowSymlinkedTargetDirectory).

- Couple ProcessBuilderMavenRunner.HARVEST_MAX_DEPTH to
  LocalProjectClasspathProvider.MAX_SCAN_DEPTH at compile time (made the
  latter package-private) so a future scan-depth bump can't silently drop
  boundary modules — the prior "coupling" was comment-only on a literal 7.

- Harden the stdout drainer loop: `!= -1` instead of `> 0` so a spurious
  zero-length read can't exit early and re-introduce a pipe-block hang.

- Close a test gap: the cold-cache branch of renderEnvState
  (envOverrideHasEntries fallback) had no coverage. Added two cold-cache
  diagnose tests for (set) and (set, no entries).

Full suite: 1070 passing (+3 new).
…nostics

Knock down the three remaining minor review notes:

- destroyProcessTree awaited each descendant's onExit().get(2s) serially
  → O(N)×2s on a wide Maven fork tree, which could overrun the caller's
  cleanup budget. Combine all exit futures (descendants + root) and wait
  once via CompletableFuture.allOf, so worst-case stays ~2s regardless of
  tree width.

- Filesystem scan: Files.list() is lazy, so a permission failure during
  iteration surfaces as UncheckedIOException (wrapping AccessDeniedException)
  and missed the dedicated handler, logging the generic "could not list"
  wording. Unwrap it so a denied subtree gets the accurate "permission
  denied — skipping subtree" message.

- countEntries javadoc claimed it stays "in lockstep" with mergeClasspaths;
  that only holds same-OS. Reword to state the host-separator vs content-
  heuristic divergence is an accepted cosmetic imprecision (log-only, never
  drives control flow), not a correctness issue.

Full suite: 1070 passing.
@novoj novoj merged commit b7f4f8e into main May 28, 2026
1 check 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

Development

Successfully merging this pull request may close these issues.

Local-project classpath fallback for expression evaluation

2 participants