You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/10/13 09:31:39 UTC

(maven) branch master updated: [MNG-8650] Add mvnval, a tool to validate POMs without building them

This is an automated email from the ASF dual-hosted git repository.

gnodet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven.git


The following commit(s) were added to refs/heads/master by this push:
     new dabd7b20b4 [MNG-8650] Add mvnval, a tool to validate POMs without building them
dabd7b20b4 is described below

commit dabd7b20b4526da443b67d50708373ab3a1b20fa
Author: Ivan Khanas <xe...@altlinux.org>
AuthorDate: Tue Oct 13 11:31:33 2026 +0200

    [MNG-8650] Add mvnval, a tool to validate POMs without building them
    
    - Add `ModelBuilderSession.validate()` API method that collects all problems (errors and warnings) instead of throwing on the first error; declared default so existing implementations keep compiling unchanged
    - Add `mvnval` CLI tool reachable as `mvnval`, `mvn --val`, and from `mvnsh`; takes any number of POM files (defaults to `./pom.xml`), writes text or JSON output, and returns structured exit codes: 0=clean, 1=errors, 2=bad usage, 3=cancelled, 4=warnings-only
    - Two validation modes: `--mode raw` (no repository access, works offline and on unpublished POMs) and `--mode effective` (default, resolves parents and BOMs, runs all checks including those needing inherited values)
    - Bundle workspace reader for effective mode: pre-scans all given POMs to build a GAV index so parent/BOM lookups among the bundle are answered from disk, making the verdict order-independent and avoiding redundant downloads
    - Honour `-o`/`--offline` in effective mode; refuse `-s`/`-ps`/`-is` (cannot redirect the settings ApiRunner reads at session-creation time)
    - Security hardening: no core extensions loaded, no container stood up, no `maven.ext.class.path` honoured; `.mvn/jvm.config` still reaches the launcher (documented in THREAT_MODEL.md)
    - `--local-repository` and `--temp-local-repository` options to aim or isolate the local repository; throwaway directory deleted on exit and on SIGTERM
    - Text output shows coloured severity labels and surrounding XML source context around each problem location; JSON output keeps absolute paths
    - Deduplicate POMs named multiple times (normalise to real path via `toRealPath`); print paths relative to working directory in text output
    - Integration tests covering raw/effective validation, bundle workspace reader, offline mode, and `mvnsh` registration
---
 THREAT_MODEL.md                                    |   1 +
 apache-maven/src/assembly/component.xml            |   1 +
 apache-maven/src/assembly/maven/bin/mvn            |   7 +-
 apache-maven/src/assembly/maven/bin/mvn.cmd        |   5 +-
 apache-maven/src/assembly/maven/bin/mvnval         |  30 +
 apache-maven/src/assembly/maven/bin/mvnval.cmd     |  39 +
 api/maven-api-cli/src/main/java/module-info.java   |   1 +
 .../org/apache/maven/api/cli/ParserRequest.java    |  26 +
 .../main/java/org/apache/maven/api/cli/Tools.java  |  10 +
 .../maven/api/cli/mvnval/ValidateOptions.java      |  98 +++
 .../apache/maven/api/cli/mvnval/package-info.java} |  25 +-
 api/maven-api-cli/src/site/markdown/index.md       |   3 +-
 .../apache/maven/api/services/ModelBuilder.java    |  27 +
 .../java/org/apache/maven/cling/MavenValCling.java |  90 ++
 .../maven/cling/invoker/CommonsCliOptions.java     |   5 +
 .../BuiltinShellCommandRegistryFactory.java        |  22 +
 .../invoker/mvnval/BundleWorkspaceReader.java      | 115 +++
 .../invoker/mvnval/CommonsCliValidateOptions.java  | 211 +++++
 .../maven/cling/invoker/mvnval/JsonReporter.java   | 126 +++
 .../maven/cling/invoker/mvnval/OutputFormat.java   |  64 ++
 .../apache/maven/cling/invoker/mvnval/Report.java  | 114 +++
 .../maven/cling/invoker/mvnval/TextReporter.java   | 245 ++++++
 .../cling/invoker/mvnval/ValidateContext.java      |  30 +-
 .../cling/invoker/mvnval/ValidateInvoker.java      | 735 +++++++++++++++++
 .../maven/cling/invoker/mvnval/ValidateParser.java |  26 +-
 .../maven/cling/invoker/mvnval/ValidationMode.java | 120 +++
 .../maven/cling/invoker/mvnval/package-info.java   |  23 +-
 .../mvnval/CommonsCliValidateOptionsTest.java      | 195 +++++
 .../cling/invoker/mvnval/OutputFormatTest.java     |  71 ++
 .../maven/cling/invoker/mvnval/ReportTest.java     | 124 +++
 .../maven/cling/invoker/mvnval/ReporterTest.java   | 300 +++++++
 .../maven/cling/invoker/mvnval/TestUtils.java      |  98 +++
 .../cling/invoker/mvnval/ValidateInvokerTest.java  | 908 +++++++++++++++++++++
 .../maven/impl/model/DefaultModelBuilder.java      | 109 ++-
 .../maven/impl/model/RawModelReadingTest.java      | 228 ++++++
 its/core-it-suite/pom.xml                          |   6 +-
 .../it/MavenITgh10442MvnvalValidatePomTest.java    | 109 +++
 ...enITmng8650MvnvalBundleWorkspaceReaderTest.java | 149 ++++
 .../mvnval-validate-pom/bundle-child/pom.xml       |  35 +
 .../mvnval-validate-pom/bundle-parent/pom.xml      |  33 +
 .../resources/mvnval-validate-pom/clean/pom.xml    |  26 +
 .../duplicate-dependency/pom.xml                   |  38 +
 42 files changed, 4544 insertions(+), 84 deletions(-)

diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index f9aa4c5aa5..a56df1bdcf 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -166,6 +166,7 @@ Maven's inputs split cleanly into **operator-supplied (trusted)** and **reposito
 | **Consumer-POM transform input** (`mvn deploy`) | **4.x only** | operator project → published | source **no**; the *published* result is a new surface for downstream | verify what you publish matches intent *(inferred, Q7)* |
 | **`mvnup` rewrite input** | **4.x only** | operator `pom.xml` | **no** — trusted, but written back in place | review the diff `mvnup` produces *(inferred, Q8)* |
 | **`mvnenc` secrets / vault** | **4.x only** | operator | **no** — trusted | manage the master key/vault (§10) *(inferred, Q9)* |
+| **`mvnval` validation input** | **4.x only** | operator `pom.xml` | **no** — trusted | never writes to a POM. By default (`--mode effective`) it builds the effective model, so it resolves parents and imported boms and caches them in the local repository, from the repositories the POM itself declares as well as Central, through whatever the user's settings mirror it to. Point it at a POM you do not trust and it fetches from URLs that POM chose; `--mode raw` reaches no repository at all, [...]
 
 **Size/shape.** Maven imposes no general bound on POM size, dependency-graph depth, or artifact size; resolution of a hostile dependency graph (deep transitive fan-out, decompression of hostile archives) is a resource surface. *(inferred, Q17)*
 
diff --git a/apache-maven/src/assembly/component.xml b/apache-maven/src/assembly/component.xml
index f8250afe42..d82e23fa8b 100644
--- a/apache-maven/src/assembly/component.xml
+++ b/apache-maven/src/assembly/component.xml
@@ -89,6 +89,7 @@ under the License.
         <include>mvnenc</include>
         <include>mvnsh</include>
         <include>mvnup</include>
+        <include>mvnval</include>
         <include>mvnDebug</include>
         <include>mvnencDebug</include>
         <!-- This is so that CI systems can periodically run the profiler -->
diff --git a/apache-maven/src/assembly/maven/bin/mvn b/apache-maven/src/assembly/maven/bin/mvn
index fe04dacb0b..6513aec57f 100755
--- a/apache-maven/src/assembly/maven/bin/mvn
+++ b/apache-maven/src/assembly/maven/bin/mvn
@@ -151,7 +151,7 @@ for arg in "$@"; do
     -V|--show-version) SHOW_VERSION=true ;;
     -q|--quiet) QUIET=true ;;
     -X|--debug) VERBOSE=true ;;
-    --enc|--shell|--up) MAIN_CLASS_OVERRIDE=true ;;
+    --enc|--shell|--up|--val) MAIN_CLASS_OVERRIDE=true ;;
     -[qvVXe]*)
       case $arg in
         *v*) VERSION_AND_EXIT=true ;;
@@ -524,6 +524,9 @@ handle_args() {
       --up)
         MAVEN_MAIN_CLASS="org.apache.maven.cling.MavenUpCling"
         ;;
+      --val)
+        MAVEN_MAIN_CLASS="org.apache.maven.cling.MavenValCling"
+        ;;
       *)
         ;;
     esac
@@ -567,7 +570,7 @@ fi
 # to preserve literal values (backslashes, ${...} placeholders) without
 # shell re-parsing. Only the base command uses eval for MAVEN_OPTS word splitting.
 # MAVEN_ARGS is only passed for the default Maven build command (MavenCling),
-# not for sub-commands like --up, --enc, or --shell which have their own options.
+# not for sub-commands like --up, --enc, --shell or --val which have their own options.
 if [ "$MAVEN_MAIN_CLASS" = "org.apache.maven.cling.MavenCling" ]; then
   eval exec "$cmd" '$MAVEN_ARGS' '"$@"'
 else
diff --git a/apache-maven/src/assembly/maven/bin/mvn.cmd b/apache-maven/src/assembly/maven/bin/mvn.cmd
index c1b52fa87f..84dafceff2 100644
--- a/apache-maven/src/assembly/maven/bin/mvn.cmd
+++ b/apache-maven/src/assembly/maven/bin/mvn.cmd
@@ -105,6 +105,7 @@ if "%_FLAG_ARG%"=="--debug" set "IS_VERBOSE=1"
 if "%_FLAG_ARG%"=="--enc" set "IS_MAIN_OVERRIDE=1"
 if "%_FLAG_ARG%"=="--shell" set "IS_MAIN_OVERRIDE=1"
 if "%_FLAG_ARG%"=="--up" set "IS_MAIN_OVERRIDE=1"
+if "%_FLAG_ARG%"=="--val" set "IS_MAIN_OVERRIDE=1"
 @REM Compact single-dash tokens (e.g. -qv, -vX) mirror the Unix script's
 @REM -[qvVXe]* handling, but only when the part after '-' is made exclusively
 @REM of the safe chars v V q X e; otherwise (e.g. -f, -D...) the token is
@@ -376,6 +377,8 @@ if "%~1"=="--debug" (
       set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenShellCling"
 ) else if "%~1"=="--up" (
       set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenUpCling"
+) else if "%~1"=="--val" (
+      set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenValCling"
 )
 exit /b 0
 
@@ -393,7 +396,7 @@ set LAUNCHER_CLASS=org.codehaus.plexus.classworlds.launcher.Launcher
 if "%MAVEN_MAIN_CLASS%"=="" @set MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenCling
 
 @REM Only pass MAVEN_ARGS for the default Maven build command (MavenCling),
-@REM not for sub-commands like --up, --enc, or --shell which have their own options.
+@REM not for sub-commands like --up, --enc, --shell or --val which have their own options.
 if not "%MAVEN_MAIN_CLASS%"=="org.apache.maven.cling.MavenCling" set "MAVEN_ARGS="
 
 if defined MAVEN_DEBUG_SCRIPT (
diff --git a/apache-maven/src/assembly/maven/bin/mvnval b/apache-maven/src/assembly/maven/bin/mvnval
new file mode 100755
index 0000000000..828ad10357
--- /dev/null
+++ b/apache-maven/src/assembly/maven/bin/mvnval
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# -----------------------------------------------------------------------------
+# Apache Maven POM Validation Script
+#
+# Environment Variable Prerequisites
+#
+#   JAVA_HOME           (Optional) Points to a Java installation.
+#   MAVEN_OPTS          (Optional) Java runtime options used when Maven is executed.
+#   MAVEN_SKIP_RC       (Optional) Flag to disable loading of mavenrc files.
+# -----------------------------------------------------------------------------
+
+"`dirname "$0"`/mvn" --val "$@"
diff --git a/apache-maven/src/assembly/maven/bin/mvnval.cmd b/apache-maven/src/assembly/maven/bin/mvnval.cmd
new file mode 100644
index 0000000000..061e696800
--- /dev/null
+++ b/apache-maven/src/assembly/maven/bin/mvnval.cmd
@@ -0,0 +1,39 @@
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements.  See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership.  The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License.  You may obtain a copy of the License at
+@REM
+@REM    http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied.  See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+
+@REM -----------------------------------------------------------------------------
+@REM Apache Maven POM Validation Script
+@REM
+@REM Environment Variable Prerequisites
+@REM
+@REM   JAVA_HOME           (Optional) Points to a Java installation.
+@REM   MAVEN_BATCH_ECHO    (Optional) Set to 'on' to enable the echoing of the batch commands.
+@REM   MAVEN_BATCH_PAUSE   (Optional) set to 'on' to wait for a key stroke before ending.
+@REM   MAVEN_OPTS          (Optional) Java runtime options used when Maven is executed.
+@REM   MAVEN_SKIP_RC       (Optional) Flag to disable loading of mavenrc files.
+@REM -----------------------------------------------------------------------------
+
+@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
+@echo off
+@REM set title of command window
+title %0
+@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
+@if "%MAVEN_BATCH_ECHO%"=="on" echo %MAVEN_BATCH_ECHO%
+
+@setlocal
+
+@call "%~dp0"mvn.cmd --val %*
diff --git a/api/maven-api-cli/src/main/java/module-info.java b/api/maven-api-cli/src/main/java/module-info.java
index 2beed2fa19..23235b1275 100644
--- a/api/maven-api-cli/src/main/java/module-info.java
+++ b/api/maven-api-cli/src/main/java/module-info.java
@@ -30,4 +30,5 @@
     exports org.apache.maven.api.cli.mvnenc;
     exports org.apache.maven.api.cli.mvnsh;
     exports org.apache.maven.api.cli.mvnup;
+    exports org.apache.maven.api.cli.mvnval;
 }
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java
index ee25ec63da..d483520cbb 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java
@@ -253,6 +253,32 @@ static Builder mvnup(@Nonnull List<String> args, @Nonnull MessageBuilderFactory
         return builder(Tools.MVNUP_CMD, Tools.MVNUP_NAME, args, messageBuilderFactory);
     }
 
+    /**
+     * Creates a new Builder instance for constructing a Maven POM Validation Tool ParserRequest.
+     *
+     * @param args the command-line arguments
+     * @param messageBuilderFactory the factory for creating message builders
+     * @return a new Builder instance
+     * @since 4.1.0
+     */
+    @Nonnull
+    static Builder mvnval(@Nonnull String[] args, @Nonnull MessageBuilderFactory messageBuilderFactory) {
+        return mvnval(Arrays.asList(args), messageBuilderFactory);
+    }
+
+    /**
+     * Creates a new Builder instance for constructing a Maven POM Validation Tool ParserRequest.
+     *
+     * @param args the command-line arguments
+     * @param messageBuilderFactory the factory for creating message builders
+     * @return a new Builder instance
+     * @since 4.1.0
+     */
+    @Nonnull
+    static Builder mvnval(@Nonnull List<String> args, @Nonnull MessageBuilderFactory messageBuilderFactory) {
+        return builder(Tools.MVNVAL_CMD, Tools.MVNVAL_NAME, args, messageBuilderFactory);
+    }
+
     /**
      * Creates a new Builder instance for constructing a ParserRequest.
      *
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
index 7559d7ffee..2ede765b20 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
@@ -42,4 +42,14 @@ private Tools() {}
 
     public static final String MVNUP_CMD = "mvnup";
     public static final String MVNUP_NAME = "Maven Upgrade Tool";
+
+    /**
+     * @since 4.1.0
+     */
+    public static final String MVNVAL_CMD = "mvnval";
+
+    /**
+     * @since 4.1.0
+     */
+    public static final String MVNVAL_NAME = "Maven POM Validation Tool";
 }
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnval/ValidateOptions.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnval/ValidateOptions.java
new file mode 100644
index 0000000000..3e4c1121dc
--- /dev/null
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnval/ValidateOptions.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api.cli.mvnval;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.cli.Options;
+
+/**
+ * Options specific to the POM validation tool.
+ *
+ * @since 4.1.0
+ */
+@Experimental
+public interface ValidateOptions extends Options {
+
+    /**
+     * Returns the requested output format, matched case-insensitively against {@code text} and
+     * {@code json}. An unrecognised value is rejected as a usage error.
+     *
+     * @return an {@link Optional} holding the requested format, or empty when not given, in
+     *         which case {@code text} is used
+     */
+    @Nonnull
+    Optional<String> format();
+
+    /**
+     * Returns the POM files to validate, as given on the command line and resolved against the
+     * working directory. The list is never empty; when no POM is named the {@link Optional} is
+     * empty instead.
+     *
+     * @return an {@link Optional} holding the paths, or empty when none were given
+     */
+    @Nonnull
+    Optional<List<String>> poms();
+
+    /**
+     * Returns how far the model builder should run, matched case-insensitively against {@code raw},
+     * which stops at the raw model and reaches no repository, and {@code effective}, which resolves
+     * parents and imports as well. An unrecognised value is rejected as a usage error.
+     *
+     * @return an {@link Optional} holding the requested mode, or empty when not given, in which
+     *         case {@code effective} is used
+     */
+    @Nonnull
+    Optional<String> mode();
+
+    /**
+     * Returns the local repository to resolve into, in place of the configured one, resolved
+     * against the working directory. Without effect in {@code raw} mode, which resolves nothing.
+     *
+     * @return an {@link Optional} holding the path, or empty when not given
+     */
+    @Nonnull
+    Optional<String> localRepository();
+
+    /**
+     * Returns whether to resolve into a directory created for this run and deleted when it ends, so
+     * that validating a POM leaves the configured local repository untouched. Without effect in
+     * {@code raw} mode, which resolves nothing.
+     *
+     * @return an {@link Optional} holding the choice, or empty when not given
+     */
+    @Nonnull
+    Optional<Boolean> tempLocalRepository();
+
+    /**
+     * Returns the number of source lines to display before and after a problem location, giving
+     * the reader the surrounding XML context. Applies to {@code text} format only; JSON output
+     * carries the raw line and column numbers instead.
+     * <p>
+     * The value {@code 0} disables context display. The default when the option is absent is
+     * {@code 2}, matching the behaviour of {@code grep -C 2}.
+     *
+     * @return an {@link Optional} holding the requested context size, or empty when not given
+     */
+    @Nonnull
+    Optional<Integer> context();
+}
diff --git a/api/maven-api-cli/src/main/java/module-info.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnval/package-info.java
similarity index 59%
copy from api/maven-api-cli/src/main/java/module-info.java
copy to api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnval/package-info.java
index 2beed2fa19..bc281e0c33 100644
--- a/api/maven-api-cli/src/main/java/module-info.java
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnval/package-info.java
@@ -17,17 +17,14 @@
  * under the License.
  */
 
-module org.apache.maven.api.cli {
-    requires transitive org.apache.maven.api;
-    requires transitive org.apache.maven.api.annotations;
-    requires transitive org.apache.maven.api.xml;
-
-    exports org.apache.maven.api.cli;
-    exports org.apache.maven.api.cli.cisupport;
-    exports org.apache.maven.api.cli.extensions;
-    exports org.apache.maven.api.cli.logging;
-    exports org.apache.maven.api.cli.mvn;
-    exports org.apache.maven.api.cli.mvnenc;
-    exports org.apache.maven.api.cli.mvnsh;
-    exports org.apache.maven.api.cli.mvnup;
-}
+/**
+ * Provides the API for the Maven POM Validation tool ({@code mvnval}).
+ *
+ * <p>This package contains the options interface for the POM validation tool,
+ * which reads and validates POM files without building them.</p>
+ *
+ * @see org.apache.maven.api.cli.Tools#MVNVAL_CMD
+ * @see org.apache.maven.api.cli.Tools#MVNVAL_NAME
+ * @since 4.1.0
+ */
+package org.apache.maven.api.cli.mvnval;
diff --git a/api/maven-api-cli/src/site/markdown/index.md b/api/maven-api-cli/src/site/markdown/index.md
index 9d7c15b4e0..707611f5db 100644
--- a/api/maven-api-cli/src/site/markdown/index.md
+++ b/api/maven-api-cli/src/site/markdown/index.md
@@ -31,7 +31,8 @@ This is the [API](./apidocs/org/apache/maven/api/cli/package-summary.html) for M
 - [`mvn`](./apidocs/org/apache/maven/api/cli/mvn/package-summary.html), the Maven build tool,
 - [`mvnenc`](./apidocs/org/apache/maven/api/cli/mvnenc/package-summary.html), the Maven Password Encryption tool,
 - [`mvnsh`](./apidocs/org/apache/maven/api/cli/mvnsh/package-summary.html), the Maven Shell tool,
-- [`mvnup`](./apidocs/org/apache/maven/api/cli/mvnup/package-summary.html), the Maven Upgrade tool.
+- [`mvnup`](./apidocs/org/apache/maven/api/cli/mvnup/package-summary.html), the Maven Upgrade tool,
+- [`mvnval`](./apidocs/org/apache/maven/api/cli/mvnval/package-summary.html), the Maven POM Validation tool.
 
 This API also defines [Core Extensions model](./core-extensions.html) for `.mvn/extensions.xml`.
 
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/services/ModelBuilder.java b/api/maven-api-core/src/main/java/org/apache/maven/api/services/ModelBuilder.java
index ea8e263392..d2b9ce0551 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/services/ModelBuilder.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/services/ModelBuilder.java
@@ -38,6 +38,33 @@ public interface ModelBuilder extends Service {
     interface ModelBuilderSession {
 
         ModelBuilderResult build(ModelBuilderRequest request) throws ModelBuilderException;
+
+        /**
+         * Validates the model described by the request, collecting every problem instead of
+         * throwing on the first one.
+         * <p>
+         * Unlike {@link #build(ModelBuilderRequest)} this does not throw when validation reports
+         * errors: the result carries errors and warnings alike, so a caller that only wants to
+         * report on a POM can see warnings that come with no error. A problem severe enough that
+         * the model cannot be read at all is still thrown.
+         * <p>
+         * An implementation must not resolve from a remote repository and must not download. How
+         * much of the pipeline it runs beyond that is its own, so a caller takes the verdict from
+         * the problems on the result rather than from the model accessors, whose {@code @Nonnull}
+         * contracts describe a fully built model and do not hold here.
+         * <p>
+         * Reading one model can need others: a subproject may leave out its parent version, which
+         * is then taken from the parent's own file model. An implementation may therefore read
+         * files besides the one the request names, up to the project root.
+         *
+         * @return the result, carrying the model that was read and the problems collected
+         * @throws ModelBuilderException if the model cannot be read at all
+         * @throws UnsupportedOperationException if this implementation does not support it
+         * @since 4.1.0
+         */
+        default ModelBuilderResult validate(ModelBuilderRequest request) throws ModelBuilderException {
+            throw new UnsupportedOperationException(getClass().getName() + " does not support validating a model");
+        }
     }
 
     Model buildRawModel(ModelBuilderRequest request) throws ModelBuilderException;
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenValCling.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenValCling.java
new file mode 100644
index 0000000000..dcf507a7c7
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenValCling.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.cli.Invoker;
+import org.apache.maven.api.cli.Parser;
+import org.apache.maven.api.cli.ParserRequest;
+import org.apache.maven.cling.invoker.ProtoLookup;
+import org.apache.maven.cling.invoker.mvnval.ValidateInvoker;
+import org.apache.maven.cling.invoker.mvnval.ValidateParser;
+import org.codehaus.plexus.classworlds.ClassWorld;
+
+/**
+ * Maven POM validation CLI "new-gen".
+ */
+public class MavenValCling extends ClingSupport {
+    /**
+     * "Normal" Java entry point. Note: Maven uses ClassWorld Launcher and this entry point is NOT used under normal
+     * circumstances.
+     */
+    public static void main(String[] args) throws IOException {
+        int exitCode = new MavenValCling().run(args, null, null, null, false);
+        System.exit(exitCode);
+    }
+
+    /**
+     * ClassWorld Launcher "enhanced" entry point: returning exitCode and accepts Class World.
+     */
+    public static int main(String[] args, ClassWorld world) throws IOException {
+        return new MavenValCling(world).run(args, null, null, null, false);
+    }
+
+    /**
+     * ClassWorld Launcher "embedded" entry point: returning exitCode and accepts Class World and streams.
+     */
+    public static int main(
+            String[] args,
+            ClassWorld world,
+            @Nullable InputStream stdIn,
+            @Nullable OutputStream stdOut,
+            @Nullable OutputStream stdErr)
+            throws IOException {
+        return new MavenValCling(world).run(args, stdIn, stdOut, stdErr, true);
+    }
+
+    public MavenValCling() {
+        super();
+    }
+
+    public MavenValCling(ClassWorld classWorld) {
+        super(classWorld);
+    }
+
+    @Override
+    protected Invoker createInvoker() {
+        return new ValidateInvoker(
+                ProtoLookup.builder().addMapping(ClassWorld.class, classWorld).build(), null);
+    }
+
+    @Override
+    protected Parser createParser() {
+        return new ValidateParser();
+    }
+
+    @Override
+    protected ParserRequest.Builder createParserRequestBuilder(String[] args) {
+        return ParserRequest.mvnval(args, createMessageBuilderFactory());
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java
index 7f5768715a..133591b49b 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java
@@ -325,6 +325,7 @@ protected static class CLIManager {
         public static final String DEBUG = "debug";
         public static final String ENC = "enc";
         public static final String UPGRADE = "up";
+        public static final String VALIDATE = "val";
         public static final String SHELL = "shell";
         public static final String YJP = "yjp";
 
@@ -451,6 +452,10 @@ protected void prepareOptions(org.apache.commons.cli.Options options) {
                     .longOpt(UPGRADE)
                     .desc("Launch the Maven Upgrade tool (script option).")
                     .get());
+            options.addOption(Option.builder()
+                    .longOpt(VALIDATE)
+                    .desc("Launch the Maven POM Validation tool (script option).")
+                    .get());
             options.addOption(Option.builder()
                     .longOpt(SHELL)
                     .desc("Launch the Maven Shell tool (script option).")
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java
index 3d989b60ed..f30b160567 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java
@@ -46,6 +46,8 @@
 import org.apache.maven.cling.invoker.mvnsh.ShellCommandRegistryFactory;
 import org.apache.maven.cling.invoker.mvnup.UpgradeInvoker;
 import org.apache.maven.cling.invoker.mvnup.UpgradeParser;
+import org.apache.maven.cling.invoker.mvnval.ValidateInvoker;
+import org.apache.maven.cling.invoker.mvnval.ValidateParser;
 import org.apache.maven.impl.util.Os;
 import org.jline.builtins.Completers;
 import org.jline.console.CmdDesc;
@@ -71,6 +73,8 @@ private static class BuiltinShellCommandRegistry extends JlineCommandRegistry im
         private final LookupContext shellContext;
         private final MavenInvoker shellMavenInvoker;
         private final MavenParser mavenParser;
+        private final ValidateInvoker shellValidateInvoker;
+        private final ValidateParser validateParser;
         private final EncryptInvoker shellEncryptInvoker;
         private final EncryptParser encryptParser;
         private final UpgradeInvoker shellUpgradeInvoker;
@@ -84,6 +88,8 @@ private BuiltinShellCommandRegistry(LookupContext shellContext) {
             this.encryptParser = new EncryptParser();
             this.shellUpgradeInvoker = new UpgradeInvoker(shellContext.invokerRequest.lookup(), contextCopier());
             this.upgradeParser = new UpgradeParser();
+            this.shellValidateInvoker = new ValidateInvoker(shellContext.invokerRequest.lookup(), contextCopier());
+            this.validateParser = new ValidateParser();
             Map<String, CommandMethods> commandExecute = new HashMap<>();
             commandExecute.put("!", new CommandMethods(this::shell, this::defaultCompleter));
             commandExecute.put("cd", new CommandMethods(this::cd, this::cdCompleter));
@@ -91,6 +97,7 @@ private BuiltinShellCommandRegistry(LookupContext shellContext) {
             commandExecute.put("mvn", new CommandMethods(this::mvn, this::mvnCompleter));
             commandExecute.put("mvnenc", new CommandMethods(this::mvnenc, this::mvnencCompleter));
             commandExecute.put("mvnup", new CommandMethods(this::mvnup, this::mvnupCompleter));
+            commandExecute.put("mvnval", new CommandMethods(this::mvnval, this::defaultCompleter));
             registerCommands(commandExecute);
         }
 
@@ -255,6 +262,21 @@ private List<Completer> mvnencCompleter(String name) {
                     shellContext.lookup.lookupMap(Goal.class).keySet())));
         }
 
+        private void mvnval(CommandInput input) {
+            try {
+                shellValidateInvoker.invoke(validateParser.parseInvocation(
+                        ParserRequest.mvnval(input.args(), shellContext.invokerRequest.messageBuilderFactory())
+                                .cwd(shellContext.cwd.get())
+                                .build()));
+            } catch (InvokerException.ExitException e) {
+                if (e.getExitCode() != 0) {
+                    shellContext.logger.error("mvnval command exited with exit code " + e.getExitCode());
+                }
+            } catch (Exception e) {
+                saveException(e);
+            }
+        }
+
         private void mvnup(CommandInput input) {
             try {
                 shellUpgradeInvoker.invoke(upgradeParser.parseInvocation(
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/BundleWorkspaceReader.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/BundleWorkspaceReader.java
new file mode 100644
index 0000000000..4489662958
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/BundleWorkspaceReader.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnval;
+
+import java.io.File;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.maven.api.model.Model;
+import org.apache.maven.impl.resolver.MavenWorkspaceReader;
+import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.aether.repository.WorkspaceRepository;
+
+/**
+ * A workspace reader backed by a set of POM files given on the command line.
+ * <p>
+ * When validating several POMs at once — for instance a deployment bundle whose
+ * members reference each other as parents or BOM imports — a parent lookup for a
+ * member would otherwise go to the remote repository.  This reader answers those
+ * lookups from the bundle itself, so the verdict does not depend on whether the
+ * parent has been published yet and the run does not download what it already has
+ * on disk.
+ * <p>
+ * The index is keyed by {@code groupId:artifactId} (GA) only: a bundle is
+ * expected to carry at most one version of any given module, and a version
+ * mismatch between a child's {@code <parent>} declaration and the parent POM
+ * found in the bundle is itself a problem for the validator to report rather
+ * than a reason to fall through to the repository.
+ * <p>
+ * Only POM artifacts are answered here.  Jar lookups and version-range queries
+ * always fall through to the local and remote repositories.
+ */
+class BundleWorkspaceReader implements MavenWorkspaceReader {
+
+    private static final WorkspaceRepository REPOSITORY = new WorkspaceRepository("bundle", null);
+
+    /** GA → POM file path, built once before any validation starts. */
+    private final Map<String, File> index;
+
+    /** GA → pre-parsed file model, for {@link MavenWorkspaceReader#findModel}. */
+    private final Map<String, Model> models;
+
+    BundleWorkspaceReader(Map<String, File> index, Map<String, Model> models) {
+        this.index = Map.copyOf(index);
+        this.models = Map.copyOf(models);
+    }
+
+    @Override
+    public WorkspaceRepository getRepository() {
+        return REPOSITORY;
+    }
+
+    /**
+     * Returns the POM file for the artifact when it is in the bundle, or {@code null} to fall
+     * through to the local and remote repositories.
+     * <p>
+     * Only answers for {@code pom} extension artifacts, never for jars or other types: the bundle
+     * contains source POMs, not packaged artifacts.
+     */
+    @Override
+    public File findArtifact(Artifact artifact) {
+        if (!"pom".equals(artifact.getExtension())) {
+            return null;
+        }
+        return index.get(ga(artifact.getGroupId(), artifact.getArtifactId()));
+    }
+
+    /**
+     * Returns the single version in the bundle for this GA, or an empty list to fall through.
+     * <p>
+     * Version-range resolution is not a concern for POM validation: a child's {@code <parent>}
+     * names an exact version and the resolver asks for that version directly via
+     * {@link #findArtifact}.  {@code findVersions} is consulted for range queries and LATEST /
+     * RELEASE, which do not appear in well-formed parent declarations.
+     */
+    @Override
+    public List<String> findVersions(Artifact artifact) {
+        return List.of();
+    }
+
+    /**
+     * Returns the pre-parsed file model for the artifact when it is in the bundle, or
+     * {@code null} to trigger normal model building.
+     * <p>
+     * Consulted by {@link org.apache.maven.impl.resolver.DefaultArtifactDescriptorReader} to skip
+     * re-parsing a POM that the workspace already has in memory.
+     */
+    @Override
+    public Model findModel(Artifact artifact) {
+        if (!"pom".equals(artifact.getExtension())) {
+            return null;
+        }
+        return models.get(ga(artifact.getGroupId(), artifact.getArtifactId()));
+    }
+
+    private static String ga(String groupId, String artifactId) {
+        return groupId + ":" + artifactId;
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/CommonsCliValidateOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/CommonsCliValidateOptions.java
new file mode 100644
index 0000000000..b2ba47bc70
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/CommonsCliValidateOptions.java
@@ -0,0 +1,211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnval;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.ParseException;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.cli.Options;
+import org.apache.maven.api.cli.ParserRequest;
+import org.apache.maven.api.cli.mvnval.ValidateOptions;
+import org.apache.maven.cling.invoker.CommonsCliOptions;
+
+/**
+ * Implementation of {@link ValidateOptions} (base + mvnval).
+ */
+public class CommonsCliValidateOptions extends CommonsCliOptions implements ValidateOptions {
+
+    /**
+     * Deliberately does not check {@code --format}. Throwing here marks the whole invocation as
+     * unparseable, and the CLI exits 1 before the tool runs, which contradicts the exit code the
+     * tool documents for bad usage. {@link ValidateInvoker} checks it instead, which also covers
+     * the value only becoming wrong once options are interpolated.
+     */
+    public static CommonsCliValidateOptions parse(String[] args) throws ParseException {
+        CLIManager cliManager = new CLIManager();
+        return new CommonsCliValidateOptions(Options.SOURCE_CLI, cliManager, cliManager.parse(args));
+    }
+
+    protected CommonsCliValidateOptions(String source, CLIManager cliManager, CommandLine commandLine) {
+        super(source, cliManager, commandLine);
+    }
+
+    @Override
+    @Nonnull
+    public Optional<String> format() {
+        return Optional.ofNullable(commandLine.getOptionValue(CLIManager.FORMAT));
+    }
+
+    @Override
+    @Nonnull
+    public Optional<String> mode() {
+        return Optional.ofNullable(commandLine.getOptionValue(CLIManager.MODE));
+    }
+
+    @Override
+    @Nonnull
+    public Optional<String> localRepository() {
+        return Optional.ofNullable(commandLine.getOptionValue(CLIManager.LOCAL_REPOSITORY));
+    }
+
+    @Override
+    @Nonnull
+    public Optional<Boolean> tempLocalRepository() {
+        return commandLine.hasOption(CLIManager.TEMP_LOCAL_REPOSITORY) ? Optional.of(true) : Optional.empty();
+    }
+
+    @Override
+    @Nonnull
+    public Optional<Integer> context() {
+        String val = commandLine.getOptionValue(CLIManager.CONTEXT);
+        if (val == null) {
+            return Optional.empty();
+        }
+        try {
+            int n = Integer.parseInt(val);
+            if (n < 0) {
+                throw new IllegalArgumentException("--context value must be non-negative: " + val);
+            }
+            return Optional.of(n);
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException("--context value must be an integer: " + val);
+        }
+    }
+
+    @Override
+    @Nonnull
+    public Optional<List<String>> poms() {
+        if (commandLine.getArgList().isEmpty()) {
+            return Optional.empty();
+        }
+        return Optional.of(commandLine.getArgList());
+    }
+
+    @Override
+    public void displayHelp(ParserRequest request, Consumer<String> printStream) {
+        super.displayHelp(request, printStream);
+        printStream.accept("");
+        printStream.accept("Validates the given POM files, or ./pom.xml when none are given. No");
+        printStream.accept("build is run: the POMs are read and reported on, never written back.");
+        printStream.accept("");
+        printStream.accept("--mode raw reads the file and raw models only: it reaches no repository");
+        printStream.accept("and works offline, but it cannot see a dependency whose version is");
+        printStream.accept("inherited. --mode effective, the default, runs that and then resolves");
+        printStream.accept("parents and imported boms so the checks needing them run as well. That");
+        printStream.accept("goes to the network, to the repositories the settings declare and to");
+        printStream.accept("any the POM itself declares, caching what it fetches locally.");
+        printStream.accept("");
+        printStream.accept("A subproject is read when its parent needs it, and only the POMs named");
+        printStream.accept("on the command line are reported on, though a problem inherited from a");
+        printStream.accept("parent is reported against the POM that inherited it.");
+        printStream.accept("");
+        printStream.accept("--local-repository aims resolution at another local repository, and");
+        printStream.accept("--temp-local-repository at one created for the run and deleted when it");
+        printStream.accept("ends, so that validating a POM leaves the configured one untouched.");
+        printStream.accept("Without --local-repository, maven.repo.local is used as elsewhere.");
+        printStream.accept("Neither option changes what --mode raw does, since it resolves nothing.");
+        printStream.accept("");
+        printStream.accept("-o works in effective mode: resolution is limited to the local");
+        printStream.accept("repository and fails fast when a parent or BOM is absent there.");
+        printStream.accept("--temp-local-repository -o together prove that the named POMs are");
+        printStream.accept("fully self-contained: if any parent is missing the run fails.");
+        printStream.accept("");
+        printStream.accept("When more than one POM is given in effective mode, members of the set");
+        printStream.accept("that reference each other as parents or BOM imports are resolved from");
+        printStream.accept("disk rather than from a repository: the verdict does not depend on");
+        printStream.accept("publication order and the run does not download what is on disk.");
+        printStream.accept("");
+        printStream.accept("-s, -ps and -is are refused: they redirect the settings file that");
+        printStream.accept("resolution reads, and that file is read before this tool can act on");
+        printStream.accept("those flags.");
+        printStream.accept("");
+        printStream.accept("Exits with 0 when nothing was reported, 1 when anything was rejected,");
+        printStream.accept("2 on bad usage, and 4 when only warnings were reported.");
+        printStream.accept("");
+        printStream.accept("--format json writes one document to standard output, and quietens the");
+        printStream.accept("log so that it stays a document. Asking for -e or -X turns the log back");
+        printStream.accept("on, and its lines land in front of the document; -V prints the version");
+        printStream.accept("banner there whatever the log level.");
+        printStream.accept("");
+        printStream.accept("-C N (--context N) shows N lines of source before and after each problem");
+        printStream.accept("location, like grep -C. The default is 2. Pass 0 to suppress. Has no");
+        printStream.accept("effect under --format json, which always reports the raw line number.");
+        printStream.accept("");
+    }
+
+    @Override
+    protected CommonsCliValidateOptions copy(
+            String source, CommonsCliOptions.CLIManager cliManager, CommandLine commandLine) {
+        return new CommonsCliValidateOptions(source, (CLIManager) cliManager, commandLine);
+    }
+
+    protected static class CLIManager extends CommonsCliOptions.CLIManager {
+
+        public static final String FORMAT = "format";
+
+        public static final String MODE = "mode";
+
+        public static final String LOCAL_REPOSITORY = "local-repository";
+
+        public static final String TEMP_LOCAL_REPOSITORY = "temp-local-repository";
+
+        public static final String CONTEXT = "context";
+
+        @Override
+        protected void prepareOptions(org.apache.commons.cli.Options options) {
+            super.prepareOptions(options);
+            options.addOption(Option.builder()
+                    .longOpt(FORMAT)
+                    .hasArg()
+                    .desc("Output format: text (default) or json.")
+                    .get());
+            options.addOption(Option.builder()
+                    .longOpt(MODE)
+                    .hasArg()
+                    .desc("How far to validate: effective (default) or raw.")
+                    .get());
+            options.addOption(Option.builder()
+                    .longOpt(LOCAL_REPOSITORY)
+                    .hasArg()
+                    .desc("Resolve into this local repository instead of the configured one.")
+                    .get());
+            options.addOption(Option.builder()
+                    .longOpt(TEMP_LOCAL_REPOSITORY)
+                    .desc("Resolve into a directory created for this run and deleted when it ends.")
+                    .get());
+            options.addOption(Option.builder("C")
+                    .longOpt(CONTEXT)
+                    .hasArg()
+                    .argName("N")
+                    .desc("Show N lines of XML context around each problem location (default 2, 0 to disable).")
+                    .get());
+        }
+
+        @Override
+        protected String commandLineSyntax(String command) {
+            // The base syntax offers goals and phases. This tool runs neither.
+            return command + " [options] [<pom> ...]";
+        }
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/JsonReporter.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/JsonReporter.java
new file mode 100644
index 0000000000..e3883be192
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/JsonReporter.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnval;
+
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.services.ModelProblem;
+
+/**
+ * Renders validation results as a JSON array, one object per validated file. Assembled by hand
+ * so the tool needs no dependency the distribution does not already carry.
+ */
+class JsonReporter {
+
+    private static final char[] HEX = "0123456789abcdef".toCharArray();
+
+    private static final char FIRST_PRINTABLE_ASCII = 0x20;
+    private static final char LAST_PRINTABLE_ASCII = 0x7e;
+
+    /** The two quotes, plus room for a few escapes. */
+    private static final int ESCAPE_HEADROOM = 16;
+
+    static void report(List<Report> reports, Consumer<String> out) {
+        out.accept(reports.stream().map(JsonReporter::toJson).collect(Collectors.joining(",", "[", "]")));
+    }
+
+    private static String toJson(Report report) {
+        StringBuilder sb =
+                new StringBuilder("{\"pom\":").append(quote(report.pom().toString()));
+        if (report.failure() != null) {
+            sb.append(",\"failure\":").append(quote(report.failure()));
+        }
+        return sb.append(",\"problems\":")
+                .append(report.problems().stream()
+                        .map(problem -> toJson(problem, report))
+                        .collect(Collectors.joining(",", "[", "]")))
+                .append('}')
+                .toString();
+    }
+
+    private static String toJson(ModelProblem problem, Report report) {
+        StringBuilder sb = new StringBuilder("{\"severity\":")
+                .append(quote(problem.getSeverity().name()))
+                .append(",\"message\":")
+                .append(quote(problem.getMessage()));
+        // Absent values are left out, not written as the -1 the model builder uses for "no location".
+        String source = report.sourceOf(problem);
+        if (source != null) {
+            sb.append(",\"source\":").append(quote(source));
+        }
+        if (problem.getLineNumber() > 0) {
+            sb.append(",\"line\":").append(problem.getLineNumber());
+        }
+        if (problem.getColumnNumber() > 0) {
+            sb.append(",\"column\":").append(problem.getColumnNumber());
+        }
+        return sb.append('}').toString();
+    }
+
+    private static String quote(@Nullable String value) {
+        if (value == null) {
+            return "null";
+        }
+        StringBuilder sb = null;
+        for (int i = 0; i < value.length(); i++) {
+            char c = value.charAt(i);
+            String escaped = escaped(c);
+            if (escaped == null) {
+                if (sb != null) {
+                    sb.append(c);
+                }
+            } else {
+                if (sb == null) {
+                    sb = new StringBuilder(value.length() + ESCAPE_HEADROOM)
+                            .append('"')
+                            .append(value, 0, i);
+                }
+                sb.append(escaped);
+            }
+        }
+        return sb == null ? '"' + value + '"' : sb.append('"').toString();
+    }
+
+    /**
+     * The replacement for a character that cannot stand for itself, or {@code null} when it can.
+     * <p>
+     * Everything outside printable ASCII is escaped, not just the control characters JSON forbids:
+     * the writer's charset is the console's, which on Windows is routinely not UTF-8, and a path
+     * can carry an unpaired surrogate that no charset can encode.
+     */
+    @Nullable
+    private static String escaped(char c) {
+        return switch (c) {
+            case '"' -> "\\\"";
+            case '\\' -> "\\\\";
+            case '\n' -> "\\n";
+            case '\r' -> "\\r";
+            case '\t' -> "\\t";
+            default ->
+                c < FIRST_PRINTABLE_ASCII || c > LAST_PRINTABLE_ASCII
+                        ? new String(new char[] {
+                            '\\', 'u', HEX[c >> 12 & 0xf], HEX[c >> 8 & 0xf], HEX[c >> 4 & 0xf], HEX[c & 0xf]
+                        })
+                        : null;
+        };
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/OutputFormat.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/OutputFormat.java
new file mode 100644
index 0000000000..f5c61936e6
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/OutputFormat.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnval;
+
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+/**
+ * Output formats the validation tool understands.
+ */
+enum OutputFormat {
+    TEXT {
+        @Override
+        void report(List<Report> reports, Path cwd, Consumer<String> out, int context, boolean color) {
+            TextReporter.report(reports, cwd, out, context, color);
+        }
+    },
+    JSON {
+        @Override
+        void report(List<Report> reports, Path cwd, Consumer<String> out, int context, boolean color) {
+            // Absolute paths only: the document may be read somewhere else entirely.
+            JsonReporter.report(reports, out);
+        }
+    };
+
+    /**
+     * Renders the reports, handing each line to {@code out} without a line separator. A format may
+     * emit any number of lines per report, including one line for all of them.
+     *
+     * @param context number of source lines before/after each problem location (text format only)
+     * @param color   whether ANSI color codes are enabled
+     */
+    abstract void report(List<Report> reports, Path cwd, Consumer<String> out, int context, boolean color);
+
+    static OutputFormat parse(String name) {
+        return Arrays.stream(values())
+                .filter(format -> format.name().equalsIgnoreCase(name))
+                .findFirst()
+                .orElseThrow(() -> new IllegalArgumentException("Unknown format '" + name + "', expected one of: "
+                        + Arrays.stream(values())
+                                .map(format -> format.name().toLowerCase(Locale.ROOT))
+                                .collect(Collectors.joining(", "))));
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/Report.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/Report.java
new file mode 100644
index 0000000000..7cda0ec706
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/Report.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnval;
+
+import java.net.URI;
+import java.nio.file.FileSystemNotFoundException;
+import java.nio.file.Path;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.services.BuilderProblem;
+import org.apache.maven.api.services.ModelProblem;
+
+/**
+ * Outcome of validating a single POM. {@code failure} is set instead of {@code problems} when the
+ * file could not be validated at all, for instance because it does not exist.
+ */
+record Report(
+        Path pom, List<ModelProblem> problems, @Nullable String failure) {
+
+    Report {
+        problems = List.copyOf(problems);
+        if (failure != null && !problems.isEmpty()) {
+            throw new IllegalArgumentException("a failed report carries no problems");
+        }
+    }
+
+    static Report of(Path pom, List<ModelProblem> problems) {
+        return new Report(pom, dedupe(problems), null);
+    }
+
+    static Report failed(Path pom, String failure) {
+        return new Report(pom, List.of(), failure);
+    }
+
+    boolean clean() {
+        return failure == null && problems.isEmpty();
+    }
+
+    boolean hasErrors() {
+        return failure != null
+                || problems.stream()
+                        .anyMatch(problem -> problem.getSeverity() == BuilderProblem.Severity.ERROR
+                                || problem.getSeverity() == BuilderProblem.Severity.FATAL);
+    }
+
+    /**
+     * Returns the file a problem came from, or {@code null} when it came from this report's own
+     * POM and naming it would only repeat what the caller printed.
+     * <p>
+     * The model builder reports the source as a {@code file:} URL while the rest of the output
+     * uses paths, so the URL is converted back. A source that is not a file at all, such as a
+     * {@code groupId:artifactId}, is passed through as it came.
+     */
+    @Nullable
+    String sourceOf(ModelProblem problem) {
+        String source = problem.getSource();
+        if (source == null || source.isEmpty()) {
+            return null;
+        }
+        Path path = toPath(source);
+        if (path == null) {
+            return source;
+        }
+        return path.toAbsolutePath().normalize().equals(pom.toAbsolutePath().normalize()) ? null : path.toString();
+    }
+
+    @Nullable
+    private static Path toPath(String source) {
+        try {
+            Path path = source.startsWith("file:") ? Path.of(URI.create(source)) : Path.of(source);
+            // Not made absolute: a source that is not a file parses as a relative path on Unix,
+            // and prefixing it with the working directory would invent a file.
+            return path.normalize();
+        } catch (IllegalArgumentException | FileSystemNotFoundException e) {
+            return null;
+        }
+    }
+
+    /**
+     * Drops problems that repeat one already in the list. The file model and the raw model are
+     * validated in turn and some checks sit in both, so a POM with {@code LATEST} for its parent
+     * version is told about it twice. That is one thing wrong with the file, not two.
+     */
+    private static List<ModelProblem> dedupe(List<ModelProblem> problems) {
+        Set<List<Object>> seen = new HashSet<>();
+        return problems.stream()
+                .filter(p -> seen.add(List.of(
+                        p.getSeverity(),
+                        String.valueOf(p.getMessage()),
+                        String.valueOf(p.getSource()),
+                        p.getLineNumber(),
+                        p.getColumnNumber())))
+                .toList();
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/TextReporter.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/TextReporter.java
new file mode 100644
index 0000000000..e693db3f10
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/TextReporter.java
@@ -0,0 +1,245 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnval;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.maven.api.services.BuilderProblem;
+import org.apache.maven.api.services.MessageBuilder;
+import org.apache.maven.api.services.ModelProblem;
+import org.apache.maven.jline.MessageUtils;
+
+/**
+ * Renders validation results as plain text, one problem per line, with optional
+ * source-context lines and ANSI color when the terminal supports it.
+ */
+class TextReporter {
+
+    private static final String INDENT = "  ";
+    private static final int MAX_LINE_WIDTH = 120;
+
+    static void report(List<Report> reports, Path cwd, Consumer<String> out, int context, boolean color) {
+        Path base = realPath(cwd);
+        for (Report report : reports) {
+            String pom = display(report.pom(), base);
+            if (report.failure() != null) {
+                out.accept(newBuilder(color).strong(pom + ":").build() + " " + report.failure());
+            } else if (report.clean()) {
+                out.accept(newBuilder(color).success(pom + ": no problems").build());
+            } else {
+                out.accept(newBuilder(color).strong(pom + ":").build());
+                for (ModelProblem problem : report.problems()) {
+                    out.accept(problemLine(problem, report, color));
+                    if (context > 0 && problem.getLineNumber() > 0) {
+                        contextLines(problem, report, context, color, out);
+                    }
+                }
+            }
+        }
+    }
+
+    /** Returns a color-aware or plain {@link MessageBuilder}. */
+    private static MessageBuilder newBuilder(boolean color) {
+        return color ? MessageUtils.builder() : new PlainMessageBuilder();
+    }
+
+    /**
+     * The path as the caller would have typed it: relative to the working directory when the file
+     * is under it, absolute otherwise, so a path outside the tree is never ambiguous. The JSON
+     * document keeps the absolute path, since whoever reads it may not share this directory.
+     */
+    private static String display(Path pom, Path base) {
+        return pom.startsWith(base) ? base.relativize(pom).toString() : pom.toString();
+    }
+
+    /**
+     * Both paths have to be canonical before one can be said to sit under the other. On macOS the
+     * working directory is routinely reached through a symbolic link, and comparing the link
+     * against the resolved file gives a relative path that climbs out and back in again.
+     */
+    private static Path realPath(Path path) {
+        try {
+            return path.toRealPath();
+        } catch (IOException e) {
+            return path.toAbsolutePath().normalize();
+        }
+    }
+
+    private static String problemLine(ModelProblem problem, Report report, boolean color) {
+        // A parser failure arrives as a message of several lines; the continuations are indented
+        // one step deeper than the entry so it still reads as one.
+        String message = String.valueOf(problem.getMessage())
+                .lines()
+                .collect(Collectors.joining(System.lineSeparator() + INDENT + INDENT));
+
+        MessageBuilder b = newBuilder(color).a(INDENT);
+        BuilderProblem.Severity severity = problem.getSeverity();
+        if (severity == BuilderProblem.Severity.FATAL || severity == BuilderProblem.Severity.ERROR) {
+            b.failure(severity.toString());
+        } else {
+            b.warning(severity.toString());
+        }
+        b.a(" ").a(message);
+
+        String where = location(problem, report);
+        if (!where.isEmpty()) {
+            b.a(" @ ").a(where);
+        }
+        return b.build();
+    }
+
+    /**
+     * Where the problem sits, as {@code file, line N, column M}, leaving out whatever is unknown.
+     * The file is named only when it is not the one being reported on.
+     * <p>
+     * {@link ModelProblem#getLocation()} is no use here: {@code DefaultModelProblem}, which is
+     * what the model builder produces, hardcodes it to an empty string.
+     */
+    private static String location(ModelProblem problem, Report report) {
+        return Stream.of(
+                        report.sourceOf(problem),
+                        problem.getLineNumber() > 0 ? "line " + problem.getLineNumber() : null,
+                        problem.getColumnNumber() > 0 ? "column " + problem.getColumnNumber() : null)
+                .filter(Objects::nonNull)
+                .collect(Collectors.joining(", "));
+    }
+
+    /**
+     * Reads the source file for the problem and emits the surrounding {@code context} lines,
+     * with a {@code >} marker on the problem line and line numbers as a gutter.
+     * <p>
+     * Lines longer than {@value #MAX_LINE_WIDTH} characters are truncated with an ellipsis so
+     * the output does not wrap on a standard terminal.
+     */
+    private static void contextLines(
+            ModelProblem problem, Report report, int context, boolean color, Consumer<String> out) {
+        Path source = resolveSourcePath(problem, report);
+        if (source == null || !Files.isRegularFile(source)) {
+            return;
+        }
+        List<String> lines;
+        try {
+            lines = Files.readAllLines(source, StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            return;
+        }
+        int total = lines.size();
+        int problemLine = problem.getLineNumber(); // 1-based
+        if (problemLine < 1 || problemLine > total) {
+            return;
+        }
+        int first = Math.max(1, problemLine - context);
+        int last = Math.min(total, problemLine + context);
+
+        // Width of the line-number gutter: enough digits for the largest line number shown.
+        int gutterWidth = Integer.toString(last).length();
+
+        for (int i = first; i <= last; i++) {
+            String text = lines.get(i - 1);
+            if (text.length() > MAX_LINE_WIDTH) {
+                text = text.substring(0, MAX_LINE_WIDTH - 1) + "\u2026";
+            }
+            boolean isProb = (i == problemLine);
+            String marker = isProb ? ">" : " ";
+            String num = String.format("%" + gutterWidth + "d", i);
+            MessageBuilder b = newBuilder(color).a(INDENT).a(marker).a(" ");
+            if (isProb) {
+                b.strong(num + " | " + text);
+            } else {
+                b.a(num).a(" | ").a(text);
+            }
+            out.accept(b.build());
+        }
+    }
+
+    /**
+     * Resolves the path of the file containing the problem. Falls back to the report's own POM
+     * when the problem has no source or the source cannot be converted to a path.
+     */
+    private static Path resolveSourcePath(ModelProblem problem, Report report) {
+        String source = problem.getSource();
+        if (source == null || source.isEmpty()) {
+            return report.pom();
+        }
+        try {
+            Path path = source.startsWith("file:") ? Path.of(URI.create(source)) : Path.of(source);
+            path = path.normalize();
+            return path.isAbsolute() ? path : report.pom().resolveSibling(path);
+        } catch (IllegalArgumentException e) {
+            return report.pom();
+        }
+    }
+
+    /**
+     * A plain {@link MessageBuilder} that produces text with no ANSI escapes.
+     * Used when color is disabled.
+     */
+    private static final class PlainMessageBuilder implements MessageBuilder {
+
+        private final StringBuilder buf = new StringBuilder();
+
+        @Override
+        public MessageBuilder style(String style) {
+            return this;
+        }
+
+        @Override
+        public MessageBuilder resetStyle() {
+            return this;
+        }
+
+        @Override
+        public MessageBuilder append(CharSequence cs) {
+            buf.append(cs);
+            return this;
+        }
+
+        @Override
+        public MessageBuilder append(CharSequence cs, int start, int end) {
+            buf.append(cs, start, end);
+            return this;
+        }
+
+        @Override
+        public MessageBuilder append(char c) {
+            buf.append(c);
+            return this;
+        }
+
+        @Override
+        public MessageBuilder setLength(int length) {
+            buf.setLength(length);
+            return this;
+        }
+
+        @Override
+        public String build() {
+            return buf.toString();
+        }
+    }
+}
diff --git a/api/maven-api-cli/src/main/java/module-info.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/ValidateContext.java
similarity index 55%
copy from api/maven-api-cli/src/main/java/module-info.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/ValidateContext.java
index 2beed2fa19..143aaefc77 100644
--- a/api/maven-api-cli/src/main/java/module-info.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/ValidateContext.java
@@ -16,18 +16,24 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+package org.apache.maven.cling.invoker.mvnval;
 
-module org.apache.maven.api.cli {
-    requires transitive org.apache.maven.api;
-    requires transitive org.apache.maven.api.annotations;
-    requires transitive org.apache.maven.api.xml;
+import org.apache.maven.api.cli.InvokerRequest;
+import org.apache.maven.api.cli.mvnval.ValidateOptions;
+import org.apache.maven.cling.invoker.LookupContext;
 
-    exports org.apache.maven.api.cli;
-    exports org.apache.maven.api.cli.cisupport;
-    exports org.apache.maven.api.cli.extensions;
-    exports org.apache.maven.api.cli.logging;
-    exports org.apache.maven.api.cli.mvn;
-    exports org.apache.maven.api.cli.mvnenc;
-    exports org.apache.maven.api.cli.mvnsh;
-    exports org.apache.maven.api.cli.mvnup;
+/**
+ * Context for the POM validation tool. It adds no state of its own and exists only to narrow
+ * {@link #options()} to {@link ValidateOptions}.
+ */
+public class ValidateContext extends LookupContext {
+
+    public ValidateContext(InvokerRequest request, ValidateOptions options) {
+        super(request, true, options);
+    }
+
+    @Override
+    public ValidateOptions options() {
+        return (ValidateOptions) super.options();
+    }
 }
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/ValidateInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/ValidateInvoker.java
new file mode 100644
index 0000000000..22ffd3f7e3
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/ValidateInvoker.java
@@ -0,0 +1,735 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnval;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+import org.apache.maven.api.Constants;
+import org.apache.maven.api.RemoteRepository;
+import org.apache.maven.api.Session;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.cli.InvokerException;
+import org.apache.maven.api.cli.InvokerRequest;
+import org.apache.maven.api.cli.mvnval.ValidateOptions;
+import org.apache.maven.api.di.Named;
+import org.apache.maven.api.di.Provides;
+import org.apache.maven.api.model.Model;
+import org.apache.maven.api.model.Profile;
+import org.apache.maven.api.model.Repository;
+import org.apache.maven.api.model.RepositoryPolicy;
+import org.apache.maven.api.services.Lookup;
+import org.apache.maven.api.services.ModelBuilder;
+import org.apache.maven.api.services.ModelBuilder.ModelBuilderSession;
+import org.apache.maven.api.services.ModelBuilderException;
+import org.apache.maven.api.services.ModelBuilderRequest;
+import org.apache.maven.api.services.ModelBuilderRequest.RequestType;
+import org.apache.maven.api.services.ModelBuilderResult;
+import org.apache.maven.api.services.RepositoryFactory;
+import org.apache.maven.api.services.SettingsBuilder;
+import org.apache.maven.api.services.Sources;
+import org.apache.maven.cling.invoker.LookupContext;
+import org.apache.maven.cling.invoker.LookupInvoker;
+import org.apache.maven.cling.logging.Slf4jConfiguration;
+import org.apache.maven.impl.InternalSession;
+import org.apache.maven.impl.standalone.ApiRunner;
+import org.apache.maven.jline.MessageUtils;
+import org.eclipse.aether.DefaultRepositorySystemSession;
+import org.eclipse.aether.spi.connector.transport.TransporterFactory;
+import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
+import org.eclipse.aether.spi.io.PathProcessor;
+import org.eclipse.aether.transport.apache.ApacheTransporterFactory;
+import org.eclipse.aether.transport.file.FileTransporterFactory;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+
+/**
+ * Validates POM files without building them.
+ * <p>
+ * Two modes, and one contains the other. {@code raw} stops at
+ * {@link ModelBuilder.ModelBuilderSession#validate}, reaching no repository at all: it works
+ * offline and on a POM whose parent is not published yet, but it cannot see a problem that
+ * inheritance introduces, such as a dependency whose version comes from the parent's
+ * {@code dependencyManagement}. {@code effective}, the default, runs that pass and then builds
+ * the effective model as well, so parents and imported boms are resolved and the checks needing
+ * them run too. Both passes, because the effective build says nothing about the reactor around a
+ * POM: on its own it passes a project whose declared subproject is not on disk, which
+ * {@code mvn} refuses to read.
+ * <p>
+ * Nothing is ever written back to a POM. In {@code effective} mode what is resolved lands in the
+ * local repository, as it does for every Maven tool. {@code --local-repository} aims that
+ * somewhere else and {@code --temp-local-repository} at a directory made for the run and deleted
+ * when it ends, for a gate that wants the configured repository left as it was.
+ * <p>
+ * It stands up no container, so the directory holding a POM cannot make this process load
+ * anything through {@code .mvn/extensions.xml}, {@code maven.ext.class.path} or
+ * {@code .mvn/settings.xml}. The user's own settings are another matter: resolution reads them
+ * for mirrors, proxies and credentials, as it has to. The JVM is outside this:
+ * {@code bin/mvn} passes {@code .mvn/jvm.config} to the launcher before this process starts.
+ * <p>
+ * {@code -o} works in {@code effective} mode: resolution hits the local repository only and
+ * fails fast when a parent or BOM is absent. Combined with {@code --temp-local-repository} it
+ * proves that the POMs being validated are fully self-contained.
+ * <p>
+ * When more than one POM is given, {@code effective} mode pre-scans them all to build a bundle
+ * workspace: a parent that is named in the bundle is resolved from disk rather than from a
+ * repository, so the verdict does not depend on publication order and the run does not download
+ * what is already on disk.
+ */
+public class ValidateInvoker extends LookupInvoker<ValidateContext> {
+
+    /** Where Central lives, for the one case where no settings file names a repository. */
+    private static final String CENTRAL_URL = "https://repo.maven.apache.org/maven2";
+
+    public static final int OK = 0;
+    public static final int ERROR = 1;
+
+    /** Bad user input. Same meaning as in {@code mvnenc} and {@code mvnup}. */
+    public static final int BAD_OPERATION = 2;
+
+    /**
+     * Interrupted. Same meaning as in {@code mvnenc} and {@code mvnup}, and reachable only with a
+     * terminal attached: without one the signal reaches the JVM instead and the exit code is its.
+     * Left out of {@code --help} for that reason.
+     */
+    public static final int CANCELED = 3;
+
+    /**
+     * Nothing was rejected, but something was reported. Numbered 4 rather than reusing a lower
+     * code: 0 to 3 mean the same thing across the Maven 4 tools, and a gate must be able to tell
+     * "this POM has warnings" from "mvnval broke" or "somebody hit Ctrl+C".
+     */
+    public static final int WARNINGS = 4;
+
+    public ValidateInvoker(Lookup protoLookup, @Nullable Consumer<LookupContext> contextConsumer) {
+        super(protoLookup, contextConsumer);
+    }
+
+    @Override
+    protected ValidateContext createContext(InvokerRequest invokerRequest) {
+        return new ValidateContext(
+                invokerRequest, (ValidateOptions) invokerRequest.options().orElse(null));
+    }
+
+    @Override
+    protected int execute(ValidateContext context) throws Exception {
+        Thread validationThread = Thread.currentThread();
+        AtomicBoolean cancellationRequested = new AtomicBoolean();
+        Terminal.SignalHandler previousHandler = context.terminal.handle(Terminal.Signal.INT, signal -> {
+            cancellationRequested.set(true);
+            validationThread.interrupt();
+        });
+        try {
+            OutputFormat format;
+            ValidationMode mode;
+            try {
+                // Checked here rather than while parsing: the parser knows the option names,
+                // not the values, and a value it did accept can still become anything once
+                // options are interpolated.
+                format = context.options().format().map(OutputFormat::parse).orElse(OutputFormat.TEXT);
+                mode = context.options().mode().map(ValidationMode::parse).orElse(ValidationMode.EFFECTIVE);
+            } catch (IllegalArgumentException e) {
+                context.logger.error(e.getMessage());
+                return BAD_OPERATION;
+            }
+            // -s/-ps/-is redirect the settings files that ApiRunner reads at session-creation time.
+            // Honouring them would require threading the alternate path into ApiRunner before it
+            // starts; that is a larger change than belongs here, so they are still refused.
+            // -o is different: it applies to the resolver session, which createSession() returns,
+            // and can be applied there after the session is built.  Raw mode resolves nothing, so
+            // none of these apply there.
+            if (mode != ValidationMode.RAW) {
+                String refused = refusedSettingsOption(context.options());
+                if (refused != null) {
+                    context.logger.error(refused
+                            + " cannot redirect the settings file mvnval reads."
+                            + " Use the default settings, or copy the relevant settings to the default location.");
+                    return BAD_OPERATION;
+                }
+            }
+
+            if (context.options().localRepository().isPresent()
+                    && context.options().tempLocalRepository().orElse(false)) {
+                context.logger.error(
+                        "--local-repository and --temp-local-repository name different directories; give one.");
+                return BAD_OPERATION;
+            }
+            String unusableRepository = unusableRepository(context);
+            if (unusableRepository != null) {
+                context.logger.error(unusableRepository);
+                return BAD_OPERATION;
+            }
+            Path localRepository = null;
+            // Only in effective mode: raw resolves nothing, so making a directory there could
+            // lose a run that would never have written to it.
+            if (mode != ValidationMode.RAW) {
+                try {
+                    localRepository = localRepository(context);
+                } catch (IOException e) {
+                    // Not ERROR: no POM was rejected, the run could not be set up.
+                    context.logger.error("Could not create a temporary local repository: " + e);
+                    return BAD_OPERATION;
+                }
+            }
+            Thread cleanup = temporary(context, localRepository) ? cleanupHook(localRepository, context) : null;
+            try {
+                List<Report> reports =
+                        validateAll(context, createSession(localRepository), mode, cancellationRequested);
+                if (cancellationRequested.get() || Thread.currentThread().isInterrupted()) {
+                    return canceled(context);
+                }
+                // determineWriter, not context.writer: on a real run that field is still empty
+                // and this is what fills it.
+                int contextLines = context.options().context().orElse(2);
+                format.report(
+                        reports,
+                        context.cwd.get(),
+                        determineWriter(context),
+                        contextLines,
+                        MessageUtils.isColorEnabled());
+                return exitCode(reports);
+            } finally {
+                if (cleanup != null) {
+                    // Delete first, deregister second. The other order leaves a window where a
+                    // signal arriving after the hook is gone kills the process before the
+                    // deletion runs.
+                    deleteRecursively(localRepository, context);
+                    removeHook(cleanup);
+                }
+            }
+        } catch (UnsupportedOperationException e) {
+            // The ModelBuilder in this container has not written validate. Nothing was wrong with
+            // any POM, so this must not be ERROR, the code a gate reads as "a POM was rejected".
+            context.logger.error("This Maven installation cannot validate POMs: " + e.getMessage());
+            return BAD_OPERATION;
+        } catch (UserInterruptException e) {
+            return canceled(context);
+        } catch (Exception e) {
+            if (cancellationRequested.get() || Thread.currentThread().isInterrupted()) {
+                return canceled(context);
+            }
+            // Without this an unexpected failure escapes to the CLI and becomes 2, which this tool
+            // documents as bad usage. Both siblings catch here too.
+            if (context.options().showErrors().orElse(false)) {
+                context.logger.error(e.getMessage(), e);
+            } else {
+                context.logger.error(e.getMessage());
+            }
+            return ERROR;
+        } finally {
+            context.terminal.handle(Terminal.Signal.INT, previousHandler);
+            if (cancellationRequested.get()) {
+                Thread.interrupted();
+            }
+        }
+    }
+
+    private static int canceled(ValidateContext context) {
+        Thread.interrupted();
+        context.logger.error("Validation canceled by user.");
+        return CANCELED;
+    }
+
+    /**
+     * Turns an unparseable command line into {@link #BAD_OPERATION}.
+     * <p>
+     * The base exits 1 for a bad argument, which is the code this tool uses for a POM it rejected.
+     * A gate has to be able to tell a typo in its own script from a POM that failed validation.
+     */
+    @Override
+    protected void validate(ValidateContext context) throws Exception {
+        try {
+            super.validate(context);
+        } catch (InvokerException.ExitException e) {
+            throw e.getExitCode() == ERROR ? new InvokerException.ExitException(BAD_OPERATION) : e;
+        }
+    }
+
+    /** Whether stdout carries a document this run must not write anything else to. */
+    private static boolean json(ValidateContext context) {
+        return context.options()
+                .format()
+                .filter(OutputFormat.JSON.name()::equalsIgnoreCase)
+                .isPresent();
+    }
+
+    /**
+     * Keeps the version banner out of the document under {@code --format json}.
+     * <p>
+     * The base prints it whenever {@code effectiveVerbose()} holds, which a CI runner setting
+     * {@code RUNNER_DEBUG=1} makes true, so re-running a job with debug logging turned the
+     * document into something no parser accepts. Asking for {@code -V} still prints it, since
+     * that is what the flag is for.
+     */
+    @Override
+    protected void preCommands(ValidateContext context) throws Exception {
+        if (json(context) && !context.options().showVersion().orElse(false)) {
+            return;
+        }
+        super.preCommands(context);
+    }
+
+    /**
+     * Suppresses resolver noise from the output unless the caller asked for it.
+     * <p>
+     * The resolver writes an {@code [INFO]} line the first time it reads a repository's prefix
+     * file. Under {@code --format json} that line breaks the document; in text mode it clutters
+     * output that is meant to list only POM problems. Neither case benefits from seeing it.
+     * The level is forced to {@code ERROR} — the same effect as {@code -q} — unless {@code -X}
+     * or {@code -e} is given, in which case the full log is what the caller wants.
+     * <p>
+     * The property must be set <em>before</em> calling {@link Slf4jConfiguration#setRootLoggerLevel}
+     * because that method itself logs at {@code INFO} when it overrides a value already set (e.g.
+     * a CI runner running with {@code DEBUG}), which would be the very noise we are suppressing.
+     */
+    @Override
+    protected void configureLogging(ValidateContext context) throws Exception {
+        super.configureLogging(context);
+        if (!context.options().verbose().orElse(false)
+                && !context.options().showErrors().orElse(false)) {
+            context.loggerLevel = Slf4jConfiguration.Level.ERROR;
+            System.setProperty(Constants.MAVEN_LOGGER_DEFAULT_LOG_LEVEL, "error");
+            context.slf4jConfiguration.setRootLoggerLevel(context.loggerLevel);
+        }
+    }
+
+    // The next five steps stand up dependency injection and read configuration. Nothing here uses
+    // either: the model builder comes from createSession(). Running them would let the directory
+    // holding the POM decide what this process loads, via .mvn/extensions.xml, .mvn/settings.xml,
+    // or maven.ext.class.path in .mvn/maven-user.properties. Refused one at a time rather than by
+    // replacing doInvoke, so a step added to the base class later still runs.
+
+    /** No container: nothing is looked up, and no extension or contributed property is loaded. */
+    @Override
+    protected void container(ValidateContext context) {}
+
+    /** No container, so no {@code PropertyContributor} to run. */
+    @Override
+    protected void postContainer(ValidateContext context) {}
+
+    /** No container to look up from. */
+    @Override
+    protected void lookup(ValidateContext context) {}
+
+    /** No {@code EventSpy} dispatch: this tool emits no build events. */
+    @Override
+    protected void init(ValidateContext context) {}
+
+    /**
+     * No settings step: the model builder runs on the session from {@link #createSession(Path)},
+     * which reads the user's settings itself when it has to resolve.
+     */
+    @Override
+    protected void settings(ValidateContext context) {}
+
+    /**
+     * Creates the session the model builder runs on. Made here because {@code LookupInvoker} only
+     * ever builds a {@code ProtoSession} while {@link ModelBuilderRequest} needs a full
+     * {@link Session}. Left {@code protected} as a seam: building one is the expensive part of
+     * every test in this package, and the tests substitute a shared one.
+     *
+     * @return the session, never {@code null}
+     */
+    protected Session createSession(@Nullable Path localRepository) {
+        Session session = ApiRunner.createSession(injector -> injector.bindImplicit(TransporterFactoryConfig.class));
+        if (localRepository != null) {
+            // Set on the session that came back, not passed to createSession, which prefers
+            // settings.getLocalRepository() over its argument and may in any case hand back a
+            // session another tool built. See derivedRepositories for why that happens.
+            session = session.withLocalRepository(session.createLocalRepository(localRepository));
+        }
+        return session.withRemoteRepositories(derivedRepositories(session));
+    }
+
+    /**
+     * Applies {@code --offline} to a session built by {@link #createSession}.
+     * <p>
+     * {@code ApiRunner.createSession} applies offline from the settings file.  This supplements
+     * that: when the caller passes {@code -o} on the command line, the session is made offline
+     * regardless of what the settings say, by setting offline to true on the underlying resolver session.
+     */
+    private static Session withOffline(Session session) {
+        // AbstractSession.withLocalRepository short-circuits when the path is unchanged
+        // (returns this). Mutate the underlying DefaultRepositorySystemSession in-place.
+        // Safe only because validateAll reassigns session immediately and does not retain
+        // the pre-mutation reference.
+        DefaultRepositorySystemSession rsession =
+                (DefaultRepositorySystemSession) InternalSession.from(session).getSession();
+        rsession.setOffline(true);
+        return session;
+    }
+
+    /**
+     * Installs a {@link BundleWorkspaceReader} on the resolver session so that parent and BOM
+     * lookups for POMs in the bundle are answered from disk rather than from a repository.
+     */
+    private static Session withWorkspaceReader(Session session, BundleWorkspaceReader reader) {
+        // AbstractSession.withLocalRepository short-circuits when the path is unchanged
+        // (returns this). Mutate the underlying DefaultRepositorySystemSession in-place.
+        // Safe only because validateAll reassigns session immediately and does not retain
+        // the pre-mutation reference.
+        DefaultRepositorySystemSession rsession =
+                (DefaultRepositorySystemSession) InternalSession.from(session).getSession();
+        rsession.setWorkspaceReader(reader);
+        return session;
+    }
+
+    /**
+     * The repositories to resolve from, derived here rather than taken as they came.
+     * <p>
+     * {@code ApiRunner.createSession} asks its injector for an unqualified {@code Session}, and
+     * {@code InjectorImpl} registers a qualified {@code @Provides} under the unqualified key as
+     * well, so a provider belonging to a sibling tool can answer it and bring its own hardcoded
+     * repositories. Deriving the list from the settings gives the same answer whichever session
+     * came back, and keeps the repositories an operator declared: a parent that lives only in an
+     * internal repository has to resolve here exactly as it does under {@code mvn}.
+     * <p>
+     * The fallback matters only where no settings file declares a repository, which for a real
+     * installation means never: {@code conf/settings.xml} ships Central. It states Central with
+     * snapshots off, as that file does, because the two-argument
+     * {@link Session#createRemoteRepository(String, String)} takes the resolver's defaults
+     * instead, which enable snapshots and lower checksum checking to a warning.
+     */
+    private static List<RemoteRepository> derivedRepositories(Session session) {
+        RepositoryFactory factory = session.getService(RepositoryFactory.class);
+        Profile settingsProfile = session.getService(SettingsBuilder.class)
+                .convert(org.apache.maven.api.settings.Profile.newBuilder()
+                        .repositories(session.getSettings().getRepositories())
+                        .build());
+        List<RemoteRepository> repositories = settingsProfile.getRepositories().stream()
+                .map(factory::createRemote)
+                .toList();
+        return repositories.isEmpty()
+                ? List.of(factory.createRemote(Repository.newBuilder()
+                        .id(RemoteRepository.CENTRAL_ID)
+                        .url(CENTRAL_URL)
+                        .releases(RepositoryPolicy.newBuilder().enabled("true").build())
+                        .snapshots(
+                                RepositoryPolicy.newBuilder().enabled("false").build())
+                        .build()))
+                : repositories;
+    }
+
+    /**
+     * The transports resolution needs. {@code ApiRunner.createSession} registers none of its own,
+     * so a session built without these reports "No transporter factories registered" and cannot
+     * fetch from anywhere. {@code mvnup} carries the same block for the same reason.
+     */
+    static class TransporterFactoryConfig {
+        @Provides
+        @Named(ApacheTransporterFactory.NAME)
+        static TransporterFactory apacheTransporterFactory(
+                ChecksumExtractor checksumExtractor, PathProcessor pathProcessor) {
+            return new ApacheTransporterFactory(checksumExtractor, pathProcessor);
+        }
+
+        @Provides
+        @Named(FileTransporterFactory.NAME)
+        static TransporterFactory fileTransporterFactory() {
+            return new FileTransporterFactory();
+        }
+    }
+
+    /** Whether {@code localRepository} is the throwaway directory this run made. */
+    private static boolean temporary(ValidateContext context, @Nullable Path localRepository) {
+        return localRepository != null
+                && context.options().tempLocalRepository().orElse(false);
+    }
+
+    /**
+     * Deletes the throwaway directory when the process is signalled.
+     * <p>
+     * The {@code finally} covers every way out of {@code execute}, but not a signal, and a CI job
+     * killed on timeout is the case this option exists for. Without the hook a {@code SIGTERM} part way
+     * through a run left the populated directory behind. {@code SIGKILL} still escapes, as it escapes every hook.
+     */
+    private static Thread cleanupHook(Path directory, ValidateContext context) {
+        Thread hook = new Thread(() -> deleteRecursively(directory, context), "mvnval-repo-cleanup");
+        Runtime.getRuntime().addShutdownHook(hook);
+        return hook;
+    }
+
+    /** Takes the hook off, unless the shutdown it guards against has already begun. */
+    private static void removeHook(Thread hook) {
+        try {
+            Runtime.getRuntime().removeShutdownHook(hook);
+        } catch (IllegalStateException e) {
+            // Shutdown is under way and the hook is running or about to; it does the same work.
+        }
+    }
+
+    /**
+     * The local repository to resolve into, or {@code null} to leave the configured one alone.
+     * The throwaway directory is made here rather than in {@link #createSession(Path)} so the
+     * caller owns it and can delete it on every path out.
+     */
+    @Nullable
+    private static Path localRepository(ValidateContext context) throws IOException {
+        if (context.options().tempLocalRepository().orElse(false)) {
+            return Files.createTempDirectory("mvnval-repo-");
+        }
+        // maven.repo.local is how every other Maven command aims the local repository, so
+        // swallowing it while offering --local-repository would be the trap this tool refuses
+        // -o and -s to avoid. The option wins, being the more specific request.
+        return context.options()
+                .localRepository()
+                .or(() -> Optional.ofNullable(
+                        context.protoSession.getEffectiveProperties().get(Constants.MAVEN_REPO_LOCAL)))
+                .map(context.cwd::resolve)
+                .orElse(null);
+    }
+
+    /**
+     * Deletes the temporary local repository, deepest entry first. A failure here is reported and
+     * not thrown: the verdict on the POMs is already given, and losing it over a leftover
+     * directory would be the wrong trade. A process killed before this runs leaves the directory
+     * behind, where the operating system's temporary cleanup finds it.
+     */
+    private static void deleteRecursively(Path directory, ValidateContext context) {
+        if (!Files.exists(directory)) {
+            return;
+        }
+        try (Stream<Path> entries = Files.walk(directory)) {
+            for (Path entry : entries.sorted(Comparator.reverseOrder()).toList()) {
+                Files.deleteIfExists(entry);
+            }
+        } catch (IOException e) {
+            context.logger.warn("Could not delete the temporary local repository " + directory + ": " + e);
+        }
+    }
+
+    /**
+     * Names the first settings-redirect option given that this tool cannot act on, or
+     * {@code null} when none was given.
+     * <p>
+     * {@code -o} is intentionally absent: it is honoured in {@link #validateAll}.
+     */
+    @Nullable
+    private static String refusedSettingsOption(ValidateOptions options) {
+        if (options.altUserSettings().isPresent()) {
+            return "--settings (-s)";
+        }
+        if (options.altProjectSettings().isPresent()) {
+            return "--project-settings (-ps)";
+        }
+        if (options.altInstallationSettings().isPresent()) {
+            // Both spellings, because the parser folds -gs into this one and naming only the
+            // long form tells someone who typed -gs about an option they did not use.
+            return "--install-settings (-is, -gs)";
+        }
+        return null;
+    }
+
+    private static List<Path> resolvePoms(ValidateContext context) {
+        List<String> args = context.options().poms().orElse(List.of());
+        return args.isEmpty()
+                ? List.of(context.cwd.resolve("pom.xml"))
+                : args.stream()
+                        .map(context.cwd::resolve)
+                        .map(ValidateInvoker::sameFileSamePath)
+                        .distinct()
+                        .toList();
+    }
+
+    private static List<Report> validateAll(
+            ValidateContext context, Session session, ValidationMode mode, AtomicBoolean cancellationRequested) {
+        // Apply --offline after session creation: the session already has mirrors, proxies and
+        // credentials from the settings; making it offline only prevents the resolver from
+        // opening connections, which is exactly what -o requests.
+        if (context.options().offline().orElse(false)) {
+            session = withOffline(session);
+        }
+
+        List<Path> poms = resolvePoms(context);
+        ModelBuilder builder = session.getService(ModelBuilder.class);
+
+        // In effective mode with more than one POM, build a bundle workspace so that parent and
+        // BOM lookups for members of the bundle are answered from disk.  This is safe to share
+        // across sessions because the index is immutable: it is built once here and never written
+        // to.  The ordering bug that forced per-POM sessions arose from mappedSources, which is
+        // mutable session state; the workspace reader has no such shared mutable state.
+        if (mode == ValidationMode.EFFECTIVE && poms.size() > 1) {
+            BundleWorkspaceReader bundleReader = buildBundleWorkspace(poms, session, builder);
+            session = withWorkspaceReader(session, bundleReader);
+        }
+
+        final Session resolvedSession = session;
+        List<Report> reports = new ArrayList<>(poms.size());
+        for (Path pom : poms) {
+            if (cancellationRequested.get() || Thread.currentThread().isInterrupted()) {
+                break;
+            }
+            ModelBuilderRequest request = ModelBuilderRequest.builder()
+                    .session(resolvedSession)
+                    .source(Sources.buildSource(pom))
+                    .requestType(RequestType.BUILD_PROJECT)
+                    .userProperties(context.protoSession.getUserProperties())
+                    .build();
+            // A session per POM. Sharing one made the verdict depend on argument order: derived
+            // sessions share mappedSources, so a groupId:artifactId registered while reading an
+            // earlier POM answered a later POM's parent lookup, and two files given in the other
+            // order came back with different problems. Sharing bought no reading either, since
+            // every pass walks the reactor from the root again.
+            reports.add(validateOne(request, builder.newSession(), mode));
+        }
+        return reports;
+    }
+
+    /**
+     * Pre-scans all input POMs to build the bundle workspace index.
+     * <p>
+     * The index maps {@code groupId:artifactId} to the POM file on disk.  Scanning uses
+     * {@link ModelBuilder#buildRawModel} so that only the file is read and no parent resolution
+     * runs.  A POM that cannot be parsed is skipped: the validation pass that follows will report
+     * it as broken, which is the right place for that verdict.
+     * <p>
+     * The index is keyed by GA rather than GAV because a bundle is expected to carry at most one
+     * version of any given module, and a version mismatch between a child's declaration and the
+     * parent found in the bundle is something the validator should report rather than a reason to
+     * fall through to the repository.
+     */
+    private static BundleWorkspaceReader buildBundleWorkspace(List<Path> poms, Session session, ModelBuilder builder) {
+        Map<String, File> index = new HashMap<>();
+        Map<String, Model> models = new HashMap<>();
+        for (Path pom : poms) {
+            if (!Files.isRegularFile(pom) || !Files.isReadable(pom)) {
+                continue;
+            }
+            try {
+                ModelBuilderRequest req = ModelBuilderRequest.builder()
+                        .session(session)
+                        .source(Sources.buildSource(pom))
+                        .requestType(RequestType.BUILD_PROJECT)
+                        .build();
+                Model model = builder.buildRawModel(req);
+                String groupId = model.getGroupId() != null
+                        ? model.getGroupId()
+                        : (model.getParent() != null ? model.getParent().getGroupId() : null);
+                String artifactId = model.getArtifactId();
+                if (groupId != null && artifactId != null) {
+                    String ga = groupId + ":" + artifactId;
+                    index.put(ga, pom.toFile());
+                    models.put(ga, model);
+                }
+            } catch (Exception e) {
+                // Validation will report this POM as broken; skip it for the workspace index.
+            }
+        }
+        return new BundleWorkspaceReader(index, models);
+    }
+
+    private static Report validateOne(
+            ModelBuilderRequest request, ModelBuilderSession builderSession, ValidationMode mode) {
+        Path pom = request.getSource().getPath();
+        String unusable = unusable(pom);
+        if (unusable != null) {
+            return Report.failed(pom, unusable);
+        }
+        try {
+            return Report.of(pom, mode.problemsFor(builderSession, request));
+        } catch (ModelBuilderException e) {
+            // A result that explains nothing would report the file as clean, so use the exception.
+            ModelBuilderResult result = e.getResult();
+            return result != null && result.getProblemCollector().hasErrorProblems()
+                    ? Report.of(pom, ValidationMode.problemsOf(result))
+                    : Report.failed(pom, describe(e));
+        } catch (UnsupportedOperationException e) {
+            // Past the catch-all below, or it would be reported as this file being bad. execute()
+            // turns it into BAD_OPERATION: the tool cannot run at all here.
+            throw e;
+        } catch (Exception e) {
+            // One bad file must not lose the verdict on the others.
+            return Report.failed(pom, describe(e));
+        }
+    }
+
+    /**
+     * Says why {@code --local-repository} cannot be used, or {@code null} when it can. A path that
+     * exists and is not a directory reaches the resolver and comes back as a ClassCastException
+     * between two exception types, reported against the POM as though the POM were at fault.
+     */
+    @Nullable
+    private static String unusableRepository(ValidateContext context) {
+        return context.options()
+                .localRepository()
+                .map(context.cwd::resolve)
+                .filter(Files::exists)
+                .filter(path -> !Files.isDirectory(path))
+                .map(path -> "--local-repository " + path + " is not a directory.")
+                .orElse(null);
+    }
+
+    /**
+     * The one spelling of a path, so that naming a file twice reports it once. {@code "pom.xml"}
+     * and {@code "./pom.xml"} differ before normalising, and on Windows or macOS {@code POM.XML}
+     * is the same file again. {@link Path#toRealPath} settles all of those, including a symbolic
+     * link, and needs the file to be there; when it is not, the normalised path goes through and
+     * {@code unusable} reports it missing.
+     */
+    private static Path sameFileSamePath(Path pom) {
+        try {
+            return pom.toRealPath();
+        } catch (IOException e) {
+            return pom.normalize();
+        }
+    }
+
+    /**
+     * Says why the file cannot be validated, or {@code null} when it can. A directory gets its own
+     * message because {@code mvn -f} accepts one and looks for a {@code pom.xml} inside, so it is
+     * the mistake most likely to be made here.
+     */
+    @Nullable
+    private static String unusable(Path pom) {
+        if (Files.isDirectory(pom)) {
+            return "is a directory, not a POM file";
+        }
+        if (!Files.exists(pom)) {
+            return "does not exist";
+        }
+        if (!Files.isRegularFile(pom)) {
+            return "not a regular file";
+        }
+        return Files.isReadable(pom) ? null : "not readable";
+    }
+
+    /** Falls back to the class name rather than printing {@code null} for a message-less one. */
+    private static String describe(Exception e) {
+        String message = e.getMessage();
+        return message != null && !message.isEmpty() ? message : e.toString();
+    }
+
+    private static int exitCode(List<Report> reports) {
+        if (reports.stream().anyMatch(Report::hasErrors)) {
+            return ERROR;
+        }
+        return reports.stream().allMatch(Report::clean) ? OK : WARNINGS;
+    }
+}
diff --git a/api/maven-api-cli/src/main/java/module-info.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/ValidateParser.java
similarity index 57%
copy from api/maven-api-cli/src/main/java/module-info.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/ValidateParser.java
index 2beed2fa19..f54a3cf4c7 100644
--- a/api/maven-api-cli/src/main/java/module-info.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/ValidateParser.java
@@ -16,18 +16,20 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+package org.apache.maven.cling.invoker.mvnval;
 
-module org.apache.maven.api.cli {
-    requires transitive org.apache.maven.api;
-    requires transitive org.apache.maven.api.annotations;
-    requires transitive org.apache.maven.api.xml;
+import org.apache.commons.cli.ParseException;
+import org.apache.maven.api.cli.Options;
+import org.apache.maven.cling.invoker.BaseParser;
 
-    exports org.apache.maven.api.cli;
-    exports org.apache.maven.api.cli.cisupport;
-    exports org.apache.maven.api.cli.extensions;
-    exports org.apache.maven.api.cli.logging;
-    exports org.apache.maven.api.cli.mvn;
-    exports org.apache.maven.api.cli.mvnenc;
-    exports org.apache.maven.api.cli.mvnsh;
-    exports org.apache.maven.api.cli.mvnup;
+public class ValidateParser extends BaseParser {
+
+    @Override
+    protected Options parseCliOptions(LocalContext context) {
+        try {
+            return CommonsCliValidateOptions.parse(context.parserRequest.args().toArray(new String[0]));
+        } catch (ParseException e) {
+            throw new IllegalArgumentException("Failed to parse command line options: " + e.getMessage(), e);
+        }
+    }
 }
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/ValidationMode.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/ValidationMode.java
new file mode 100644
index 0000000000..3551fafcba
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/ValidationMode.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnval;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.stream.Collectors;
+
+import org.apache.maven.api.services.ModelBuilder.ModelBuilderSession;
+import org.apache.maven.api.services.ModelBuilderException;
+import org.apache.maven.api.services.ModelBuilderRequest;
+import org.apache.maven.api.services.ModelBuilderRequest.RequestType;
+import org.apache.maven.api.services.ModelBuilderResult;
+import org.apache.maven.api.services.ModelProblem;
+
+/**
+ * How far the model builder runs before the verdict is taken.
+ */
+enum ValidationMode {
+    /**
+     * Reads the file and raw models only. Resolves no parent and opens no connection, so it works
+     * offline and on a POM whose parent is not published yet, at the cost of the checks that need
+     * an effective model.
+     */
+    RAW {
+        @Override
+        List<ModelProblem> problemsFor(ModelBuilderSession builderSession, ModelBuilderRequest request)
+                throws ModelBuilderException {
+            return rawProblems(builderSession, request);
+        }
+    },
+
+    /**
+     * Everything {@code raw} checks, plus what needs the effective model: parents and imported
+     * boms are resolved, so a dependency whose version comes from the parent is checked too.
+     */
+    EFFECTIVE {
+        @Override
+        List<ModelProblem> problemsFor(ModelBuilderSession builderSession, ModelBuilderRequest request)
+                throws ModelBuilderException {
+            // Raw first, and on its own request. The effective build reports nothing about the
+            // reactor, so a subproject this POM names but does not have passes unnoticed through
+            // build() alone, while mvn refuses to read the project.
+            List<ModelProblem> problems = rawProblems(builderSession, request);
+            if (problems.stream().anyMatch(problem -> problem.getSeverity() == ModelProblem.Severity.ERROR)) {
+                // Nothing is gained by resolving parents for a POM that is already rejected, and
+                // it would go to the network to say so.
+                return problems;
+            }
+            List<ModelProblem> all = new ArrayList<>(problems);
+            // No problem is reported twice because the builder caches the file and raw models per
+            // source, so this request hits that cache and re-runs neither check. Partition that
+            // cache by request type and every raw problem would appear twice.
+            try {
+                // Completes the model: inheritance, interpolation, then validateEffectiveModel. It
+                // throws once it has collected an error, so throwing is the ordinary path here.
+                ModelBuilderRequest effectiveRequest = ModelBuilderRequest.builder(request)
+                        .requestType(RequestType.BUILD_EFFECTIVE)
+                        .build();
+                all.addAll(problemsOf(builderSession.build(effectiveRequest)));
+                return all;
+            } catch (ModelBuilderException e) {
+                // Carry the raw warnings out with the exception's own problems. Letting it fly
+                // would report only what the effective pass saw and lose the rest.
+                ModelBuilderResult result = e.getResult();
+                if (result == null) {
+                    throw e;
+                }
+                all.addAll(problemsOf(result));
+                return all;
+            }
+        }
+    };
+
+    /** Collects the problems this mode can see, in the order the model builder reports them. */
+    abstract List<ModelProblem> problemsFor(ModelBuilderSession builderSession, ModelBuilderRequest request)
+            throws ModelBuilderException;
+
+    /**
+     * The raw pass. {@code BUILD_PROJECT} is what makes the builder look for the parent beside the
+     * POM and check its {@code relativePath}; it does not make this a build, since {@code validate}
+     * stops before inheritance.
+     */
+    private static List<ModelProblem> rawProblems(ModelBuilderSession builderSession, ModelBuilderRequest request)
+            throws ModelBuilderException {
+        return problemsOf(builderSession.validate(request));
+    }
+
+    static List<ModelProblem> problemsOf(ModelBuilderResult result) {
+        return result.getProblemCollector().problems().toList();
+    }
+
+    static ValidationMode parse(String name) {
+        return Arrays.stream(values())
+                .filter(mode -> mode.name().equalsIgnoreCase(name))
+                .findFirst()
+                .orElseThrow(() -> new IllegalArgumentException("Unknown mode '" + name + "', expected one of: "
+                        + Arrays.stream(values())
+                                .map(mode -> mode.name().toLowerCase(Locale.ROOT))
+                                .collect(Collectors.joining(", "))));
+    }
+}
diff --git a/api/maven-api-cli/src/main/java/module-info.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/package-info.java
similarity index 59%
copy from api/maven-api-cli/src/main/java/module-info.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/package-info.java
index 2beed2fa19..f758fba1a9 100644
--- a/api/maven-api-cli/src/main/java/module-info.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnval/package-info.java
@@ -17,17 +17,12 @@
  * under the License.
  */
 
-module org.apache.maven.api.cli {
-    requires transitive org.apache.maven.api;
-    requires transitive org.apache.maven.api.annotations;
-    requires transitive org.apache.maven.api.xml;
-
-    exports org.apache.maven.api.cli;
-    exports org.apache.maven.api.cli.cisupport;
-    exports org.apache.maven.api.cli.extensions;
-    exports org.apache.maven.api.cli.logging;
-    exports org.apache.maven.api.cli.mvn;
-    exports org.apache.maven.api.cli.mvnenc;
-    exports org.apache.maven.api.cli.mvnsh;
-    exports org.apache.maven.api.cli.mvnup;
-}
+/**
+ * Implementation of the Maven POM Validation tool ({@code mvnval}).
+ *
+ * <p>This package contains the implementation classes for the POM validation tool,
+ * which reads and validates POM files without building them.</p>
+ *
+ * @since 4.1.0
+ */
+package org.apache.maven.cling.invoker.mvnval;
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/CommonsCliValidateOptionsTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/CommonsCliValidateOptionsTest.java
new file mode 100644
index 0000000000..7774ee8c40
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/CommonsCliValidateOptionsTest.java
@@ -0,0 +1,195 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnval;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.commons.cli.ParseException;
+import org.apache.maven.api.cli.ParserRequest;
+import org.apache.maven.jline.JLineMessageBuilderFactory;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for the {@link CommonsCliValidateOptions} class.
+ * Tests that POM paths arrive as positional arguments, that the options carrying a value take
+ * one, and that a bad {@code --format} value reaches the invoker rather than being rejected
+ * while the options are parsed.
+ */
+@DisplayName("CommonsCliValidateOptions")
+class CommonsCliValidateOptionsTest {
+
+    @Test
+    @DisplayName("should report no poms and no format when given no arguments")
+    void shouldReportNothingWhenGivenNoArguments() throws ParseException {
+        CommonsCliValidateOptions options = CommonsCliValidateOptions.parse(new String[0]);
+
+        assertTrue(options.poms().isEmpty(), "no positional arguments means no poms");
+        assertTrue(options.format().isEmpty(), "no --format means no format");
+    }
+
+    @Test
+    @DisplayName("should take poms from the positional arguments, in order")
+    void shouldTakePomsFromPositionalArguments() throws ParseException {
+        CommonsCliValidateOptions options = CommonsCliValidateOptions.parse(new String[] {"a/pom.xml", "b/pom.xml"});
+
+        assertEquals(
+                List.of("a/pom.xml", "b/pom.xml"),
+                options.poms().orElseThrow(),
+                "positional arguments should be the poms to validate");
+    }
+
+    @Test
+    @DisplayName("should read the format from --format")
+    void shouldReadFormatFromOption() throws ParseException {
+        assertEquals(
+                "json",
+                CommonsCliValidateOptions.parse(new String[] {"--format", "json"})
+                        .format()
+                        .orElseThrow());
+    }
+
+    @Test
+    @DisplayName("should carry a bad --format through to the invoker rather than failing to parse")
+    void shouldNotRejectBadFormatWhileParsing() throws ParseException {
+        // Failing here would mark the invocation unparseable and exit 1, while the tool documents
+        // 2 for bad usage. ValidateInvoker rejects the value and returns that code.
+        CommonsCliValidateOptions options = CommonsCliValidateOptions.parse(new String[] {"--format", "xml"});
+
+        assertEquals("xml", options.format().orElseThrow());
+    }
+
+    @Test
+    @DisplayName("should accept the --val flag the launcher script passes through")
+    void shouldAcceptLauncherFlag() throws ParseException {
+        // The mvn script selects the tool with --val but does not remove the flag, so the
+        // whole argument list, --val included, reaches this parser.
+        CommonsCliValidateOptions options = CommonsCliValidateOptions.parse(new String[] {"--val", "a/pom.xml"});
+
+        assertEquals(
+                List.of("a/pom.xml"),
+                options.poms().orElseThrow(),
+                "--val is the launcher's own flag and must not be taken for a pom");
+    }
+
+    @Test
+    @DisplayName("should keep poms and format independent")
+    void shouldKeepPomsAndFormatIndependent() throws ParseException {
+        CommonsCliValidateOptions options =
+                CommonsCliValidateOptions.parse(new String[] {"--format", "text", "only/pom.xml"});
+
+        assertEquals("text", options.format().orElseThrow());
+        assertEquals(List.of("only/pom.xml"), options.poms().orElseThrow());
+    }
+
+    @Test
+    @DisplayName("should not offer goals or phases in the usage line")
+    void shouldNotOfferGoalsInUsage() throws ParseException {
+        List<String> help = new ArrayList<>();
+        CommonsCliValidateOptions.parse(new String[] {"--help"})
+                .displayHelp(
+                        ParserRequest.mvnval(new String[0], new JLineMessageBuilderFactory())
+                                .build(),
+                        help::add);
+
+        String usage =
+                help.stream().filter(l -> l.contains("usage:")).findFirst().orElseThrow();
+        assertTrue(usage.contains("<pom>"), "mvnval takes POM files: " + usage);
+        assertFalse(usage.contains("goal"), "mvnval runs no goals or phases: " + usage);
+    }
+
+    @Test
+    @DisplayName("should take a value for --mode without swallowing the pom")
+    void shouldTakeModeValue() throws ParseException {
+        CommonsCliValidateOptions options = CommonsCliValidateOptions.parse(new String[] {"--mode", "raw", "pom.xml"});
+
+        assertEquals("raw", options.mode().orElseThrow());
+        assertEquals(List.of("pom.xml"), options.poms().orElseThrow(), "the value must not swallow the pom");
+    }
+
+    @Test
+    @DisplayName("should take the local repository path")
+    void shouldTakeLocalRepositoryPath() throws ParseException {
+        CommonsCliValidateOptions options =
+                CommonsCliValidateOptions.parse(new String[] {"--local-repository", "/tmp/repo", "pom.xml"});
+
+        assertEquals("/tmp/repo", options.localRepository().orElseThrow());
+        assertEquals(List.of("pom.xml"), options.poms().orElseThrow(), "the value must not swallow the pom");
+        assertTrue(options.tempLocalRepository().isEmpty(), "the two options are separate");
+    }
+
+    @Test
+    @DisplayName("should take the throwaway local repository as a flag with no value")
+    void shouldTakeTempLocalRepository() throws ParseException {
+        CommonsCliValidateOptions options =
+                CommonsCliValidateOptions.parse(new String[] {"--temp-local-repository", "pom.xml"});
+
+        assertEquals(true, options.tempLocalRepository().orElseThrow());
+        assertEquals(List.of("pom.xml"), options.poms().orElseThrow(), "the flag takes no argument");
+    }
+
+    @Test
+    @DisplayName("should fold the deprecated -gs into the installation settings")
+    void shouldFoldDeprecatedGlobalSettings() throws ParseException {
+        // The parser accepts both spellings for one option, which is why the refusal message
+        // names both: someone who typed -gs would not recognise --install-settings.
+        CommonsCliValidateOptions options =
+                CommonsCliValidateOptions.parse(new String[] {"-gs", "settings.xml", "pom.xml"});
+
+        assertEquals("settings.xml", options.altInstallationSettings().orElseThrow());
+    }
+
+    @Test
+    @DisplayName("should leave the mode empty when it is not given")
+    void shouldLeaveModeEmptyWhenNotGiven() throws ParseException {
+        CommonsCliValidateOptions options = CommonsCliValidateOptions.parse(new String[] {"pom.xml"});
+
+        assertTrue(options.mode().isEmpty(), "empty, not a default, so the invoker decides what the default is");
+    }
+
+    @Test
+    @DisplayName("should take a context size from -C")
+    void shouldTakeContextShortOption() throws ParseException {
+        CommonsCliValidateOptions options = CommonsCliValidateOptions.parse(new String[] {"-C", "5", "pom.xml"});
+
+        assertEquals(5, options.context().orElseThrow());
+        assertEquals(List.of("pom.xml"), options.poms().orElseThrow(), "the value must not swallow the pom");
+    }
+
+    @Test
+    @DisplayName("should take a context size from --context")
+    void shouldTakeContextLongOption() throws ParseException {
+        CommonsCliValidateOptions options = CommonsCliValidateOptions.parse(new String[] {"--context", "0", "pom.xml"});
+
+        assertEquals(0, options.context().orElseThrow());
+    }
+
+    @Test
+    @DisplayName("should leave context empty when not given")
+    void shouldLeaveContextEmptyWhenNotGiven() throws ParseException {
+        CommonsCliValidateOptions options = CommonsCliValidateOptions.parse(new String[] {"pom.xml"});
+
+        assertTrue(options.context().isEmpty(), "empty, not a default, so the invoker decides what the default is");
+    }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/OutputFormatTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/OutputFormatTest.java
new file mode 100644
index 0000000000..f247b7e3f1
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/OutputFormatTest.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnval;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for the {@link OutputFormat} enum.
+ * Tests that names are matched case insensitively and that unknown names are rejected
+ * rather than silently falling back to the default.
+ */
+@DisplayName("OutputFormat")
+class OutputFormatTest {
+
+    @Test
+    @DisplayName("should match a name regardless of case")
+    void shouldMatchNameRegardlessOfCase() {
+        assertSame(OutputFormat.JSON, OutputFormat.parse("json"));
+        assertSame(OutputFormat.JSON, OutputFormat.parse("JSON"));
+        assertSame(OutputFormat.TEXT, OutputFormat.parse("Text"));
+    }
+
+    @Test
+    @DisplayName("should reject an unknown name instead of falling back")
+    void shouldRejectUnknownName() {
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> OutputFormat.parse("xml"));
+        assertTrue(e.getMessage().contains("xml"), "the message should name the rejected value: " + e.getMessage());
+        assertTrue(
+                e.getMessage().contains("text") && e.getMessage().contains("json"),
+                "the message should list the accepted values: " + e.getMessage());
+    }
+
+    @Test
+    @DisplayName("should render something for every format")
+    void shouldRenderSomethingForEveryFormat() {
+        Report report = Report.of(Path.of("pom.xml"), List.of());
+
+        for (OutputFormat format : OutputFormat.values()) {
+            List<String> lines = new ArrayList<>();
+            format.report(List.of(report), Path.of("").toAbsolutePath(), lines::add, 0, false);
+
+            assertFalse(lines.isEmpty(), "every format must produce output: " + format);
+        }
+    }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/ReportTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/ReportTest.java
new file mode 100644
index 0000000000..76349145f6
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/ReportTest.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnval;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.maven.api.services.BuilderProblem;
+import org.apache.maven.api.services.ModelProblem;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for the {@link Report} record.
+ * Tests the invariants the compact constructor enforces and the clean/dirty distinction.
+ */
+@DisplayName("Report")
+class ReportTest {
+
+    private static final Path POM = Path.of("pom.xml");
+
+    @Nested
+    @DisplayName("Problems")
+    class ProblemTests {
+
+        @Test
+        @DisplayName("should report a problem once when two validation phases both found it")
+        void shouldReportRepeatedProblemOnce() {
+            // The parent version check sits in both validateFileModel and validateRawModel, so
+            // the model builder hands the same finding over twice.
+            ModelProblem problem =
+                    TestUtils.problem(BuilderProblem.Severity.WARNING, "'parent.version' is LATEST", "pom.xml", 3, 66);
+            ModelProblem again =
+                    TestUtils.problem(BuilderProblem.Severity.WARNING, "'parent.version' is LATEST", "pom.xml", 3, 66);
+
+            assertEquals(1, Report.of(POM, List.of(problem, again)).problems().size());
+        }
+
+        @Test
+        @DisplayName("should keep two problems that differ only in where they sit")
+        void shouldKeepProblemsAtDifferentPlaces() {
+            ModelProblem first = TestUtils.problem(BuilderProblem.Severity.WARNING, "same words", "pom.xml", 3, 1);
+            ModelProblem second = TestUtils.problem(BuilderProblem.Severity.WARNING, "same words", "pom.xml", 9, 1);
+
+            assertEquals(2, Report.of(POM, List.of(first, second)).problems().size());
+        }
+    }
+
+    @Nested
+    @DisplayName("State")
+    class StateTests {
+
+        @Test
+        @DisplayName("should be clean when there is neither a failure nor a problem")
+        void shouldBeCleanWhenNeitherFailureNorProblem() {
+            assertTrue(Report.of(POM, List.of()).clean(), "a report with no problems should be clean");
+        }
+
+        @Test
+        @DisplayName("should not be clean when a problem was reported")
+        void shouldNotBeCleanWhenProblemReported() {
+            Report report = Report.of(
+                    POM, List.of(TestUtils.problem(BuilderProblem.Severity.WARNING, "warn", "pom.xml", 1, 1)));
+            assertFalse(report.clean(), "a report carrying a problem should not be clean");
+        }
+
+        @Test
+        @DisplayName("should not be clean when the file could not be validated")
+        void shouldNotBeCleanWhenValidationFailed() {
+            assertFalse(Report.failed(POM, "not a readable file").clean(), "a failed report should not be clean");
+        }
+    }
+
+    @Nested
+    @DisplayName("Invariants")
+    class InvariantTests {
+
+        @Test
+        @DisplayName("should reject a failure that also carries problems")
+        void shouldRejectFailureCarryingProblems() {
+            List<ModelProblem> problems =
+                    List.of(TestUtils.problem(BuilderProblem.Severity.ERROR, "boom", "pom.xml", 1, 1));
+            assertThrows(
+                    IllegalArgumentException.class,
+                    () -> new Report(POM, problems, "not a readable file"),
+                    "a failed report must not also carry problems");
+        }
+
+        @Test
+        @DisplayName("should copy the problem list so later changes cannot leak in")
+        void shouldCopyProblemList() {
+            List<ModelProblem> mutable = new ArrayList<>();
+            mutable.add(TestUtils.problem(BuilderProblem.Severity.ERROR, "first", "pom.xml", 1, 1));
+            Report report = Report.of(POM, mutable);
+
+            mutable.add(TestUtils.problem(BuilderProblem.Severity.ERROR, "second", "pom.xml", 2, 2));
+
+            assertEquals(1, report.problems().size(), "the report should not see additions made after it was built");
+        }
+    }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/ReporterTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/ReporterTest.java
new file mode 100644
index 0000000000..f547524489
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/ReporterTest.java
@@ -0,0 +1,300 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnval;
+
+import java.nio.file.Path;
+import java.util.List;
+
+import org.apache.maven.api.services.BuilderProblem;
+import org.apache.maven.api.services.ModelProblem;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for the two output formats.
+ * Tests that both formats derive a problem's location from its parts rather than from
+ * {@link ModelProblem#getLocation()}, and that the JSON document escapes what it must.
+ */
+@DisplayName("Reporter")
+class ReporterTest {
+
+    private static final Path POM = Path.of("bad/pom.xml");
+
+    /** Text output names the POM on the first line and lists the problems under it. */
+    private static final int FIRST_PROBLEM_LINE = 1;
+
+    /** The problem line of a rendered text report, without the "  SEVERITY message" prefix. */
+    private static String locationOf(ModelProblem problem) {
+        String line = TestUtils.render(OutputFormat.TEXT, List.of(Report.of(POM, List.of(problem))))
+                .get(FIRST_PROBLEM_LINE);
+        int at = line.indexOf(" @ ");
+        return at < 0 ? "" : line.substring(at + 3);
+    }
+
+    @Test
+    @DisplayName("should not rely on getLocation, which the model builder leaves empty")
+    void shouldNotRelyOnGetLocation() {
+        ModelProblem problem = TestUtils.problem(BuilderProblem.Severity.ERROR, "boom", "bad/pom.xml", 13, 17);
+
+        assertEquals(
+                "",
+                problem.getLocation(),
+                "DefaultModelProblem hardcodes getLocation() to empty; the reporter must build it itself");
+        assertEquals("line 13, column 17", locationOf(problem), "minus the file already named on the line above");
+    }
+
+    @Test
+    @DisplayName("should leave out the parts of a location that are unknown")
+    void shouldLeaveOutUnknownLocationParts() {
+        assertEquals(
+                "",
+                locationOf(TestUtils.problem(BuilderProblem.Severity.WARNING, "no place", null, -1, -1)),
+                "a problem with no location at all should add nothing");
+        assertEquals(
+                "",
+                locationOf(TestUtils.problem(BuilderProblem.Severity.WARNING, "same file", "bad/pom.xml", 0, 0)),
+                "a source that is the file being reported on adds nothing");
+        assertEquals(
+                "line 7",
+                locationOf(TestUtils.problem(BuilderProblem.Severity.WARNING, "line only", null, 7, 0)),
+                "a problem with only a line should produce just the line");
+    }
+
+    @Test
+    @DisplayName("should name the source as a path when the problem comes from another file")
+    void shouldNameSourceOfAnotherFileAsPath() {
+        Path other = Path.of("other/pom.xml").toAbsolutePath().normalize();
+        ModelProblem problem = TestUtils.problem(
+                BuilderProblem.Severity.ERROR, "elsewhere", other.toUri().toString(), 4, 0);
+
+        assertEquals(
+                other + ", line 4",
+                locationOf(problem),
+                "the model builder reports the source as a file URL; the output uses paths");
+    }
+
+    @Test
+    @DisplayName("should indent the continuation lines of a multi-line message")
+    void shouldIndentContinuationLines() {
+        // A parser failure arrives as several lines; unindented, the tail would look like a
+        // separate entry rather than part of this problem.
+        ModelProblem problem = TestUtils.problem(
+                BuilderProblem.Severity.FATAL, "Non-parseable POM" + System.lineSeparator() + "at [1,19]", null, 0, 0);
+
+        List<String> lines = TestUtils.render(OutputFormat.TEXT, List.of(Report.of(POM, List.of(problem))));
+
+        assertEquals(
+                "  FATAL Non-parseable POM" + System.lineSeparator() + "    at [1,19]", lines.get(FIRST_PROBLEM_LINE));
+    }
+
+    @Test
+    @DisplayName("should pass a source that is not a file through unchanged")
+    void shouldPassNonFileSourceThrough() {
+        // On Unix this parses as a relative path, and resolving it would name a file in the
+        // working directory that was never involved.
+        ModelProblem problem = TestUtils.problem(BuilderProblem.Severity.ERROR, "imported", "org.test:bom:1.0", 0, 0);
+
+        assertEquals("org.test:bom:1.0", Report.of(POM, List.of(problem)).sourceOf(problem));
+    }
+
+    @Nested
+    @DisplayName("Text")
+    class TextTests {
+
+        @Test
+        @DisplayName("should report a clean file on one line")
+        void shouldReportCleanFileOnOneLine() {
+            List<String> lines = TestUtils.render(OutputFormat.TEXT, List.of(Report.of(POM, List.of())));
+            assertEquals(List.of(POM + ": no problems"), lines);
+        }
+
+        @Test
+        @DisplayName("should append the location after the message")
+        void shouldAppendLocationAfterMessage() {
+            List<String> lines = TestUtils.render(
+                    OutputFormat.TEXT,
+                    List.of(Report.of(
+                            POM,
+                            List.of(TestUtils.problem(BuilderProblem.Severity.ERROR, "boom", "bad/pom.xml", 13, 17)))));
+
+            assertEquals(POM + ":", lines.get(0));
+            assertEquals("  ERROR boom @ line 13, column 17", lines.get(FIRST_PROBLEM_LINE));
+        }
+
+        @Test
+        @DisplayName("should omit the location separator when there is no location")
+        void shouldOmitLocationSeparatorWhenNoLocation() {
+            List<String> lines = TestUtils.render(
+                    OutputFormat.TEXT,
+                    List.of(Report.of(
+                            POM,
+                            List.of(TestUtils.problem(BuilderProblem.Severity.WARNING, "no place", null, -1, -1)))));
+
+            assertEquals(
+                    "  WARNING no place", lines.get(FIRST_PROBLEM_LINE), "no location means no trailing separator");
+        }
+
+        @Test
+        @DisplayName("should report a file that could not be read")
+        void shouldReportUnreadableFile() {
+            List<String> lines =
+                    TestUtils.render(OutputFormat.TEXT, List.of(Report.failed(POM, "not a readable file")));
+            assertEquals(List.of(POM + ": not a readable file"), lines);
+        }
+
+        @Test
+        @DisplayName("should emit context lines around a problem when the source file exists")
+        void shouldEmitContextLines() throws Exception {
+            // Write a temporary POM with some content so the context reader can find the file.
+            java.nio.file.Path tmp = java.nio.file.Files.createTempFile("mvnval-test-", ".xml");
+            try {
+                java.nio.file.Files.writeString(tmp, "<project>\n  <bad/>\n  <also/>\n</project>\n");
+                // Problem on line 2, context=1 → lines 1–3 should appear.
+                List<String> lines = TestUtils.render(
+                        OutputFormat.TEXT,
+                        List.of(Report.of(
+                                tmp,
+                                List.of(TestUtils.problem(
+                                        BuilderProblem.Severity.ERROR, "bad element", tmp.toString(), 2, 3)))),
+                        1);
+                // Verify the marker line is present.
+                assertTrue(
+                        lines.stream().anyMatch(l -> l.contains("> ") && l.contains("<bad/>")),
+                        "problem line must carry the > marker: " + lines);
+                // Verify the surrounding context line is present.
+                assertTrue(
+                        lines.stream().anyMatch(l -> !l.contains("> ") && l.contains("<project>")),
+                        "context line before the problem must appear: " + lines);
+                assertTrue(
+                        lines.stream().anyMatch(l -> !l.contains("> ") && l.contains("<also/>")),
+                        "context line after the problem must appear: " + lines);
+            } finally {
+                java.nio.file.Files.deleteIfExists(tmp);
+            }
+        }
+
+        @Test
+        @DisplayName("should suppress context when --context 0 is used")
+        void shouldSuppressContextWhenZero() throws Exception {
+            java.nio.file.Path tmp = java.nio.file.Files.createTempFile("mvnval-test-", ".xml");
+            try {
+                java.nio.file.Files.writeString(tmp, "<project>\n  <bad/>\n</project>\n");
+                List<String> lines = TestUtils.render(
+                        OutputFormat.TEXT,
+                        List.of(Report.of(
+                                tmp,
+                                List.of(TestUtils.problem(
+                                        BuilderProblem.Severity.ERROR, "bad element", tmp.toString(), 2, 1)))),
+                        0);
+                // No gutter lines — only the header and the problem line itself.
+                assertTrue(lines.stream().noneMatch(l -> l.contains("|")), "no gutter lines when context=0: " + lines);
+            } finally {
+                java.nio.file.Files.deleteIfExists(tmp);
+            }
+        }
+    }
+
+    @Nested
+    @DisplayName("JSON")
+    class JsonTests {
+
+        @Test
+        @DisplayName("should emit one object per file in a single array")
+        void shouldEmitOneObjectPerFile() {
+            List<String> lines = TestUtils.render(
+                    OutputFormat.JSON,
+                    List.of(Report.of(Path.of("a.xml"), List.of()), Report.of(Path.of("b.xml"), List.of())));
+
+            assertEquals(1, lines.size(), "the whole document should be written as one line");
+            assertEquals("[{\"pom\":\"a.xml\",\"problems\":[]},{\"pom\":\"b.xml\",\"problems\":[]}]", lines.get(0));
+        }
+
+        @Test
+        @DisplayName("should leave out absent fields rather than emitting the -1 sentinel")
+        void shouldLeaveOutAbsentFields() {
+            String json = TestUtils.render(
+                            OutputFormat.JSON,
+                            List.of(Report.of(
+                                    POM,
+                                    List.of(TestUtils.problem(
+                                            BuilderProblem.Severity.WARNING, "no place", null, -1, -1)))))
+                    .get(0);
+
+            assertFalse(json.contains("-1"), "the -1 the model builder uses for \"unknown\" must not leak: " + json);
+            assertFalse(json.contains("\"line\""), "an unknown line should be left out entirely: " + json);
+            assertFalse(json.contains("\"source\""), "an unknown source should be left out entirely: " + json);
+            assertFalse(json.contains("file:"), "a file URL must not reach the document: " + json);
+            assertTrue(json.contains("\"severity\":\"WARNING\""), json);
+        }
+
+        @Test
+        @DisplayName("should escape quotes, backslashes and control characters")
+        void shouldEscapeQuotesBackslashesAndControlCharacters() {
+            String json = TestUtils.render(
+                            OutputFormat.JSON,
+                            List.of(Report.of(
+                                    POM,
+                                    List.of(TestUtils.problem(
+                                            BuilderProblem.Severity.ERROR,
+                                            "say \"hi\"\nand\ttab\\end",
+                                            "bad/pom.xml",
+                                            1,
+                                            1)))))
+                    .get(0);
+
+            assertTrue(json.contains("say \\\"hi\\\""), "quotes should be escaped: " + json);
+            assertTrue(json.contains("\\n"), "newlines should be escaped: " + json);
+            assertTrue(json.contains("\\t"), "tabs should be escaped: " + json);
+            assertTrue(json.contains("\\\\end"), "backslashes should be escaped: " + json);
+        }
+
+        @Test
+        @DisplayName("should escape everything outside printable ASCII")
+        void shouldEscapeEverythingOutsidePrintableAscii() {
+            // A lone high surrogate is reachable from a filename and no charset can encode it; a
+            // console that is not UTF-8 would mangle the accented letter. Both are escaped so the
+            // document survives whatever the writer's charset turns out to be.
+            String json = TestUtils.render(
+                            OutputFormat.JSON,
+                            List.of(Report.of(
+                                    POM,
+                                    List.of(TestUtils.problem(
+                                            BuilderProblem.Severity.ERROR, "\u0001 café \ud83d", null, 1, 1)))))
+                    .get(0);
+
+            assertTrue(json.contains("\\u0001"), "control characters should be escaped: " + json);
+            assertTrue(json.contains("\\u00e9"), "non-ASCII should be escaped: " + json);
+            assertTrue(json.contains("\\ud83d"), "an unpaired surrogate should be escaped: " + json);
+            assertTrue(json.chars().allMatch(c -> c >= 0x20 && c <= 0x7e), "the document should be plain ASCII");
+        }
+
+        @Test
+        @DisplayName("should record a file that could not be read as a failure")
+        void shouldRecordUnreadableFileAsFailure() {
+            String json = TestUtils.render(OutputFormat.JSON, List.of(Report.failed(POM, "not a readable file")))
+                    .get(0);
+            assertTrue(json.contains("\"failure\":\"not a readable file\""), json);
+        }
+    }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/TestUtils.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/TestUtils.java
new file mode 100644
index 0000000000..1f41cfd982
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/TestUtils.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnval;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.maven.api.cli.InvokerRequest;
+import org.apache.maven.api.cli.Logger;
+import org.apache.maven.api.cli.ParserRequest;
+import org.apache.maven.api.cli.mvnval.ValidateOptions;
+import org.apache.maven.api.services.BuilderProblem;
+import org.apache.maven.api.services.ModelProblem;
+import org.apache.maven.impl.model.DefaultModelProblem;
+import org.jline.terminal.Terminal;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+final class TestUtils {
+
+    private TestUtils() {}
+
+    /**
+     * Creates a problem the way the model builder does, through {@link DefaultModelProblem}.
+     * Fakes must not be used here: {@code DefaultModelProblem.getLocation()} returns an empty
+     * string, and a fake that returns a location hides the bug that fact caused.
+     */
+    static ModelProblem problem(BuilderProblem.Severity severity, String message, String source, int line, int column) {
+        return new DefaultModelProblem(
+                message, severity, ModelProblem.Version.V40, source, line, column, "org.test:test:1.0", null);
+    }
+
+    /** Collects the lines a reporter writes, in order, as if run from an unrelated directory. */
+    static List<String> render(OutputFormat format, List<Report> reports) {
+        return render(format, reports, 0);
+    }
+
+    /** Collects the lines a reporter writes with the given source-context window. */
+    static List<String> render(OutputFormat format, List<Report> reports, int context) {
+        List<String> lines = new ArrayList<>();
+        format.report(reports, Paths.get("/elsewhere"), lines::add, context, false);
+        return lines;
+    }
+
+    static ValidateContext createMockContext(Path workingDirectory, ValidateOptions options) {
+        InvokerRequest request = mock(InvokerRequest.class);
+
+        when(request.cwd()).thenReturn(workingDirectory);
+        when(request.installationDirectory()).thenReturn(Paths.get("/maven"));
+        when(request.userHomeDirectory()).thenReturn(Paths.get("/home/user"));
+        when(request.topDirectory()).thenReturn(workingDirectory);
+        when(request.rootDirectory()).thenReturn(Optional.empty());
+        when(request.userProperties()).thenReturn(Map.of());
+        when(request.systemProperties()).thenReturn(Map.of());
+        when(request.options()).thenReturn(Optional.ofNullable(options));
+
+        ParserRequest parserRequest = mock(ParserRequest.class);
+        Logger logger = mock(Logger.class);
+        doAnswer(invocation -> {
+                    System.err.println("[ERROR] " + invocation.getArgument(0));
+                    return null;
+                })
+                .when(logger)
+                .error(anyString());
+
+        when(request.parserRequest()).thenReturn(parserRequest);
+        when(parserRequest.logger()).thenReturn(logger);
+
+        ValidateContext context = new ValidateContext(request, options);
+        // LookupInvoker.createTerminal fills this in on a real run; the invoker installs its
+        // interrupt handler on it, so a test driving execute() directly needs one here.
+        context.terminal = mock(Terminal.class);
+        return context;
+    }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/ValidateInvokerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/ValidateInvokerTest.java
new file mode 100644
index 0000000000..2834a7198f
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnval/ValidateInvokerTest.java
@@ -0,0 +1,908 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnval;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.maven.api.ProtoSession;
+import org.apache.maven.api.Session;
+import org.apache.maven.api.cli.InvokerException;
+import org.apache.maven.api.cli.mvnval.ValidateOptions;
+import org.apache.maven.api.services.ModelBuilder;
+import org.apache.maven.cling.invoker.ProtoLookup;
+import org.apache.maven.impl.standalone.ApiRunner;
+import org.jline.terminal.Terminal;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for the {@link ValidateInvoker} class.
+ * Tests that the verdict depends on the file alone, that a file which cannot be read does not
+ * lose the verdict on the others, and that the exit code follows the reports.
+ */
+@DisplayName("ValidateInvoker")
+class ValidateInvokerTest {
+
+    private static final String DUPLICATE_DEPENDENCY = """
+            <project xmlns="http://maven.apache.org/POM/4.0.0">
+              <modelVersion>4.0.0</modelVersion>
+              %s
+              <groupId>org.test</groupId>
+              <artifactId>test</artifactId>
+              <version>1.0</version>
+              <dependencies>
+                <dependency>
+                  <groupId>junit</groupId><artifactId>junit</artifactId><version>4.13.2</version>
+                </dependency>
+                <dependency>
+                  <groupId>junit</groupId><artifactId>junit</artifactId><version>4.12</version>
+                </dependency>
+              </dependencies>
+            </project>
+            """;
+
+    private static final String UNRESOLVABLE_PARENT = """
+            <parent>
+                <groupId>org.nowhere</groupId>
+                <artifactId>does-not-exist</artifactId>
+                <version>999.0.0</version>
+              </parent>""";
+
+    @TempDir
+    Path tempDir;
+
+    private List<String> output;
+
+    /** Every path handed to the seam, so a test can assert on the one the run actually made. */
+    private List<Path> aimed;
+
+    private ValidateInvoker invoker;
+
+    @BeforeEach
+    void setUp() {
+        output = new ArrayList<>();
+        // One session for the whole test: building one is the expensive part of every test here.
+        Session shared = ApiRunner.createSession();
+        aimed = new ArrayList<>();
+        invoker = new ValidateInvoker(ProtoLookup.builder().build(), null) {
+            @Override
+            protected Session createSession(Path localRepository) {
+                // withLocalRepository is the line production uses, so an aimed repository is
+                // exercised rather than stubbed. The transports and the settings-derived
+                // repository list production adds are not: this session has neither.
+                aimed.add(localRepository);
+                return localRepository == null
+                        ? shared
+                        : shared.withLocalRepository(shared.createLocalRepository(localRepository));
+            }
+        };
+    }
+
+    private Path writePom(String name, String parent) throws Exception {
+        Path pom = Files.createDirectories(tempDir.resolve(name)).resolve("pom.xml");
+        Files.writeString(pom, String.format(DUPLICATE_DEPENDENCY, parent));
+        return pom;
+    }
+
+    private int run(List<String> poms) throws Exception {
+        return run(poms, Optional.empty());
+    }
+
+    private int run(List<String> poms, Optional<String> format) throws Exception {
+        return run(poms, format, Optional.of("raw"));
+    }
+
+    /** Defaults to raw: the tests using this helper read files only. */
+    private int run(List<String> poms, Optional<String> format, Optional<String> mode) throws Exception {
+        ValidateOptions options = mock(ValidateOptions.class);
+        when(options.format()).thenReturn(format);
+        when(options.mode()).thenReturn(mode);
+        when(options.poms()).thenReturn(poms.isEmpty() ? Optional.empty() : Optional.of(poms));
+
+        ValidateContext context = TestUtils.createMockContext(tempDir, options);
+        context.writer = output::add;
+        return invoker.execute(context);
+    }
+
+    @Nested
+    @DisplayName("Wiring")
+    class WiringTests {
+
+        @Test
+        @DisplayName("should register the transports resolution needs")
+        void shouldRegisterTransports() throws Exception {
+            // The block whose absence produces "No transporter factories registered". Proved over
+            // file://, which needs FileTransporterFactory and no network. Every other test here
+            // replaces createSession, so without this the block could be deleted and stay green.
+            Path repository = Files.createDirectories(tempDir.resolve("served/org/test/far/1.0"));
+            String parent = """
+                    <project xmlns="http://maven.apache.org/POM/4.0.0">
+                      <modelVersion>4.0.0</modelVersion>
+                      <groupId>org.test</groupId><artifactId>far</artifactId>
+                      <version>1.0</version><packaging>pom</packaging>
+                    </project>""";
+            Files.writeString(repository.resolve("far-1.0.pom"), parent);
+            // A repository without checksums is refused before the content is looked at, so the
+            // transport would never be exercised.
+            Files.writeString(repository.resolve("far-1.0.pom.sha1"), sha1(parent));
+            Path pom = Files.createDirectories(tempDir.resolve("fetcher")).resolve("pom.xml");
+            Files.writeString(pom, """
+                    <project xmlns="http://maven.apache.org/POM/4.0.0">
+                      <modelVersion>4.0.0</modelVersion>
+                      <parent>
+                        <groupId>org.test</groupId><artifactId>far</artifactId>
+                        <version>1.0</version><relativePath/>
+                      </parent>
+                      <artifactId>fetcher</artifactId>
+                      <repositories>
+                        <repository><id>served</id><url>%s</url></repository>
+                      </repositories>
+                    </project>""".formatted(tempDir.resolve("served").toUri()));
+
+            ValidateOptions options = mock(ValidateOptions.class);
+            when(options.mode()).thenReturn(Optional.of("effective"));
+            when(options.localRepository())
+                    .thenReturn(Optional.of(tempDir.resolve("into").toString()));
+            when(options.poms()).thenReturn(Optional.of(List.of(pom.toString())));
+            ValidateContext context = TestUtils.createMockContext(tempDir, options);
+            context.writer = output::add;
+
+            int exitCode = new ValidateInvoker(ProtoLookup.builder().build(), null).execute(context);
+
+            assertEquals(ValidateInvoker.OK, exitCode, output.toString());
+            assertTrue(
+                    Files.exists(tempDir.resolve("into/org/test/far/1.0/far-1.0.pom")),
+                    "the parent has to arrive through a transport: " + output);
+        }
+
+        @Test
+        @DisplayName("should exit on bad usage when the model builder cannot validate")
+        void shouldRefuseAModelBuilderThatCannotValidate() throws Exception {
+            // validate() is a default that throws, so an out-of-tree ModelBuilder may not have it.
+            // Unreachable from the shipped CLI, which is why it is worth pinning here: nothing was
+            // wrong with the POM, so this must not be the code that means a POM was rejected.
+            ValidateInvoker refusing = new ValidateInvoker(ProtoLookup.builder().build(), null) {
+                @Override
+                protected Session createSession(Path localRepository) {
+                    Session session = mock(Session.class);
+                    ModelBuilder builder = mock(ModelBuilder.class);
+                    ModelBuilder.ModelBuilderSession builderSession = mock(ModelBuilder.ModelBuilderSession.class);
+                    when(session.getService(ModelBuilder.class)).thenReturn(builder);
+                    when(builder.newSession()).thenReturn(builderSession);
+                    when(builderSession.validate(any()))
+                            .thenThrow(new UnsupportedOperationException("does not support validating a model"));
+                    return session;
+                }
+            };
+            ValidateOptions options = mock(ValidateOptions.class);
+            when(options.mode()).thenReturn(Optional.of("raw"));
+            when(options.poms())
+                    .thenReturn(Optional.of(List.of(writePom("unsupported", "").toString())));
+            ValidateContext context = TestUtils.createMockContext(tempDir, options);
+            context.writer = output::add;
+
+            assertEquals(ValidateInvoker.BAD_OPERATION, refusing.execute(context));
+        }
+    }
+
+    private static String sha1(String content) throws Exception {
+        byte[] digest = java.security.MessageDigest.getInstance("SHA-1").digest(content.getBytes(UTF_8));
+        StringBuilder hex = new StringBuilder(digest.length * 2);
+        for (byte b : digest) {
+            hex.append(String.format("%02x", b));
+        }
+        return hex.toString();
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"raw", "effective"})
+    void shouldLetUserPropertiesOverridePomProperties(String mode) throws Exception {
+        Path root = Files.createDirectories(tempDir.resolve("properties"));
+        Files.writeString(root.resolve("pom.xml"), """
+                <project xmlns="http://maven.apache.org/POM/4.0.0">
+                  <modelVersion>4.0.0</modelVersion>
+                  <groupId>org.test</groupId><artifactId>parent</artifactId>
+                  <version>1.0</version><packaging>pom</packaging>
+                  <properties>
+                    <child>missing</child>
+                    <dependencyVersion/>
+                  </properties>
+                  <modules><module>${child}</module></modules>
+                  <dependencies>
+                    <dependency>
+                      <groupId>org.test</groupId><artifactId>dependency</artifactId>
+                      <version>${dependencyVersion}</version>
+                    </dependency>
+                  </dependencies>
+                </project>
+                """);
+        Path module = Files.createDirectories(root.resolve("mod"));
+        Files.writeString(module.resolve("pom.xml"), """
+                <project xmlns="http://maven.apache.org/POM/4.0.0">
+                  <modelVersion>4.0.0</modelVersion>
+                  <groupId>org.test</groupId><artifactId>mod</artifactId><version>1.0</version>
+                </project>
+                """);
+        ValidateOptions options = mock(ValidateOptions.class);
+        when(options.mode()).thenReturn(Optional.of(mode));
+        ValidateContext context = TestUtils.createMockContext(root, options);
+        context.protoSession = ProtoSession.newBuilder()
+                .withUserProperties(Map.of("child", "mod", "dependencyVersion", "1.0"))
+                .withTopDirectory(root)
+                .build();
+        context.writer = output::add;
+
+        assertEquals(ValidateInvoker.OK, invoker.execute(context), output.toString());
+        assertEquals(1, output.size());
+        assertTrue(output.get(0).endsWith(": no problems"), output.toString());
+    }
+
+    @Test
+    void shouldCancelFromAnotherThreadAfterTheModelBuilderClearsTheInterrupt() throws Exception {
+        ValidateOptions options = mock(ValidateOptions.class);
+        when(options.mode()).thenReturn(Optional.of("raw"));
+        when(options.poms())
+                .thenReturn(Optional.of(List.of(
+                        writePom("first", "").toString(), writePom("second", "").toString())));
+        ValidateContext context = TestUtils.createMockContext(tempDir, options);
+        context.writer = output::add;
+        Terminal.SignalHandler previousHandler = mock(Terminal.SignalHandler.class);
+        AtomicReference<Terminal.SignalHandler> handler = new AtomicReference<>();
+        doAnswer(call -> {
+                    handler.set(call.getArgument(1));
+                    return previousHandler;
+                })
+                .when(context.terminal)
+                .handle(eq(Terminal.Signal.INT), any());
+
+        Session session = mock(Session.class);
+        ModelBuilder builder = mock(ModelBuilder.class);
+        ModelBuilder.ModelBuilderSession builderSession = mock(ModelBuilder.ModelBuilderSession.class);
+        when(session.getService(ModelBuilder.class)).thenReturn(builder);
+        when(builder.newSession()).thenReturn(builderSession);
+        AtomicBoolean validationThreadInterrupted = new AtomicBoolean();
+        when(builderSession.validate(any())).thenAnswer(call -> {
+            CompletableFuture.runAsync(() -> handler.get().handle(Terminal.Signal.INT))
+                    .join();
+            validationThreadInterrupted.set(Thread.interrupted());
+            throw new IllegalStateException("Interrupted model read");
+        });
+        ValidateInvoker canceling = new ValidateInvoker(ProtoLookup.builder().build(), null) {
+            @Override
+            protected Session createSession(Path localRepository) {
+                return session;
+            }
+        };
+
+        try {
+            assertEquals(ValidateInvoker.CANCELED, canceling.execute(context));
+            assertTrue(validationThreadInterrupted.get());
+            assertFalse(Thread.currentThread().isInterrupted());
+            assertTrue(output.isEmpty(), output.toString());
+            verify(builderSession, times(1)).validate(any());
+            verify(context.terminal).handle(Terminal.Signal.INT, previousHandler);
+        } finally {
+            Thread.interrupted();
+        }
+    }
+
+    @Nested
+    @DisplayName("Determinism")
+    class DeterminismTests {
+
+        @Test
+        @DisplayName("should reach the same verdict whether or not the parent can be resolved")
+        void shouldReachSameVerdictRegardlessOfParent() throws Exception {
+            Path withParent = writePom("with-parent", UNRESOLVABLE_PARENT);
+            Path withoutParent = writePom("without-parent", "");
+
+            int exitWith = run(List.of(withParent.toString()));
+            List<String> linesWith = List.copyOf(output);
+            output.clear();
+            int exitWithout = run(List.of(withoutParent.toString()));
+
+            assertEquals(exitWith, exitWithout, "an unresolvable parent must not change the exit code");
+            assertTrue(
+                    linesWith.stream().anyMatch(l -> l.contains("must be unique")),
+                    "the duplicate dependency should be reported even with an unresolvable parent: " + linesWith);
+            assertTrue(
+                    output.stream().anyMatch(l -> l.contains("must be unique")),
+                    "and also without a parent: " + output);
+        }
+
+        @Test
+        @DisplayName("should print a path under the working directory as the caller typed it")
+        void shouldPrintPathsRelativeToTheWorkingDirectory() throws Exception {
+            Path pom = writePom("near", "");
+            Path outsideRoot = Files.createTempDirectory("mvnval-outside-");
+            Path outsideDirectory = outsideRoot.resolve("far");
+            Path outside = outsideDirectory.resolve("pom.xml");
+            try {
+                Files.createDirectories(outsideDirectory);
+                Files.writeString(outside, String.format(DUPLICATE_DEPENDENCY, ""));
+
+                String far = outside.toRealPath().toString();
+                run(List.of(pom.toString(), outside.toString()));
+
+                assertTrue(
+                        output.stream().anyMatch(line -> line.startsWith(Path.of("near", "pom.xml") + ":")),
+                        "a file under the working directory is named relative to it: " + output);
+                assertTrue(
+                        output.stream().anyMatch(line -> line.startsWith(far)),
+                        "a file outside it keeps its absolute path, or it would be ambiguous: " + output);
+            } finally {
+                Files.deleteIfExists(outside);
+                Files.deleteIfExists(outsideDirectory);
+                Files.deleteIfExists(outsideRoot);
+            }
+        }
+
+        @Test
+        @DisplayName("should read a POM named twice only once")
+        void shouldReadAPomNamedTwiceOnce() throws Exception {
+            Path pom = writePom("twice", "");
+            List<String> named = new ArrayList<>(List.of(pom.toString(), "./twice/pom.xml"));
+            try {
+                // Windows refuses this without developer mode, and the two spellings above
+                // already make the point there.
+                named.add(Files.createSymbolicLink(tempDir.resolve("linked.xml"), pom)
+                        .toString());
+            } catch (IOException | UnsupportedOperationException e) {
+                // no link on this machine
+            }
+
+            int exitCode = run(named);
+
+            assertEquals(ValidateInvoker.ERROR, exitCode, output.toString());
+            assertEquals(
+                    1,
+                    output.stream().filter(line -> line.endsWith(":")).count(),
+                    "one file named several ways is one report: " + output);
+        }
+    }
+
+    @Nested
+    @DisplayName("Reporting")
+    class ReportingTests {
+
+        @Test
+        @DisplayName("should not invent a missing parent version for a 4.1.0 subproject")
+        void shouldNotInventMissingParentVersionForSubproject() throws Exception {
+            Path root = Files.createDirectories(tempDir.resolve("reactor"));
+            Files.createDirectories(root.resolve(".mvn"));
+            Files.writeString(root.resolve("pom.xml"), """
+                    <project xmlns="http://maven.apache.org/POM/4.1.0">
+                      <modelVersion>4.1.0</modelVersion>
+                      <groupId>org.test</groupId>
+                      <artifactId>parent</artifactId>
+                      <version>1.0</version>
+                      <packaging>pom</packaging>
+                      <subprojects>
+                        <subproject>child</subproject>
+                      </subprojects>
+                    </project>
+                    """);
+            Path child = Files.createDirectories(root.resolve("child"));
+            Files.writeString(child.resolve("pom.xml"), """
+                    <project xmlns="http://maven.apache.org/POM/4.1.0">
+                      <modelVersion>4.1.0</modelVersion>
+                      <parent>
+                        <groupId>org.test</groupId>
+                        <artifactId>parent</artifactId>
+                      </parent>
+                      <artifactId>child</artifactId>
+                    </project>
+                    """);
+
+            int exitCode = run(List.of(child.resolve("pom.xml").toString()));
+
+            assertEquals(
+                    ValidateInvoker.OK,
+                    exitCode,
+                    "leaving out the parent version is what model 4.1.0 is for, not an error: " + output);
+        }
+
+        @Test
+        @DisplayName("should not blame a POM for a sibling module that cannot be read")
+        void shouldNotBlameSiblingModule() throws Exception {
+            Path root = Files.createDirectories(tempDir.resolve("siblings"));
+            Files.createDirectories(root.resolve(".mvn"));
+            Files.writeString(root.resolve("pom.xml"), """
+                    <project xmlns="http://maven.apache.org/POM/4.1.0">
+                      <modelVersion>4.1.0</modelVersion>
+                      <groupId>org.test</groupId>
+                      <artifactId>parent</artifactId>
+                      <version>1.0</version>
+                      <packaging>pom</packaging>
+                      <subprojects>
+                        <subproject>good</subproject>
+                        <subproject>broken</subproject>
+                      </subprojects>
+                    </project>
+                    """);
+            Path good = Files.createDirectories(root.resolve("good"));
+            Files.writeString(good.resolve("pom.xml"), """
+                    <project xmlns="http://maven.apache.org/POM/4.1.0">
+                      <modelVersion>4.1.0</modelVersion>
+                      <parent><groupId>org.test</groupId><artifactId>parent</artifactId></parent>
+                      <artifactId>good</artifactId>
+                    </project>
+                    """);
+            Path broken = Files.createDirectories(root.resolve("broken"));
+            Files.writeString(broken.resolve("pom.xml"), "<project><not even xml");
+
+            int exitCode = run(List.of(good.resolve("pom.xml").toString()));
+
+            assertEquals(
+                    ValidateInvoker.OK,
+                    exitCode,
+                    "the reactor is mapped to infer the parent version, not to judge the siblings: " + output);
+            assertTrue(
+                    output.stream().noneMatch(l -> l.contains("broken")),
+                    "the broken sibling must not be reported against this file: " + output);
+        }
+
+        @Test
+        @DisplayName("should report a file that does not exist without losing the others")
+        void shouldReportMissingFileWithoutLosingOthers() throws Exception {
+            Path good = writePom("good", "");
+
+            int exitCode = run(List.of(tempDir.resolve("nope/pom.xml").toString(), good.toString()));
+
+            assertEquals(ValidateInvoker.ERROR, exitCode);
+            assertTrue(
+                    output.stream().anyMatch(l -> l.contains("does not exist")),
+                    "the missing file should be reported: " + output);
+            assertTrue(
+                    output.stream().anyMatch(l -> l.contains("must be unique")),
+                    "the other file should still be validated: " + output);
+        }
+
+        @Test
+        @DisplayName("should report a warning that comes with no error, and exit with the warning code")
+        void shouldReportWarningWithoutError() throws Exception {
+            Path pom = Files.createDirectories(tempDir.resolve("warn-only")).resolve("pom.xml");
+            Files.writeString(pom, """
+                    <project xmlns="http://maven.apache.org/POM/4.0.0">
+                      <modelVersion>4.0.0</modelVersion>
+                      <parent>
+                        <groupId>org.test</groupId><artifactId>par</artifactId><version>LATEST</version>
+                      </parent>
+                      <artifactId>child</artifactId>
+                    </project>
+                    """);
+
+            int exitCode = run(List.of(pom.toString()));
+
+            assertEquals(
+                    ValidateInvoker.WARNINGS,
+                    exitCode,
+                    "warnings without errors are what a gate passes back to maintainers: " + output);
+            assertTrue(
+                    output.stream().anyMatch(l -> l.contains("WARNING")),
+                    "the warning must be reported, not swallowed: " + output);
+        }
+
+        @Test
+        @DisplayName("should exit with an error when a problem was reported")
+        void shouldExitWithErrorWhenProblemReported() throws Exception {
+            assertEquals(ValidateInvoker.ERROR, run(List.of(writePom("bad", "").toString())));
+        }
+    }
+
+    @Nested
+    @DisplayName("Extensions")
+    class ExtensionTests {
+
+        @Test
+        @DisplayName("should stand up no container, so the POM's directory cannot make it load code")
+        void shouldStandUpNoContainer() throws Exception {
+            // Booting it would resolve and run whatever .mvn/extensions.xml declares, and whatever
+            // maven.ext.class.path names in .mvn/maven-user.properties.
+            ValidateContext context = TestUtils.createMockContext(tempDir, mock(ValidateOptions.class));
+
+            invoker.container(context);
+            invoker.postContainer(context);
+            invoker.lookup(context);
+            invoker.init(context);
+            invoker.settings(context);
+
+            assertNull(context.containerCapsule, "no container");
+            assertNull(context.lookup, "nothing to look up from");
+            assertNull(context.eventSpyDispatcher, "no event spies");
+            assertNull(context.effectiveSettings, "no settings, so no mirrors, proxies or credentials");
+        }
+    }
+
+    @Nested
+    @DisplayName("Format")
+    class FormatTests {
+
+        @Test
+        @DisplayName("should emit one JSON document when json is asked for")
+        void shouldEmitJsonDocument() throws Exception {
+            Path pom = writePom("as-json", "");
+
+            int exitCode = run(List.of(pom.toString()), Optional.of("json"));
+
+            assertEquals(ValidateInvoker.ERROR, exitCode);
+            assertEquals(1, output.size(), "the whole report is one document, not a line per problem: " + output);
+            assertTrue(output.get(0).startsWith("[{"), "expected a JSON array: " + output);
+            assertTrue(output.get(0).contains("\"severity\":\"ERROR\""), output.get(0));
+        }
+
+        @Test
+        @DisplayName("should turn an unparseable command line into the bad-usage code")
+        void shouldTurnParseFailureIntoBadOperation() {
+            ValidateContext context = TestUtils.createMockContext(tempDir, mock(ValidateOptions.class));
+            when(context.invokerRequest.parsingFailed()).thenReturn(true);
+
+            InvokerException.ExitException e =
+                    assertThrows(InvokerException.ExitException.class, () -> invoker.validate(context));
+
+            assertEquals(
+                    ValidateInvoker.BAD_OPERATION,
+                    e.getExitCode(),
+                    "the base exits 1, which this tool uses for a rejected POM");
+        }
+
+        @Test
+        @DisplayName("should reject an unknown format with the bad-usage code, not the parse-failure one")
+        void shouldRejectUnknownFormatWithBadOperation() throws Exception {
+            // Rejecting this while parsing would mark the invocation unparseable and exit 1,
+            // which is not the code the tool documents for bad usage.
+            int exitCode = run(List.of(writePom("xml-format", "").toString()), Optional.of("xml"));
+
+            assertEquals(ValidateInvoker.BAD_OPERATION, exitCode);
+        }
+
+        @Test
+        @DisplayName("should reject a format that only became unknown after interpolation")
+        void shouldRejectFormatUnknownAfterInterpolation() throws Exception {
+            // The parser checks --format, but options are interpolated afterwards, so a value the
+            // parser accepted can still be anything by the time the invoker reads it.
+            int exitCode = run(List.of(writePom("bad-format", "").toString()), Optional.of("yaml"));
+
+            assertEquals(ValidateInvoker.BAD_OPERATION, exitCode, "a bad format is a usage error, not a verdict");
+        }
+
+        @Test
+        @DisplayName("should reject an unknown mode with the bad-usage code")
+        void shouldRejectUnknownModeWithBadOperation() throws Exception {
+            int exitCode = run(List.of(writePom("bad-mode", "").toString()), Optional.empty(), Optional.of("deep"));
+
+            assertEquals(ValidateInvoker.BAD_OPERATION, exitCode);
+        }
+    }
+
+    @Nested
+    @DisplayName("Effective mode")
+    class EffectiveTests {
+
+        private Path project(String parent, String relativePath) throws Exception {
+            Path root = Files.createDirectories(tempDir.resolve("proj"));
+            Files.writeString(root.resolve("pom.xml"), """
+                    <project xmlns="http://maven.apache.org/POM/4.0.0">
+                      <modelVersion>4.0.0</modelVersion>
+                      <groupId>org.test</groupId><artifactId>par</artifactId>
+                      <version>1.0</version><packaging>pom</packaging>
+                    </project>""");
+            Path child = Files.createDirectories(root.resolve("child")).resolve("pom.xml");
+            Files.writeString(child, """
+                    <project xmlns="http://maven.apache.org/POM/4.0.0">
+                      <modelVersion>4.0.0</modelVersion>
+                      <parent>
+                        <groupId>org.test</groupId><artifactId>%s</artifactId>
+                        <version>1.0</version>%s
+                      </parent>
+                      <artifactId>child</artifactId>
+                      <dependencies>
+                        <dependency><groupId>junit</groupId><artifactId>junit</artifactId></dependency>
+                      </dependencies>
+                    </project>""".formatted(parent, relativePath));
+            return child;
+        }
+
+        private int runEffective(Path pom) throws Exception {
+            ValidateOptions options = mock(ValidateOptions.class);
+            when(options.mode()).thenReturn(Optional.of("effective"));
+            when(options.poms()).thenReturn(Optional.of(List.of(pom.toString())));
+            ValidateContext context = TestUtils.createMockContext(tempDir, options);
+            context.writer = output::add;
+            return invoker.execute(context);
+        }
+
+        @Test
+        @DisplayName("should report a problem that only the effective model shows")
+        void shouldReportInheritedProblem() throws Exception {
+            // A dependency with no version of its own is checked in validateEffectiveModel, which
+            // raw mode never reaches. The parent is next door, so nothing has to be resolved.
+            int exitCode = runEffective(project("par", ""));
+
+            assertEquals(ValidateInvoker.ERROR, exitCode, output.toString());
+            assertTrue(
+                    output.stream().anyMatch(line -> line.contains("'dependencies.dependency.version'")),
+                    "the inherited missing version is what effective mode is for: " + output);
+        }
+
+        @Test
+        @DisplayName("should go looking for a parent that is not on disk")
+        void shouldResolveParentThatIsNotOnDisk() throws Exception {
+            // Names a parent no file provides, so raw mode is clean and effective mode has to
+            // reach for it. The shared session registers no transport, so the reach fails wherever
+            // it is run, and the failure is the evidence.
+            int exitCode = runEffective(project("absent", "<relativePath/>"));
+
+            assertEquals(ValidateInvoker.ERROR, exitCode, output.toString());
+            assertTrue(
+                    output.stream().anyMatch(line -> line.contains("Non-resolvable parent POM")),
+                    "effective mode has to reach for a parent it cannot read from disk: " + output);
+        }
+
+        @Test
+        @DisplayName("should honour --offline and fail when a parent is not in the local repository")
+        void shouldHonourOfflineInEffectiveMode() throws Exception {
+            ValidateOptions options = mock(ValidateOptions.class);
+            when(options.mode()).thenReturn(Optional.of("effective"));
+            when(options.offline()).thenReturn(Optional.of(true));
+            when(options.tempLocalRepository()).thenReturn(Optional.of(true));
+            when(options.poms())
+                    .thenReturn(Optional.of(
+                            List.of(project("absent", "<relativePath/>").toString())));
+            ValidateContext context = TestUtils.createMockContext(tempDir, options);
+            context.writer = output::add;
+
+            // -o is now honoured: the run fails because the parent cannot be fetched offline,
+            // but the failure is a validation result (exit 1), not a bad-usage error (exit 2).
+            int exitCode = invoker.execute(context);
+            assertNotEquals(
+                    ValidateInvoker.BAD_OPERATION,
+                    exitCode,
+                    "--offline must be honoured, not refused as bad usage: " + output);
+        }
+
+        @Test
+        @DisplayName("should see a subproject that is not on disk, as the build does")
+        void shouldReportMissingSubproject() throws Exception {
+            // The regression this mode was changed for. build() reports nothing about the reactor
+            // around a POM, so on its own it called this project clean while mvn refuses to read
+            // it. Effective mode therefore runs the raw pass first and keeps its problems.
+            Path root = Files.createDirectories(tempDir.resolve("ghosted"));
+            Files.writeString(root.resolve("pom.xml"), """
+                    <project xmlns="http://maven.apache.org/POM/4.0.0">
+                      <modelVersion>4.0.0</modelVersion>
+                      <groupId>org.test</groupId><artifactId>ghosted</artifactId>
+                      <version>1.0</version><packaging>pom</packaging>
+                      <modules><module>ghost</module></modules>
+                    </project>""");
+
+            int exitCode = runEffective(root.resolve("pom.xml"));
+
+            assertEquals(ValidateInvoker.ERROR, exitCode, output.toString());
+            assertTrue(
+                    output.stream().anyMatch(line -> line.contains("Child subproject ghost")),
+                    "the default mode must not pass a project the build cannot read: " + output);
+        }
+
+        @Test
+        @DisplayName("should refuse an alternate settings file it cannot honour")
+        void shouldRefuseAlternateSettings() throws Exception {
+            // Resolution runs on the session ApiRunner builds, which reads ~/.m2/settings.xml and
+            // nothing else. Accepting the option would resolve through the default file's mirrors
+            // and credentials while the caller believed they had redirected it.
+            ValidateOptions options = mock(ValidateOptions.class);
+            when(options.mode()).thenReturn(Optional.of("effective"));
+            when(options.altUserSettings()).thenReturn(Optional.of("ci-settings.xml"));
+            when(options.poms())
+                    .thenReturn(Optional.of(List.of(project("par", "").toString())));
+            ValidateContext context = TestUtils.createMockContext(tempDir, options);
+            context.writer = output::add;
+
+            assertEquals(ValidateInvoker.BAD_OPERATION, invoker.execute(context));
+        }
+
+        @Test
+        @DisplayName("should accept an alternate settings file in raw mode, where it changes nothing")
+        void shouldAcceptAlternateSettingsInRawMode() throws Exception {
+            ValidateOptions options = mock(ValidateOptions.class);
+            when(options.mode()).thenReturn(Optional.of("raw"));
+            when(options.altUserSettings()).thenReturn(Optional.of("ci-settings.xml"));
+            when(options.poms())
+                    .thenReturn(Optional.of(List.of(project("par", "").toString())));
+            ValidateContext context = TestUtils.createMockContext(tempDir, options);
+            context.writer = output::add;
+
+            assertEquals(ValidateInvoker.OK, invoker.execute(context), output.toString());
+        }
+
+        @Test
+        @DisplayName("should read the parent from an aimed local repository")
+        void shouldUseTheAimedLocalRepository() throws Exception {
+            // Proved positively, because a failed resolution writes nothing and so says nothing
+            // about where it looked: the parent is laid out by hand in the aimed repository and
+            // nowhere else, so a clean verdict is only reachable by reading it from there.
+            Path repo = tempDir.resolve("aimed");
+            Path laid = Files.createDirectories(repo.resolve("org/test/absent/1.0"));
+            Files.writeString(laid.resolve("absent-1.0.pom"), """
+                    <project xmlns="http://maven.apache.org/POM/4.0.0">
+                      <modelVersion>4.0.0</modelVersion>
+                      <groupId>org.test</groupId><artifactId>absent</artifactId>
+                      <version>1.0</version><packaging>pom</packaging>
+                      <dependencyManagement><dependencies>
+                        <dependency>
+                          <groupId>junit</groupId><artifactId>junit</artifactId><version>4.13.2</version>
+                        </dependency>
+                      </dependencies></dependencyManagement>
+                    </project>""");
+            ValidateOptions options = mock(ValidateOptions.class);
+            when(options.mode()).thenReturn(Optional.of("effective"));
+            when(options.localRepository()).thenReturn(Optional.of(repo.toString()));
+            when(options.poms())
+                    .thenReturn(Optional.of(
+                            List.of(project("absent", "<relativePath/>").toString())));
+            ValidateContext context = TestUtils.createMockContext(tempDir, options);
+            context.writer = output::add;
+
+            int exitCode = invoker.execute(context);
+
+            assertEquals(ValidateInvoker.OK, exitCode, output.toString());
+            assertTrue(
+                    output.stream().noneMatch(line -> line.contains("Non-resolvable")),
+                    "the aimed repository has the parent, so nothing should go looking: " + output);
+        }
+
+        @Test
+        @DisplayName("should delete the temporary local repository when the run ends")
+        void shouldDeleteTheTemporaryLocalRepository() throws Exception {
+            ValidateOptions options = mock(ValidateOptions.class);
+            when(options.mode()).thenReturn(Optional.of("effective"));
+            when(options.tempLocalRepository()).thenReturn(Optional.of(true));
+            when(options.poms())
+                    .thenReturn(Optional.of(
+                            List.of(project("absent", "<relativePath/>").toString())));
+            ValidateContext context = TestUtils.createMockContext(tempDir, options);
+            context.writer = output::add;
+
+            invoker.execute(context);
+
+            // The directory this run made, not everything in the temp directory: another mvnval
+            // on the same machine would fail that.
+            Path made = aimed.stream().filter(Objects::nonNull).findFirst().orElse(null);
+            assertNotNull(made, "effective mode with --temp-local-repository must aim somewhere");
+            assertFalse(Files.exists(made), "a throwaway repository that outlives the run is not throwaway");
+        }
+
+        @Test
+        @DisplayName("should give the same verdict whichever order the POMs are named in")
+        void shouldNotDependOnArgumentOrder() throws Exception {
+            // The regression a shared ModelBuilderSession caused: derived sessions share
+            // mappedSources, so a decoy declaring the same groupId:artifactId as a real parent
+            // answered the child's parent lookup and changed its verdict. Two roots with the same
+            // coordinates and different packaging is the shape that broke.
+            Path child = project("par", "");
+            Path decoy = Files.createDirectories(tempDir.resolve("decoy")).resolve("pom.xml");
+            Files.writeString(decoy, """
+                    <project xmlns="http://maven.apache.org/POM/4.0.0">
+                      <modelVersion>4.0.0</modelVersion>
+                      <groupId>org.test</groupId><artifactId>par</artifactId>
+                      <version>1.0</version><packaging>jar</packaging>
+                    </project>""");
+
+            List<String> childFirst = runEffectiveOn(List.of(child.toString(), decoy.toString()));
+            List<String> decoyFirst = runEffectiveOn(List.of(decoy.toString(), child.toString()));
+
+            assertEquals(
+                    Set.copyOf(childFirst),
+                    Set.copyOf(decoyFirst),
+                    "the same files must get the same problems whichever order they are given in");
+        }
+
+        private List<String> runEffectiveOn(List<String> poms) throws Exception {
+            ValidateOptions options = mock(ValidateOptions.class);
+            when(options.mode()).thenReturn(Optional.of("effective"));
+            when(options.poms()).thenReturn(Optional.of(poms));
+            ValidateContext context = TestUtils.createMockContext(tempDir, options);
+            List<String> lines = new ArrayList<>();
+            context.writer = lines::add;
+            invoker.execute(context);
+            return lines;
+        }
+
+        @Test
+        @DisplayName("should refuse a local repository that is not a directory")
+        void shouldRefuseALocalRepositoryThatIsNotADirectory() throws Exception {
+            // Without this the path reaches the resolver and comes back as a cast between two
+            // exception types, reported against the POM as though the POM were at fault.
+            Path file = Files.writeString(tempDir.resolve("not-a-dir"), "");
+            ValidateOptions options = mock(ValidateOptions.class);
+            when(options.mode()).thenReturn(Optional.of("effective"));
+            when(options.localRepository()).thenReturn(Optional.of(file.toString()));
+            when(options.poms())
+                    .thenReturn(Optional.of(List.of(project("par", "").toString())));
+            ValidateContext context = TestUtils.createMockContext(tempDir, options);
+            context.writer = output::add;
+
+            assertEquals(ValidateInvoker.BAD_OPERATION, invoker.execute(context));
+        }
+
+        @Test
+        @DisplayName("should refuse two different local repositories")
+        void shouldRefuseBothLocalRepositoryOptions() throws Exception {
+            ValidateOptions options = mock(ValidateOptions.class);
+            when(options.localRepository()).thenReturn(Optional.of("somewhere"));
+            when(options.tempLocalRepository()).thenReturn(Optional.of(true));
+            when(options.poms())
+                    .thenReturn(Optional.of(List.of(project("par", "").toString())));
+            ValidateContext context = TestUtils.createMockContext(tempDir, options);
+            context.writer = output::add;
+
+            assertEquals(ValidateInvoker.BAD_OPERATION, invoker.execute(context));
+        }
+
+        @Test
+        @DisplayName("should leave the same project alone in raw mode")
+        void shouldNotResolveInRawMode() throws Exception {
+            Path pom = project("absent", "<relativePath/>");
+            ValidateOptions options = mock(ValidateOptions.class);
+            when(options.mode()).thenReturn(Optional.of("raw"));
+            when(options.poms()).thenReturn(Optional.of(List.of(pom.toString())));
+            ValidateContext context = TestUtils.createMockContext(tempDir, options);
+            context.writer = output::add;
+
+            int exitCode = invoker.execute(context);
+
+            assertEquals(ValidateInvoker.OK, exitCode, output.toString());
+            assertTrue(
+                    output.stream().noneMatch(line -> line.contains("Non-resolvable")),
+                    "raw mode must not go looking for the parent: " + output);
+        }
+    }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java
index 7da260afb6..c85809c17e 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java
@@ -228,15 +228,7 @@ public ModelBuilderResult build(ModelBuilderRequest request) throws ModelBuilder
             RequestTraceHelper.ResolverTrace trace = RequestTraceHelper.enter(request.getSession(), request);
             try {
                 // Create or derive a session based on the request
-                ModelBuilderSessionState session;
-                if (mainSession == null) {
-                    mainSession = new ModelBuilderSessionState(request);
-                    session = mainSession;
-                } else {
-                    session = mainSession.deriveTopLevel(
-                            request,
-                            new DefaultModelBuilderResult(request, ProblemCollector.create(mainSession.session)));
-                }
+                ModelBuilderSessionState session = sessionFor(request);
                 // Build the request
                 if (request.getRequestType() == ModelBuilderRequest.RequestType.BUILD_PROJECT) {
                     // build the build poms
@@ -260,6 +252,38 @@ public ModelBuilderResult build(ModelBuilderRequest request) throws ModelBuilder
                 RequestTraceHelper.exit(trace);
             }
         }
+
+        /**
+         * Stops after the raw model, so no inheritance, interpolation or profile injection
+         * happens: only the source, the file model, the raw model and the problems are populated
+         * on the result. Reaches no repository. The checks run at strict level for any build
+         * request type; {@code BUILD_PROJECT} additionally looks for a parent beside the POM.
+         */
+        @Override
+        public ModelBuilderResult validate(ModelBuilderRequest request) throws ModelBuilderException {
+            RequestTraceHelper.ResolverTrace trace = RequestTraceHelper.enter(request.getSession(), request);
+            try {
+                ModelBuilderSessionState session = sessionFor(request);
+                // Map the reactor first: a 4.1.0 subproject may leave out its parent version,
+                // taken from the parent's own file model, so without this a valid subproject
+                // fails with "'parent.version' is missing". A POM outside any project still reads.
+                session.loadReactor(false, false);
+                session.readRawModel();
+                return session.result;
+            } finally {
+                RequestTraceHelper.exit(trace);
+            }
+        }
+
+        /** Creates the session on the first request, then derives one with its own result. */
+        private ModelBuilderSessionState sessionFor(ModelBuilderRequest request) {
+            if (mainSession == null) {
+                mainSession = new ModelBuilderSessionState(request);
+                return mainSession;
+            }
+            return mainSession.deriveTopLevel(
+                    request, new DefaultModelBuilderResult(request, ProblemCollector.create(mainSession.session)));
+        }
     }
 
     protected class ModelBuilderSessionState implements ModelProblemCollector {
@@ -271,6 +295,9 @@ protected class ModelBuilderSessionState implements ModelProblemCollector {
         final Set<ImportWarningKey> reportedImportWarnings;
         final Map<String, ProblemCollector<ModelProblem>> reactorProblemCollectors;
 
+        /** See {@link #loadReactor(boolean, boolean)}. */
+        private boolean reportReactorLoadFailures = true;
+
         String source;
         Model sourceModel;
         Model rootModel;
@@ -458,7 +485,7 @@ private ModelBuilderSessionState derive(
                 }
             }
             boolean derivedExternalOrigin = externalOrigin || isExternalOrigin(request);
-            return new ModelBuilderSessionState(
+            ModelBuilderSessionState derived = new ModelBuilderSessionState(
                     session,
                     request,
                     result,
@@ -471,6 +498,9 @@ private ModelBuilderSessionState derive(
                     derivedRepos,
                     new LinkedHashSet<>(),
                     derivedExternalOrigin);
+            // Carried over so the reactor walk keeps its reporting mode whichever state runs it.
+            derived.reportReactorLoadFailures = reportReactorLoadFailures;
+            return derived;
         }
 
         @Override
@@ -634,9 +664,14 @@ public void add(
                 source = getSource();
             }
 
-            if (line <= 0 && column <= 0 && exception instanceof ModelParserException e) {
-                line = e.getLineNumber();
-                column = e.getColumnNumber();
+            if (line <= 0 && column <= 0) {
+                if (exception instanceof ModelParserException e) {
+                    line = e.getLineNumber();
+                    column = e.getColumnNumber();
+                } else if (exception instanceof XmlReaderException e && e.getLocation() != null) {
+                    line = e.getLocation().getLineNumber();
+                    column = e.getLocation().getColumnNumber();
+                }
             }
 
             ModelProblem problem =
@@ -957,7 +992,17 @@ private Map<String, String> getPropertiesWithProfiles(Model model) {
             return getPropertiesWithProfiles(model, new HashMap<>());
         }
 
-        private void buildBuildPom() throws ModelBuilderException {
+        /**
+         * Loads the file models of the whole reactor from the project root, so that every model
+         * that may be needed to read another one is mapped. With {@code mandatoryRoot} false, a
+         * source outside any project is read on its own instead of failing.
+         * <p>
+         * With {@code reportLoadFailures} false, a module that cannot be loaded is logged instead
+         * of reported, so that a caller reading a single POM is not told its file is broken when
+         * a sibling is. Reading the POM it did ask about reports that one's own failure anyway.
+         */
+        private void loadReactor(boolean mandatoryRoot, boolean reportLoadFailures) {
+            this.reportReactorLoadFailures = reportLoadFailures;
             // Retrieve and normalize the source path, ensuring it's non-null and in absolute form
             Path top = request.getSource().getPath();
             if (top == null) {
@@ -970,11 +1015,12 @@ private void buildBuildPom() throws ModelBuilderException {
             try {
                 rootDirectory = session.getRootDirectory();
             } catch (IllegalStateException e) {
-                rootDirectory = session.getService(RootLocator.class).findMandatoryRoot(top);
+                RootLocator rootLocator = session.getService(RootLocator.class);
+                rootDirectory = mandatoryRoot ? rootLocator.findMandatoryRoot(top) : rootLocator.findRoot(top);
             }
 
             // Locate and normalize the root POM if it exists, fallback to top otherwise
-            Path root = modelProcessor.locateExistingPom(rootDirectory);
+            Path root = rootDirectory != null ? modelProcessor.locateExistingPom(rootDirectory) : null;
             if (root != null) {
                 root = root.toAbsolutePath().normalize();
             } else {
@@ -983,6 +1029,10 @@ private void buildBuildPom() throws ModelBuilderException {
 
             // Load all models starting from the root
             loadFromRoot(root, top);
+        }
+
+        private void buildBuildPom() throws ModelBuilderException {
+            loadReactor(true, true);
 
             // Check for errors after loading models
             if (hasErrors()) {
@@ -1052,14 +1102,17 @@ private void loadFromRoot(Path root, Path top) {
                 loadFilePom(executor, top, root, Set.of(), r);
             }
             if (result.getFileModel() == null && !Objects.equals(top, root)) {
-                logger.warn(
-                        "The top project ({}) cannot be found in the reactor from root project ({}). "
-                                + "Make sure the root directory is correct (a missing '.mvn' directory in the root "
-                                + "project is the most common cause) and the project is correctly included "
-                                + "in the reactor (missing activated profiles, command line options, etc.). For this "
-                                + "build, the top project will be used as the root project.",
-                        top,
-                        root);
+                // Only a warning when a build is what was asked for. A caller reading one POM has
+                // no reactor to get wrong, and the retry below is routine there, not a misconfiguration.
+                (reportReactorLoadFailures ? logger.atWarn() : logger.atDebug())
+                        .log(
+                                "The top project ({}) cannot be found in the reactor from root project ({}). "
+                                        + "Make sure the root directory is correct (a missing '.mvn' directory in the root "
+                                        + "project is the most common cause) and the project is correctly included "
+                                        + "in the reactor (missing activated profiles, command line options, etc.). For this "
+                                        + "build, the top project will be used as the root project.",
+                                top,
+                                root);
                 mappedSources.clear();
                 reportedImportWarnings.clear();
                 reactorProblemCollectors.clear();
@@ -1154,7 +1207,13 @@ private void loadFilePom(
                 }
             } catch (ModelBuilderException e) {
                 // gathered with problem collector
-                add(Severity.ERROR, Version.V40, "Failed to load project " + pom, e);
+                if (reportReactorLoadFailures) {
+                    add(Severity.ERROR, Version.V40, "Failed to load project " + pom, e);
+                } else {
+                    // The caller asked about one POM. If that POM is the one that failed, reading
+                    // it reports the failure again in a moment, with the real reason attached.
+                    logger.debug("Failed to load project {} while mapping the reactor", pom, e);
+                }
             }
         }
 
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/RawModelReadingTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/RawModelReadingTest.java
new file mode 100644
index 0000000000..210343b035
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/RawModelReadingTest.java
@@ -0,0 +1,228 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.impl.model;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.maven.api.Session;
+import org.apache.maven.api.services.BuilderProblem.Severity;
+import org.apache.maven.api.services.ModelBuilder;
+import org.apache.maven.api.services.ModelBuilderException;
+import org.apache.maven.api.services.ModelBuilderRequest;
+import org.apache.maven.api.services.ModelProblem;
+import org.apache.maven.api.services.Sources;
+import org.apache.maven.impl.standalone.ApiRunner;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests {@link ModelBuilder.ModelBuilderSession#validate}, which reads and validates a POM without
+ * building it: it returns the problems instead of throwing on them, and it reads only files.
+ */
+class RawModelReadingTest {
+
+    private static final String DUPLICATE_DEPENDENCY = """
+            <dependencies>
+              <dependency>
+                <groupId>junit</groupId><artifactId>junit</artifactId><version>4.13.2</version>
+              </dependency>
+              <dependency>
+                <groupId>junit</groupId><artifactId>junit</artifactId><version>4.12</version>
+              </dependency>
+            </dependencies>""";
+
+    private static final String PARENT_AT_LATEST = """
+            <parent>
+              <groupId>org.test</groupId><artifactId>par</artifactId><version>LATEST</version>
+            </parent>""";
+
+    private Session session;
+    private ModelBuilder builder;
+
+    @BeforeEach
+    void setUp() {
+        session = ApiRunner.createSession();
+        builder = session.getService(ModelBuilder.class);
+    }
+
+    @Test
+    void shouldReturnValidationErrorsRatherThanThrowOnThem(@TempDir Path directory) throws IOException {
+        Path pom = writePom(directory, "duplicate", coordinates("duplicate") + DUPLICATE_DEPENDENCY);
+
+        assertTrue(
+                messages(readRaw(pom), Severity.ERROR).stream().anyMatch(m -> m.contains("must be unique")),
+                "the duplicate dependency should come back as a problem, not as an exception");
+    }
+
+    @Test
+    void shouldReturnWarningThatComesWithNoError(@TempDir Path directory) throws IOException {
+        Path pom = writePom(directory, "warning", PARENT_AT_LATEST + coordinates("warning"));
+
+        List<ModelProblem> problems = readRaw(pom);
+
+        assertTrue(
+                messages(problems, Severity.WARNING).stream().anyMatch(m -> m.contains("LATEST")),
+                "a warning is worth reporting even though nothing was rejected: " + messages(problems));
+        assertEquals(List.of(), messages(problems, Severity.ERROR), "nothing should be rejected here");
+    }
+
+    @Test
+    void shouldReportSubprojectThatIsDeclaredButNotThere(@TempDir Path directory) throws IOException {
+        // Found while mapping the reactor, so it is only seen because the whole project is read.
+        Path root = writeReactor(directory, "absent");
+
+        assertTrue(
+                messages(readRaw(root), Severity.ERROR).stream().anyMatch(m -> m.contains("absent")),
+                "a root POM that names a subproject which is not there is worth reporting");
+    }
+
+    @Test
+    void shouldInferParentVersionOmittedBySubproject(@TempDir Path directory) throws IOException {
+        // Leaving out the parent version is what model 4.1.0 is for. It is taken from the parent's
+        // own file model, which means the reactor has to be mapped before the subproject is read.
+        Path root = writeReactor(directory, "good");
+        Path subproject = writeSubproject(root, "good");
+
+        assertEquals(List.of(), messages(readRaw(subproject)), "a versionless parent is not an error in 4.1.0");
+    }
+
+    @Test
+    void shouldNotReportSiblingThatCannotBeRead(@TempDir Path directory) throws IOException {
+        Path root = writeReactor(directory, "good", "broken");
+        Path subproject = writeSubproject(root, "good");
+        Files.writeString(Files.createDirectories(root.resolveSibling("broken")).resolve("pom.xml"), "<project><bad");
+
+        assertEquals(
+                List.of(),
+                messages(readRaw(subproject)),
+                "the reactor is mapped to find the parent, not to pass judgement on the siblings");
+    }
+
+    @Test
+    void shouldThrowWhenTheModelCannotBeReadAtAll(@TempDir Path directory) throws IOException {
+        Path pom = directory.resolve("pom.xml");
+        Files.writeString(pom, "<project><not even xml");
+
+        assertThrows(ModelBuilderException.class, () -> readRaw(pom));
+    }
+
+    @Test
+    void shouldRejectValidateWhenAnImplementationDoesNotSupportIt() {
+        ModelBuilder.ModelBuilderSession unsupporting = request -> {
+            throw new UnsupportedOperationException("build is not under test");
+        };
+
+        assertThrows(UnsupportedOperationException.class, () -> unsupporting.validate(null));
+    }
+
+    @Test
+    void shouldValidateSeveralProjectsOnOneSession(@TempDir Path directory) throws IOException {
+        // Two reactors meeting in one session under the same coordinates is the case that would
+        // break if the session kept a single source per groupId:artifactId.
+        Path first = writeSubproject(writeReactor(directory.resolve("first"), "good"), "good");
+        Path second = writeSubproject(writeReactor(directory.resolve("second"), "good"), "good");
+        ModelBuilder.ModelBuilderSession shared = builder.newSession();
+
+        assertEquals(List.of(), messages(readRaw(shared, first)), "the first project is clean on its own");
+        assertEquals(List.of(), messages(readRaw(shared, second)), "the second project sees the same session");
+    }
+
+    private List<ModelProblem> readRaw(Path pom) {
+        return readRaw(builder.newSession(), pom);
+    }
+
+    private List<ModelProblem> readRaw(ModelBuilder.ModelBuilderSession builderSession, Path pom) {
+        return builderSession
+                .validate(ModelBuilderRequest.builder()
+                        .session(session)
+                        .source(Sources.buildSource(pom))
+                        .requestType(ModelBuilderRequest.RequestType.BUILD_PROJECT)
+                        .build())
+                .getProblemCollector()
+                .problems()
+                .toList();
+    }
+
+    private static List<String> messages(List<ModelProblem> problems) {
+        return problems.stream().map(ModelProblem::getMessage).toList();
+    }
+
+    private static List<String> messages(List<ModelProblem> problems, Severity severity) {
+        return problems.stream()
+                .filter(problem -> problem.getSeverity() == severity)
+                .map(ModelProblem::getMessage)
+                .toList();
+    }
+
+    private static String coordinates(String artifactId) {
+        return "<groupId>org.test</groupId><artifactId>" + artifactId + "</artifactId><version>1.0</version>";
+    }
+
+    private static String project(String modelVersion, String body) {
+        return """
+                <project xmlns="http://maven.apache.org/POM/%s">
+                  <modelVersion>%s</modelVersion>
+                  %s
+                </project>
+                """.formatted(modelVersion, modelVersion, body);
+    }
+
+    private static Path writePom(Path directory, String name, String body) throws IOException {
+        Path pom = Files.createDirectories(directory.resolve(name)).resolve("pom.xml");
+        Files.writeString(pom, project(ModelBuilder.MODEL_VERSION_4_0_0, body));
+        return pom;
+    }
+
+    /** Writes a 4.1.0 root POM, with the {@code .mvn} directory that makes its directory the root. */
+    private static Path writeReactor(Path directory, String... subprojects) throws IOException {
+        Path root = Files.createDirectories(directory.resolve("reactor"));
+        Files.createDirectories(root.resolve(".mvn"));
+        String declared = Arrays.stream(subprojects)
+                .map(name -> "<subproject>" + name + "</subproject>")
+                .collect(Collectors.joining());
+        Files.writeString(
+                root.resolve("pom.xml"),
+                project(
+                        ModelBuilder.MODEL_VERSION_4_1_0,
+                        coordinates("reactor") + "<packaging>pom</packaging><subprojects>" + declared
+                                + "</subprojects>"));
+        return root.resolve("pom.xml");
+    }
+
+    private static Path writeSubproject(Path root, String name) throws IOException {
+        Path pom = Files.createDirectories(root.resolveSibling(name)).resolve("pom.xml");
+        Files.writeString(
+                pom,
+                project(
+                        ModelBuilder.MODEL_VERSION_4_1_0,
+                        "<parent><groupId>org.test</groupId><artifactId>reactor</artifactId></parent>" + "<artifactId>"
+                                + name + "</artifactId>"));
+        return pom;
+    }
+}
diff --git a/its/core-it-suite/pom.xml b/its/core-it-suite/pom.xml
index bb6596feec..b18902348a 100644
--- a/its/core-it-suite/pom.xml
+++ b/its/core-it-suite/pom.xml
@@ -519,7 +519,7 @@ under the License.
                 <unzip dest="${mavenHome}" src="${maven.multiModuleProjectDirectory}/apache-maven/target/apache-maven-${project.version}-bin.zip">
                   <regexpmapper from="^([^/]+)/(.*)$" handledirsep="true" to="\2" />
                 </unzip>
-                <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc,mvnup" perm="ugo+rx" />
+                <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc,mvnup,mvnval" perm="ugo+rx" />
               </target>
             </configuration>
           </execution>
@@ -805,7 +805,7 @@ under the License.
                     <unzip dest="${mavenHome}" src="${project.build.directory}/maven-bin.zip">
                       <globmapper from="apache-maven-${mavenVersion}/*" handledirsep="true" to="*" />
                     </unzip>
-                    <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc,mvnup" perm="ugo+rx" />
+                    <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc,mvnup,mvnval" perm="ugo+rx" />
                   </target>
                 </configuration>
               </execution>
@@ -860,7 +860,7 @@ under the License.
                     <unzip dest="${mavenHome}" src="${mavenDistro}">
                       <regexpmapper from="^([^/]+)/(.*)$" handledirsep="true" to="\2" />
                     </unzip>
-                    <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc,mvnup" perm="ugo+rx" />
+                    <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc,mvnup,mvnval" perm="ugo+rx" />
                   </target>
                 </configuration>
               </execution>
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh10442MvnvalValidatePomTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh10442MvnvalValidatePomTest.java
new file mode 100644
index 0000000000..33dd77999c
--- /dev/null
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh10442MvnvalValidatePomTest.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.it;
+
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration test for the {@code mvnval} POM validation tool.
+ * <p>
+ * Verifies that {@code mvn --val} reports a clean POM as such and reports the problems of a
+ * broken one, and that the verdict does not depend on a build being possible: validation stops
+ * after the raw model, so no parent is resolved and nothing is downloaded.
+ * <p>
+ * Uses {@code mvn --val} rather than the {@code mvnval} script directly. The {@code --val} flag
+ * is handled by the {@code mvn} launcher, which points {@code MAVEN_MAIN_CLASS} at
+ * {@code MavenValCling}.
+ *
+ * @since 4.1.0
+ */
+class MavenITgh10442MvnvalValidatePomTest extends AbstractMavenIntegrationTestCase {
+
+    /**
+     * A POM with nothing wrong with it should be reported as having no problems, and the tool
+     * should exit successfully.
+     */
+    @Test
+    void testReportsCleanPom() throws Exception {
+        Path testDir = extractResources("mvnval-validate-pom");
+
+        Verifier verifier = newVerifier(testDir.resolve("clean").toString());
+        verifier.setForkJvm(true);
+        verifier.setLogFileName("clean.txt");
+        verifier.addCliArgument("--val");
+        verifier.addCliArgument("--mode");
+        verifier.addCliArgument("raw");
+        verifier.execute();
+        verifier.verifyErrorFreeLog();
+
+        verifier.verifyTextInLog("no problems");
+    }
+
+    /**
+     * A POM with a duplicate dependency should make the tool exit with a non-zero code and name
+     * the problem together with the line it sits on.
+     */
+    @Test
+    void testReportsDuplicateDependency() throws Exception {
+        Path testDir = extractResources("mvnval-validate-pom");
+
+        Verifier verifier = newVerifier(testDir.resolve("duplicate-dependency").toString());
+        verifier.setForkJvm(true);
+        verifier.setLogFileName("duplicate.txt");
+        verifier.addCliArgument("--val");
+        verifier.addCliArgument("--mode");
+        verifier.addCliArgument("raw");
+
+        assertThrows(
+                VerificationException.class, verifier::execute, "Validation should have exited with a non-zero code");
+
+        verifier.verifyTextInLog("must be unique");
+        // Which line is not the point, and hard-coding it would tie the test to the length of the
+        // license header above the dependency.
+        assertTrue(
+                verifier.loadLogLines().stream().anyMatch(l -> l.matches(".*must be unique.*line \\d+.*")),
+                "the problem should be reported with the line it sits on");
+    }
+
+    /**
+     * The JSON format should produce a single machine-readable document rather than log lines.
+     */
+    @Test
+    void testReportsJson() throws Exception {
+        Path testDir = extractResources("mvnval-validate-pom");
+
+        Verifier verifier = newVerifier(testDir.resolve("clean").toString());
+        verifier.setForkJvm(true);
+        verifier.setLogFileName("json.txt");
+        verifier.addCliArgument("--val");
+        verifier.addCliArgument("--mode");
+        verifier.addCliArgument("raw");
+        verifier.addCliArgument("--format");
+        verifier.addCliArgument("json");
+        verifier.execute();
+        verifier.verifyErrorFreeLog();
+
+        verifier.verifyTextInLog("\"problems\":[]");
+    }
+}
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8650MvnvalBundleWorkspaceReaderTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8650MvnvalBundleWorkspaceReaderTest.java
new file mode 100644
index 0000000000..4a01e23b39
--- /dev/null
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8650MvnvalBundleWorkspaceReaderTest.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.it;
+
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration tests for {@code mvnval} bundle workspace reader and {@code --offline} support
+ * (MNG-8650).
+ *
+ * <h2>Bundle workspace reader</h2>
+ * <p>
+ * When several POMs are given at once in {@code effective} mode, {@code mvnval} pre-scans them
+ * to build an in-memory GA index and installs a workspace reader on the resolver session that
+ * answers parent and BOM lookups for bundle members from disk.  This means:
+ * <ul>
+ *   <li>A child whose parent lives in the same bundle is resolved locally, not from a
+ *       repository — so the run succeeds even when the parent has not been published yet.</li>
+ *   <li>The verdict is the same regardless of the order in which the POMs are named on the
+ *       command line.</li>
+ * </ul>
+ *
+ * <h2>Offline mode</h2>
+ * <p>
+ * {@code -o} is now honoured in effective mode.  Combined with {@code --temp-local-repository}
+ * it proves that the named POMs are fully self-contained: if a parent or BOM import is absent
+ * the run exits with code 1 (validation error), never silently fetches it from the network.
+ *
+ * @since 4.1.0
+ */
+class MavenITmng8650MvnvalBundleWorkspaceReaderTest extends AbstractMavenIntegrationTestCase {
+
+    /**
+     * Validates a parent+child bundle in {@code effective} mode when the child's
+     * {@code <relativePath/>} is disabled, so the parent cannot be found by filesystem
+     * discovery.  The bundle workspace reader must supply the parent from the bundle itself.
+     * <p>
+     * Both POMs are passed in the natural order (parent first).
+     */
+    @Test
+    void testBundleParentResolvedFromDiskParentFirst() throws Exception {
+        Path testDir = extractResources("mvnval-validate-pom");
+
+        Verifier verifier = newVerifier(testDir, null);
+        verifier.setForkJvm(true);
+        verifier.setLogFileName("bundle-parent-first.txt");
+        verifier.addCliArgument("--val");
+        // Both paths are relative to the verifier basedir (testDir).
+        verifier.addCliArgument("bundle-parent/pom.xml");
+        verifier.addCliArgument("bundle-child/pom.xml");
+        verifier.execute();
+        verifier.verifyErrorFreeLog();
+
+        verifier.verifyTextInLog("no problems");
+    }
+
+    /**
+     * Same as {@link #testBundleParentResolvedFromDiskParentFirst} but with the POMs named in
+     * reverse order (child first).  The verdict must be identical: the workspace index is built
+     * before any validation runs, so order on the command line cannot affect the result.
+     */
+    @Test
+    void testBundleParentResolvedFromDiskChildFirst() throws Exception {
+        Path testDir = extractResources("mvnval-validate-pom");
+
+        Verifier verifier = newVerifier(testDir, null);
+        verifier.setForkJvm(true);
+        verifier.setLogFileName("bundle-child-first.txt");
+        verifier.addCliArgument("--val");
+        verifier.addCliArgument("bundle-child/pom.xml");
+        verifier.addCliArgument("bundle-parent/pom.xml");
+        verifier.execute();
+        verifier.verifyErrorFreeLog();
+
+        verifier.verifyTextInLog("no problems");
+    }
+
+    /**
+     * Validates a bundle in {@code effective} mode with {@code --offline} and a throwaway local
+     * repository.  The parent lives in the bundle, so resolution succeeds even though no
+     * repository is reachable and the local repository is empty.
+     * <p>
+     * This is the "prove self-containment" idiom: if any parent or BOM were absent from the
+     * bundle the run would fail with exit code 1.
+     */
+    @Test
+    void testOfflineBundleResolvesFromDisk() throws Exception {
+        Path testDir = extractResources("mvnval-validate-pom");
+
+        Verifier verifier = newVerifier(testDir, null);
+        verifier.setForkJvm(true);
+        verifier.setLogFileName("bundle-offline.txt");
+        verifier.addCliArgument("--val");
+        verifier.addCliArgument("--offline");
+        verifier.addCliArgument("--temp-local-repository");
+        verifier.addCliArgument("bundle-parent/pom.xml");
+        verifier.addCliArgument("bundle-child/pom.xml");
+        verifier.execute();
+        verifier.verifyErrorFreeLog();
+
+        verifier.verifyTextInLog("no problems");
+    }
+
+    /**
+     * Validates that {@code --offline} with a throwaway local repository fails when the parent
+     * is NOT in the bundle (only the child is given).  The run must exit with a non-zero code
+     * and report a "Non-resolvable parent" problem — not silently fetch anything.
+     */
+    @Test
+    void testOfflineSinglePomFailsWhenParentAbsent() throws Exception {
+        Path testDir = extractResources("mvnval-validate-pom");
+
+        Verifier verifier = newVerifier(testDir, null);
+        verifier.setForkJvm(true);
+        verifier.setLogFileName("bundle-offline-missing-parent.txt");
+        verifier.addCliArgument("--val");
+        verifier.addCliArgument("--offline");
+        verifier.addCliArgument("--temp-local-repository");
+        verifier.addCliArgument("bundle-child/pom.xml");
+
+        assertThrows(
+                VerificationException.class, verifier::execute, "--offline with a missing parent must fail validation");
+
+        assertTrue(
+                verifier.loadLogLines().stream().anyMatch(l -> l.contains("Non-resolvable parent")),
+                "Failure must be reported as a non-resolvable parent: " + verifier.loadLogLines());
+    }
+}
diff --git a/its/core-it-suite/src/test/resources/mvnval-validate-pom/bundle-child/pom.xml b/its/core-it-suite/src/test/resources/mvnval-validate-pom/bundle-child/pom.xml
new file mode 100644
index 0000000000..d8cb78f5a8
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/mvnval-validate-pom/bundle-child/pom.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+<!--
+  Child POM for the mvnval bundle workspace reader IT.
+  Declares a parent with <relativePath/> (disabled): only the bundle workspace
+  reader installed by mvnval can supply the parent without going to the network.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0">
+  <modelVersion>4.0.0</modelVersion>
+  <parent>
+    <groupId>org.apache.maven.its.mvnval</groupId>
+    <artifactId>bundle-parent</artifactId>
+    <version>1.0</version>
+    <relativePath />
+  </parent>
+  <artifactId>bundle-child</artifactId>
+  <packaging>pom</packaging>
+</project>
diff --git a/its/core-it-suite/src/test/resources/mvnval-validate-pom/bundle-parent/pom.xml b/its/core-it-suite/src/test/resources/mvnval-validate-pom/bundle-parent/pom.xml
new file mode 100644
index 0000000000..4841c81278
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/mvnval-validate-pom/bundle-parent/pom.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+<!--
+  Parent POM for the mvnval bundle workspace reader IT.
+  Lives in a sibling directory next to the child so that no filesystem-based
+  parent discovery (relativePath) can link them: only the bundle workspace
+  reader installed by mvnval can resolve this parent when the child is
+  validated together with it.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0">
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>org.apache.maven.its.mvnval</groupId>
+  <artifactId>bundle-parent</artifactId>
+  <version>1.0</version>
+  <packaging>pom</packaging>
+</project>
diff --git a/its/core-it-suite/src/test/resources/mvnval-validate-pom/clean/pom.xml b/its/core-it-suite/src/test/resources/mvnval-validate-pom/clean/pom.xml
new file mode 100644
index 0000000000..c2d41eee35
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/mvnval-validate-pom/clean/pom.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0">
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>org.apache.maven.its.mvnval</groupId>
+  <artifactId>clean</artifactId>
+  <version>1.0</version>
+  <packaging>pom</packaging>
+</project>
diff --git a/its/core-it-suite/src/test/resources/mvnval-validate-pom/duplicate-dependency/pom.xml b/its/core-it-suite/src/test/resources/mvnval-validate-pom/duplicate-dependency/pom.xml
new file mode 100644
index 0000000000..19c1e749ab
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/mvnval-validate-pom/duplicate-dependency/pom.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0">
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>org.apache.maven.its.mvnval</groupId>
+  <artifactId>duplicate-dependency</artifactId>
+  <version>1.0</version>
+
+  <dependencies>
+    <dependency>
+      <groupId>junit</groupId>
+      <artifactId>junit</artifactId>
+      <version>4.13.2</version>
+    </dependency>
+    <dependency>
+      <groupId>junit</groupId>
+      <artifactId>junit</artifactId>
+      <version>4.12</version>
+    </dependency>
+  </dependencies>
+</project>