diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java
new file mode 100644
index 000000000000..540cda6bcedc
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java
@@ -0,0 +1,219 @@
+/*
+ * 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.build.context;
+
+import java.nio.file.Path;
+import java.util.Collection;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.annotations.Provider;
+
+/**
+ * Provides incremental build support by tracking inputs, outputs and their relationships.
+ *
+ *
This is the primary entry point for mojos that want to participate in incremental builds.
+ * The build context tracks which files were processed in the previous build, detects changes
+ * (new, modified, removed), and automatically cleans up stale outputs whose inputs no longer
+ * exist.
+ *
+ * One-to-one transformation
+ *
+ * The most common pattern: each input file produces one output file. Use
+ * {@link #registerAndProcessInputs(Path, Collection, Collection)} to register and filter
+ * in a single call — only changed inputs are returned:
+ *
+ * {@code
+ * @Inject
+ * BuildContext buildContext;
+ *
+ * public void execute() {
+ * for (Input input : buildContext.registerAndProcessInputs(
+ * sourceDir, List.of("**/*.xml"), null)) {
+ *
+ * Path outPath = outputDir.resolve(sourceDir.relativize(input.getPath()));
+ * Output output = input.associateOutput(outPath);
+ * try (OutputStream os = output.newOutputStream()) {
+ * transform(input.getPath(), os);
+ * }
+ * }
+ * }
+ * }
+ *
+ * Two-pass processing with status inspection
+ *
+ * When you need to inspect the change status before deciding what to do, use
+ * {@link #registerInputs(Path, Collection, Collection)} to get {@link Metadata} wrappers,
+ * then call {@link Metadata#process()} on the ones you want to process:
+ *
+ * {@code
+ * for (Metadata meta : buildContext.registerInputs(sourceDir, null, null)) {
+ * if (meta.getStatus() != Status.UNMODIFIED) {
+ * Input input = meta.process();
+ * // ... process the changed input
+ * }
+ * }
+ * }
+ *
+ * Configuration and non-file state
+ *
+ * The build context automatically tracks non-file state that affects
+ * outputs: mojo parameter values, plugin classpath contents, and any other configuration
+ * provided through the
+ * {@link org.apache.maven.api.build.context.spi.BuildContextEnvironment#getParameters()
+ * environment parameters}. If any configuration value changes between builds, the context
+ * escalates — all inputs are treated as modified, forcing a full rebuild.
+ * Mojos do not need to implement their own options-change detection.
+ *
+ * Aggregated builds
+ *
+ * When multiple inputs contribute to a single output (e.g., merging property files,
+ * generating an index), use {@link #newInputSet()} to create an {@link InputSet}:
+ *
+ * {@code
+ * InputSet inputSet = buildContext.newInputSet();
+ * inputSet.registerInputs(sourceDir, List.of("**/*.properties"), null);
+ * inputSet.aggregate(mergedOutput, (output, inputs) -> {
+ * // only called if any input changed since the last build
+ * Properties merged = new Properties();
+ * for (Input input : inputs) {
+ * try (InputStream is = Files.newInputStream(input.getPath())) {
+ * merged.load(is);
+ * }
+ * }
+ * try (OutputStream os = output.newOutputStream()) {
+ * merged.store(os, null);
+ * }
+ * });
+ * }
+ *
+ * @since 4.1.0
+ * @see Input
+ * @see Output
+ * @see InputSet
+ * @see Metadata
+ * @see Status
+ */
+@Experimental
+@NotThreadSafe
+@Provider
+public interface BuildContext {
+
+ /**
+ * {@return whether the build needs to process any inputs}
+ */
+ boolean isProcessingRequired();
+
+ /**
+ * Registers and processes the given output file.
+ *
+ * @param outputFile the output file path
+ * @return the processed output resource
+ */
+ @Nonnull
+ Output processOutput(@Nonnull Path outputFile);
+
+ /**
+ * Creates a new {@link InputSet} which can be used to associate inputs to outputs.
+ *
+ * @return a new input set, never {@code null}
+ */
+ @Nonnull
+ InputSet newInputSet();
+
+ /**
+ * Registers a single input file with this build context.
+ *
+ * This method is useful for registering individual files that are not part of a
+ * directory tree — for example, dependency JARs, configuration files, or other
+ * external inputs whose change should trigger reprocessing. Like directory-based
+ * registration, the returned {@link Metadata} provides the file's change
+ * {@link Status} relative to the previous build.
+ *
+ * @param inputFile the input file to register
+ * @return the metadata representing the input file, never {@code null}
+ * @throws IllegalArgumentException if {@code inputFile} is not a file or cannot be read
+ */
+ @Nonnull
+ Metadata registerInput(@Nonnull Path inputFile);
+
+ /**
+ * Registers inputs identified by {@code basedir} and {@code includes}/{@code excludes} ant
+ * patterns.
+ *
+ * When a file is found under {@code basedir}, it will be registered if it does not match
+ * {@code excludes} patterns and matches {@code includes} patterns. {@code null} or empty includes
+ * parameter will match all files. {@code excludes} match takes precedence over {@code includes}:
+ * if a file matches one of the excludes patterns it will not be registered regardless of includes
+ * patterns match.
+ *
+ * The implementation is not expected to handle changes to {@code basedir}, {@code includes} or
+ * {@code excludes} incrementally.
+ *
+ * @param basedir the base directory to look for inputs, must not be {@code null}
+ * @param includes patterns of the files to register, may be {@code null}
+ * @param excludes patterns of the files to ignore, may be {@code null}
+ * @return the metadata for the registered inputs
+ * @throws BuildContextException if an I/O error occurs
+ */
+ @Nonnull
+ Collection extends Metadata > registerInputs(
+ @Nonnull Path basedir, @Nullable Collection includes, @Nullable Collection excludes);
+
+ /**
+ * Registers inputs identified by {@code basedir} and {@code includes}/{@code excludes} ant
+ * patterns, then marks inputs that are {@link Status#NEW NEW} or {@link Status#MODIFIED MODIFIED}
+ * as processed.
+ *
+ * The returned collection contains all matching inputs, including
+ * {@link Status#UNMODIFIED UNMODIFIED} ones. Unmodified inputs are included so that
+ * output associations and metadata can be carried forward. Only new and modified inputs
+ * are marked as processed internally.
+ *
+ * @param basedir the base directory to look for inputs, must not be {@code null}
+ * @param includes patterns of the files to register, may be {@code null}
+ * @param excludes patterns of the files to ignore, may be {@code null}
+ * @return all registered inputs (new, modified, and unmodified)
+ * @throws BuildContextException if an I/O error occurs
+ */
+ @Nonnull
+ Collection extends Input> registerAndProcessInputs(
+ @Nonnull Path basedir, @Nullable Collection includes, @Nullable Collection excludes);
+
+ /**
+ * Marks skipped build execution. All inputs, outputs and their associated metadata are carried
+ * over to the next build as-is. No context modification operations ({@code register*} or
+ * {@code process}) are permitted after this call.
+ */
+ void markSkipExecution();
+
+ /**
+ * Sets whether the build should continue even if there are build errors.
+ *
+ * @param failOnError {@code true} to fail on error, {@code false} to continue
+ */
+ void setFailOnError(boolean failOnError);
+
+ /**
+ * {@return whether the build should fail on error}
+ */
+ boolean isFailOnError();
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java
new file mode 100644
index 000000000000..b4aaf5c120aa
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java
@@ -0,0 +1,56 @@
+/*
+ * 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.build.context;
+
+import java.io.Serial;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.services.MavenException;
+
+/**
+ * Exception thrown when an error occurs during incremental build context operations.
+ *
+ * This unchecked exception wraps I/O errors and other failures that occur while
+ * registering inputs, writing outputs, persisting state, or walking the file system.
+ * It extends {@link MavenException} so that it can be caught alongside other Maven
+ * API exceptions.
+ *
+ * @since 4.1.0
+ * @see BuildContext
+ */
+@Experimental
+public class BuildContextException extends MavenException {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ public BuildContextException() {}
+
+ public BuildContextException(String message) {
+ super(message);
+ }
+
+ public BuildContextException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public BuildContextException(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java
new file mode 100644
index 000000000000..3d3ba612c2b1
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java
@@ -0,0 +1,69 @@
+/*
+ * 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.build.context;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import org.apache.maven.api.annotations.Experimental;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+/**
+ * Optional annotation that customizes how the incremental build implementation handles
+ * configuration parameters for change detection.
+ *
+ * When a mojo is re-executed, the build context compares the current configuration
+ * parameter values against those from the previous build. If any tracked parameter has
+ * changed, all inputs are treated as modified, forcing a full rebuild. This annotation
+ * controls which parameters participate in that comparison.
+ *
+ * By default, all mojo parameters are considered. Use {@code @Incremental(consider = false)}
+ * to exclude parameters that do not affect the output (e.g., logging verbosity, thread count).
+ * This annotation is mandatory on {@link org.apache.maven.api.Project}
+ * and {@link org.apache.maven.api.Session} attributes to explicitly indicate whether
+ * they should be considered:
+ *
+ * {@code
+ * @Parameter(defaultValue = "${project}", readonly = true)
+ * @Incremental(consider = false)
+ * private Project project;
+ *
+ * @Parameter(property = "outputDirectory", required = true)
+ * @Incremental // considered by default
+ * private Path outputDirectory;
+ * }
+ *
+ * @since 4.1.0
+ * @see BuildContext
+ */
+@Experimental
+@Retention(RUNTIME)
+@Target({FIELD, METHOD, PARAMETER, TYPE})
+public @interface Incremental {
+
+ /**
+ * {@return whether to consider (the default) or ignore the annotated configuration parameter}
+ */
+ boolean consider() default true;
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java
new file mode 100644
index 000000000000..e5f29c31f746
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java
@@ -0,0 +1,73 @@
+/*
+ * 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.build.context;
+
+import java.nio.file.Path;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+import org.apache.maven.api.annotations.Provider;
+
+/**
+ * Represents an input resource in the incremental build context.
+ *
+ * An {@code Input} is obtained either by calling {@link Metadata#process()} on a registered
+ * input metadata, or directly from
+ * {@link BuildContext#registerAndProcessInputs(java.nio.file.Path, java.util.Collection, java.util.Collection)}.
+ * Once you have an {@code Input}, you can read its file and associate it with one or more
+ * {@link Output} resources to establish the input-to-output relationship used for
+ * stale-output cleanup:
+ *
+ * {@code
+ * Input input = buildContext.registerInput(sourceFile).process();
+ *
+ * // One input -> one output
+ * Output output = input.associateOutput(targetFile);
+ * try (OutputStream os = output.newOutputStream()) {
+ * transform(input.getPath(), os);
+ * }
+ *
+ * // One input -> multiple outputs
+ * Output header = input.associateOutput(headerFile);
+ * Output body = input.associateOutput(bodyFile);
+ * }
+ *
+ * When an input is removed in a subsequent build, the build context automatically deletes
+ * all outputs that were associated with it in the previous build.
+ *
+ * @since 4.1.0
+ * @see BuildContext#registerInput(java.nio.file.Path)
+ * @see Output
+ * @see Metadata#process()
+ */
+@Experimental
+@NotThreadSafe
+@Provider
+public interface Input extends Resource {
+
+ /**
+ * Associates this input with the given output file and returns the output resource.
+ *
+ * @param outputFile the path of the output file to associate
+ * @return the associated output resource
+ */
+ @Nonnull
+ Output associateOutput(@Nonnull Path outputFile);
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java
new file mode 100644
index 000000000000..4f0ec7c5d278
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java
@@ -0,0 +1,146 @@
+/*
+ * 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.build.context;
+
+import java.io.Serializable;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.function.BiConsumer;
+import java.util.function.BinaryOperator;
+import java.util.function.Function;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.annotations.Provider;
+
+/**
+ * Represents a set of inputs being aggregated into one or more outputs.
+ *
+ * An input set is the mechanism for many-to-one (or many-to-few )
+ * transformations, where multiple input files contribute to a single output file. The
+ * build context tracks which inputs belong to the set and only invokes the aggregation
+ * callback when at least one input has changed since the previous build.
+ *
+ * Typical use cases include:
+ *
+ * Merging multiple property files or configuration fragments into one
+ * Generating an index or manifest from a set of source files
+ * Building a ZIP or JAR from a directory tree
+ * Computing aggregate statistics or checksums
+ *
+ *
+ * Basic aggregation
+ *
+ * {@code
+ * InputSet inputSet = buildContext.newInputSet();
+ * inputSet.registerInputs(sourceDir, List.of("**/*.properties"), null);
+ *
+ * // The callback is only invoked if any input changed
+ * boolean written = inputSet.aggregate(mergedOutput, (output, inputs) -> {
+ * Properties merged = new Properties();
+ * for (Input input : inputs) {
+ * try (InputStream is = Files.newInputStream(input.getPath())) {
+ * merged.load(is);
+ * }
+ * }
+ * try (OutputStream os = output.newOutputStream()) {
+ * merged.store(os, null);
+ * }
+ * });
+ * }
+ *
+ * Indirect metadata aggregation
+ *
+ * The second {@link #aggregate(java.nio.file.Path, String, Serializable, Function,
+ * BinaryOperator, BiConsumer) aggregate} overload supports caching per-input metadata
+ * across builds. This is useful when extracting metadata from each input is expensive
+ * (e.g., parsing an AST). Only changed inputs are re-processed; cached metadata is
+ * reused for unchanged inputs.
+ *
+ * @since 4.1.0
+ * @see BuildContext#newInputSet()
+ * @see Input
+ * @see Output
+ */
+@Experimental
+@NotThreadSafe
+@Provider
+public interface InputSet {
+
+ /**
+ * Adds a previously registered input to this set.
+ *
+ * @param inputMetadata the input metadata to add
+ */
+ void addInput(@Nonnull Metadata inputMetadata);
+
+ /**
+ * Registers an input file and adds it to this set.
+ *
+ * @param inputFile the input file to register
+ * @return the metadata for the registered input
+ */
+ @Nonnull
+ Metadata registerInput(@Nonnull Path inputFile);
+
+ /**
+ * Registers input files matching the given patterns and adds them to this set.
+ *
+ * @param basedir the base directory to scan for inputs
+ * @param includes patterns of files to include, may be {@code null} to match all
+ * @param excludes patterns of files to exclude, may be {@code null}
+ * @return the metadata for all registered inputs
+ */
+ @Nonnull
+ Collection extends Metadata > registerInputs(
+ @Nonnull Path basedir, @Nullable Collection includes, @Nullable Collection excludes);
+
+ /**
+ * Aggregates all registered inputs into the given output file.
+ *
+ * @param outputFile the output file to write
+ * @param aggregator a consumer that receives the output and the collection of inputs
+ * @return {@code true} if the output was written, {@code false} if it was up-to-date
+ */
+ boolean aggregate(@Nonnull Path outputFile, @Nonnull BiConsumer> aggregator);
+
+ /**
+ * Performs an indirect metadata aggregation. The metadata for each input file is cached
+ * across builds, avoiding recomputation when an input has not changed.
+ *
+ * @param outputFile the output file to write
+ * @param stepId a unique identifier for this aggregation step
+ * @param identity the identity value for the accumulator
+ * @param mapper extracts metadata from each input
+ * @param accumulator combines metadata values
+ * @param writer writes the accumulated result to the output
+ * @param the metadata type, must be {@link Serializable}
+ * @return {@code true} if the output was rewritten, {@code false} if it was up-to-date
+ * @throws BuildContextException if an error occurs
+ */
+ boolean aggregate(
+ @Nonnull Path outputFile,
+ @Nonnull String stepId,
+ @Nonnull T identity,
+ @Nonnull Function mapper,
+ @Nonnull BinaryOperator accumulator,
+ @Nonnull BiConsumer writer);
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java
new file mode 100644
index 000000000000..3773cca79fbe
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java
@@ -0,0 +1,118 @@
+/*
+ * 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.build.context;
+
+import java.nio.file.Path;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+import org.apache.maven.api.annotations.Provider;
+
+/**
+ * Wraps a registered resource with its file path and change status,
+ * and provides a {@link #process()} method to obtain the full resource handle.
+ *
+ * {@code Metadata} is a lightweight wrapper returned by
+ * {@link BuildContext#registerInput(java.nio.file.Path)} and
+ * {@link BuildContext#registerInputs(java.nio.file.Path, java.util.Collection, java.util.Collection)}.
+ * It allows mojos to inspect a resource's {@link Status} before deciding whether to
+ * process it, enabling selective processing patterns:
+ *
+ * {@code
+ * for (Metadata meta : buildContext.registerInputs(sourceDir, null, null)) {
+ * switch (meta.getStatus()) {
+ * case NEW:
+ * case MODIFIED:
+ * Input input = meta.process();
+ * compile(input.getPath());
+ * break;
+ * case REMOVED:
+ * // The build context handles cleanup of associated outputs
+ * break;
+ * case UNMODIFIED:
+ * // Nothing to do — skip this input
+ * break;
+ * }
+ * }
+ * }
+ *
+ * Calling {@link #process()} marks the resource as "processed" in this build. The build
+ * context uses this information to determine which outputs are stale: if an input was
+ * registered but not processed, its associated outputs from the previous build are
+ * carried over unchanged.
+ *
+ * Two-pass processing
+ *
+ * The separation between registration and processing enables a two-pass
+ * pattern useful for tools whose outputs cannot be predicted in advance (e.g., a Java
+ * compiler producing inner-class files). In the first pass, register inputs and inspect
+ * their status to decide what to process. In the second pass — after the tool has run —
+ * call {@link #process()} and associate the actual outputs:
+ *
+ * {@code
+ * // Pass 1: determine what changed
+ * var all = buildContext.registerInputs(sourceDir, null, null);
+ * var changed = all.stream()
+ * .filter(m -> m.getStatus() != Status.UNMODIFIED)
+ * .toList();
+ *
+ * // Run the tool on changed inputs only
+ * tool.process(changed.stream().map(Metadata::getPath).toList());
+ *
+ * // Pass 2: associate outputs discovered after processing
+ * for (Metadata meta : all) {
+ * Input input = meta.process();
+ * for (Path output : discoverOutputs(input.getPath())) {
+ * input.associateOutput(output);
+ * }
+ * }
+ * }
+ *
+ * @param the resource type ({@link Input} or {@link Output})
+ * @since 4.1.0
+ * @see BuildContext#registerInput(java.nio.file.Path)
+ * @see BuildContext#registerInputs(java.nio.file.Path, java.util.Collection, java.util.Collection)
+ * @see Status
+ */
+@Experimental
+@NotThreadSafe
+@Provider
+public interface Metadata {
+
+ /**
+ * {@return the path of the registered resource}
+ */
+ @Nonnull
+ Path getPath();
+
+ /**
+ * {@return the change status of the resource relative to the previous build}
+ */
+ @Nonnull
+ Status getStatus();
+
+ /**
+ * Marks this resource for processing and returns the full resource handle.
+ *
+ * @return the resource to process
+ */
+ @Nonnull
+ R process();
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java
new file mode 100644
index 000000000000..60f14d349c91
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java
@@ -0,0 +1,89 @@
+/*
+ * 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.build.context;
+
+import java.io.BufferedWriter;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.nio.charset.Charset;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+import org.apache.maven.api.annotations.Provider;
+
+/**
+ * Represents an output resource in the incremental build context.
+ *
+ * An {@code Output} is obtained by calling {@link Input#associateOutput(java.nio.file.Path)}
+ * or {@link BuildContext#processOutput(java.nio.file.Path)}. It provides methods to write the
+ * output content:
+ *
+ * {@code
+ * Output output = input.associateOutput(targetPath);
+ *
+ * // Binary output
+ * try (OutputStream os = output.newOutputStream()) {
+ * writeBytes(os);
+ * }
+ *
+ * // Text output
+ * try (BufferedWriter w = output.newBufferedWriter(StandardCharsets.UTF_8)) {
+ * w.write("generated content");
+ * }
+ * }
+ *
+ * The output stream returned by {@link #newOutputStream()} may be a caching stream
+ * that only overwrites the target file when the content has actually changed. This prevents
+ * downstream tools and IDEs from seeing a modified timestamp on an unchanged file, avoiding
+ * unnecessary cascading rebuilds.
+ *
+ * The build context automatically creates parent directories for the output file
+ * if they do not exist.
+ *
+ * @since 4.1.0
+ * @see Input#associateOutput(java.nio.file.Path)
+ * @see BuildContext#processOutput(java.nio.file.Path)
+ */
+@Experimental
+@NotThreadSafe
+@Provider
+public interface Output extends Resource {
+
+ /**
+ * Returns a new caching output stream for this output resource.
+ *
+ * @return a new output stream, never {@code null}
+ * @throws BuildContextException if an I/O error occurs
+ */
+ @Nonnull
+ OutputStream newOutputStream();
+
+ /**
+ * Returns a new buffered writer for this output resource using the given charset.
+ *
+ * @param charset the charset to use for encoding
+ * @return a new buffered writer, never {@code null}
+ * @throws BuildContextException if an I/O error occurs
+ */
+ @Nonnull
+ default BufferedWriter newBufferedWriter(@Nonnull Charset charset) {
+ return new BufferedWriter(new OutputStreamWriter(newOutputStream(), charset));
+ }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java
new file mode 100644
index 000000000000..26470afe3fe6
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java
@@ -0,0 +1,83 @@
+/*
+ * 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.build.context;
+
+import java.nio.file.Path;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.annotations.Provider;
+
+/**
+ * Represents a build resource (input or output file) tracked by the incremental build context.
+ *
+ * This is the base interface for both {@link Input} and {@link Output}. Every resource has
+ * a file {@link #getPath() path}, a change {@link #getStatus() status}, and the ability to
+ * carry diagnostic {@link #addMessage messages} that are reported to the user at the end
+ * of the build.
+ *
+ * Diagnostic messages are attached to resources rather than logged directly so that IDEs
+ * can display them at the correct file and line location:
+ *
+ * {@code
+ * try {
+ * compile(input.getPath());
+ * } catch (CompileError e) {
+ * input.addMessage(e.getLine(), e.getColumn(),
+ * e.getMessage(), Severity.ERROR, e);
+ * }
+ * }
+ *
+ * @since 4.1.0
+ * @see Input
+ * @see Output
+ * @see Severity
+ * @see Status
+ */
+@Experimental
+@NotThreadSafe
+@Provider
+public interface Resource {
+
+ /**
+ * {@return the path of this resource}
+ */
+ @Nonnull
+ Path getPath();
+
+ /**
+ * {@return the change status of this resource relative to the previous build}
+ */
+ @Nonnull
+ Status getStatus();
+
+ /**
+ * Attaches a diagnostic message to this resource at the given source location.
+ *
+ * @param line the 1-based line number, or {@code 0} if unknown
+ * @param column the 1-based column number, or {@code 0} if unknown
+ * @param message the human-readable message text
+ * @param severity the severity level
+ * @param cause the underlying cause, or {@code null}
+ */
+ void addMessage(
+ int line, int column, @Nonnull String message, @Nonnull Severity severity, @Nullable Throwable cause);
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java
new file mode 100644
index 000000000000..6f5408530baa
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java
@@ -0,0 +1,44 @@
+/*
+ * 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.build.context;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
+
+/**
+ * Severity level for build messages attached to resources.
+ *
+ * Used with {@link Resource#addMessage(int, int, String, Severity, Throwable)} to
+ * classify diagnostic messages. Messages are collected during the build and reported
+ * through the {@link org.apache.maven.api.build.context.spi.Sink} at commit time.
+ *
+ * @since 4.1.0
+ * @see Resource#addMessage(int, int, String, Severity, Throwable)
+ * @see org.apache.maven.api.build.context.spi.Message
+ */
+@Experimental
+@Immutable
+public enum Severity {
+ /** An error that should fail the build (unless fail-on-error is disabled). */
+ ERROR,
+ /** A warning that does not fail the build. */
+ WARNING,
+ /** An informational message. */
+ INFO
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java
new file mode 100644
index 000000000000..c2fd81ad1345
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java
@@ -0,0 +1,59 @@
+/*
+ * 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.build.context;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
+
+/**
+ * Indicates the change status of a resource between the current and previous build.
+ *
+ * The status is determined by comparing the current file metadata (timestamp, size)
+ * against the state saved from the previous build. It is available on both
+ * {@link Metadata#getStatus()} (before processing) and {@link Resource#getStatus()}
+ * (after processing).
+ *
+ * @since 4.1.0
+ * @see Metadata#getStatus()
+ * @see Resource#getStatus()
+ */
+@Experimental
+@Immutable
+public enum Status {
+
+ /**
+ * Resource is new in this build, i.e. it was not present in the previous build.
+ */
+ NEW,
+
+ /**
+ * Resource changed since the previous build.
+ */
+ MODIFIED,
+
+ /**
+ * Resource did not change since the previous build.
+ */
+ UNMODIFIED,
+
+ /**
+ * Resource was removed since the previous build.
+ */
+ REMOVED
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java
new file mode 100644
index 000000000000..61c69c15fe92
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java
@@ -0,0 +1,280 @@
+/*
+ * 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.
+ */
+
+/**
+ * Incremental build context API for Apache Maven 4.
+ *
+ * Overview
+ *
+ * This package provides the API for incremental builds in Maven. An incremental
+ * build skips work that is already up-to-date, avoiding redundant processing when only a
+ * subset of source files have changed. The central abstraction is {@link BuildContext},
+ * which tracks inputs (source files), outputs (generated
+ * files), and the relationships between them across successive builds.
+ *
+ * Mojos that perform file transformations (compilation, code generation, resource
+ * filtering, etc.) can use this API to:
+ *
+ * Detect which input files are new, modified, or removed since the last build
+ * Process only the changed inputs and regenerate only the affected outputs
+ * Automatically clean up stale outputs whose inputs have been removed
+ * Report diagnostic messages (errors, warnings) attached to specific resources
+ *
+ *
+ * Architecture
+ *
+ * The API is organized around four key abstractions:
+ *
+ *
+ * BuildContext The entry point. Registers inputs, creates outputs,
+ * +-- Input tracks relationships between them.
+ * +-- Output
+ * +-- InputSet Groups inputs for aggregated operations
+ *
+ * Metadata<R> Wraps a resource with its change status before
+ * it is "processed" into a full Input or Output handle.
+ *
+ * Status Change status: NEW, MODIFIED, UNMODIFIED, REMOVED.
+ *
+ * Severity Diagnostic level: ERROR, WARNING, INFO.
+ *
+ *
+ * Use Case 1: One-to-one file transformation
+ *
+ * The simplest pattern: each input file produces exactly one output file.
+ * Only changed inputs are processed; stale outputs are cleaned up automatically.
+ *
+ * {@code
+ * @Inject
+ * BuildContext buildContext;
+ *
+ * public void execute() {
+ * // Register all .xml files under src/main/resources, excluding tests
+ * for (Input input : buildContext.registerAndProcessInputs(
+ * sourceDir,
+ * List.of("**/*.xml"),
+ * List.of("**/test-*"))) {
+ *
+ * Path outputPath = outputDir.resolve(
+ * sourceDir.relativize(input.getPath()));
+ *
+ * Output output = input.associateOutput(outputPath);
+ * try (OutputStream os = output.newOutputStream()) {
+ * transform(input.getPath(), os);
+ * }
+ * }
+ * }
+ * }
+ *
+ * Use Case 2: Aggregated output (many inputs to one output)
+ *
+ * Some operations aggregate multiple inputs into a single output (e.g., generating
+ * an index, merging property files, building a ZIP archive). Use {@link InputSet} for this:
+ *
+ * {@code
+ * @Inject
+ * BuildContext buildContext;
+ *
+ * public void execute() {
+ * InputSet inputSet = buildContext.newInputSet();
+ * inputSet.registerInputs(sourceDir, List.of("**/*.properties"), null);
+ *
+ * // aggregate() only invokes the callback if any input changed
+ * inputSet.aggregate(mergedOutput, (output, inputs) -> {
+ * Properties merged = new Properties();
+ * for (Input input : inputs) {
+ * try (InputStream is = Files.newInputStream(input.getPath())) {
+ * merged.load(is);
+ * }
+ * }
+ * try (OutputStream os = output.newOutputStream()) {
+ * merged.store(os, "Merged properties");
+ * }
+ * });
+ * }
+ * }
+ *
+ * Use Case 3: Conditional execution with status checks
+ *
+ * When a mojo does not produce individual output files but performs an action
+ * (e.g., deploying, validating), use {@link BuildContext#isProcessingRequired()}
+ * or inspect the {@link Status} of individual resources to decide whether to act:
+ *
+ * {@code
+ * @Inject
+ * BuildContext buildContext;
+ *
+ * public void execute() {
+ * Iterable extends Metadata > inputs =
+ * buildContext.registerInputs(sourceDir, null, null);
+ *
+ * boolean hasChanges = false;
+ * for (Metadata meta : inputs) {
+ * if (meta.getStatus() != Status.UNMODIFIED) {
+ * hasChanges = true;
+ * Input input = meta.process();
+ * validate(input.getPath());
+ * }
+ * }
+ *
+ * if (!hasChanges) {
+ * buildContext.markSkipExecution();
+ * }
+ * }
+ * }
+ *
+ * Use Case 4: Reporting diagnostics
+ *
+ * Attach diagnostic messages to resources so that IDEs and build tools can
+ * display them at the correct location:
+ *
+ * {@code
+ * Input input = buildContext.registerInput(sourceFile);
+ * try {
+ * compile(input.getPath());
+ * } catch (CompileError e) {
+ * input.addMessage(e.getLine(), e.getColumn(),
+ * e.getMessage(), Severity.ERROR, e);
+ * }
+ * }
+ *
+ * Use Case 5: Two-pass processing (compile, then associate outputs)
+ *
+ * When a tool produces outputs that cannot be predicted before processing
+ * (e.g., a Java compiler generating inner-class files like {@code Foo$1.class},
+ * {@code Foo$Inner.class}), use the two-pass API: register inputs first to determine
+ * what changed, run the tool, then associate the actual outputs afterward:
+ *
+ * {@code
+ * @Inject
+ * BuildContext buildContext;
+ *
+ * public void execute() {
+ * // Pass 1: register sources and inspect their status
+ * var allInputs = buildContext.registerInputs(sourceDir, List.of("**/*.java"), null);
+ * List> toCompile = new ArrayList<>();
+ * for (Metadata meta : allInputs) {
+ * if (meta.getStatus() != Status.UNMODIFIED) {
+ * toCompile.add(meta);
+ * }
+ * }
+ *
+ * if (toCompile.isEmpty()) {
+ * buildContext.markSkipExecution();
+ * return;
+ * }
+ *
+ * // Compile the changed sources
+ * List sourceFiles = toCompile.stream()
+ * .map(Metadata::getPath).toList();
+ * compiler.compile(sourceFiles, outputDir);
+ *
+ * // Pass 2: associate the actual outputs (now known after compilation)
+ * for (Metadata meta : allInputs) {
+ * Input input = meta.process();
+ * String baseName = getClassName(input.getPath()); // Foo.java → Foo
+ * // Scan for Foo.class, Foo$Inner.class, Foo$1.class, etc.
+ * for (Path classFile : findClassFiles(outputDir, baseName)) {
+ * input.associateOutput(classFile);
+ * }
+ * }
+ * }
+ * }
+ *
+ * When a previously registered input is removed in a subsequent build, the build
+ * context automatically deletes all outputs that were associated with it — including
+ * any inner-class files discovered during the previous build's pass 2.
+ *
+ * Configuration change detection (non-file state)
+ *
+ * Incremental builds must consider more than just file changes. A mojo's behavior
+ * also depends on its configuration — compiler flags, plugin versions,
+ * dependency classpath, and other parameters. If any of these change between builds,
+ * all inputs must be reprocessed even if no source files were modified.
+ *
+ * The Maven runtime handles this automatically . Before each mojo
+ * execution, Maven:
+ *
+ * Digests all mojo parameters — every {@code @Parameter}-annotated
+ * field is reflected, its value evaluated, and a digest computed. This covers
+ * compiler options, output directories, filter configurations, etc.
+ * Digests the plugin classpath — the SHA-1 hash of every plugin
+ * dependency JAR's contents (not just timestamps) is computed and cached per
+ * session.
+ * Compares with the previous build — if any digest differs from
+ * the value stored in the state file, the build context escalates : all
+ * inputs are treated as modified, forcing a full rebuild.
+ *
+ *
+ * This means mojos do not need to implement their own
+ * options-change or classpath-change detection. The build context infrastructure
+ * handles it transparently. A mojo that only uses {@code registerInputs()} and
+ * {@code associateOutput()} will automatically get correct incremental behavior
+ * even when configuration changes — no additional code required.
+ *
+ * The configuration digest is provided through
+ * {@link org.apache.maven.api.build.context.spi.BuildContextEnvironment#getParameters()
+ * BuildContextEnvironment.getParameters()}, which the Maven runtime populates via
+ * a {@code MojoConfigurationDigester}.
+ *
+ * Escalation
+ *
+ * The build context escalates to a full build (treating all inputs as
+ * modified) in several situations:
+ *
+ * No previous state file exists (first build, or after a clean)
+ * The configuration digest has changed (mojo parameters or plugin classpath)
+ * Previously tracked output files are missing on disk
+ * The workspace explicitly requests escalation
+ * ({@link org.apache.maven.api.build.context.spi.Workspace.Mode#ESCALATED})
+ *
+ *
+ * Escalation is transparent to mojos — they use the same API regardless. The only
+ * visible effect is that {@link Status#UNMODIFIED} inputs become rare or absent,
+ * so the mojo ends up processing everything.
+ *
+ * Lifecycle and state persistence
+ *
+ * The build context persists its state (file timestamps, input-output associations,
+ * resource attributes, and configuration digests) between builds in a state file
+ * managed by the implementation. On each build:
+ *
+ * The context loads the previous state (if any), compares configuration digests,
+ * and compares file timestamps to determine each input's {@link Status}
+ * The mojo registers inputs and creates outputs through the API
+ * At commit time, the context saves the new state and cleans up stale outputs
+ * (outputs associated with inputs that no longer exist)
+ *
+ *
+ * Mojos do not manage the state file directly. The Maven runtime (or IDE integration)
+ * handles initialization and commit through the
+ * {@link org.apache.maven.api.build.context.spi SPI} interfaces.
+ *
+ * @since 4.1.0
+ * @see BuildContext
+ * @see Input
+ * @see Output
+ * @see InputSet
+ * @see Metadata
+ * @see org.apache.maven.api.build.context.spi
+ */
+@Experimental
+package org.apache.maven.api.build.context;
+
+import org.apache.maven.api.annotations.Experimental;
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java
new file mode 100644
index 000000000000..fbda5b8bc659
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java
@@ -0,0 +1,104 @@
+/*
+ * 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.build.context.spi;
+
+import java.io.Serializable;
+import java.nio.file.Path;
+import java.util.Map;
+
+import org.apache.maven.api.annotations.Consumer;
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.annotations.ThreadSafe;
+
+/**
+ * Provides the environment configuration needed to initialize a build context,
+ * including state file location, workspace, parameters, and an optional finalizer.
+ *
+ * This interface is implemented by the Maven runtime (or IDE integration) and
+ * passed to the build context constructor. It bundles everything the context needs
+ * to initialize:
+ *
+ * State file — where to persist input/output relationships between
+ * builds. Typically located under {@code target/} so that a clean build starts fresh.
+ * Workspace — the file system abstraction (see {@link Workspace}).
+ * Parameters — mojo configuration values. The build context compares
+ * these against the previous build; if any change, all inputs are treated as modified.
+ * Finalizer — optional callback that commits the context after
+ * mojo execution (see {@link BuildContextFinalizer}).
+ *
+ *
+ * @since 4.1.0
+ * @see Workspace
+ * @see BuildContextFinalizer
+ * @see CommittableBuildContext
+ */
+@Experimental
+@ThreadSafe
+@Consumer
+public interface BuildContextEnvironment {
+
+ /**
+ * {@return the path to the file where build context state is persisted}
+ */
+ @Nonnull
+ Path getStateFile();
+
+ /**
+ * {@return the workspace that provides file system abstraction}
+ */
+ @Nonnull
+ Workspace getWorkspace();
+
+ /**
+ * Returns the configuration parameters for this build context.
+ *
+ * These parameters represent non-file state that affects the build
+ * output — mojo configuration values, dependency digests, and any other context that
+ * determines what the mojo will produce. The build context compares these values against
+ * the previous build's stored parameters; if any value has changed, all inputs
+ * are treated as modified (escalation ), forcing a full rebuild.
+ *
+ * In the Maven runtime, this map is populated automatically by a
+ * {@code MojoConfigurationDigester} that:
+ *
+ * Reflects on every {@code @Parameter}-annotated field of the mojo class,
+ * evaluates its expression, and computes a digest of the resolved value
+ * Computes a SHA-1 digest of the plugin's classpath JARs (by content,
+ * not by timestamp, so rebuilding unchanged sources does not trigger
+ * false escalation)
+ *
+ *
+ * This means mojos automatically get correct incremental behavior when their
+ * configuration changes — compiler flags, output directories, filter tokens,
+ * plugin dependency versions — without any extra code. The mojo only needs to
+ * register its file inputs and associate outputs; the build context handles the rest.
+ *
+ * @return the configuration parameters, never {@code null}
+ */
+ @Nonnull
+ Map getParameters();
+
+ /**
+ * {@return the optional context finalizer, or {@code null} if none is configured}
+ */
+ @Nullable
+ BuildContextFinalizer getFinalizer();
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java
new file mode 100644
index 000000000000..f74c87bd738b
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java
@@ -0,0 +1,58 @@
+/*
+ * 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.build.context.spi;
+
+import org.apache.maven.api.annotations.Consumer;
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.ThreadSafe;
+
+/**
+ * Callback interface for registering build contexts that should be committed
+ * at the end of a mojo execution.
+ *
+ * The Maven runtime implements this interface to collect all
+ * {@link CommittableBuildContext} instances created during a mojo's execution and
+ * commit them in a single batch after the mojo completes. This ensures that:
+ *
+ * State is persisted only after the mojo succeeds (no partial state on failure)
+ * Diagnostic messages from all contexts are reported together
+ * Stale output cleanup happens atomically
+ *
+ *
+ * A mojo that uses multiple {@link org.apache.maven.api.build.context.BuildContext}
+ * instances (e.g., for different source roots) will have each context registered
+ * individually through this finalizer.
+ *
+ * @since 4.1.0
+ * @see CommittableBuildContext#commit(Sink)
+ * @see BuildContextEnvironment#getFinalizer()
+ */
+@Experimental
+@ThreadSafe
+@Consumer
+public interface BuildContextFinalizer {
+
+ /**
+ * Registers a build context to be committed when the mojo execution completes.
+ *
+ * @param context the build context to register
+ */
+ void registerContext(@Nonnull CommittableBuildContext context);
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommittableBuildContext.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommittableBuildContext.java
new file mode 100644
index 000000000000..9bb197fe24e9
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommittableBuildContext.java
@@ -0,0 +1,56 @@
+/*
+ * 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.build.context.spi;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.NotThreadSafe;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.annotations.Provider;
+import org.apache.maven.api.build.context.BuildContext;
+
+/**
+ * Extended {@link BuildContext} that supports committing state changes and
+ * reporting messages through a {@link Sink}.
+ *
+ * This interface is implemented by the build context implementation, not by mojos.
+ * The Maven runtime (or IDE integration) uses it to commit all state changes at the
+ * end of a mojo execution, typically through a {@link BuildContextFinalizer}:
+ *
+ * The mojo registers inputs, creates outputs, and attaches messages via
+ * the {@link BuildContext} API
+ * The finalizer calls {@link #commit(Sink)} which persists the new state,
+ * deletes stale outputs, and reports messages through the sink
+ *
+ *
+ * @since 4.1.0
+ * @see BuildContextFinalizer
+ * @see Sink
+ */
+@Experimental
+@NotThreadSafe
+@Provider
+public interface CommittableBuildContext extends BuildContext {
+
+ /**
+ * Commits all changes in this build context and reports messages to the given sink.
+ *
+ * @param sink the sink that receives build messages, or {@code null} if no message reporting is needed
+ */
+ void commit(@Nullable Sink sink);
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java
new file mode 100644
index 000000000000..86f873b8efea
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java
@@ -0,0 +1,128 @@
+/*
+ * 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.build.context.spi;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.build.context.Status;
+
+/**
+ * Immutable snapshot of a file's state (path, last-modified time, size) and its
+ * change {@link Status} relative to the previous build.
+ *
+ * Instances are produced by {@link Workspace#walk(java.nio.file.Path)} and consumed
+ * by the build context implementation to determine which inputs have changed. The
+ * two-argument constructor reads file attributes from the filesystem automatically;
+ * the four-argument constructor allows the workspace to supply pre-computed values
+ * (e.g., from an IDE's file-watcher cache).
+ *
+ * @since 4.1.0
+ * @see Workspace#walk(java.nio.file.Path)
+ * @see Status
+ */
+@Experimental
+@Immutable
+public final class FileState {
+
+ private final Path path;
+ private final FileTime lastModified;
+ private final long size;
+ private final Status status;
+
+ /**
+ * Creates a file state with explicit attributes.
+ *
+ * @param path the file path
+ * @param lastModified the last-modified time, or {@code null} for removed files
+ * @param size the file size in bytes
+ * @param status the change status
+ */
+ public FileState(@Nonnull Path path, @Nullable FileTime lastModified, long size, @Nonnull Status status) {
+ this.path = path;
+ this.lastModified = lastModified;
+ this.size = size;
+ this.status = status;
+ }
+
+ /**
+ * Creates a file state by reading attributes from the file system.
+ * For {@link Status#REMOVED} files, the last-modified time is set to {@code null}
+ * and the size to {@code 0}.
+ *
+ * @param path the file path
+ * @param status the change status
+ * @throws BuildContextException if the file attributes cannot be read
+ */
+ public FileState(@Nonnull Path path, @Nonnull Status status) {
+ this.path = path;
+ this.status = status;
+ if (status == Status.REMOVED) {
+ lastModified = null;
+ size = 0;
+ } else {
+ try {
+ BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
+ this.lastModified = attrs.lastModifiedTime();
+ this.size = attrs.size();
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+ }
+
+ /**
+ * {@return the file path}
+ */
+ @Nonnull
+ public Path getPath() {
+ return path;
+ }
+
+ /**
+ * {@return the last-modified time, or {@code null} for removed files}
+ */
+ @Nullable
+ public FileTime getLastModified() {
+ return lastModified;
+ }
+
+ /**
+ * {@return the file size in bytes}
+ */
+ public long getSize() {
+ return size;
+ }
+
+ /**
+ * {@return the change status relative to the previous build}
+ */
+ @Nonnull
+ public Status getStatus() {
+ return status;
+ }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java
new file mode 100644
index 000000000000..9006e7eb9ef6
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java
@@ -0,0 +1,141 @@
+/*
+ * 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.build.context.spi;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Objects;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.build.context.Severity;
+
+/**
+ * An immutable diagnostic message attached to a build resource.
+ *
+ * Messages are created by
+ * {@link org.apache.maven.api.build.context.Resource#addMessage(int, int, String,
+ * org.apache.maven.api.build.context.Severity, Throwable) Resource.addMessage()},
+ * stored internally by the build context, and delivered to the {@link Sink} at
+ * commit time. Each message carries a source location (line/column), severity,
+ * human-readable text, and an optional root cause.
+ *
+ * This class implements {@link java.io.Serializable} so that messages can be
+ * persisted as part of the build context state and re-reported on subsequent builds
+ * if the resource has not been reprocessed.
+ *
+ * @since 4.1.0
+ * @see org.apache.maven.api.build.context.Resource#addMessage(int, int, String,
+ * org.apache.maven.api.build.context.Severity, Throwable)
+ * @see Sink#messages(java.nio.file.Path, boolean, java.util.Collection)
+ * @see org.apache.maven.api.build.context.Severity
+ */
+@Experimental
+@Immutable
+public class Message implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 7798138299696868415L;
+
+ private final int line;
+ private final int column;
+ private final String message;
+ private final Severity severity;
+ private final Throwable cause;
+ private final int hashCode;
+
+ /**
+ * Creates a new message.
+ *
+ * @param line the 1-based line number, or {@code 0} if unknown
+ * @param column the 1-based column number, or {@code 0} if unknown
+ * @param message the human-readable message text
+ * @param severity the severity level
+ * @param cause the underlying cause, or {@code null}
+ */
+ public Message(
+ int line, int column, @Nonnull String message, @Nonnull Severity severity, @Nullable Throwable cause) {
+ this.line = line;
+ this.column = column;
+ this.message = message;
+ this.severity = severity;
+ this.cause = cause;
+ this.hashCode = Objects.hash(line, column, message, severity, cause);
+ }
+
+ /**
+ * {@return the 1-based line number, or {@code 0} if unknown}
+ */
+ public int getLine() {
+ return line;
+ }
+
+ /**
+ * {@return the 1-based column number, or {@code 0} if unknown}
+ */
+ public int getColumn() {
+ return column;
+ }
+
+ /**
+ * {@return the human-readable message text}
+ */
+ @Nonnull
+ public String getMessage() {
+ return message;
+ }
+
+ /**
+ * {@return the severity level}
+ */
+ @Nonnull
+ public Severity getSeverity() {
+ return severity;
+ }
+
+ /**
+ * {@return the underlying cause, or {@code null}}
+ */
+ @Nullable
+ public Throwable getCause() {
+ return cause;
+ }
+
+ @Override
+ public int hashCode() {
+ return hashCode;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (!(obj instanceof Message other)) {
+ return false;
+ }
+ return line == other.line
+ && column == other.column
+ && Objects.equals(message, other.message)
+ && Objects.equals(severity, other.severity)
+ && Objects.equals(cause, other.cause);
+ }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java
new file mode 100644
index 000000000000..33452b2d0844
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.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.api.build.context.spi;
+
+import java.nio.file.Path;
+import java.util.Collection;
+
+import org.apache.maven.api.annotations.Consumer;
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+
+/**
+ * Receives diagnostic messages produced during a build context commit.
+ *
+ * When {@link CommittableBuildContext#commit(Sink)} is called, the build context
+ * iterates over all resources and delivers their attached messages to this sink.
+ * Implementations typically bridge to the IDE's problem/markers view, the Maven
+ * build log, or an error reporter:
+ *
+ * {@link #messages(java.nio.file.Path, boolean, java.util.Collection)} — delivers
+ * messages for a resource. The {@code isNew} flag lets the sink decide whether
+ * to append or replace existing markers.
+ * {@link #clear(java.nio.file.Path)} — removes all previously reported messages
+ * for a resource (e.g., when the resource is no longer an input or all errors
+ * have been fixed).
+ *
+ *
+ * @since 4.1.0
+ * @see CommittableBuildContext#commit(Sink)
+ * @see Message
+ * @see org.apache.maven.api.build.context.Resource#addMessage(int, int, String,
+ * org.apache.maven.api.build.context.Severity, Throwable)
+ */
+@Experimental
+@NotThreadSafe
+@Consumer
+public interface Sink {
+
+ /**
+ * Reports messages for a resource.
+ *
+ * @param resource the resource path the messages belong to
+ * @param isNew {@code true} if the resource is new in this build
+ * @param messages the diagnostic messages to report
+ */
+ void messages(@Nonnull Path resource, boolean isNew, @Nonnull Collection messages);
+
+ /**
+ * Clears all previously reported messages for a resource.
+ *
+ * @param resource the resource path to clear messages for
+ */
+ void clear(@Nonnull Path resource);
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java
new file mode 100644
index 000000000000..48cf73300968
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java
@@ -0,0 +1,175 @@
+/*
+ * 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.build.context.spi;
+
+import java.io.OutputStream;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.util.stream.Stream;
+
+import org.apache.maven.api.annotations.Consumer;
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.ThreadSafe;
+import org.apache.maven.api.build.context.Status;
+
+/**
+ * Provides a layer of indirection between the {@link org.apache.maven.api.build.context.BuildContext}
+ * and the underlying file store.
+ *
+ * This is the most important SPI interface. IDE integrations supply a workspace implementation
+ * that is aware of the IDE's virtual file system (e.g., Eclipse's {@code IWorkspace}), while
+ * command-line Maven builds use a direct filesystem implementation. The workspace determines:
+ *
+ * How files are read and written (allowing the IDE to intercept file operations)
+ * How change detection works (full scan vs. IDE-provided delta)
+ * When output files should be deleted (notifying the IDE's resource tracker)
+ *
+ *
+ * The {@link Mode} controls how {@link #walk(java.nio.file.Path)} behaves, which in turn
+ * controls how aggressively the build context scans for changes:
+ *
+ * {@link Mode#NORMAL} — Full scan; the build context compares timestamps
+ * against saved state. Used by command-line Maven.
+ * {@link Mode#DELTA} — The workspace already knows the changed files
+ * (e.g., from an IDE file watcher). Only the delta is returned, making builds fast.
+ * {@link Mode#ESCALATED} — Fallback from DELTA when the change set may
+ * be incomplete (e.g., after an IDE crash). Forces a full scan to recover.
+ * {@link Mode#SUPPRESSED} — No processing; all inputs appear unmodified.
+ * Used for configuration-only builds or when incremental support is disabled.
+ *
+ *
+ * @since 4.1.0
+ * @see BuildContextEnvironment#getWorkspace()
+ * @see org.apache.maven.api.build.context.BuildContext
+ */
+@Experimental
+@ThreadSafe
+@Consumer
+public interface Workspace {
+
+ /**
+ * {@return the current workspace mode}
+ */
+ @Nonnull
+ Mode getMode();
+
+ /**
+ * Returns an escalated view of this workspace, where all files are treated as new.
+ *
+ * @return the escalated workspace
+ */
+ @Nonnull
+ Workspace escalate();
+
+ /**
+ * {@return {@code true} if the file exists in this workspace}
+ *
+ * @param file the file path to check
+ */
+ boolean isPresent(@Nonnull Path file);
+
+ /**
+ * {@return {@code true} if the path is a regular file in this workspace}
+ *
+ * @param file the file path to check
+ */
+ boolean isRegularFile(@Nonnull Path file);
+
+ /**
+ * {@return {@code true} if the path is a directory in this workspace}
+ *
+ * @param file the file path to check
+ */
+ boolean isDirectory(@Nonnull Path file);
+
+ /**
+ * Deletes the specified file from this workspace.
+ *
+ * @param file the file to delete
+ * @throws org.apache.maven.api.build.context.BuildContextException if an I/O error occurs
+ */
+ void deleteFile(@Nonnull Path file);
+
+ /**
+ * Notifies the workspace that the given output path has been processed.
+ *
+ * @param path the output path
+ */
+ void processOutput(@Nonnull Path path);
+
+ /**
+ * Returns an output stream for the specified file. The workspace may optimize this
+ * using a caching stream that only overwrites the file when the content changes.
+ *
+ * @param path the file to write to
+ * @return a new output stream
+ * @throws org.apache.maven.api.build.context.BuildContextException if an I/O error occurs
+ */
+ @Nonnull
+ OutputStream newOutputStream(@Nonnull Path path);
+
+ /**
+ * Determines the resource status based on its last-modified time and size.
+ *
+ * @param file the file to check
+ * @param lastModified the previously recorded last-modified time
+ * @param size the previously recorded file size
+ * @return the change status
+ */
+ @Nonnull
+ Status getResourceStatus(@Nonnull Path file, @Nonnull FileTime lastModified, long size);
+
+ /**
+ * Walks a file tree rooted at the given directory. The files visited and their status
+ * depend on the workspace {@link Mode}:
+ *
+ * {@code NORMAL} — all files are visited with status {@link Status#NEW}.
+ * The build context calculates the actual input status.
+ * {@code DELTA} — only {@link Status#NEW}, {@link Status#MODIFIED}
+ * or {@link Status#REMOVED} files are visited.
+ * {@code ESCALATED} — all files are visited with status {@link Status#NEW}.
+ * Used when the user explicitly requests a full rebuild in an IDE.
+ * {@code SUPPRESSED} — used during "configuration" builds where all inputs
+ * are assumed up-to-date and no outputs are expected.
+ *
+ *
+ * @param basedir the root directory to walk
+ * @return a stream of file states
+ * @throws org.apache.maven.api.build.context.BuildContextException if an I/O error occurs
+ */
+ @Nonnull
+ Stream walk(@Nonnull Path basedir);
+
+ /**
+ * The workspace operating mode.
+ *
+ * @since 4.1.0
+ */
+ enum Mode {
+ /** Normal mode — the build context determines resource status. */
+ NORMAL,
+ /** Delta mode — only changed files are visited. */
+ DELTA,
+ /** Escalated mode — all files treated as new (full rebuild). */
+ ESCALATED,
+ /** Suppressed mode — configuration-only build, no outputs expected. */
+ SUPPRESSED
+ }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.java
new file mode 100644
index 000000000000..ede767dc9dee
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.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.
+ */
+
+/**
+ * Service Provider Interface (SPI) for the incremental build context.
+ *
+ * Overview
+ *
+ * This package contains the interfaces that integrators implement
+ * to connect the incremental build context to a specific runtime environment (Maven CLI,
+ * an IDE, a custom build tool). Mojo authors typically do not implement these interfaces;
+ * they use the consumer API in {@link org.apache.maven.api.build.context} instead.
+ *
+ * Key SPI interfaces
+ *
+ *
+ * SPI roles and their implementors
+ *
+ * Interface
+ * Role
+ * Typical implementor
+ *
+ *
+ * {@link Workspace}
+ * File-system abstraction: read/write/delete files, detect changes
+ * IDE workspace adapter, filesystem implementation
+ *
+ *
+ * {@link BuildContextEnvironment}
+ * Provides the state file path, workspace, and mojo parameters
+ * Maven runtime, IDE plugin
+ *
+ *
+ * {@link BuildContextFinalizer}
+ * Commits all registered build contexts at the end of a mojo execution
+ * Maven runtime
+ *
+ *
+ * {@link CommittableBuildContext}
+ * Extends {@link org.apache.maven.api.build.context.BuildContext} with commit
+ * and message-reporting capabilities
+ * Build context implementation
+ *
+ *
+ * {@link Sink}
+ * Receives diagnostic messages during commit
+ * IDE error reporter, Maven log bridge
+ *
+ *
+ *
+ * How the pieces fit together
+ *
+ *
+ * +-----------------------+
+ * | BuildContextEnvironment|
+ * | stateFile, workspace, |
+ * | parameters, finalizer |
+ * +-----------+-----------+
+ * |
+ * v creates
+ * +--------------------+
+ * | CommittableBuildContext|
+ * | (extends BuildContext)|
+ * +----------+---------+
+ * |
+ * +----------------+----------------+
+ * | |
+ * v reads/writes via v at end of mojo
+ * +----------+ +-------------------+
+ * | Workspace | | BuildContextFinalizer|
+ * | file ops | | commit(Sink) |
+ * +----------+ +-------------------+
+ *
+ *
+ * Workspace modes
+ *
+ * The {@link Workspace} abstraction supports four operating modes, allowing the
+ * same mojo code to behave correctly in different environments:
+ *
+ * {@link Workspace.Mode#NORMAL NORMAL} — Full filesystem
+ * scan with timestamp/size comparison (command-line Maven).
+ * {@link Workspace.Mode#DELTA DELTA} — The workspace already
+ * knows which files changed (IDE with file-watcher). Only the delta is scanned,
+ * avoiding a full directory walk.
+ * {@link Workspace.Mode#ESCALATED ESCALATED} — Fallback from
+ * DELTA mode when the change set is incomplete (e.g., after an IDE crash). Performs
+ * a full scan to recover correct state.
+ * {@link Workspace.Mode#SUPPRESSED SUPPRESSED} — All inputs
+ * appear unmodified. Used for read-only analysis or when incremental support is
+ * explicitly disabled.
+ *
+ *
+ * Data classes
+ *
+ * {@link FileState} and {@link Message} are immutable value objects used to
+ * communicate file metadata and diagnostic messages between the build context
+ * implementation and the workspace / sink.
+ *
+ * @since 4.1.0
+ * @see org.apache.maven.api.build.context
+ * @see Workspace
+ * @see BuildContextEnvironment
+ * @see CommittableBuildContext
+ */
+@Experimental
+package org.apache.maven.api.build.context.spi;
+
+import org.apache.maven.api.annotations.Experimental;
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/services/PathMatcherFactory.java b/api/maven-api-core/src/main/java/org/apache/maven/api/services/PathMatcherFactory.java
index 9f83e2e0f8bf..9977bc5ad4f5 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/services/PathMatcherFactory.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/services/PathMatcherFactory.java
@@ -21,6 +21,7 @@
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.util.Collection;
+import java.util.Map;
import java.util.Objects;
import org.apache.maven.api.Service;
@@ -123,6 +124,33 @@ default PathMatcher createIncludeOnlyMatcher(@Nonnull Path baseDirectory, Collec
return createPathMatcher(baseDirectory, includes, null, false);
}
+ /**
+ * Creates a map of subdirectory paths to path matchers, optimized for targeted directory walks.
+ *
+ * This method decomposes include patterns by parsing their literal leading path segments
+ * to determine the narrowest possible subdirectories that need to be walked. For example,
+ * includes {@code ["src/main/java/**/*.java", "src/test/**/*.java"]} produces two entries
+ * keyed by {@code src/main/java} and {@code src/test}, each with a matcher scoped to only
+ * those patterns.
+ *
+ * Callers can then walk each key directory independently, applying only the associated matcher,
+ * instead of walking the entire base directory. For patterns with no literal prefix (e.g.
+ * {@code "**/*.xml"}), the base directory itself is used as the key.
+ *
+ * For single-file patterns with no wildcards at all, the map key is the file path itself
+ * and the matcher does a direct equality check.
+ *
+ * @param baseDirectory the base directory for resolving paths
+ * @param includes the patterns of files to include, or null/empty for including all files
+ * @param excludes the patterns of files to exclude, or null/empty for no exclusion
+ * @return a map of subdirectory (or file) paths to their corresponding path matchers
+ * @throws NullPointerException if baseDirectory is null
+ * @since 4.1.0
+ */
+ @Nonnull
+ Map createSubdirectoryMatchers(
+ @Nonnull Path baseDirectory, Collection includes, Collection excludes);
+
/**
* Returns a filter for directories that may contain paths accepted by the given matcher.
* The given path matcher should be an instance created by this service.
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java
new file mode 100644
index 000000000000..7747acd3ca98
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java
@@ -0,0 +1,140 @@
+/*
+ * 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.internal.build.context.impl.maven;
+
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.function.Supplier;
+
+import org.apache.maven.api.build.context.InputSet;
+import org.apache.maven.api.build.context.spi.BuildContextEnvironment;
+import org.apache.maven.api.build.context.spi.CommittableBuildContext;
+import org.apache.maven.api.build.context.spi.Sink;
+import org.apache.maven.api.di.Inject;
+import org.apache.maven.api.di.MojoExecutionScoped;
+import org.apache.maven.api.di.Named;
+import org.apache.maven.api.di.Typed;
+import org.apache.maven.api.services.PathMatcherFactory;
+import org.apache.maven.internal.build.context.impl.DefaultBuildContext;
+import org.apache.maven.internal.build.context.impl.DefaultInput;
+import org.apache.maven.internal.build.context.impl.DefaultInputMetadata;
+import org.apache.maven.internal.build.context.impl.DefaultOutput;
+
+/**
+ * The Maven runtime's {@link org.apache.maven.api.build.context.BuildContext BuildContext}
+ * implementation, delegating to a {@link MojoExecutionScopedBuildContext} that is created
+ * fresh for each mojo execution.
+ *
+ * This class is injected into mojos as the {@code BuildContext} binding. It uses a
+ * {@link Supplier} to lazily obtain the scoped delegate, ensuring that each mojo execution
+ * gets its own isolated build context with its own state file, configuration digest, and
+ * input/output tracking.
+ *
+ * @since 4.1.0
+ * @see MojoExecutionScopedBuildContext
+ * @see MavenBuildContextConfiguration
+ */
+@Named
+public class MavenBuildContext implements CommittableBuildContext {
+
+ private final Supplier provider;
+
+ @Inject
+ public MavenBuildContext(Supplier delegate) {
+ this.provider = delegate;
+ }
+
+ MojoExecutionScopedBuildContext getDelegate() {
+ return provider.get();
+ }
+
+ public boolean isFailOnError() {
+ return getDelegate().isFailOnError();
+ }
+
+ @Override
+ public boolean isProcessingRequired() {
+ return getDelegate().isProcessingRequired();
+ }
+
+ @Override
+ public DefaultOutput processOutput(Path outputFile) {
+ return getDelegate().processOutput(outputFile);
+ }
+
+ @Override
+ public InputSet newInputSet() {
+ return getDelegate().newInputSet();
+ }
+
+ @Override
+ public DefaultInputMetadata registerInput(Path inputFile) {
+ return getDelegate().registerInput(inputFile);
+ }
+
+ @Override
+ public Collection extends DefaultInputMetadata> registerInputs(
+ Path basedir, Collection includes, Collection excludes) {
+ return getDelegate().registerInputs(basedir, includes, excludes);
+ }
+
+ @Override
+ public Collection extends DefaultInput> registerAndProcessInputs(
+ Path basedir, Collection includes, Collection excludes) {
+ return getDelegate().registerAndProcessInputs(basedir, includes, excludes);
+ }
+
+ @Override
+ public void markSkipExecution() {
+ getDelegate().markSkipExecution();
+ }
+
+ @Override
+ public void setFailOnError(boolean failOnError) {
+ getDelegate().setFailOnError(failOnError);
+ }
+
+ @Override
+ public void commit(Sink sink) {
+ getDelegate().commit(sink);
+ }
+
+ /**
+ * The per-mojo-execution build context instance. Created once per mojo execution via
+ * the {@link MojoExecutionScoped} DI scope, initialized from a
+ * {@link MavenBuildContextConfiguration} that provides the state file location,
+ * configuration digest, workspace, and finalizer.
+ *
+ * At construction time, the context loads the previous build state (if any),
+ * compares the configuration digest, and determines whether to escalate to a full
+ * build. All subsequent {@code registerInputs} / {@code associateOutput} calls
+ * operate on this instance's state, which is committed at the end of mojo execution
+ * by the {@link MavenBuildContextFinalizer}.
+ */
+ @Named
+ @Typed(MojoExecutionScopedBuildContext.class)
+ @MojoExecutionScoped
+ public static class MojoExecutionScopedBuildContext extends DefaultBuildContext {
+ @Inject
+ public MojoExecutionScopedBuildContext(
+ BuildContextEnvironment configuration, PathMatcherFactory pathMatcherFactory) {
+ super(configuration, pathMatcherFactory);
+ }
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java
new file mode 100644
index 000000000000..9188c2c2ea71
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java
@@ -0,0 +1,133 @@
+/*
+ * 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.internal.build.context.impl.maven;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Map;
+
+import org.apache.maven.api.MojoExecution;
+import org.apache.maven.api.Project;
+import org.apache.maven.api.build.context.spi.BuildContextEnvironment;
+import org.apache.maven.api.build.context.spi.BuildContextFinalizer;
+import org.apache.maven.api.build.context.spi.Workspace;
+import org.apache.maven.api.di.Inject;
+import org.apache.maven.api.di.MojoExecutionScoped;
+import org.apache.maven.api.di.Named;
+import org.apache.maven.api.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.internal.build.context.impl.maven.digest.MojoConfigurationDigester;
+
+/**
+ * Provides the {@link BuildContextEnvironment} for a single mojo execution.
+ *
+ * This class wires together the pieces that initialize a
+ * {@link org.apache.maven.internal.build.context.impl.DefaultBuildContext DefaultBuildContext}:
+ *
+ * State file — persisted at
+ * {@code ${project.build.directory}/incremental/___}.
+ * A {@code clean} build deletes the entire {@code target/} directory, so the context
+ * starts fresh.
+ * Parameters — computed by {@link MojoConfigurationDigester}, which
+ * digests all mojo parameters and the plugin classpath. Changes between builds
+ * trigger escalation.
+ * Workspace — the {@link ProjectWorkspace} for file-system access.
+ * Finalizer — the {@link MavenBuildContextFinalizer} that commits
+ * the context after mojo execution.
+ *
+ *
+ * @since 4.1.0
+ * @see MojoConfigurationDigester
+ * @see MavenBuildContextFinalizer
+ */
+@Named
+@MojoExecutionScoped
+public class MavenBuildContextConfiguration implements BuildContextEnvironment {
+
+ private final ProjectWorkspace workspace;
+ private final Path stateFile;
+ private final Map parameters;
+ private final MavenBuildContextFinalizer finalizer;
+
+ @Inject
+ public MavenBuildContextConfiguration(
+ ProjectWorkspace workspace,
+ MojoConfigurationDigester digester,
+ MavenBuildContextFinalizer finalizer,
+ Project project,
+ MojoExecution execution)
+ throws IOException {
+ this.workspace = workspace;
+ this.finalizer = finalizer;
+ this.stateFile = getExecutionStateLocation(project, execution);
+ this.parameters = digester.digest();
+ }
+
+ @Override
+ public Path getStateFile() {
+ return stateFile;
+ }
+
+ @Override
+ public Workspace getWorkspace() {
+ return workspace;
+ }
+
+ @Override
+ public Map getParameters() {
+ return parameters;
+ }
+
+ @Override
+ public BuildContextFinalizer getFinalizer() {
+ return finalizer;
+ }
+
+ /**
+ * Returns conventional location of MojoExecution incremental build state
+ */
+ public Path getExecutionStateLocation(Project project, MojoExecution execution) {
+ Path stateDirectory = getProjectStateLocation(project);
+ String builderId = getExecutionId(execution);
+ return stateDirectory.resolve(builderId);
+ }
+
+ /**
+ * Returns conventional MojoExecution identifier used by incremental build tools.
+ */
+ public String getExecutionId(MojoExecution execution) {
+ PluginDescriptor pluginDescriptor = execution.getPlugin().getDescriptor();
+ String builderId = pluginDescriptor.getGroupId()
+ + '_'
+ + pluginDescriptor.getArtifactId()
+ + '_'
+ + execution.getGoal()
+ + '_'
+ + execution.getExecutionId();
+ return builderId;
+ }
+
+ /**
+ * Returns conventional location of MavenProject incremental build state
+ */
+ public Path getProjectStateLocation(Project project) {
+ return Paths.get(project.getBuild().getDirectory(), "incremental");
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java
new file mode 100644
index 000000000000..c49572dba3e3
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java
@@ -0,0 +1,138 @@
+/*
+ * 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.internal.build.context.impl.maven;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.build.context.BuildContext;
+import org.apache.maven.api.build.context.Severity;
+import org.apache.maven.api.build.context.spi.BuildContextFinalizer;
+import org.apache.maven.api.build.context.spi.CommittableBuildContext;
+import org.apache.maven.api.build.context.spi.Message;
+import org.apache.maven.api.build.context.spi.Sink;
+import org.apache.maven.api.di.Inject;
+import org.apache.maven.api.di.MojoExecutionScoped;
+import org.apache.maven.api.di.Named;
+import org.apache.maven.execution.MojoExecutionEvent;
+import org.apache.maven.execution.scope.WeakMojoExecutionListener;
+import org.apache.maven.plugin.MojoExecutionException;
+
+@Named
+@MojoExecutionScoped
+public class MavenBuildContextFinalizer implements WeakMojoExecutionListener, BuildContextFinalizer {
+
+ private final List contexts = new ArrayList<>();
+
+ private final Sink sink;
+
+ @Inject
+ public MavenBuildContextFinalizer(@Nullable Sink sink) {
+ this.sink = sink;
+ }
+
+ public void registerContext(CommittableBuildContext context) {
+ contexts.add(context);
+ }
+
+ protected List extends BuildContext> getRegisteredContexts() {
+ return contexts;
+ }
+
+ @Override
+ public void afterMojoExecutionSuccess(MojoExecutionEvent event) throws MojoExecutionException {
+ if (contexts.isEmpty()) {
+ return;
+ }
+ try {
+ final Map> allMessages = new HashMap<>();
+ for (CommittableBuildContext context : contexts) {
+ context.commit(new Sink() {
+ @Override
+ public void clear(Path resource) {
+ if (sink != null) {
+ sink.clear(resource);
+ }
+ }
+
+ @Override
+ public void messages(Path resource, boolean isNew, Collection messages) {
+ if (sink != null) {
+ sink.messages(resource, isNew, messages);
+ }
+ allMessages.put(resource, messages);
+ }
+ });
+ }
+
+ if (sink == null) {
+ failBuild(allMessages);
+ }
+ } catch (Exception e) {
+ throw new MojoExecutionException("Could not maintain incremental build state", e);
+ }
+ }
+
+ protected void failBuild(final Map> messages) throws MojoExecutionException {
+ // without messageSink, have to raise exception if there were errors
+ int errorCount = 0;
+ StringBuilder errors = new StringBuilder();
+ for (Map.Entry> entry : messages.entrySet()) {
+ Object resource = entry.getKey();
+ for (Message message : entry.getValue()) {
+ if (message.getSeverity() == Severity.ERROR) {
+ errorCount++;
+ errors.append(String.format(
+ "%s:[%d:%d] %s\n",
+ resource.toString(), message.getLine(), message.getColumn(), message.getMessage()));
+ }
+ }
+ }
+ final Set failOnErrors = extractFailOnErrors(contexts);
+ if (failOnErrors.size() != 1) {
+ throw new IllegalStateException("Contexts FailOnError property have different values.");
+ }
+
+ final Boolean failOnError = failOnErrors.iterator().next();
+ if (errorCount > 0 && failOnError) {
+ throw new MojoExecutionException(errorCount + " error(s) encountered:\n" + errors);
+ }
+ }
+
+ private Set extractFailOnErrors(List contexts) {
+ final Set result = new HashSet<>();
+ for (BuildContext context : contexts) {
+ result.add(context.isFailOnError());
+ }
+ return result;
+ }
+
+ @Override
+ public void beforeMojoExecution(MojoExecutionEvent event) throws MojoExecutionException {}
+
+ @Override
+ public void afterExecutionFailure(MojoExecutionEvent event) {}
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/ProjectWorkspace.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/ProjectWorkspace.java
new file mode 100644
index 000000000000..9a8a62ffba3a
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/ProjectWorkspace.java
@@ -0,0 +1,117 @@
+/*
+ * 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.internal.build.context.impl.maven;
+
+import java.io.OutputStream;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.util.stream.Stream;
+
+import org.apache.maven.api.Project;
+import org.apache.maven.api.build.context.Status;
+import org.apache.maven.api.build.context.spi.FileState;
+import org.apache.maven.api.build.context.spi.Workspace;
+import org.apache.maven.api.di.Inject;
+import org.apache.maven.api.di.MojoExecutionScoped;
+import org.apache.maven.api.di.Typed;
+import org.apache.maven.internal.build.context.impl.FilesystemWorkspace;
+
+/**
+ * Eclipse Workspace implementation is scoped to a project and does not "see" resources outside
+ * project basedir. This implementation dispatches Workspace calls to either Eclipse implementation
+ * or Filesystem workspace implementation, depending on whether requested resource is inside or
+ * outside of project basedir.
+ */
+@Typed(ProjectWorkspace.class)
+@MojoExecutionScoped
+public class ProjectWorkspace implements Workspace {
+
+ private final Workspace workspace;
+
+ private final Project project;
+
+ private final Path basedir;
+
+ private final FilesystemWorkspace filesystem;
+
+ @Inject
+ public ProjectWorkspace(Project project, Workspace workspace, FilesystemWorkspace filesystem) {
+ this.project = project;
+ this.basedir = project.getBasedir().normalize();
+ this.workspace = workspace;
+ this.filesystem = filesystem;
+ }
+
+ protected Workspace getWorkspace(Path file) {
+ if (file.normalize().startsWith(basedir)) {
+ return workspace;
+ }
+ return filesystem;
+ }
+
+ @Override
+ public Mode getMode() {
+ return Mode.NORMAL;
+ }
+
+ @Override
+ public Workspace escalate() {
+ return new ProjectWorkspace(project, workspace.escalate(), filesystem);
+ }
+
+ @Override
+ public boolean isPresent(Path file) {
+ return getWorkspace(file).isPresent(file);
+ }
+
+ @Override
+ public boolean isRegularFile(Path file) {
+ return getWorkspace(file).isRegularFile(file);
+ }
+
+ @Override
+ public boolean isDirectory(Path file) {
+ return getWorkspace(file).isDirectory(file);
+ }
+
+ @Override
+ public void deleteFile(Path file) {
+ getWorkspace(file).deleteFile(file);
+ }
+
+ @Override
+ public void processOutput(Path path) {
+ getWorkspace(path).processOutput(path);
+ }
+
+ @Override
+ public OutputStream newOutputStream(Path path) {
+ return getWorkspace(path).newOutputStream(path);
+ }
+
+ @Override
+ public Status getResourceStatus(Path file, FileTime lastModified, long size) {
+ return getWorkspace(file).getResourceStatus(file, lastModified, size);
+ }
+
+ @Override
+ public Stream walk(Path basedir) {
+ return getWorkspace(basedir).walk(basedir);
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/BytesHash.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/BytesHash.java
new file mode 100644
index 000000000000..e2b6ae74df5c
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/BytesHash.java
@@ -0,0 +1,52 @@
+/*
+ * 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.internal.build.context.impl.maven.digest;
+
+import java.io.Serializable;
+import java.util.Arrays;
+
+@SuppressWarnings("serial")
+public class BytesHash implements Serializable {
+
+ // no serialVersionUID, want deserialization to fail if state format changes
+
+ private final byte[] bytes;
+
+ public BytesHash(byte[] bytes) {
+ this.bytes = bytes;
+ }
+
+ // TODO toString
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (!(obj instanceof BytesHash)) {
+ return false;
+ }
+ return Arrays.equals(bytes, ((BytesHash) obj).bytes);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(bytes);
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java
new file mode 100644
index 000000000000..4ec72c70aad6
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java
@@ -0,0 +1,200 @@
+/*
+ * 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.internal.build.context.impl.maven.digest;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Serializable;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipException;
+import java.util.zip.ZipFile;
+
+import org.apache.maven.api.Artifact;
+import org.apache.maven.api.Session;
+import org.apache.maven.api.SessionData;
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.services.ArtifactManager;
+
+/**
+ * Computes a content-based SHA-1 digest of Maven plugin classpath dependencies.
+ *
+ * Unlike simple timestamp comparison, this digester hashes the actual contents
+ * of each dependency artifact (JAR entries sorted by name, or recursive file contents for
+ * exploded directories). This makes it immune to file timestamp changes caused by rebuilding
+ * the same sources — a common scenario with SNAPSHOT dependencies in a reactor build.
+ *
+ * Digests are cached per session via {@link SessionData} so that the same artifact is not
+ * re-hashed for every mojo execution that depends on it. The cache key is the artifact's
+ * GAV coordinate string.
+ *
+ * This digester is used by {@link MojoConfigurationDigester} to produce the
+ * {@code mojo.classpath} entry in the build context configuration parameters. When the
+ * combined classpath digest changes between builds, the build context escalates to a full
+ * rebuild.
+ *
+ * @since 4.1.0
+ * @see MojoConfigurationDigester
+ */
+class ClasspathDigester {
+
+ @SuppressWarnings("unchecked")
+ private static final SessionData.Key> CACHE_KEY =
+ (SessionData.Key>)
+ (SessionData.Key>) SessionData.key(ConcurrentMap.class, ClasspathDigester.class.getName());
+
+ private final ConcurrentMap cache;
+ private final ArtifactManager artifactManager;
+
+ ClasspathDigester(Session session) {
+ this.cache = getCache(session);
+ this.artifactManager = session.getService(ArtifactManager.class);
+ }
+
+ private static ConcurrentMap getCache(Session session) {
+ // this assumes that session data does not change during reactor build
+ SessionData sessionData = session.getData();
+ return sessionData.computeIfAbsent(CACHE_KEY, ConcurrentHashMap::new);
+ }
+
+ static void digest(MessageDigest digester, InputStream is) {
+ try {
+ byte[] buf = new byte[4 * 1024];
+ int r;
+ while ((r = is.read(buf)) > 0) {
+ digester.update(buf, 0, r);
+ }
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ static void digestFile(MessageDigest digester, Path file) {
+ try (InputStream is = Files.newInputStream(file)) {
+ digest(digester, is);
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ static void digestZip(MessageDigest digester, Path file) {
+ try (ZipFile zip = new ZipFile(file.toFile())) {
+ zip.stream()
+ // sort entries.
+ // order of jar/zip entries is not important but may change from one build to the next
+ .sorted(Comparator.comparing(ZipEntry::getName))
+ .forEachOrdered(entry -> {
+ try (InputStream is = zip.getInputStream(entry)) {
+ digest(digester, is);
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ });
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ private static byte[] digestArtifactFile(Path file) {
+ byte[] hash;
+ if (Files.isRegularFile(file)) {
+ hash = digestZipOrFile(file);
+ } else if (Files.isDirectory(file)) {
+ hash = digestDirectory(file);
+ } else {
+ // does not exist, use token empty array to avoid rechecking
+ hash = new byte[0];
+ }
+ return hash;
+ }
+
+ private static byte[] digestZipOrFile(Path file) {
+ MessageDigest d = SHA1Digester.newInstance();
+ try {
+ digestZip(d, file);
+ } catch (BuildContextException e) {
+ if (e.getCause() instanceof ZipException) {
+ // not a valid ZIP/JAR, fall back to plain file digest
+ d = SHA1Digester.newInstance();
+ digestFile(d, file);
+ } else {
+ throw e;
+ }
+ }
+ return d.digest();
+ }
+
+ private static byte[] digestDirectory(Path file) {
+ MessageDigest d = SHA1Digester.newInstance();
+ try (Stream s = Files.walk(file)) {
+ s.filter(Files::isRegularFile).sorted().forEach(f -> digestFile(d, f));
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ return d.digest();
+ }
+
+ public Serializable digest(List artifacts) {
+ MessageDigest digester = SHA1Digester.newInstance();
+ for (Artifact artifact : artifacts) {
+ Optional pathOpt = artifactManager.getPath(artifact);
+ if (pathOpt.isEmpty()) {
+ // Skip artifacts whose path is not resolved (e.g. the root dependency node
+ // in the plugin's dependency tree, or provided-scope API artifacts)
+ continue;
+ }
+ Path file = pathOpt.get();
+ String cacheKey = getArtifactKey(artifact);
+ byte[] cached = cache.get(cacheKey);
+ if (cached == null) {
+ byte[] hash = digestArtifactFile(file);
+ cached = cache.putIfAbsent(cacheKey, hash);
+ if (cached == null) {
+ cached = hash;
+ }
+ }
+ digester.update(cached);
+ }
+ return new BytesHash(digester.digest());
+ }
+
+ private String getArtifactKey(Artifact artifact) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(artifact.getGroupId());
+ sb.append(':');
+ sb.append(artifact.getArtifactId());
+ sb.append(':');
+ sb.append(artifact.getExtension());
+ sb.append(':');
+ sb.append(artifact.getVersion());
+ if (artifact.getClassifier() != null) {
+ sb.append(':');
+ sb.append(artifact.getClassifier());
+ }
+ return sb.toString();
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/Digesters.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/Digesters.java
new file mode 100644
index 000000000000..9520d71f6f47
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/Digesters.java
@@ -0,0 +1,197 @@
+/*
+ * 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.internal.build.context.impl.maven.digest;
+
+import javax.xml.stream.XMLStreamException;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.Serializable;
+import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.Member;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Properties;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import org.apache.maven.api.Project;
+import org.apache.maven.api.RemoteRepository;
+import org.apache.maven.api.Session;
+import org.apache.maven.api.build.context.Incremental;
+import org.apache.maven.model.v4.MavenStaxWriter;
+
+class Digesters {
+
+ private static final Map, Digester>> DIGESTERS;
+
+ static {
+ Map, Digester>> digesters = new LinkedHashMap<>();
+ // common Maven objects
+ digesters.put(RemoteRepository.class, (Digester) Digesters::digestRemoteRepository);
+ // digesters.put(Artifact.class, (Digester) Digesters::digestArtifact);
+ digesters.put(Project.class, (Digester) Digesters::digestProject);
+ digesters.put(Session.class, (Digester) Digesters::digestSession);
+ //
+ digesters.put(Collection.class, (Digester>) Digesters::digestCollection);
+ //
+ digesters.put(Serializable.class, (Digester) (member, value) -> value);
+ DIGESTERS = Collections.unmodifiableMap(digesters);
+ }
+
+ public static Serializable digest(Member member, Object value) {
+ // TODO: check on mojo as a default
+ Incremental configuration = getConfiguration(member);
+ if (configuration != null && !configuration.consider()) {
+ return null; // no digest, ignore
+ }
+
+ return rawtypesDigest(member, value);
+ }
+
+ static Incremental getConfiguration(Member member) {
+ if (member instanceof AnnotatedElement) {
+ return ((AnnotatedElement) member).getAnnotation(Incremental.class);
+ }
+ return null;
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private static Serializable rawtypesDigest(Member member, Object value) {
+ return ((Digester) getDigester(value)).digest(member, value);
+ }
+
+ private static Digester> getDigester(Object value) {
+ Digester> digester = null;
+ for (Map.Entry, Digester>> entry : DIGESTERS.entrySet()) {
+ if (entry.getKey().isInstance(value)) {
+ digester = entry.getValue();
+ break;
+ }
+ }
+ if (digester == null) {
+ throw new UnsupportedParameterTypeException(value.getClass());
+ }
+ return digester;
+ }
+
+ private static Serializable digestProject(Member member, Project value) {
+ if (getConfiguration(member) == null) {
+ throw new IllegalArgumentException("Explicit @Incremental required: " + member);
+ }
+
+ final MessageDigest digester = SHA1Digester.newInstance();
+
+ // effective pom.xml defines project configuration, rebuild whenever project configuration
+ // changes we can't be more specific here because mojo can access entire project model, not
+ // just its own configuration
+ try {
+ new MavenStaxWriter()
+ .write(
+ new OutputStream() {
+ @Override
+ public void write(int b) throws IOException {
+ digester.update((byte) b);
+ }
+ },
+ value.getModel());
+ } catch (IOException | XMLStreamException e) {
+ // can't happen
+ }
+
+ return new BytesHash(digester.digest());
+ }
+
+ private static Serializable digestSession(Member member, Session session) {
+ if (getConfiguration(member) == null) {
+ throw new IllegalArgumentException("Explicit @Incremental required: " + member);
+ }
+
+ // execution properties define build parameters passed in from command line and jvm used
+ SortedMap executionProperties = new TreeMap<>();
+ Properties props = new Properties();
+ props.putAll(session.getSystemProperties());
+ props.putAll(session.getUserProperties());
+ for (Map.Entry property : props.entrySet()) {
+ // TODO unit test non-string keys do not cause problems at runtime
+ // TODO test if non-string values can or cannot be used
+ Object key = property.getKey();
+ Object value = property.getValue();
+ if (key instanceof String && value instanceof String) {
+ executionProperties.put(key.toString(), value.toString());
+ }
+ }
+
+ // m2e workspace launch
+ executionProperties.remove("classworlds.conf");
+
+ // Environment has PID of java process (env.JAVA_MAIN_CLASS_), SSH_AGENT_PID,
+ // unique TMPDIR (on OSX) and other volatile variables.
+ executionProperties.entrySet().removeIf(property -> property.getKey().startsWith("env."));
+
+ MessageDigest digester = SHA1Digester.newInstance();
+
+ for (Map.Entry property : executionProperties.entrySet()) {
+ digester.update(property.getKey().getBytes(StandardCharsets.UTF_8));
+ digester.update(property.getValue().getBytes(StandardCharsets.UTF_8));
+ }
+
+ return new BytesHash(digester.digest());
+ }
+
+ // private static Serializable digestArtifact(Member member, Artifact value) {
+ // return value.getPath().get().toFile();
+ // }
+
+ private static Serializable digestRemoteRepository(Member member, RemoteRepository value) {
+ return value.getUrl();
+ }
+
+ private static Serializable digestCollection(Member member, Collection> collection) {
+ // TODO consider collapsing to single SHA1 hash
+ ArrayList digest = new ArrayList<>();
+ for (Object element : collection) {
+ Serializable elementDigest = rawtypesDigest(member, element);
+ if (elementDigest != null) {
+ digest.add(elementDigest);
+ }
+ }
+ return digest;
+ }
+
+ interface Digester {
+ Serializable digest(Member member, T value);
+ }
+
+ public static class UnsupportedParameterTypeException extends IllegalArgumentException {
+
+ private static final long serialVersionUID = 1L;
+
+ final Class> type;
+
+ UnsupportedParameterTypeException(Class> type) {
+ this.type = type;
+ }
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java
new file mode 100644
index 000000000000..ce88e98c0f9e
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java
@@ -0,0 +1,216 @@
+/*
+ * 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.internal.build.context.impl.maven.digest;
+
+import javax.xml.stream.XMLStreamException;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.io.StringWriter;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.maven.api.Artifact;
+import org.apache.maven.api.MojoExecution;
+import org.apache.maven.api.Project;
+import org.apache.maven.api.Session;
+import org.apache.maven.api.di.Inject;
+import org.apache.maven.api.di.MojoExecutionScoped;
+import org.apache.maven.api.di.Named;
+import org.apache.maven.api.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.api.xml.XmlNode;
+import org.apache.maven.internal.xml.XmlNodeWriter;
+import org.apache.maven.plugin.PluginParameterExpressionEvaluatorV4;
+import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluationException;
+import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluator;
+
+/**
+ * Produces a deterministic digest of a mojo execution's configuration for incremental build
+ * change detection. The digest is stored in the
+ * {@link org.apache.maven.api.build.context.spi.BuildContextEnvironment#getParameters() build
+ * context parameters}; if any value changes between builds, the build context escalates to a
+ * full rebuild.
+ *
+ * The digester captures two categories of non-file state:
+ *
+ * Plugin classpath ({@code mojo.classpath}) — a SHA-1 hash of every
+ * plugin dependency JAR's contents (delegated to {@link ClasspathDigester}). This
+ * detects plugin upgrades or SNAPSHOT rebuilds.
+ * Mojo parameters ({@code mojo.parameter.}) — each
+ * {@code @Parameter}-annotated field of the mojo class is reflected, its configured
+ * expression is evaluated, and the resolved value is digested through
+ * {@link Digesters#digest(java.lang.reflect.Field, Object)}. This detects changes
+ * to compiler flags, output directories, filter tokens, and any other configuration.
+ *
+ *
+ * Parameters whose types are not supported by {@link Digesters} are reported as errors
+ * rather than silently ignored, ensuring that the build context does not miss a configuration
+ * change.
+ *
+ * @since 4.1.0
+ * @see ClasspathDigester
+ * @see Digesters
+ * @see org.apache.maven.api.build.context.spi.BuildContextEnvironment#getParameters()
+ */
+@Named
+@MojoExecutionScoped
+public class MojoConfigurationDigester {
+
+ private final ClasspathDigester classpathDigester;
+ private final Session session;
+ private final Project project;
+ private final MojoExecution execution;
+
+ @Inject
+ public MojoConfigurationDigester(Session session, Project project, MojoExecution execution) {
+ this.session = session;
+ this.project = project;
+ this.execution = execution;
+ this.classpathDigester = new ClasspathDigester(session);
+ }
+
+ /**
+ * Computes the configuration digest for the current mojo execution.
+ *
+ * The returned map contains:
+ *
+ * {@code mojo.classpath} — the combined SHA-1 of all plugin dependency JARs
+ * {@code mojo.parameter.} — a digest for each resolved mojo parameter
+ *
+ *
+ * @return an ordered map of digest keys to their serializable digest values
+ * @throws IOException if an error occurs while reading plugin JARs or evaluating parameters
+ */
+ public Map digest() throws IOException {
+ Map result = new LinkedHashMap<>();
+
+ MojoDescriptor mojoDescriptor = execution.getDescriptor();
+ List classpath = new ArrayList<>(execution.getPlugin().getDependencies());
+ result.put("mojo.classpath", classpathDigester.digest(classpath));
+
+ XmlNode node = execution.getConfiguration().orElse(null);
+ if (node != null) {
+ List errors = new ArrayList<>();
+ ExpressionEvaluator evaluator = new PluginParameterExpressionEvaluatorV4(session, project);
+ Class> mojoClass;
+ try {
+ mojoClass = execution.getPlugin().getClassLoader().loadClass(mojoDescriptor.getImplementation());
+ } catch (ClassNotFoundException e) {
+ errors.add("mojo class not found " + mojoDescriptor.getImplementation());
+ mojoClass = null;
+ }
+ if (mojoClass != null) {
+ for (XmlNode child : node.getChildren()) {
+ String name = fromXML(child.getName());
+ try {
+ Field field = getField(mojoClass, name);
+ if (field != null) {
+ String expression = child.getValue();
+ if (expression == null) {
+ expression = getChildrenXml(child);
+ }
+ if (expression == null) {
+ expression = child.getAttribute("default-value");
+ }
+ if (expression != null) {
+ Object value = evaluator.evaluate(expression);
+ if (value != null) {
+ Serializable digest = Digesters.digest(field, value);
+ if (digest != null) {
+ result.put("mojo.parameter." + name, digest);
+ }
+ }
+ }
+ }
+ } catch (Digesters.UnsupportedParameterTypeException e) {
+ errors.add("parameter " + name + " has unsupported type " + e.type.getName());
+ } catch (ExpressionEvaluationException | XMLStreamException e) {
+ errors.add("parameter " + name + " " + e.getMessage());
+ }
+ }
+ }
+ if (!errors.isEmpty()) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(project.toString());
+ sb.append(" could not digest configuration of ").append(execution);
+ for (String error : errors) {
+ sb.append("\n ").append(error);
+ }
+ throw new IllegalArgumentException(sb.toString());
+ }
+ }
+ return result;
+ }
+
+ private String getChildrenXml(XmlNode node) throws XMLStreamException {
+ List children = node.getChildren();
+ if (children.isEmpty()) {
+ return null;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (XmlNode child : children) {
+ append(sb, child);
+ }
+ return sb.toString();
+ }
+
+ private void append(StringBuilder sb, XmlNode node) throws XMLStreamException {
+ StringWriter sw = new StringWriter();
+ XmlNodeWriter.write(sw, node);
+ sb.append(sw.toString());
+ }
+
+ private Field getField(Class> clazz, String name) {
+ for (Field field : clazz.getDeclaredFields()) {
+ if (name.equals(field.getName())) {
+ return field;
+ }
+ }
+ if (clazz.getSuperclass() != null) {
+ return getField(clazz.getSuperclass(), name);
+ }
+ return null;
+ }
+
+ // first-name --> firstName
+ protected String fromXML(final String elementName) {
+ boolean firstToken = true;
+ boolean firstLetter = true;
+ int rindex = 0;
+ int windex = 0;
+ int[] codepoints = elementName.codePoints().toArray();
+ while (rindex < codepoints.length) {
+ int cp = codepoints[rindex++];
+ if (cp == '-') {
+ firstToken = false;
+ firstLetter = true;
+ } else {
+ if (firstLetter) {
+ cp = firstToken ? Character.toLowerCase(cp) : Character.toTitleCase(cp);
+ firstLetter = false;
+ }
+ codepoints[windex++] = cp;
+ }
+ }
+ return new String(codepoints, 0, windex);
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/SHA1Digester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/SHA1Digester.java
new file mode 100644
index 000000000000..144c77c1dccc
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/SHA1Digester.java
@@ -0,0 +1,52 @@
+/*
+ * 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.internal.build.context.impl.maven.digest;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+// CHECKSTYLE_OFF: LineLength
+
+/**
+ * TODO: replace somehow with
+ * org.apache.maven.buildcache.hash.HashFactory
+ */
+public class SHA1Digester {
+
+ public static MessageDigest newInstance() {
+ try {
+ return MessageDigest.getInstance("SHA1");
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("Unsupported JVM", e);
+ }
+ }
+
+ //
+ // convenient helpers
+ //
+
+ public static BytesHash digest(String string) {
+ MessageDigest digest = newInstance();
+ if (string != null) {
+ digest.update(string.getBytes(StandardCharsets.UTF_8));
+ }
+ return new BytesHash(digest.digest());
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java
index 15f3d8df7b64..1a9a2ed79e7d 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java
@@ -60,6 +60,7 @@
import org.apache.maven.di.Injector;
import org.apache.maven.di.Key;
import org.apache.maven.execution.MavenSession;
+import org.apache.maven.execution.MojoExecutionEvent;
import org.apache.maven.execution.scope.internal.MojoExecutionScope;
import org.apache.maven.execution.scope.internal.MojoExecutionScopeModule;
import org.apache.maven.internal.impl.DefaultLog;
@@ -556,9 +557,11 @@ private T loadV4Mojo(
org.apache.maven.api.MojoExecution execution = new DefaultMojoExecution(sessionV4, mojoExecution);
org.apache.maven.api.plugin.Log log = new DefaultLog(
LoggerFactory.getLogger(mojoExecution.getMojoDescriptor().getFullGoalName()));
+ Injector injector;
try {
- Injector injector = Injector.create();
+ injector = Injector.create();
injector.discover(pluginRealm);
+ configureV4MojoInjector(injector);
// Add known classes
// TODO: get those from the existing plexus scopes ?
injector.bindInstance(Session.class, sessionV4);
@@ -676,7 +679,92 @@ private T loadV4Mojo(
}
}
- return mojo;
+ return wrapV4MojoWithBuildContextFinalizer(mojoInterface, mojo, injector, session, mojoExecution);
+ }
+
+ /**
+ * Configures scope handling and core implementation bindings for the V4 mojo injector.
+ * Registers {@code @MojoExecutionScoped} as a singleton scope (safe because one injector
+ * is created per mojo execution) and makes the incremental {@code BuildContext}
+ * implementation available for injection into V4 mojos.
+ */
+ private void configureV4MojoInjector(Injector injector) {
+ // Treat @MojoExecutionScoped as a singleton scope since each V4 injector
+ // is created per mojo execution. Use a shared key-based cache so that different
+ // injection points for the same type get the same instance (the DI framework may
+ // compile the same binding multiple times for qualified vs unqualified keys).
+ // Use HashMap (not ConcurrentHashMap) because ConcurrentHashMap.computeIfAbsent
+ // throws "Recursive update" when scoped beans depend on each other and hash to
+ // the same bucket. V4 mojo execution is single-threaded, so HashMap is safe.
+ HashMap, Object> scopeCache = new HashMap<>();
+ injector.bindScope(org.apache.maven.api.di.MojoExecutionScoped.class, new org.apache.maven.di.Scope() {
+ @SuppressWarnings("unchecked")
+ @Override
+ public Supplier scope(Key key, Supplier unscoped) {
+ return () -> {
+ U existing = (U) scopeCache.get(key);
+ if (existing == null) {
+ existing = unscoped.get();
+ scopeCache.put(key, existing);
+ }
+ return existing;
+ };
+ }
+ });
+
+ // Bind incremental BuildContext implementation classes.
+ // We skip MavenBuildContext here because it requires Supplier
+ // for lazy per-execution creation in the V3 (Plexus/Sisu) path, and the Maven DI system does
+ // not auto-wrap T bindings into Supplier. Since each V4 injector is already per-execution,
+ // we bind BuildContext directly to MojoExecutionScopedBuildContext instead.
+ injector.bindImplicit(org.apache.maven.internal.build.context.impl.FilesystemWorkspace.class);
+ injector.bindImplicit(org.apache.maven.internal.build.context.impl.maven.ProjectWorkspace.class);
+ injector.bindImplicit(
+ org.apache.maven.internal.build.context.impl.maven.digest.MojoConfigurationDigester.class);
+ injector.bindImplicit(org.apache.maven.internal.build.context.impl.maven.MavenBuildContextFinalizer.class);
+ injector.bindImplicit(org.apache.maven.internal.build.context.impl.maven.MavenBuildContextConfiguration.class);
+ injector.bindImplicit(
+ org.apache.maven.internal.build.context.impl.maven.MavenBuildContext.MojoExecutionScopedBuildContext
+ .class);
+ // Expose the scoped MojoExecutionScopedBuildContext as BuildContext for plugin injection.
+ // @Typed on the inner class restricts its implicit bindings to its own type only,
+ // so we need an explicit binding to make it available as the BuildContext interface.
+ injector.bindSupplier(
+ org.apache.maven.api.build.context.BuildContext.class,
+ () -> injector.getInstance(
+ org.apache.maven.internal.build.context.impl.maven.MavenBuildContext
+ .MojoExecutionScopedBuildContext.class));
+ }
+
+ /**
+ * Wraps a V4 mojo to call the BuildContext finalizer after successful execution.
+ *
+ * V4 mojos bypass the Sisu {@code MojoExecutionScope}, so {@code WeakMojoExecutionListener}
+ * instances created by the V4 injector are never notified by the normal lifecycle. This method
+ * detects when a {@code MavenBuildContextFinalizer} was created (because the mojo injects
+ * {@code BuildContext}) and wraps the mojo's {@code execute()} to call
+ * {@code afterMojoExecutionSuccess()} afterward, ensuring incremental build state is persisted.
+ */
+ @SuppressWarnings("unchecked")
+ private T wrapV4MojoWithBuildContextFinalizer(
+ Class mojoInterface, T mojo, Injector injector, MavenSession session, MojoExecution mojoExecution) {
+ if (mojoInterface != org.apache.maven.api.plugin.Mojo.class) {
+ return mojo;
+ }
+ org.apache.maven.internal.build.context.impl.maven.MavenBuildContextFinalizer finalizer;
+ try {
+ finalizer = injector.getInstance(
+ org.apache.maven.internal.build.context.impl.maven.MavenBuildContextFinalizer.class);
+ } catch (org.apache.maven.di.impl.DIException ignored) {
+ return mojo;
+ }
+ org.apache.maven.internal.build.context.impl.maven.MavenBuildContextFinalizer f = finalizer;
+ org.apache.maven.api.plugin.Mojo realMojo = (org.apache.maven.api.plugin.Mojo) mojo;
+ return (T) (org.apache.maven.api.plugin.Mojo) () -> {
+ realMojo.execute();
+ f.afterMojoExecutionSuccess(
+ new MojoExecutionEvent(session, session.getCurrentProject(), mojoExecution, null));
+ };
}
private T loadV3Mojo(
diff --git a/impl/maven-impl/pom.xml b/impl/maven-impl/pom.xml
index 693e1ac66b2f..11f719ff7889 100644
--- a/impl/maven-impl/pom.xml
+++ b/impl/maven-impl/pom.xml
@@ -130,6 +130,11 @@ under the License.
junit-jupiter-api
test
+
+ org.junit.jupiter
+ junit-jupiter-params
+ test
+
org.apache.maven
maven-logging
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultPathMatcherFactory.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultPathMatcherFactory.java
index f26f6cde068d..aa29b95213cb 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultPathMatcherFactory.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultPathMatcherFactory.java
@@ -21,6 +21,7 @@
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.util.Collection;
+import java.util.Map;
import java.util.Objects;
import org.apache.maven.api.annotations.Nonnull;
@@ -55,6 +56,15 @@ public PathMatcher createPathMatcher(
return PathSelector.of(baseDirectory, includes, excludes, useDefaultExcludes);
}
+ @Nonnull
+ @Override
+ public Map createSubdirectoryMatchers(
+ @Nonnull Path baseDirectory, Collection includes, Collection excludes) {
+ requireNonNull(baseDirectory, "baseDirectory cannot be null");
+
+ return PathSelector.ofSubdirectories(baseDirectory, includes, excludes);
+ }
+
@Nonnull
@Override
public PathMatcher createExcludeOnlyMatcher(
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/PathSelector.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/PathSelector.java
index 0f3d1a3c1259..34694f90c769 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/PathSelector.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/PathSelector.java
@@ -25,10 +25,13 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
+import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
+import java.util.StringTokenizer;
import org.apache.maven.api.annotations.Nonnull;
@@ -261,6 +264,150 @@ public static PathMatcher of(
return new PathSelector(directory, includes, excludes, useDefaultExcludes).simplify();
}
+ /**
+ * Creates a map of subdirectory paths to path matchers, optimized for targeted directory walks.
+ *
+ * This method decomposes include patterns by parsing their literal leading path segments
+ * (the segments before the first wildcard) to determine the narrowest possible subdirectories
+ * that need to be walked. For example, includes {@code ["src/main/java/**/*.java",
+ * "src/test/**/*.java"]} produces two entries keyed by {@code src/main/java} and
+ * {@code src/test}, each with a matcher scoped to only those patterns.
+ *
+ * For patterns with no literal prefix (e.g. {@code "**/*.xml"}), the base directory
+ * itself is used as the key. For single-file patterns with no wildcards at all, the map
+ * key is the file path itself and the matcher does a direct equality check.
+ *
+ * @param basedir the base directory for resolving paths
+ * @param includes the include patterns, may be {@code null} or empty (meaning include all)
+ * @param excludes the exclude patterns, may be {@code null} or empty (meaning exclude none)
+ * @return a map of subdirectory (or file) paths to their corresponding path matchers
+ * @throws NullPointerException if basedir is null
+ * @since 4.1.0
+ */
+ public static Map ofSubdirectories(
+ @Nonnull Path basedir, Collection includes, Collection excludes) {
+ Objects.requireNonNull(basedir, "basedir cannot be null");
+ if (includes == null || includes.isEmpty()) {
+ // No includes → match everything under basedir
+ return Map.of(basedir, of(basedir, includes, excludes, false));
+ }
+
+ // Build a trie from include patterns, splitting on literal path segments.
+ // Each leaf holds the glob portion (from the first wildcard segment onward).
+ IncludesTrie root = new IncludesTrie();
+ for (String include : includes) {
+ // Ant shorthand: trailing "/" means "everything under this directory"
+ if (include.endsWith("/")) {
+ include = include + "**";
+ }
+ IncludesTrie trie = root;
+ StringTokenizer st = new StringTokenizer(include, "/");
+ while (st.hasMoreTokens()) {
+ String segment = st.nextToken();
+ if (segment.contains("*") || segment.contains("?")) {
+ // This segment has wildcards — collect it and remaining segments as a glob
+ StringBuilder glob = new StringBuilder(segment);
+ while (st.hasMoreTokens()) {
+ glob.append('/').append(st.nextToken());
+ }
+ trie.addPattern(glob.toString());
+ break;
+ }
+ trie = trie.child(segment);
+ }
+ }
+
+ // Convert trie leaves into Path → PathMatcher entries
+ Map result = new HashMap<>();
+ trie2map(root, basedir, null, excludes, result);
+ return result;
+ }
+
+ /**
+ * Recursively converts an {@link IncludesTrie} into map entries.
+ */
+ private static void trie2map(
+ IncludesTrie node,
+ Path basedir,
+ String relpath,
+ Collection excludes,
+ Map result) {
+ if (node.children != null) {
+ for (Map.Entry entry : node.children.entrySet()) {
+ String childPath = relpath != null ? relpath + "/" + entry.getKey() : entry.getKey();
+ trie2map(entry.getValue(), basedir, childPath, excludes, result);
+ }
+ } else {
+ Path entryPath = relpath != null ? basedir.resolve(relpath) : basedir;
+ if (node.patterns != null) {
+ // Reconstruct full include patterns by prepending the literal prefix
+ List scopedIncludes = new ArrayList<>(node.patterns.size());
+ for (String pattern : node.patterns) {
+ scopedIncludes.add(relpath != null ? relpath + "/" + pattern : pattern);
+ }
+ result.put(entryPath, of(basedir, scopedIncludes, excludes, false));
+ } else {
+ // No wildcards at all — this is a single exact file path
+ result.put(entryPath, singlePathMatcher(basedir, relpath));
+ }
+ }
+ }
+
+ /**
+ * Creates a matcher that matches exactly one file by path equality.
+ */
+ private static PathMatcher singlePathMatcher(Path basedir, String relpath) {
+ Path target = relpath != null ? basedir.resolve(relpath) : basedir;
+ return path -> path.equals(target);
+ }
+
+ /**
+ * A trie for decomposing include patterns into per-subdirectory groups.
+ * Each non-leaf node represents a literal directory segment; each leaf
+ * holds the wildcard-containing glob tail(s) for that subtree.
+ */
+ private static class IncludesTrie {
+ Map children;
+ Collection patterns;
+
+ void addPattern(String glob) {
+ if (patterns == null) {
+ patterns = new LinkedHashSet<>();
+ }
+ patterns.add(glob);
+ // Once we have patterns at this level, collapse any children into patterns too
+ if (children != null) {
+ for (Map.Entry entry : children.entrySet()) {
+ entry.getValue().collectInto(entry.getKey(), patterns);
+ }
+ children = null;
+ }
+ }
+
+ private void collectInto(String prefix, Collection target) {
+ if (children != null) {
+ for (Map.Entry entry : children.entrySet()) {
+ entry.getValue().collectInto(prefix + "/" + entry.getKey(), target);
+ }
+ }
+ if (patterns != null) {
+ for (String pattern : patterns) {
+ target.add(prefix + "/" + pattern);
+ }
+ }
+ }
+
+ IncludesTrie child(String name) {
+ if (patterns != null) {
+ return this; // already collecting patterns at this level
+ }
+ if (children == null) {
+ children = new HashMap<>();
+ }
+ return children.computeIfAbsent(name, k -> new IncludesTrie());
+ }
+ }
+
/**
* Returns the given array of excludes, optionally expanded with a default set of excludes,
* then with unnecessary excludes omitted. An unnecessary exclude is an exclude which will never
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java
new file mode 100644
index 000000000000..e719e5db0f09
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java
@@ -0,0 +1,916 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.Serializable;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.PathMatcher;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.function.BinaryOperator;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.build.context.Input;
+import org.apache.maven.api.build.context.Metadata;
+import org.apache.maven.api.build.context.Output;
+import org.apache.maven.api.build.context.Severity;
+import org.apache.maven.api.build.context.Status;
+import org.apache.maven.api.build.context.spi.BuildContextEnvironment;
+import org.apache.maven.api.build.context.spi.BuildContextFinalizer;
+import org.apache.maven.api.build.context.spi.CommittableBuildContext;
+import org.apache.maven.api.build.context.spi.Message;
+import org.apache.maven.api.build.context.spi.Sink;
+import org.apache.maven.api.build.context.spi.Workspace;
+import org.apache.maven.api.services.PathMatcherFactory;
+import org.apache.maven.impl.DefaultPathMatcherFactory;
+
+/**
+ * Default implementation of the incremental build context.
+ *
+ * This class manages the full lifecycle of an incremental build: loading previous state,
+ * comparing configuration and file timestamps, tracking input-output associations, and
+ * cleaning up stale outputs at commit time.
+ *
+ * Escalation
+ *
+ * At construction, the context decides whether to escalate to a full build
+ * (treating all inputs as modified). Escalation is triggered when:
+ *
+ * The previous state file does not exist or cannot be read (first build or after clean)
+ * The {@linkplain #getConfigurationChanged() configuration has changed} — any entry
+ * in the configuration map differs from the previous build's stored configuration
+ * Any previously tracked output file is missing on disk
+ * The workspace explicitly requests escalation
+ * ({@link Workspace.Mode#ESCALATED})
+ *
+ *
+ * Stale output cleanup
+ *
+ * At {@linkplain #finalizeContext() commit time}, the context performs a three-pass cleanup:
+ *
+ * Carry over unprocessed (up-to-date) inputs and collect their associated outputs
+ * Carry over outputs whose inputs are all up-to-date
+ * Delete outputs from the previous build that are not carried over — these are
+ * stale outputs whose inputs were removed, modified, or re-associated
+ *
+ *
+ * @since 4.1.0
+ */
+public class DefaultBuildContext implements CommittableBuildContext {
+ final Workspace workspace;
+ final Path stateFile;
+ final DefaultBuildContextState state;
+ final DefaultBuildContextState oldState;
+ /**
+ * Whether the build has been escalated to a full rebuild. When escalated, all input
+ * files are treated as requiring processing regardless of their actual file timestamps.
+ * Escalation is triggered by configuration changes, missing state, or missing outputs.
+ */
+ private final boolean escalated;
+ /**
+ * Resources known to be deleted since previous build. Includes both resources reported as deleted
+ * by Workspace and resources explicitly delete through this build context.
+ */
+ private final Set deletedResources = new HashSet<>();
+ /**
+ * Resources selected for processing during this build. This includes resources created, changed
+ * and deleted through this build context.
+ */
+ private final Set processedResources = new HashSet<>();
+ /**
+ * Indicates that no further modifications to this build context are allowed.
+ */
+ private boolean closed;
+ /**
+ * Indicates whether the build will continue even if there are compilation errors.
+ */
+ private boolean failOnError = true;
+
+ /**
+ * Factory for creating path matchers with Ant-style pattern support.
+ */
+ private final PathMatcherFactory pathMatcherFactory;
+
+ public DefaultBuildContext(BuildContextEnvironment env) {
+ this(env.getWorkspace(), env.getStateFile(), env.getParameters(), env.getFinalizer(), null);
+ }
+
+ public DefaultBuildContext(BuildContextEnvironment env, @Nullable PathMatcherFactory pathMatcherFactory) {
+ this(env.getWorkspace(), env.getStateFile(), env.getParameters(), env.getFinalizer(), pathMatcherFactory);
+ }
+
+ public DefaultBuildContext(
+ Workspace workspace,
+ Path stateFile,
+ Map configuration,
+ BuildContextFinalizer finalizer) {
+ this(workspace, stateFile, configuration, finalizer, null);
+ }
+
+ /**
+ * Creates a new build context with the given workspace, state file, and configuration.
+ *
+ * The constructor loads the previous build state from {@code stateFile} (if it exists),
+ * then compares each entry in {@code configuration} against the stored values. If any
+ * configuration value has changed — or if the state file is missing or any previously
+ * tracked output has been deleted — the context escalates to a full build.
+ *
+ * The {@code configuration} map typically contains digested mojo parameters and
+ * plugin classpath hashes, provided by the Maven runtime. See
+ * {@link org.apache.maven.api.build.context.spi.BuildContextEnvironment#getParameters()
+ * BuildContextEnvironment.getParameters()} for details.
+ *
+ * @param workspace the file-system abstraction for reading, writing, and deleting files
+ * @param stateFile the path to the binary state file, or {@code null} for a
+ * stateless (always-escalated) context
+ * @param configuration the configuration parameters to compare against the previous build
+ * @param finalizer optional callback that commits the context after mojo execution
+ * @param pathMatcherFactory optional factory for Ant-style path matchers; defaults to
+ * {@link DefaultPathMatcherFactory} if {@code null}
+ */
+ public DefaultBuildContext(
+ Workspace workspace,
+ Path stateFile,
+ Map configuration,
+ BuildContextFinalizer finalizer,
+ @Nullable PathMatcherFactory pathMatcherFactory) {
+ // preconditions
+ Objects.requireNonNull(workspace, "workspace");
+ Objects.requireNonNull(configuration, "configuration");
+
+ this.pathMatcherFactory = pathMatcherFactory != null ? pathMatcherFactory : new DefaultPathMatcherFactory();
+ this.stateFile = stateFile != null ? stateFile.toAbsolutePath() : null;
+ this.state = DefaultBuildContextState.withConfiguration(configuration);
+ this.oldState = DefaultBuildContextState.loadFrom(this.stateFile);
+
+ final boolean configurationChanged = getConfigurationChanged();
+ if (workspace.getMode() == Workspace.Mode.ESCALATED) {
+ this.escalated = true;
+ this.workspace = workspace;
+ } else if (workspace.getMode() == Workspace.Mode.SUPPRESSED) {
+ this.escalated = false;
+ this.workspace = workspace;
+ } else if (configurationChanged || !isPresent(oldState.getOutputs())) {
+ this.escalated = true;
+ this.workspace = workspace.escalate();
+ } else {
+ this.escalated = false;
+ this.workspace = workspace;
+ }
+
+ if (escalated && this.stateFile != null) {
+ if (!Files.isReadable(this.stateFile)) {
+ logInfo("Previous incremental build state does not exist, performing full build");
+ } else {
+ logInfo("Incremental build configuration change detected, performing full build");
+ }
+ } else {
+ logInfo("Performing incremental build");
+ }
+
+ if (finalizer != null) {
+ finalizer.registerContext(this);
+ }
+ }
+
+ private static boolean containsOnly(Collection collection, Path element) {
+ return collection.stream().allMatch(element::equals);
+ }
+
+ static Path normalize(Path input) {
+ if (input == null) {
+ throw new IllegalArgumentException();
+ }
+ return getCanonicalPath(input);
+ }
+
+ static Path getCanonicalPath(Path path) {
+ try {
+ return path.toRealPath();
+ } catch (IOException e) {
+ Path parent = path.getParent();
+ if (parent == null) {
+ return path.toAbsolutePath().normalize();
+ }
+ return getCanonicalPath(parent).resolve(path.getFileName());
+ }
+ }
+
+ public boolean isFailOnError() {
+ return failOnError;
+ }
+
+ @Override
+ public void setFailOnError(boolean failOnError) {
+ this.failOnError = failOnError;
+ }
+
+ protected void logInfo(String message) {
+ System.out.println(message);
+ }
+
+ private boolean isPresent(Collection outputs) {
+ // in some scenarios, notable classpath change caused by changes to pom.xml,
+ // jdt builder deletes all files from target/classes directory during incremental workspace
+ // build. this behaviour is not communicated to m2e (or any other workspace builder) and thus
+ // m2e does not recreate deleted outputs
+ // this workaround escalates the build if any of the old outputs were deleted
+ return outputs.stream().allMatch(Files::isRegularFile);
+ }
+
+ /**
+ * Determines whether the build configuration has changed since the previous build.
+ *
+ * Compares every key in the union of the current and previous configuration maps.
+ * A change in any value (including keys present in one map but not the other) triggers
+ * escalation to a full build. Values are compared using {@link Objects#equals}, so
+ * the digest values must implement {@code equals()} correctly (e.g., byte-array wrappers
+ * like {@code BytesHash}).
+ *
+ * @return {@code true} if any configuration entry differs from the previous build
+ */
+ private boolean getConfigurationChanged() {
+ Map configuration = state.configuration;
+ Map oldConfiguration = oldState.configuration;
+ return Stream.concat(configuration.keySet().stream(), oldConfiguration.keySet().stream())
+ .distinct()
+ .anyMatch(k -> !Objects.equals(configuration.get(k), oldConfiguration.get(k)));
+ }
+
+ @Override
+ public boolean isProcessingRequired() {
+ return isEscalated()
+ || state.getResources().keySet().stream()
+ .anyMatch(resource ->
+ !state.isOutput(resource) && getResourceStatus(resource) != Status.UNMODIFIED)
+ || oldState.getResources().keySet().stream()
+ .anyMatch(resource -> !oldState.isOutput(resource) && !state.isResource(resource));
+ }
+
+ @Override
+ public DefaultOutput processOutput(Path outputFile) {
+ outputFile = normalize(outputFile);
+ DefaultOutputMetadata metadata = registerNormalizedOutput(outputFile);
+ return processOutput(metadata);
+ }
+
+ protected DefaultOutput processOutput(DefaultOutputMetadata metadata) {
+ processResource(metadata.getPath());
+ workspace.processOutput(metadata.getPath());
+ return newOutput(metadata);
+ }
+
+ @Override
+ public DefaultInputSet newInputSet() {
+ return new DefaultInputSet(this);
+ }
+
+ @Override
+ public DefaultInputMetadata registerInput(Path inputFile) {
+ inputFile = normalize(inputFile);
+ BasicFileAttributes attrs = readAttributes(inputFile);
+ return registerNormalizedInput(inputFile, attrs.lastModifiedTime(), attrs.size());
+ }
+
+ static BasicFileAttributes readAttributes(Path inputFile) {
+ try {
+ return Files.readAttributes(inputFile, BasicFileAttributes.class);
+ } catch (NoSuchFileException e) {
+ return new BasicFileAttributes() {
+ @Override
+ public FileTime lastModifiedTime() {
+ return null;
+ }
+
+ @Override
+ public FileTime lastAccessTime() {
+ return null;
+ }
+
+ @Override
+ public FileTime creationTime() {
+ return null;
+ }
+
+ @Override
+ public boolean isRegularFile() {
+ return false;
+ }
+
+ @Override
+ public boolean isDirectory() {
+ return false;
+ }
+
+ @Override
+ public boolean isSymbolicLink() {
+ return false;
+ }
+
+ @Override
+ public boolean isOther() {
+ return false;
+ }
+
+ @Override
+ public long size() {
+ return 0;
+ }
+
+ @Override
+ public Object fileKey() {
+ return null;
+ }
+ };
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ @Override
+ public Collection extends DefaultInputMetadata> registerInputs(
+ Path basedir, Collection includes, Collection excludes) {
+ basedir = normalize(basedir);
+ Map matchers = pathMatcherFactory.createSubdirectoryMatchers(basedir, includes, excludes);
+ List result = matchers.entrySet().stream()
+ .flatMap(e -> workspace
+ .walk(e.getKey())
+ .filter(s ->
+ !Files.isDirectory(s.getPath()) && e.getValue().matches(s.getPath())))
+ .map(s -> {
+ if (s.getStatus() == Status.REMOVED) {
+ deletedResources.add(s.getPath());
+ } else {
+ registerInput(new FileState(s.getPath(), s.getLastModified(), s.getSize()));
+ }
+ return new DefaultInputMetadata(DefaultBuildContext.this, oldState, s.getPath());
+ })
+ .collect(Collectors.toList());
+ if (workspace.getMode() == Workspace.Mode.DELTA) {
+ // only NEW, MODIFIED and REMOVED resources are reported in DELTA mode
+ // need to find any UNMODIFIED
+ final PathMatcher absoluteMatcher = pathMatcherFactory.createPathMatcher(basedir, includes, excludes);
+ for (FileState fileState : oldState.getResources().values()) {
+ Path path = fileState.getPath();
+ if (!state.isResource(path) && !deletedResources.contains(path) && absoluteMatcher.matches(path)) {
+ result.add(registerNormalizedInput(path, fileState.getLastModified(), fileState.getSize()));
+ }
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public Collection extends DefaultInput> registerAndProcessInputs(
+ Path basedir, Collection includes, Collection excludes) {
+ return registerInputs(basedir, includes, excludes).stream()
+ .map(m -> {
+ switch (m.getStatus()) {
+ case NEW:
+ case MODIFIED:
+ return processInput(m);
+ default:
+ return new DefaultInput(this, state, m.getPath());
+ }
+ })
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Marks skipped build execution. All inputs, outputs and their associated metadata are carried
+ * over to the next build as-is. No context modification operations (register* or process) are
+ * permitted after this call.
+ */
+ @Override
+ public void markSkipExecution() {
+ if (!processedResources.isEmpty()) {
+ throw new IllegalStateException();
+ }
+ closed = true;
+ }
+
+ protected DefaultInputMetadata registerNormalizedInput(Path resourceFile, FileTime lastModified, long length) {
+ assertOpen();
+ if (!state.isResource(resourceFile)) {
+ registerInput(newFileState(resourceFile, lastModified, length));
+ }
+ return new DefaultInputMetadata(this, oldState, resourceFile);
+ }
+
+ private FileState newFileState(Path path) {
+ BasicFileAttributes attrs = readAttributes(path);
+ return newFileState(path, attrs.lastModifiedTime(), attrs.size());
+ }
+
+ private FileState newFileState(Path file, FileTime lastModified, long size) {
+ if (!workspace.isPresent(file)) {
+ throw new IllegalArgumentException("File does not exist or cannot be read " + file);
+ }
+ return new FileState(file, lastModified, size);
+ }
+
+ protected DefaultOutputMetadata registerNormalizedOutput(Path outputFile) {
+ assertOpen();
+ if (!state.isResource(outputFile)) {
+ state.putResource(outputFile, null); // placeholder
+ state.addOutput(outputFile);
+ } else {
+ if (!state.isOutput(outputFile)) {
+ throw new IllegalStateException("Already registered as input " + outputFile);
+ }
+ }
+ return new DefaultOutputMetadata(this, oldState, outputFile);
+ }
+
+ public boolean aggregate(
+ Collection extends DefaultInputMetadata> inputs,
+ Path outputFile,
+ BiConsumer> creator) {
+ DefaultOutputMetadata output = registerOutput(outputFile);
+ return aggregate(inputs, output, creator);
+ }
+
+ public boolean aggregate(
+ Collection extends DefaultInputMetadata> inputs,
+ DefaultOutputMetadata output,
+ BiConsumer> creator) {
+ associate(inputs, output);
+ boolean processingRequired = isEscalated();
+ if (!processingRequired) {
+ processingRequired = isProcessingRequired(inputs, output);
+ }
+ if (processingRequired) {
+ DefaultOutput outputResource = processOutput(output);
+ List inputResources = inputs.stream().map(this::processInput).collect(Collectors.toList());
+ creator.accept(outputResource, inputResources);
+ } else {
+ markUptodateOutput(output.getPath());
+ }
+ return processingRequired;
+ }
+
+ public boolean aggregate(
+ Collection extends DefaultInputMetadata> inputs,
+ Path outputFile,
+ String stepId,
+ T identity,
+ Function mapper,
+ BinaryOperator accumulator,
+ BiConsumer writer) {
+ DefaultOutputMetadata output = registerOutput(outputFile);
+ associate(inputs, output);
+ boolean processingRequired = isEscalated() || isProcessingRequired(inputs, output);
+ if (processingRequired) {
+ T metadata = inputs.stream()
+ .map(input -> getMetadata(input, stepId, mapper))
+ .reduce(identity, accumulator);
+ T oldMetadata = getOutputInputs(oldState, outputFile).stream()
+ .map(inputFile -> oldState.getResourceAttribute(inputFile, stepId))
+ .reduce(identity, accumulator);
+ if (!Objects.equals(metadata, oldMetadata)) {
+ DefaultOutput outputResource = processOutput(output);
+ writer.accept(outputResource, metadata);
+ return true;
+ }
+ } else {
+ markUptodateOutput(output.getPath());
+ }
+ return false;
+ }
+
+ private T getMetadata(
+ DefaultMetadata input, String stepId, Function mapper) {
+ if (input.getStatus() != Status.UNMODIFIED) {
+ return mapper.apply(input.process());
+ } else {
+ return oldState.getResourceAttribute(input.getPath(), stepId);
+ }
+ }
+
+ /**
+ * Finalizes the build context by carrying over up-to-date state and cleaning up stale outputs.
+ *
+ * This method implements a three-pass algorithm:
+ *
+ * Pass 1 — carry over up-to-date inputs. For each input from the
+ * previous build that was neither processed nor deleted in this build (and is still
+ * registered), carry over its state (timestamps, messages, attributes, output
+ * associations). Collect the set of outputs associated with these carried-over inputs.
+ * Pass 2 — carry over up-to-date outputs. For each output from the
+ * previous build whose all associated inputs were carried over, carry over
+ * the output's state as well.
+ * Pass 3 — delete stale outputs. Any output from the previous build
+ * that was not carried over (because its input was deleted, modified, or
+ * re-associated to a different output) is deleted from disk via the workspace.
+ *
+ *
+ * This is the mechanism that provides automatic stale-output cleanup: when a source file
+ * is removed, all output files that were associated with it via
+ * {@link org.apache.maven.api.build.context.Input#associateOutput(Path)} in the previous
+ * build are automatically deleted. For example, deleting {@code Foo.java} will clean up
+ * {@code Foo.class}, {@code Foo$Inner.class}, and any other outputs associated with it.
+ *
+ * Limitation: only simple input → output associations are supported.
+ * An output with multiple inputs, or a resource that is both input and output, may produce
+ * unexpected results.
+ */
+ protected void finalizeContext() {
+
+ Set uptodateOldOutputs = new HashSet<>();
+ Set uptodateOldInputs = new HashSet<>();
+ for (Path resource : oldState.getResources().keySet()) {
+ if (oldState.isOutput(resource)) {
+ continue;
+ }
+
+ if (isProcessedResource(resource) || isDeletedResource(resource) || !isRegisteredResource(resource)) {
+ // deleted or processed resource, nothing to carry over
+ continue;
+ }
+
+ if (state.isOutput(resource)) {
+ // resource flipped from input to output without going through delete
+ throw new BuildContextException(
+ new IllegalStateException("Inconsistent resource type change " + resource));
+ }
+
+ // carry over
+ state.putResource(resource, oldState.getResource(resource));
+ state.setResourceMessages(resource, oldState.getResourceMessages(resource));
+ state.setResourceAttributes(resource, oldState.getResourceAttributes(resource));
+ state.setResourceOutputs(resource, oldState.getResourceOutputs(resource));
+ uptodateOldInputs.add(resource);
+ }
+
+ for (Path oldOutput : oldState.getOutputs()) {
+ Collection outputInputs = oldState.getOutputInputs(oldOutput);
+ if (outputInputs != null && uptodateOldInputs.containsAll(outputInputs)) {
+ uptodateOldOutputs.add(oldOutput);
+ }
+ }
+
+ for (Path output : uptodateOldOutputs) {
+ if (state.isResource(output)) {
+ // can't carry-over registered resources
+ // throw new IllegalStateException( "Can't carry over " + output );
+ }
+
+ state.putResource(output, oldState.getResource(output));
+ state.addOutput(output);
+ state.setResourceMessages(output, oldState.getResourceMessages(output));
+ state.setResourceAttributes(output, oldState.getResourceAttributes(output));
+ }
+
+ for (Path output : oldState.getOutputs()) {
+ if (!state.isOutput(output)) {
+ deleteOutput(output);
+ }
+ }
+ }
+
+ protected void deleteOutput(Path resource) {
+ if (!oldState.isOutput(resource) && !state.isOutput(resource)) {
+ // not an output known to this build context
+ throw new IllegalArgumentException();
+ }
+
+ workspace.deleteFile(resource);
+
+ deletedResources.add(resource);
+ processedResources.add(resource);
+
+ state.removeResource(resource);
+ state.removeOutput(resource);
+
+ state.removeResourceAttributes(resource);
+ state.removeResourceMessages(resource);
+ state.removeResourceOutputs(resource);
+ }
+
+ protected boolean isEscalated() {
+ return escalated;
+ }
+
+ // re-create output if any its inputs were added, changed or deleted since previous build
+ private boolean isProcessingRequired(
+ Collection extends DefaultInputMetadata> inputs, DefaultOutputMetadata output) {
+ if (getResourceStatus(output.getPath()) == Status.MODIFIED) {
+ return true;
+ }
+ if (inputs.stream().anyMatch(r -> r.getStatus() != Status.UNMODIFIED)) {
+ return true;
+ }
+ List inputFiles = inputs.stream().map(Metadata::getPath).collect(Collectors.toList());
+ return getOutputInputs(oldState, output.getPath()).stream().anyMatch(r -> !inputFiles.contains(r));
+ }
+
+ protected boolean isProcessedResource(Path resource) {
+ return processedResources.contains(resource);
+ }
+
+ protected Set getProcessedResources() {
+ return processedResources;
+ }
+
+ protected boolean isProcessed() {
+ return !processedResources.isEmpty();
+ }
+
+ protected void markProcessedResource(Path resource) {
+ processedResources.add(resource);
+ }
+
+ private DefaultOutputMetadata registerOutput(Path outputFile) {
+ outputFile = normalize(outputFile);
+ if (isRegisteredResource(outputFile)) {
+ // only allow single registration of the same output. not sure why/if multiple will be needed
+ throw new BuildContextException(new IllegalStateException("Output already registered " + outputFile));
+ }
+ return registerNormalizedOutput(outputFile);
+ }
+
+ private Collection getOutputInputs(DefaultBuildContextState contextState, Path outputFile) {
+ Collection inputs = contextState.getOutputInputs(outputFile);
+ return inputs != null && !inputs.isEmpty() ? inputs : Collections.emptyList();
+ }
+
+ protected boolean isRegisteredResource(Path resource) {
+ return state.isResource(resource);
+ }
+
+ protected boolean isDeletedResource(Path resource) {
+ return deletedResources.contains(resource);
+ }
+
+ protected void markUptodateOutput(Path outputFile) {
+ if (!oldState.isOutput(outputFile)) {
+ throw new IllegalArgumentException();
+ }
+ state.putResource(outputFile, oldState.getResource(outputFile));
+ state.addOutput(outputFile);
+ }
+
+ /**
+ * Adds the resource to this build's resource set. The resource must exist, i.e. it's status must
+ * not be REMOVED.
+ *
+ * @param holder the file state of the resource to register
+ * @return the normalized path of the registered resource
+ */
+ protected Path registerInput(FileState holder) {
+ Path resource = holder.getPath();
+ FileState other = state.getResource(resource);
+ if (other == null) {
+ if (getResourceStatus(holder) == Status.REMOVED) {
+ throw new BuildContextException(new IllegalArgumentException("Resource does not exist " + resource));
+ }
+ state.putResource(resource, holder);
+ } else {
+ if (state.isOutput(resource)) {
+ throw new BuildContextException(new IllegalStateException("Already registered as output " + resource));
+ }
+ if (!holder.equals(other)) {
+ throw new BuildContextException(
+ new IllegalArgumentException("Inconsistent resource state " + resource));
+ }
+ state.putResource(resource, holder);
+ }
+ return resource;
+ }
+
+ private Status getResourceStatus(FileState fileState) {
+ return workspace.getResourceStatus(fileState.getPath(), fileState.getLastModified(), fileState.getSize());
+ }
+
+ private void assertOpen() {
+ if (closed) {
+ throw new IllegalStateException();
+ }
+ }
+
+ protected DefaultInput processInput(DefaultInputMetadata metadata) {
+ final Path resource = metadata.getPath();
+ if (metadata.context != this || !state.isResource(resource)) {
+ throw new IllegalArgumentException();
+ }
+ processResource(resource);
+ return new DefaultInput(this, state, resource);
+ }
+
+ private void processResource(final Path resource) {
+ processedResources.add(resource);
+
+ // reset all metadata associated with the resource during this build
+ // state.removeResourceAttributes( resource );
+ // state.removeResourceMessages( resource );
+ // state.removeResourceOutputs( resource );
+ }
+
+ protected Status getResourceStatus(Path resource) {
+ if (deletedResources.contains(resource)) {
+ return Status.REMOVED;
+ }
+
+ FileState oldResourceState = oldState.getResource(resource);
+ if (oldResourceState == null) {
+ return Status.NEW;
+ }
+
+ Status status = getResourceStatus(oldResourceState);
+
+ if (status == Status.UNMODIFIED && escalated) {
+ status = Status.MODIFIED;
+ }
+
+ return status;
+ }
+
+ protected DefaultOutput associate(DefaultInput input, DefaultOutput output) {
+ if (input.context != this) {
+ throw new BuildContextException(new IllegalArgumentException());
+ }
+ if (output.context != this) {
+ throw new BuildContextException(new IllegalArgumentException());
+ }
+
+ assertAssociation(input, output);
+
+ state.putResourceOutput(input.getPath(), output.getPath());
+ return output;
+ }
+
+ private void associate(Iterable extends DefaultInputMetadata> inputs, DefaultOutputMetadata output) {
+ inputs.forEach(r -> state.putResourceOutput(r.getPath(), output.getPath()));
+ }
+
+ protected Collection extends DefaultOutputMetadata> getAssociatedOutputs(
+ DefaultBuildContextState contextState, Path resource) {
+ Collection outputFiles = contextState.getResourceOutputs(resource);
+ if (outputFiles == null || outputFiles.isEmpty()) {
+ return Collections.emptyList();
+ }
+ List outputs = new ArrayList<>();
+ for (Path outputFile : outputFiles) {
+ outputs.add(new DefaultOutputMetadata(this, contextState, outputFile));
+ }
+ return outputs;
+ }
+
+ protected void assertAssociation(DefaultInput resource, DefaultOutput output) {
+ Path input = resource.getPath();
+ Path outputFile = output.getPath();
+
+ // input --> output --> output2 is not supported (until somebody provides a usecase)
+ if (state.isOutput(input)) {
+ throw new BuildContextException(new UnsupportedOperationException());
+ }
+
+ // each output can only be associated with a single input
+ Collection inputs = state.getOutputInputs(outputFile);
+ if (inputs != null && !inputs.isEmpty() && !containsOnly(inputs, input)) {
+ throw new BuildContextException(new UnsupportedOperationException());
+ }
+ }
+
+ protected Serializable setResourceAttribute(Path resource, String key, T value) {
+ state.putResourceAttribute(resource, key, value);
+ // TODO odd this always returns previous build state. need to think about it
+ return oldState.getResourceAttribute(resource, key);
+ }
+
+ protected T getResourceAttribute(
+ DefaultBuildContextState contextState, Path resource, String key, Class clazz) {
+ Map attributes = contextState.getResourceAttributes(resource);
+ return attributes != null ? clazz.cast(attributes.get(key)) : null;
+ }
+
+ void addMessage(Path resource, int line, int column, String message, Severity severity, Throwable cause) {
+ // this is likely called as part of builder error handling logic.
+ // to make IAE easier to troubleshoot, link cause to the exception thrown
+ if (resource == null) {
+ throw new IllegalArgumentException("resource cannot be null", cause);
+ }
+ if (severity == null) {
+ throw new IllegalArgumentException("severity cannot be null", cause);
+ }
+ state.addResourceMessage(resource, new Message(line, column, message, severity, cause));
+ log(resource, line, column, message, severity, cause);
+ }
+
+ OutputStream newOutputStream(DefaultOutput output) {
+ return workspace.newOutputStream(output.getPath());
+ }
+
+ DefaultOutput newOutput(DefaultOutputMetadata resource) {
+ return new DefaultOutput(this, state, resource.resource);
+ }
+
+ public void commit(@Nullable Sink sink) {
+ if (closed) {
+ return;
+ }
+ this.closed = true;
+
+ // messages recorded during this build
+ Map> newMessages = new HashMap<>(state.getResourceMessages());
+
+ finalizeContext();
+
+ // assert inputs didn't change
+ for (Map.Entry entry : state.getResources().entrySet()) {
+ Path resource = entry.getKey();
+ FileState holder = entry.getValue();
+ if (!state.isOutput(resource) && holder.getStatus() != Status.UNMODIFIED) {
+ throw new BuildContextException(new IllegalStateException("Unexpected input change " + resource));
+ }
+ }
+
+ // timestamp new outputs
+ state.getOutputs().forEach(outputFile -> state.computeResourceIfAbsent(outputFile, this::newFileState));
+
+ if (stateFile != null) {
+ try (OutputStream os = workspace.newOutputStream(stateFile)) {
+ state.storeTo(os);
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ // new messages are logged as soon as they are reported during the build
+ // replay old messages so the user can still see them
+ Map> allMessages = new HashMap<>(state.getResourceMessages());
+
+ if (!allMessages.keySet().equals(newMessages.keySet())) {
+ for (Map.Entry> entry : allMessages.entrySet()) {
+ Path resource = entry.getKey();
+ if (!newMessages.containsKey(resource)) {
+ for (Message message : entry.getValue()) {
+ log(
+ resource,
+ message.getLine(),
+ message.getColumn(),
+ message.getMessage(),
+ message.getSeverity(),
+ message.getCause());
+ }
+ }
+ }
+ }
+
+ // processedResources includes resources added, changed and deleted during this build
+ // clear all old messages associated with the processed resources during previous builds
+ if (sink != null) {
+ for (Path resource : processedResources) {
+ sink.clear(resource);
+ }
+ for (Path resource : oldState.getResources().keySet()) {
+ if (!state.isResource(resource)) {
+ sink.clear(resource);
+ }
+ }
+ for (Map.Entry> entry : allMessages.entrySet()) {
+ Path resource = entry.getKey();
+ boolean isNew = newMessages.containsKey(resource);
+ sink.messages(resource, isNew, entry.getValue());
+ }
+ }
+ }
+
+ private void log(Path resource, int line, int column, String message, Severity severity, Throwable cause) {
+ // TODO
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextState.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextState.java
new file mode 100644
index 000000000000..ea87d076e252
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextState.java
@@ -0,0 +1,520 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.ObjectStreamClass;
+import java.io.OutputStream;
+import java.io.Serializable;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import org.apache.maven.api.build.context.spi.Message;
+
+public class DefaultBuildContextState implements Serializable {
+
+ private static final long serialVersionUID = 6195150574931820441L;
+
+ final Map configuration;
+
+ private final Set outputs;
+
+ private final Map resources;
+
+ private final Map> resourceOutputs;
+
+ // pure in-memory performance optimization, always reflects contents of resourceOutputs
+ private final Map> outputInputs;
+
+ private final Map> resourceAttributes;
+
+ private final Map> resourceMessages;
+
+ private DefaultBuildContextState(
+ Map configuration,
+ Map inputs,
+ Set outputs,
+ Map> resourceOutputs,
+ Map> outputInputs,
+ Map> resourceAttributes,
+ Map> resourceMessages) {
+ this.configuration = configuration;
+ this.resources = inputs;
+ this.outputs = outputs;
+ this.resourceOutputs = resourceOutputs;
+ this.outputInputs = outputInputs;
+ this.resourceAttributes = resourceAttributes;
+ this.resourceMessages = resourceMessages;
+ }
+
+ public static DefaultBuildContextState withConfiguration(Map configuration) {
+ HashMap copy = new HashMap<>(configuration);
+ // configuration marker used to distinguish between empty and new state
+ copy.put("incremental", Boolean.TRUE);
+ return new DefaultBuildContextState(
+ Collections.unmodifiableMap(copy), // configuration
+ new HashMap<>(), // inputs
+ new HashSet<>(), // outputs
+ new HashMap<>(), // inputOutputs
+ new HashMap<>(), // outputInputs
+ new HashMap<>(), // resourceAttributes
+ new HashMap<>() // messages
+ );
+ }
+
+ public static DefaultBuildContextState emptyState() {
+ return new DefaultBuildContextState(
+ Collections.emptyMap(), // configuration
+ Collections.emptyMap(), // inputs
+ Collections.emptySet(), // outputs
+ Collections.emptyMap(), // inputOutputs
+ Collections.emptyMap(), // outputInputs
+ Collections.emptyMap(), // resourceAttributes
+ Collections.emptyMap() // messages
+ );
+ }
+
+ private static void writeMap(ObjectOutputStream oos, Map, ?> map) throws IOException {
+ oos.writeInt(map.size());
+ for (Map.Entry, ?> entry : map.entrySet()) {
+ oos.writeObject(entry.getKey());
+ oos.writeObject(entry.getValue());
+ }
+ }
+
+ private static void writeMultimap(ObjectOutputStream oos, Map, ? extends Collection>> mmap) throws IOException {
+ oos.writeInt(mmap.size());
+ for (Map.Entry, ? extends Collection>> entry : mmap.entrySet()) {
+ oos.writeObject(entry.getKey());
+ writeCollection(oos, entry.getValue());
+ }
+ }
+
+ private static void writeCollection(ObjectOutputStream oos, Collection> collection) throws IOException {
+ if (collection == null || collection.isEmpty()) {
+ oos.writeInt(0);
+ } else {
+ oos.writeInt(collection.size());
+ for (Object element : collection) {
+ oos.writeObject(element);
+ }
+ }
+ }
+
+ private static void writeDoublemap(ObjectOutputStream oos, Map, ? extends Map, ?>> dmap) throws IOException {
+ oos.writeInt(dmap.size());
+ for (Map.Entry, ? extends Map, ?>> entry : dmap.entrySet()) {
+ oos.writeObject(entry.getKey());
+ writeMap(oos, entry.getValue());
+ }
+ }
+
+ public static DefaultBuildContextState loadFrom(Path stateFile) {
+ // TODO verify stateFile location has not changed since last build
+ // TODO wrap collections in corresponding immutable collections
+
+ if (stateFile == null) {
+ // transient build context
+ return DefaultBuildContextState.emptyState();
+ }
+
+ try {
+ // TODO does it matter if TCCL or super is called first?
+ try (ObjectInputStream is =
+ new PathAwareInputStream(
+ stateFile.getFileSystem(), new BufferedInputStream(Files.newInputStream(stateFile))) {
+ @Override
+ protected Class> resolveClass(ObjectStreamClass desc)
+ throws IOException, ClassNotFoundException {
+ // TODO does it matter if TCCL or super is called first?
+ try {
+ ClassLoader tccl = Thread.currentThread().getContextClassLoader();
+ return tccl.loadClass(desc.getName());
+ } catch (ClassNotFoundException e) {
+ return super.resolveClass(desc);
+ }
+ }
+ }) {
+ Map configuration = readMap(is);
+ Set outputs = readSet(is);
+ Map resources = readMap(is);
+ Map> resourceOutputs = readMultimap(is);
+ Map> outputInputs = invertMultimap(resourceOutputs);
+ Map> resourceAttributes = readDoublemap(is);
+ Map> messages = readMultimap(is);
+
+ DefaultBuildContextState state = new DefaultBuildContextState(
+ configuration, resources, outputs, resourceOutputs, outputInputs, resourceAttributes, messages);
+ return state;
+ }
+ // ignore secondary exceptions
+ } catch (FileNotFoundException e) {
+ // this is expected, silently ignore
+ } catch (RuntimeException e) {
+ // this is a bug in our code, let it bubble up as build failure
+ throw e;
+ } catch (Exception e) {
+ // this is almost certainly caused by incompatible state file, log and continue
+ }
+ return DefaultBuildContextState.emptyState();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map readMap(ObjectInputStream ois) throws IOException, ClassNotFoundException {
+ Map map = new HashMap<>();
+ int size = ois.readInt();
+ for (int i = 0; i < size; i++) {
+ K key = (K) ois.readObject();
+ V value = (V) ois.readObject();
+ map.put(key, value);
+ }
+ return Collections.unmodifiableMap(map);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map> readMultimap(ObjectInputStream ois)
+ throws IOException, ClassNotFoundException {
+ Map> mmap = new HashMap<>();
+ int size = ois.readInt();
+ for (int i = 0; i < size; i++) {
+ K key = (K) ois.readObject();
+ Collection value = readCollection(ois);
+ mmap.put(key, value);
+ }
+ return Collections.unmodifiableMap(mmap);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Collection readCollection(ObjectInputStream ois) throws IOException, ClassNotFoundException {
+ int size = ois.readInt();
+ if (size == 0) {
+ return Collections.emptyList();
+ }
+ Collection collection = new ArrayList();
+ for (int i = 0; i < size; i++) {
+ collection.add((V) ois.readObject());
+ }
+ return Collections.unmodifiableCollection(collection);
+ }
+
+ private static Set readSet(ObjectInputStream ois) throws IOException, ClassNotFoundException {
+ Collection collection = readCollection(ois);
+ return collection != null ? Collections.unmodifiableSet(new HashSet(collection)) : Collections.emptySet();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map> readDoublemap(ObjectInputStream ois)
+ throws IOException, ClassNotFoundException {
+ int size = ois.readInt();
+ Map> dmap = new HashMap<>();
+ for (int i = 0; i < size; i++) {
+ K key = (K) ois.readObject();
+ Map value = readMap(ois);
+ dmap.put(key, value);
+ }
+ return Collections.unmodifiableMap(dmap);
+ }
+
+ private static Map> invertMultimap(Map> mmap) {
+ Map> inverted = new HashMap<>();
+ for (Map.Entry> entry : mmap.entrySet()) {
+ for (V value : entry.getValue()) {
+ Collection keys = inverted.computeIfAbsent(value, k -> new ArrayList<>());
+ keys.add(entry.getKey());
+ }
+ }
+ return Collections.unmodifiableMap(inverted);
+ }
+
+ private static boolean put(Map> multimap, K key, V value) {
+ Collection values = multimap.computeIfAbsent(key, k -> new LinkedHashSet());
+ return values.add(value);
+ }
+
+ public String getStats() {
+ return String.valueOf(configuration.size())
+ + ' '
+ + resources.size()
+ + ' '
+ + outputs.size()
+ + ' '
+ + resourceOutputs.size()
+ + ' '
+ + outputInputs.size()
+ + ' '
+ + resourceAttributes.size()
+ + ' '
+ + resourceMessages.size()
+ + ' ';
+ }
+
+ //
+ // getters and settings
+ //
+
+ // resources
+
+ public void storeTo(OutputStream os) throws IOException {
+ ObjectOutputStream oos = new PathAwareOutputStream(os);
+ try {
+ writeMap(oos, this.configuration);
+ writeCollection(oos, this.outputs);
+ writeMap(oos, this.resources);
+
+ writeMultimap(oos, resourceOutputs);
+ writeDoublemap(oos, resourceAttributes);
+ writeMultimap(oos, resourceMessages);
+
+ } finally {
+ oos.flush();
+ }
+ }
+
+ public void putResource(Path resource, FileState holder) {
+ resources.put(resource, holder);
+ }
+
+ public FileState getResource(Path resource) {
+ return resources.get(resource);
+ }
+
+ public void computeResourceIfAbsent(Path resource, Function supplier) {
+ resources.computeIfAbsent(resource, supplier);
+ }
+
+ public boolean isResource(Path resource) {
+ return resources.containsKey(resource);
+ }
+
+ public FileState removeResource(Path resource) {
+ return resources.remove(resource);
+ }
+
+ // outputInputs
+
+ public Map getResources() {
+ return Collections.unmodifiableMap(resources);
+ }
+
+ // outputs
+
+ public Collection getOutputInputs(Path outputFile) {
+ return outputInputs.get(outputFile);
+ }
+
+ public Collection getOutputs() {
+ return Collections.unmodifiableCollection(outputs);
+ }
+
+ public boolean isOutput(Path outputFile) {
+ return outputs.contains(outputFile);
+ }
+
+ public boolean addOutput(Path output) {
+ return outputs.add(output);
+ }
+
+ // resourceOutputs
+
+ public boolean removeOutput(Path output) {
+ return outputs.remove(output);
+ }
+
+ public boolean putResourceOutput(Path resource, Path output) {
+ put(outputInputs, output, resource);
+ return put(resourceOutputs, resource, output);
+ }
+
+ public Collection getResourceOutputs(Path resource) {
+ return resourceOutputs.get(resource);
+ }
+
+ public Collection setResourceOutputs(Path resource, Collection newOutputs) {
+ if (newOutputs == null || newOutputs.isEmpty()) {
+ return resourceOutputs.remove(resource);
+ }
+ return resourceOutputs.put(resource, newOutputs);
+ }
+
+ public Collection removeResourceOutputs(Path resource) {
+ Collection removedOutputs = resourceOutputs.remove(resource);
+ removeOutputInputs(removedOutputs, resource);
+ return removedOutputs;
+ }
+
+ // resourceAttributes
+
+ private void removeOutputInputs(Collection outputPaths, Path resource) {
+ if (outputPaths == null) {
+ return;
+ }
+ for (Path output : outputPaths) {
+ Collection inputs = outputInputs.get(output);
+ if (inputs == null || !inputs.remove(resource)) {
+ throw new IllegalStateException();
+ }
+ if (inputs.isEmpty()) {
+ outputInputs.remove(output);
+ }
+ }
+ }
+
+ public Map removeResourceAttributes(Path resource) {
+ return resourceAttributes.remove(resource);
+ }
+
+ public Map getResourceAttributes(Path resource) {
+ return resourceAttributes.get(resource);
+ }
+
+ public Serializable putResourceAttribute(Path resource, String key, Serializable value) {
+ return resourceAttributes
+ .computeIfAbsent(resource, k -> new LinkedHashMap<>())
+ .put(key, value);
+ }
+
+ @SuppressWarnings("unchecked")
+ public T getResourceAttribute(Path resource, String key) {
+ return (T) resourceAttributes
+ .getOrDefault(resource, Collections.emptyMap())
+ .get(key);
+ }
+
+ // resourceMessages
+
+ public Map setResourceAttributes(Path resource, Map attributes) {
+ if (attributes == null || attributes.isEmpty()) {
+ return resourceAttributes.remove(resource);
+ }
+ return resourceAttributes.put(resource, attributes);
+ }
+
+ public Collection removeResourceMessages(Path resource) {
+ return resourceMessages.remove(resource);
+ }
+
+ public Collection getResourceMessages(Path resource) {
+ return resourceMessages.get(resource);
+ }
+
+ public Collection setResourceMessages(Path resource, Collection messages) {
+ if (messages == null || messages.isEmpty()) {
+ return resourceMessages.remove(resource);
+ }
+ return resourceMessages.put(resource, messages);
+ }
+
+ public boolean addResourceMessage(Path resource, Message message) {
+ return put(resourceMessages, resource, message);
+ }
+
+ public Map> getResourceMessages() {
+ return Collections.unmodifiableMap(resourceMessages);
+ }
+
+ /**
+ * Path is not serializable
+ * so this class is used as a workaround during serialization.
+ */
+ static class StoredPath implements Serializable {
+ private final String path;
+
+ StoredPath(Path path) {
+ this.path = path.toString();
+ }
+
+ public Path toPath(FileSystem fileSystem) {
+ return fileSystem.getPath(path);
+ }
+ }
+
+ /**
+ * FileTime is not serializable and Instant can not be used as a replacement,
+ * so this class is used as a workaround during serialization.
+ */
+ static class StoredInstant implements Serializable {
+ private final long seconds;
+ private final int nanos;
+
+ StoredInstant(FileTime time) {
+ Instant instant = time.toInstant();
+ this.seconds = instant.getEpochSecond();
+ this.nanos = instant.getNano();
+ }
+
+ public FileTime toFileTime() {
+ return FileTime.from(Instant.ofEpochSecond(seconds, nanos));
+ }
+ }
+
+ static class PathAwareInputStream extends ObjectInputStream {
+ private final FileSystem fileSystem;
+
+ PathAwareInputStream(FileSystem fileSystem, InputStream in) throws IOException {
+ super(in);
+ this.fileSystem = fileSystem;
+ enableResolveObject(true);
+ }
+
+ @Override
+ protected Object resolveObject(Object obj) {
+ return obj instanceof StoredPath
+ ? ((StoredPath) obj).toPath(fileSystem)
+ : obj instanceof StoredInstant ? ((StoredInstant) obj).toFileTime() : obj;
+ }
+ }
+
+ static class PathAwareOutputStream extends ObjectOutputStream {
+ PathAwareOutputStream(OutputStream os) throws IOException {
+ super(new BufferedOutputStream(os));
+ enableReplaceObject(true);
+ }
+
+ @Override
+ protected Object replaceObject(Object obj) {
+ return obj instanceof Path
+ ? new StoredPath((Path) obj)
+ : obj instanceof FileTime ? new StoredInstant((FileTime) obj) : obj;
+ }
+
+ @Override
+ protected void writeObjectOverride(Object obj) throws IOException {
+ super.writeObjectOverride(obj);
+ }
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInput.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInput.java
new file mode 100644
index 000000000000..bf89ebfdb4f5
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInput.java
@@ -0,0 +1,34 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.nio.file.Path;
+
+import org.apache.maven.api.build.context.Input;
+
+public class DefaultInput extends DefaultResource implements Input {
+ public DefaultInput(DefaultBuildContext context, DefaultBuildContextState state, Path resource) {
+ super(context, state, resource);
+ }
+
+ @Override
+ public DefaultOutput associateOutput(Path outputFile) {
+ return context.associate(this, context.processOutput(outputFile));
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputMetadata.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputMetadata.java
new file mode 100644
index 000000000000..0e60b0d8ad22
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputMetadata.java
@@ -0,0 +1,34 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.nio.file.Path;
+
+import org.apache.maven.api.build.context.Input;
+
+public class DefaultInputMetadata extends DefaultMetadata {
+ public DefaultInputMetadata(DefaultBuildContext context, DefaultBuildContextState state, Path resource) {
+ super(context, state, resource);
+ }
+
+ @Override
+ public DefaultInput process() {
+ return context.processInput(this);
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputSet.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputSet.java
new file mode 100644
index 000000000000..c61c651f13e6
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputSet.java
@@ -0,0 +1,83 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.Serializable;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.function.BinaryOperator;
+import java.util.function.Function;
+
+import org.apache.maven.api.build.context.Input;
+import org.apache.maven.api.build.context.InputSet;
+import org.apache.maven.api.build.context.Metadata;
+import org.apache.maven.api.build.context.Output;
+
+public class DefaultInputSet implements InputSet {
+
+ private final DefaultBuildContext context;
+
+ private final Set inputs = new LinkedHashSet<>();
+
+ DefaultInputSet(DefaultBuildContext context) {
+ this.context = context;
+ }
+
+ @Override
+ public void addInput(Metadata inputMetadata) {
+ if (!(inputMetadata instanceof DefaultInputMetadata)) {
+ throw new IllegalArgumentException("inputMetadata is not an instance of " + DefaultInputMetadata.class);
+ }
+ inputs.add((DefaultInputMetadata) inputMetadata);
+ }
+
+ @Override
+ public Metadata registerInput(Path inputFile) {
+ DefaultInputMetadata input = context.registerInput(inputFile);
+ inputs.add(input);
+ return input;
+ }
+
+ @Override
+ public Collection extends Metadata > registerInputs(
+ Path basedir, Collection includes, Collection excludes) {
+ Collection extends DefaultInputMetadata> newInputs = context.registerInputs(basedir, includes, excludes);
+ this.inputs.addAll(newInputs);
+ return newInputs;
+ }
+
+ @Override
+ public boolean aggregate(Path outputFile, BiConsumer> aggregator) {
+ return context.aggregate(inputs, outputFile, aggregator);
+ }
+
+ @Override
+ public boolean aggregate(
+ Path outputFile,
+ String stepId,
+ T identity,
+ Function mapper,
+ BinaryOperator accumulator,
+ BiConsumer writer) {
+ return context.aggregate(inputs, outputFile, stepId, identity, mapper, accumulator, writer);
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultMetadata.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultMetadata.java
new file mode 100644
index 000000000000..23de290eb653
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultMetadata.java
@@ -0,0 +1,73 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.nio.file.Path;
+import java.util.Objects;
+
+import org.apache.maven.api.build.context.Metadata;
+import org.apache.maven.api.build.context.Resource;
+import org.apache.maven.api.build.context.Status;
+
+public abstract class DefaultMetadata implements Metadata {
+ /** The build context that owns this metadata. */
+ protected final DefaultBuildContext context;
+ /** The build context state associated with this metadata. */
+ protected final DefaultBuildContextState state;
+ /** The path of the resource described by this metadata. */
+ protected final Path resource;
+
+ public DefaultMetadata(DefaultBuildContext context, DefaultBuildContextState state, Path resource) {
+ this.context = context;
+ this.state = state;
+ this.resource = resource;
+ }
+
+ @Override
+ public Path getPath() {
+ return resource;
+ }
+
+ @Override
+ public Status getStatus() {
+ return context.getResourceStatus(resource);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ DefaultMetadata> that = (DefaultMetadata>) o;
+ return context == that.context && state == that.state && resource.equals(that.resource);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(resource);
+ }
+
+ @Override
+ public String toString() {
+ return resource.toString();
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutput.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutput.java
new file mode 100644
index 000000000000..6b0807b7957f
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutput.java
@@ -0,0 +1,35 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.OutputStream;
+import java.nio.file.Path;
+
+import org.apache.maven.api.build.context.Output;
+
+public class DefaultOutput extends DefaultResource implements Output {
+ public DefaultOutput(DefaultBuildContext context, DefaultBuildContextState state, Path resource) {
+ super(context, state, resource);
+ }
+
+ @Override
+ public OutputStream newOutputStream() {
+ return context.newOutputStream(this);
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutputMetadata.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutputMetadata.java
new file mode 100644
index 000000000000..2068ecb3b45e
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutputMetadata.java
@@ -0,0 +1,34 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.nio.file.Path;
+
+import org.apache.maven.api.build.context.Output;
+
+public class DefaultOutputMetadata extends DefaultMetadata {
+ public DefaultOutputMetadata(DefaultBuildContext context, DefaultBuildContextState state, Path resource) {
+ super(context, state, resource);
+ }
+
+ @Override
+ public DefaultOutput process() {
+ return context.processOutput(this);
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultResource.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultResource.java
new file mode 100644
index 000000000000..20c9f360cec1
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultResource.java
@@ -0,0 +1,78 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.nio.file.Path;
+import java.util.Objects;
+
+import org.apache.maven.api.build.context.Resource;
+import org.apache.maven.api.build.context.Severity;
+import org.apache.maven.api.build.context.Status;
+
+public abstract class DefaultResource implements Resource {
+ /** The build context that owns this resource. */
+ protected final DefaultBuildContext context;
+ /** The build context state associated with this resource. */
+ protected final DefaultBuildContextState state;
+ /** The path of this resource. */
+ protected final Path resource;
+
+ public DefaultResource(DefaultBuildContext context, DefaultBuildContextState state, Path resource) {
+ this.context = context;
+ this.state = state;
+ this.resource = resource;
+ }
+
+ @Override
+ public Path getPath() {
+ return resource;
+ }
+
+ @Override
+ public Status getStatus() {
+ return context.getResourceStatus(resource);
+ }
+
+ @Override
+ public void addMessage(int line, int column, String message, Severity severity, Throwable cause) {
+ context.addMessage(getPath(), line, column, message, severity, cause);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ DefaultResource that = (DefaultResource) o;
+ return context == that.context && state == that.state && resource.equals(that.resource);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(resource);
+ }
+
+ @Override
+ public String toString() {
+ return resource.toString();
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileState.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileState.java
new file mode 100644
index 000000000000..bdcb96580e83
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileState.java
@@ -0,0 +1,100 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.util.Objects;
+
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.build.context.Status;
+
+public class FileState implements Serializable {
+
+ private static final long serialVersionUID = 1;
+
+ @Nonnull
+ private final Path path;
+
+ private final FileTime lastModified;
+ private final long size;
+
+ public FileState(@Nonnull Path path, FileTime lastModified, long size) {
+ this.path = Objects.requireNonNull(path, "path can not be null");
+ this.lastModified = lastModified;
+ this.size = size;
+ }
+
+ @Nonnull
+ public Path getPath() {
+ return path;
+ }
+
+ public FileTime getLastModified() {
+ return lastModified;
+ }
+
+ public long getSize() {
+ return size;
+ }
+
+ public Status getStatus() {
+ try {
+ if (!Files.isRegularFile(path) || !Files.isReadable(path)) {
+ return Status.REMOVED;
+ }
+ BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
+ if (size == attrs.size() && Objects.equals(lastModified, attrs.lastModifiedTime())) {
+ return Status.UNMODIFIED;
+ }
+ return Status.MODIFIED;
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(path, lastModified, size);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (!(obj instanceof FileState)) {
+ return false;
+ }
+ FileState other = (FileState) obj;
+ return Objects.equals(path, other.path)
+ && Objects.equals(lastModified, other.lastModified)
+ && size == other.size;
+ }
+
+ @Override
+ public String toString() {
+ return "FileState[path=" + path + ", lastModified=" + lastModified + ", size=" + size + "]";
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FilesystemWorkspace.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FilesystemWorkspace.java
new file mode 100644
index 000000000000..5a8361e08fcb
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FilesystemWorkspace.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.internal.build.context.impl;
+
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.build.context.Status;
+import org.apache.maven.api.build.context.spi.FileState;
+import org.apache.maven.api.build.context.spi.Workspace;
+import org.apache.maven.api.di.Named;
+
+@Named
+public class FilesystemWorkspace implements Workspace {
+
+ @Override
+ public Mode getMode() {
+ return Mode.NORMAL;
+ }
+
+ @Override
+ public Workspace escalate() {
+ return this;
+ }
+
+ @Override
+ public void deleteFile(Path file) {
+ try {
+ Files.deleteIfExists(file);
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ @Override
+ public void processOutput(Path path) {}
+
+ @Override
+ public OutputStream newOutputStream(Path path) {
+ try {
+ Files.createDirectories(path.getParent());
+ return new BufferedOutputStream(Files.newOutputStream(path));
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ @Override
+ public Status getResourceStatus(Path file, FileTime lastModified, long length) {
+ if (!isRegularFile(file) && !isDirectory(file)) {
+ return Status.REMOVED;
+ }
+ try {
+ BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class);
+ return Objects.equals(length, attrs.size()) && Objects.equals(lastModified, attrs.lastModifiedTime())
+ ? Status.UNMODIFIED
+ : Status.MODIFIED;
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ @Override
+ public boolean isPresent(Path file) {
+ return isRegularFile(file) && Files.isReadable(file);
+ }
+
+ @Override
+ public boolean isRegularFile(Path file) {
+ return Files.isRegularFile(file);
+ }
+
+ @Override
+ public boolean isDirectory(Path file) {
+ return Files.isDirectory(file);
+ }
+
+ @Override
+ public Stream walk(Path basedir) {
+ if (Files.isDirectory(basedir)) {
+ try {
+ return Files.walk(basedir).map(path -> new FileState(path, Status.NEW));
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ } else {
+ return Stream.empty();
+ }
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/AbstractBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/AbstractBuildContextTest.java
new file mode 100644
index 000000000000..a9d4e3bb64d5
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/AbstractBuildContextTest.java
@@ -0,0 +1,60 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.Serializable;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.maven.api.build.context.spi.Workspace;
+import org.junit.jupiter.api.io.TempDir;
+
+abstract class AbstractBuildContextTest {
+ /** Temporary directory for tests. */
+ @TempDir
+ @SuppressWarnings("checkstyle:VisibilityModifier")
+ protected Path temp;
+
+ protected static List toList(Iterable iterable) {
+ if (iterable == null) {
+ return null;
+ }
+
+ List result = new ArrayList();
+ for (T t : iterable) {
+ result.add(t);
+ }
+ return result;
+ }
+
+ protected TestBuildContext newBuildContext() {
+ return newBuildContext(Collections.emptyMap());
+ }
+
+ protected TestBuildContext newBuildContext(Map config) {
+ return new TestBuildContext(temp.resolve("buildstate.ctx"), config);
+ }
+
+ protected TestBuildContext newBuildContext(Workspace workspace) {
+ return new TestBuildContext(workspace, temp.resolve("buildstate.ctx"), Collections.emptyMap());
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java
new file mode 100644
index 000000000000..51cf8176e507
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.function.BiConsumer;
+
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.build.context.Input;
+import org.apache.maven.api.build.context.Output;
+import org.apache.maven.api.build.context.Resource;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class DefaultAggregatorBuildContextTest extends AbstractBuildContextTest {
+
+ @Test
+ void testBasic() throws Exception {
+ DefaultBuildContext.getCanonicalPath(Paths.get("/oo/bar"));
+
+ Path outputFile = temp.resolve("output");
+
+ Path basedir = Files.createTempDirectory(temp, "").toRealPath();
+ Path a = basedir.resolve("a");
+ Files.createFile(a);
+
+ // initial build
+ FileIndexer indexer = new FileIndexer();
+ DefaultBuildContext actx = newContext();
+ DefaultInputSet inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(1, indexer.outputs.size());
+ assertEquals(1, indexer.inputs.size());
+
+ // no-change rebuild
+ indexer = new FileIndexer();
+ actx = newContext();
+ inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(0, indexer.outputs.size());
+
+ // no-change rebuild
+ indexer = new FileIndexer();
+ actx = newContext();
+
+ inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(0, indexer.outputs.size());
+
+ // new input
+ Path b = basedir.resolve("b");
+ Files.createFile(b);
+ indexer = new FileIndexer();
+ actx = newContext();
+ inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(1, indexer.outputs.size());
+
+ // removed input
+ Files.delete(a);
+ indexer = new FileIndexer();
+ actx = newContext();
+ inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(1, indexer.outputs.size());
+
+ // no-change rebuild
+ indexer = new FileIndexer();
+ actx = newContext();
+ inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(0, indexer.outputs.size());
+
+ // removed output
+ Files.delete(outputFile);
+ indexer = new FileIndexer();
+ actx = newContext();
+ inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(1, indexer.outputs.size());
+
+ // no-change rebuild
+ indexer = new FileIndexer();
+ actx = newContext();
+ inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(0, indexer.outputs.size());
+ }
+
+ private DefaultBuildContext newContext() {
+ Path stateFile = temp.resolve("buildstate.ctx");
+ return new DefaultBuildContext(new FilesystemWorkspace(), stateFile, new HashMap<>(), null);
+ }
+
+ @Test
+ void testEmpty() throws Exception {
+ Path outputFile = temp.resolve("output");
+ Path basedir = Files.createTempDirectory(temp, "");
+
+ FileIndexer indexer = new FileIndexer();
+ DefaultBuildContext actx = newContext();
+ DefaultInputSet output = actx.newInputSet();
+ output.registerInputs(basedir, null, null);
+ output.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(1, indexer.outputs.size());
+ }
+
+ @SuppressWarnings("checkstyle:VisibilityModifier")
+ private static class FileIndexer implements BiConsumer> {
+ final List inputs = new ArrayList<>();
+ final List outputs = new ArrayList<>();
+
+ @Override
+ public void accept(Output output, Collection inputCollection) {
+ outputs.add(output.getPath());
+ try (BufferedWriter w = output.newBufferedWriter(StandardCharsets.UTF_8)) {
+ for (Resource input : inputCollection) {
+ Path path = input.getPath();
+ this.inputs.add(path);
+ w.write(path.toAbsolutePath().toString());
+ w.newLine();
+ }
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBasicBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBasicBuildContextTest.java
new file mode 100644
index 000000000000..a1d3bf38db48
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBasicBuildContextTest.java
@@ -0,0 +1,57 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.Serializable;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class DefaultBasicBuildContextTest {
+ /** Temporary directory for tests. */
+ @TempDir
+ private Path temp;
+
+ private DefaultBuildContext newContext() {
+ Path stateFile = temp.resolve("buildstate.ctx");
+ return new DefaultBuildContext(new FilesystemWorkspace(), stateFile, new HashMap(), null);
+ }
+
+ @Test
+ void testDeletedOutput() throws Exception {
+ Path input = Files.createFile(temp.resolve("input"));
+ Path output = Files.createFile(temp.resolve("output"));
+
+ DefaultBuildContext ctx;
+
+ ctx = newContext();
+ ctx.registerInput(input);
+ ctx.processOutput(output);
+ ctx.commit(null);
+
+ Files.delete(output);
+
+ ctx = newContext();
+ ctx.registerInput(input);
+ ctx.commit(null);
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextStateTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextStateTest.java
new file mode 100644
index 000000000000..62af3292dccf
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextStateTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.ObjectOutputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Collections;
+import java.util.HashMap;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class DefaultBuildContextStateTest {
+ /** Temporary directory for tests. */
+ @TempDir
+ private Path temp;
+
+ @Test
+ void testRoundtrip() throws Exception {
+ Path file = Files.createTempFile(temp, "", "");
+ DefaultBuildContextState state = DefaultBuildContextState.withConfiguration(new HashMap<>());
+ BasicFileAttributes attrs = DefaultBuildContext.readAttributes(file);
+ state.putResource(file, new FileState(file, attrs.lastModifiedTime(), attrs.size()));
+
+ Path stateFile = Files.createTempFile(temp, "", "");
+ try (OutputStream os = Files.newOutputStream(stateFile)) {
+ state.storeTo(os);
+ }
+
+ state = DefaultBuildContextState.loadFrom(stateFile);
+
+ assertNotNull(state.getResource(file));
+ }
+
+ @Test
+ void testStateDoesNotExist() throws Exception {
+ DefaultBuildContextState state = DefaultBuildContextState.loadFrom(temp.resolve("does-not-exist"));
+ assertTrue(state.configuration.isEmpty());
+ }
+
+ @Test
+ void testEmptyState() throws Exception {
+ Path stateFile = Files.createTempFile(temp, "", "");
+ assertTrue(DefaultBuildContextState.loadFrom(stateFile).configuration.isEmpty());
+ }
+
+ @Test
+ void testCorruptedState() throws Exception {
+ Path corrupted = Files.createTempFile(temp, "", "");
+ Files.write(corrupted, Collections.singletonList("test"), StandardOpenOption.APPEND);
+ assertTrue(DefaultBuildContextState.loadFrom(corrupted).configuration.isEmpty());
+ }
+
+ @Test
+ void testIncompatibleState() throws Exception {
+ Path incompatible = Files.createTempFile(temp, "", "");
+ ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(incompatible));
+ oos.writeUTF("incompatible");
+ oos.close();
+ assertTrue(DefaultBuildContextState.loadFrom(incompatible).configuration.isEmpty());
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextTest.java
new file mode 100644
index 000000000000..5c43d08f59fe
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextTest.java
@@ -0,0 +1,605 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.Serializable;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import com.google.common.jimfs.Configuration;
+import com.google.common.jimfs.Jimfs;
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.build.context.Input;
+import org.apache.maven.api.build.context.Metadata;
+import org.apache.maven.api.build.context.Status;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import static org.apache.maven.api.build.context.Status.MODIFIED;
+import static org.apache.maven.api.build.context.Status.NEW;
+import static org.apache.maven.api.build.context.Status.UNMODIFIED;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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;
+
+class DefaultBuildContextTest extends AbstractBuildContextTest {
+
+ private static void assertIncludedPaths(Collection expected, Collection actual) throws IOException {
+ assertEquals(toString(expected), toString(actual));
+ }
+
+ private static String toString(Collection files) throws IOException {
+ return files.stream()
+ .map(DefaultBuildContext::normalize)
+ .map(Path::toString)
+ .sorted()
+ .collect(Collectors.joining("\n", "", "\n"));
+ }
+
+ @Test
+ void testRegisterInputInputFileDoesNotExist() throws Exception {
+ Path file = Paths.get("target/does_not_exist");
+ assertTrue(!Files.exists(file) && !Files.isReadable(file));
+ assertThrows(IllegalArgumentException.class, () -> newBuildContext().registerInput(file));
+ }
+
+ @Test
+ void testRegisterInput() throws Exception {
+ // this is NOT part of API but rather currently implemented behaviour
+ // API allows #registerInput return different instances
+
+ Path file = Paths.get("src/test/resources/simplelogger.properties");
+ assertTrue(Files.exists(file) && Files.isReadable(file));
+ TestBuildContext context = newBuildContext();
+ assertNotNull(context.registerInput(file));
+ assertNotNull(context.registerInput(file));
+ }
+
+ @Test
+ void testOutputWithoutInputs() throws Exception {
+ TestBuildContext context = newBuildContext();
+
+ Path outputFile = Files.createFile(temp.resolve("output_without_inputs"));
+ context.processOutput(outputFile);
+
+ // is not deleted by commit
+ context.commit();
+ assertTrue(Files.isReadable(outputFile));
+
+ // is not deleted after rebuild with re-registration
+ context = newBuildContext();
+ context.processOutput(outputFile);
+ context.commit();
+ assertTrue(Files.isReadable(outputFile));
+
+ // deleted after rebuild without re-registration
+ context = newBuildContext();
+ context.commit();
+ assertFalse(Files.isReadable(outputFile));
+ }
+
+ @Test
+ void testGetInputStatus() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+
+ // initial build
+ TestBuildContext context = newBuildContext();
+ // first time invocation returns Input for processing
+ assertEquals(NEW, context.registerInput(inputFile).getStatus());
+ // second invocation still returns NEW
+ assertEquals(NEW, context.registerInput(inputFile).getStatus());
+ context.commit();
+
+ // new build
+ context = newBuildContext();
+ // input file was not modified since last build
+ assertEquals(UNMODIFIED, context.registerInput(inputFile).getStatus());
+ context.commit();
+
+ // new build
+ Files.write(inputFile, Collections.singletonList("test"), StandardOpenOption.APPEND);
+ context = newBuildContext();
+ // input file was modified since last build
+ assertEquals(MODIFIED, context.registerInput(inputFile).getStatus());
+ }
+
+ @Test
+ void testInputModifiedAfterRegistration() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = Files.createFile(temp.resolve("outputFile"));
+
+ TestBuildContext context = newBuildContext();
+ DefaultInput input = context.registerInput(inputFile).process();
+ input.associateOutput(outputFile);
+ context.commit();
+
+ TestBuildContext context2 = newBuildContext();
+ context2.registerInput(inputFile);
+ // this is incorrect use of build-avoidance API
+ // input has changed after it was registered for processing
+ // IllegalStateException is raised to prevent unexpected process/not-process flip-flop
+ Files.write(inputFile, Collections.singletonList("test"), StandardOpenOption.APPEND);
+ assertThrows(BuildContextException.class, context2::commit);
+ assertTrue(Files.isReadable(outputFile));
+ }
+
+ @Test
+ void testCommitOrphanedOutputsCleanup() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = Files.createFile(temp.resolve("outputFile"));
+
+ TestBuildContext context = newBuildContext();
+ DefaultInput input = context.registerInput(inputFile).process();
+ input.associateOutput(outputFile);
+ context.commit();
+
+ // input is not part of input set any more
+ // associated output must be cleaned up
+ context = newBuildContext();
+ context.commit();
+ assertFalse(Files.isReadable(outputFile));
+ }
+
+ @Test
+ void testCommitStaleOutputCleanup() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile1 = Files.createFile(temp.resolve("outputFile1"));
+ Path outputFile2 = Files.createFile(temp.resolve("outputFile2"));
+
+ TestBuildContext context = newBuildContext();
+ DefaultInput input = context.registerInput(inputFile).process();
+ input.associateOutput(outputFile1);
+ input.associateOutput(outputFile2);
+ context.commit();
+
+ context = newBuildContext();
+ input = context.registerInput(inputFile).process();
+ input.associateOutput(outputFile1);
+ context.commit();
+ assertFalse(Files.isReadable(outputFile2));
+
+ context = newBuildContext();
+ DefaultInputMetadata metadata = context.registerInput(inputFile);
+ assertEquals(1, toList(context.getAssociatedOutputs(metadata)).size());
+ context.commit();
+ }
+
+ @Test
+ void testCreateStateParentDirectory() throws Exception {
+ Path stateFile = temp.resolve("sub/dir/buildstate.ctx");
+ TestBuildContext context = new TestBuildContext(stateFile, Collections.emptyMap());
+ context.commit();
+ assertTrue(Files.isReadable(stateFile));
+ }
+
+ @Test
+ void testRegisterInputNullInput() throws Exception {
+ assertThrows(IllegalArgumentException.class, () -> newBuildContext().registerInput((Path) null));
+ }
+
+ @ParameterizedTest
+ @MethodSource("fileSystems")
+ void testRegisterAndProcessInputs(String type, Supplier fs) throws Exception {
+ Path target = fs.get().getPath("target");
+ Files.createDirectories(target);
+ temp = Files.createTempDirectory(target, "tmp");
+
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = Files.createFile(temp.resolve("outputFile"));
+ List includes = Collections.singletonList("**/" + inputFile.getFileName());
+
+ TestBuildContext context = newBuildContext();
+ List extends DefaultInput> inputs = toList(context.registerAndProcessInputs(temp, includes, null));
+ assertEquals(1, inputs.size());
+ assertEquals(NEW, inputs.get(0).getStatus());
+ inputs.get(0).associateOutput(outputFile);
+ context.commit();
+
+ // no change rebuild
+ context = newBuildContext();
+ inputs = toList(context.registerAndProcessInputs(temp, includes, null));
+ assertEquals(1, inputs.size());
+ assertEquals(UNMODIFIED, inputs.get(0).getStatus());
+ context.commit();
+ }
+
+ @Test
+ void testInputDeleted() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = Files.createFile(temp.resolve("outputFile"));
+
+ TestBuildContext context = newBuildContext();
+ context.registerInput(inputFile).process().associateOutput(outputFile);
+ context.commit();
+
+ //
+ Files.delete(inputFile);
+ context = newBuildContext();
+ context.commit();
+ assertFalse(Files.exists(outputFile));
+ }
+
+ @Test
+ void testInputInListDeleted() throws Exception {
+ Path input = temp.resolve("input");
+ Path output = temp.resolve("output");
+ Files.createDirectories(input);
+ Files.createDirectories(output);
+
+ Path inputFile = Files.createFile(input.resolve("file"));
+ Path outputFile = Files.createFile(output.resolve("file"));
+
+ TestBuildContext context = newBuildContext();
+ Collection extends Input> inputs = context.registerAndProcessInputs(input, null, null);
+ assertNotNull(inputs);
+ assertFalse(inputs.isEmpty());
+ Input i = inputs.iterator().next();
+ i.associateOutput(outputFile);
+ context.commit();
+
+ //
+ Files.delete(inputFile);
+ context = newBuildContext();
+ context.commit();
+ assertFalse(Files.exists(outputFile));
+ }
+
+ @Test
+ void testGetAssociatedOutputs() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = Files.createFile(temp.resolve("outputFile"));
+
+ TestBuildContext context = newBuildContext();
+ context.registerInput(inputFile).process().associateOutput(outputFile);
+ context.commit();
+
+ //
+ context = newBuildContext();
+ DefaultInputMetadata metadata = context.registerInput(inputFile);
+ List extends Metadata> outputs = toList(context.getAssociatedOutputs(metadata));
+ assertEquals(1, outputs.size());
+ assertEquals(Status.UNMODIFIED, outputs.get(0).getStatus());
+ context.commit();
+
+ //
+ Files.write(outputFile, Collections.singletonList("test"), StandardOpenOption.APPEND);
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ outputs = toList(context.getAssociatedOutputs(metadata));
+ assertEquals(1, outputs.size());
+ assertEquals(Status.MODIFIED, outputs.get(0).getStatus());
+ context.commit();
+
+ //
+ Files.delete(outputFile);
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ outputs = toList(context.getAssociatedOutputs(metadata));
+ assertEquals(1, outputs.size());
+ assertEquals(Status.REMOVED, outputs.get(0).getStatus());
+ context.commit();
+ }
+
+ @Test
+ void testGetRegisteredInputs() throws Exception {
+ Path inputFile1 = Files.createFile(temp.resolve("inputFile1"));
+ Path inputFile2 = Files.createFile(temp.resolve("inputFile2"));
+ Path inputFile3 = Files.createFile(temp.resolve("inputFile3"));
+ Path inputFile4 = Files.createFile(temp.resolve("inputFile4"));
+
+ TestBuildContext context = newBuildContext();
+ inputFile1 = context.registerInput(inputFile1).getPath();
+ inputFile2 = context.registerInput(inputFile2).getPath();
+ inputFile3 = context.registerInput(inputFile3).getPath();
+ context.commit();
+
+ Files.write(inputFile3, Collections.singletonList("test"), StandardOpenOption.APPEND);
+
+ context = newBuildContext();
+
+ // context.registerInput(inputFile1); DELETED
+ context.registerInput(inputFile2); // UNMODIFIED
+ context.registerInput(inputFile3); // MODIFIED
+ inputFile4 = context.registerInput(inputFile4).getPath(); // NEW
+
+ Map inputs = new TreeMap<>();
+ for (Metadata input : context.getRegisteredInputs()) {
+ inputs.put(input.getPath(), input);
+ }
+
+ assertEquals(4, inputs.size());
+ // assertEquals(ResourceStatus.REMOVED, inputs.get(inputFile1).getStatus());
+ assertEquals(Status.UNMODIFIED, inputs.get(inputFile2).getStatus());
+ assertEquals(Status.MODIFIED, inputs.get(inputFile3).getStatus());
+ assertEquals(Status.NEW, inputs.get(inputFile4).getStatus());
+ }
+
+ @Test
+ void testInputAttributes() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+
+ TestBuildContext context = newBuildContext();
+ DefaultInputMetadata metadata = context.registerInput(inputFile);
+ assertNull(context.getAttribute(metadata, "key", String.class));
+ DefaultInput input = metadata.process();
+ assertNull(context.setAttribute(input, "key", "value"));
+ context.commit();
+
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ assertEquals("value", context.getAttribute(metadata, "key", String.class));
+ context.commit();
+
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ assertEquals("value", context.getAttribute(metadata, "key", String.class));
+ input = metadata.process();
+ assertNull(context.getAttribute(input, "key", String.class));
+ assertEquals("value", context.setAttribute(input, "key", "newValue"));
+ assertEquals("value", context.setAttribute(input, "key", "newValue"));
+ assertEquals("newValue", context.getAttribute(input, "key", String.class));
+ context.commit();
+
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ assertEquals("newValue", context.getAttribute(metadata, "key", String.class));
+ context.commit();
+ }
+
+ @Test
+ void testOutputStatus() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = temp.resolve("outputFile");
+
+ assertFalse(Files.isReadable(outputFile));
+
+ TestBuildContext context = newBuildContext();
+ DefaultOutput output = context.registerInput(inputFile).process().associateOutput(outputFile);
+ assertEquals(Status.NEW, output.getStatus());
+ output.newOutputStream().close();
+ context.commit();
+
+ // no-change rebuild
+ context = newBuildContext();
+ output = context.registerInput(inputFile).process().associateOutput(outputFile);
+ assertEquals(Status.UNMODIFIED, output.getStatus());
+ context.commit();
+
+ // modified output
+ Files.write(outputFile, Collections.singletonList("test"));
+ context = newBuildContext();
+ output = context.registerInput(inputFile).process().associateOutput(outputFile);
+ assertEquals(Status.MODIFIED, output.getStatus());
+ context.commit();
+
+ // no-change rebuild
+ context = newBuildContext();
+ output = context.registerInput(inputFile).process().associateOutput(outputFile);
+ assertEquals(Status.UNMODIFIED, output.getStatus());
+ context.commit();
+
+ // deleted output
+ Files.delete(outputFile);
+ context = newBuildContext();
+ output = context.registerInput(inputFile).process().associateOutput(outputFile);
+ assertEquals(Status.REMOVED, output.getStatus());
+ output.newOutputStream().close(); // processed outputs must exit or commit fails
+ context.commit();
+ }
+
+ @Test
+ void testStateSerializationUseTCCL() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+
+ TestBuildContext context = newBuildContext();
+
+ URL dummyJar = new File("src/test/projects/dummy/dummy-1.0.jar").toURI().toURL();
+ ClassLoader tccl = new URLClassLoader(new URL[] {dummyJar});
+ ClassLoader origTCCL = Thread.currentThread().getContextClassLoader();
+ try {
+ Thread.currentThread().setContextClassLoader(tccl);
+
+ Object dummy = tccl.loadClass("dummy.Dummy").newInstance();
+
+ DefaultResource input = context.registerInput(inputFile).process();
+ context.setAttribute(input, "dummy", (Serializable) dummy);
+ context.commit();
+
+ context = newBuildContext();
+ assertFalse(context.isEscalated());
+ assertNotNull(context.getAttribute(context.registerInput(inputFile), "dummy", Serializable.class));
+ // no commit
+ } finally {
+ Thread.currentThread().setContextClassLoader(origTCCL);
+ }
+
+ // sanity check, make sure empty state is loaded without proper TCCL
+ context = newBuildContext();
+ assertTrue(context.isEscalated());
+ }
+
+ @Test
+ void testConfigurationChange() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("input"));
+ Path outputFile = Files.createFile(temp.resolve("output"));
+ Path looseOutputFile = Files.createFile(temp.resolve("looseOutputFile"));
+
+ TestBuildContext context = newBuildContext();
+ context.registerInput(inputFile).process().associateOutput(outputFile);
+ context.processOutput(looseOutputFile);
+ context.commit();
+
+ context = newBuildContext(Collections.singletonMap("config", "parameter"));
+ DefaultInputMetadata metadata = context.registerInput(inputFile);
+ assertEquals(Status.MODIFIED, metadata.getStatus());
+ DefaultInput input = metadata.process();
+ assertEquals(Status.MODIFIED, input.getStatus());
+ DefaultOutput output = input.associateOutput(outputFile);
+ assertEquals(Status.MODIFIED, output.getStatus());
+ DefaultOutput looseOutput = context.processOutput(looseOutputFile);
+ assertEquals(Status.MODIFIED, looseOutput.getStatus());
+ }
+
+ @Test
+ void testRegisterInputsIncludesExcludes() throws Exception {
+ Files.createDirectory(temp.resolve("folder"));
+ Path f1 = Files.createFile(temp.resolve("input1.txt"));
+ Path f2 = Files.createFile(temp.resolve("folder/input2.txt"));
+ Path f3 = Files.createFile(temp.resolve("folder/input3.log"));
+
+ TestBuildContext context = newBuildContext();
+ List actual;
+
+ actual = toFileList(context.registerInputs(temp, null, Collections.singletonList("**")));
+ assertIncludedPaths(Collections.emptyList(), actual);
+
+ actual = toFileList(context.registerInputs(temp, null, null));
+ assertIncludedPaths(Arrays.asList(f1, f2, f3), actual);
+
+ actual = toFileList(context.registerInputs(temp, Collections.singletonList("**/*.txt"), null));
+ assertIncludedPaths(Arrays.asList(f1, f2), actual);
+
+ actual = toFileList(
+ context.registerInputs(temp, Collections.singletonList("**"), Collections.singletonList("**/*.log")));
+ assertIncludedPaths(Arrays.asList(f1, f2), actual);
+ }
+
+ @Test
+ void testRegisterInputsDirectoryMatching() throws Exception {
+ Files.createDirectory(temp.resolve("folder"));
+ Files.createDirectory(temp.resolve("folder/subfolder"));
+ Path f1 = Files.createFile(temp.resolve("input1.txt"));
+ Path f2 = Files.createFile(temp.resolve("folder/input2.txt"));
+ Path f3 = Files.createFile(temp.resolve("folder/subfolder/input3.txt"));
+
+ TestBuildContext context = newBuildContext();
+ List actual;
+
+ // from http://ant.apache.org/manual/dirtasks.html#patterns
+ // When ** is used as the name of a directory in the pattern, it matches zero or more
+ // directories.
+
+ actual = toFileList(context.registerInputs(temp, Collections.singletonList("**/*.txt"), null));
+ assertIncludedPaths(Arrays.asList(f1, f2, f3), actual);
+
+ actual = toFileList(context.registerInputs(temp, Collections.singletonList("folder/**/*.txt"), null));
+ assertIncludedPaths(Arrays.asList(f2, f3), actual);
+
+ actual = toFileList(context.registerInputs(temp, Collections.singletonList("folder/*.txt"), null));
+ assertIncludedPaths(Collections.singletonList(f2), actual);
+
+ // / is a shortcut for /**
+ actual = toFileList(context.registerInputs(temp, Collections.singletonList("/"), null));
+ assertIncludedPaths(Arrays.asList(f1, f2, f3), actual);
+ actual = toFileList(context.registerInputs(temp, Collections.singletonList("folder/"), null));
+ assertIncludedPaths(Arrays.asList(f2, f3), actual);
+
+ // leading / does not matter
+ actual = toFileList(context.registerInputs(temp, Collections.singletonList("/folder/"), null));
+ assertIncludedPaths(Arrays.asList(f2, f3), actual);
+ }
+
+ private List toFileList(Iterable extends DefaultMetadata> inputs) {
+ List files = new ArrayList<>();
+ for (DefaultMetadata input : inputs) {
+ files.add(input.getPath());
+ }
+ return files;
+ }
+
+ @Test
+ void testClosedContext() throws Exception {
+ TestBuildContext context = newBuildContext();
+
+ context.commit();
+ assertThrows(IllegalStateException.class, () -> context.registerInput(Files.createTempFile(temp, "", "")));
+ assertThrows(IllegalStateException.class, () -> context.processOutput(Files.createTempFile(temp, "", "")));
+ }
+
+ @Test
+ void testSkipExecution() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = Files.createFile(temp.resolve("outputFile"));
+
+ TestBuildContext context = newBuildContext();
+ DefaultInput input = context.registerInput(inputFile).process();
+ input.associateOutput(outputFile);
+ context.commit();
+
+ // make a change
+ Files.write(inputFile, Collections.singletonList("test"), StandardOpenOption.APPEND);
+
+ // skip execution
+ context = newBuildContext();
+ context.markSkipExecution();
+ context.commit();
+ assertTrue(Files.isReadable(outputFile));
+
+ //
+ context = newBuildContext();
+ DefaultInputMetadata inputMetadata = context.registerInput(inputFile);
+ assertEquals(Status.MODIFIED, inputMetadata.getStatus());
+ inputMetadata.process();
+ context.commit();
+ assertFalse(Files.isReadable(outputFile));
+ }
+
+ @Test
+ void testSkipExecutionModifiedContext() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = Files.createFile(temp.resolve("outputFile"));
+
+ TestBuildContext context = newBuildContext();
+ DefaultInput input = context.registerInput(inputFile).process();
+ input.associateOutput(outputFile);
+
+ assertThrows(IllegalStateException.class, context::markSkipExecution);
+ }
+
+ static Stream fileSystems() {
+ return Stream.of(
+ Arguments.of("Windows", (Supplier) () -> Jimfs.newFileSystem(Configuration.windows())),
+ Arguments.of("Unix", (Supplier) () -> Jimfs.newFileSystem(Configuration.unix())),
+ Arguments.of("MacOS", (Supplier) () -> Jimfs.newFileSystem(Configuration.osX())),
+ Arguments.of("Native", (Supplier) FileSystems::getDefault));
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultOutputTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultOutputTest.java
new file mode 100644
index 000000000000..e62f0e3cded7
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultOutputTest.java
@@ -0,0 +1,52 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+
+import org.apache.maven.api.build.context.Output;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class DefaultOutputTest {
+
+ /** Temporary directory for tests. */
+ @TempDir
+ private Path temp;
+
+ private TestBuildContext newBuildContext() {
+ Path stateFile = temp.resolve("buildstate.ctx");
+ return new TestBuildContext(stateFile, Collections.emptyMap());
+ }
+
+ @Test
+ void testOutputStreamCreateParentDirectories() throws Exception {
+ Path outputFile = temp.resolve("sub/dir/outputFile");
+
+ TestBuildContext context = newBuildContext();
+ Output output = context.processOutput(outputFile);
+ output.newOutputStream().close();
+
+ assertTrue(Files.isReadable(outputFile));
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DeltaWorkspaceTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DeltaWorkspaceTest.java
new file mode 100644
index 000000000000..de50ea454fa5
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DeltaWorkspaceTest.java
@@ -0,0 +1,289 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.build.context.Metadata;
+import org.apache.maven.api.build.context.Status;
+import org.apache.maven.api.build.context.spi.FileState;
+import org.apache.maven.api.build.context.spi.Workspace;
+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;
+
+class DeltaWorkspaceTest extends AbstractBuildContextTest {
+
+ static void touch(Path path) throws InterruptedException {
+ if (!Files.isRegularFile(path)) {
+ throw new IllegalArgumentException("Not a file " + path);
+ } else {
+ File file = path.toFile();
+ long lastModified = file.lastModified();
+ file.setLastModified(System.currentTimeMillis());
+ if (lastModified == file.lastModified()) {
+ Thread.sleep(1000L);
+ file.setLastModified(System.currentTimeMillis());
+ }
+ }
+ }
+
+ @Test
+ void testGetRegisteredInputs() throws Exception {
+ DeltaWorkspace workspace;
+ TestBuildContext ctx;
+
+ // initial build
+ ctx = newBuildContext();
+ Path basedir = Files.createDirectory(temp.resolve("basedir"));
+ Path a = Files.createFile(temp.resolve("basedir/a"));
+ assertEquals(
+ 1, toList(ctx.registerAndProcessInputs(basedir, null, null)).size());
+ assertEquals(1, toList(ctx.getRegisteredInputs()).size());
+ ctx.commit();
+
+ // no change rebuild
+ workspace = new DeltaWorkspace();
+ ctx = newBuildContext(workspace);
+ assertEquals(
+ 1, toList(ctx.registerAndProcessInputs(basedir, null, null)).size());
+ assertEquals(1, toList(ctx.getRegisteredInputs()).size());
+ assertEquals(0, toList(ctx.getProcessedResources()).size());
+ ctx.commit();
+
+ // add input
+ workspace = new DeltaWorkspace();
+ Path b = Files.createFile(temp.resolve("basedir/b"));
+ workspace.added.add(DefaultBuildContext.normalize(b));
+ ctx = newBuildContext(workspace);
+ assertEquals(
+ 2, toList(ctx.registerAndProcessInputs(basedir, null, null)).size());
+ assertEquals(2, toList(ctx.getRegisteredInputs()).size());
+ assertEquals(1, toList(ctx.getProcessedResources()).size());
+ ctx.commit();
+
+ // modify input
+ workspace = new DeltaWorkspace();
+ workspace.modified.add(DefaultBuildContext.normalize(a));
+ ctx = newBuildContext(workspace);
+ assertEquals(
+ 2, toList(ctx.registerAndProcessInputs(basedir, null, null)).size());
+ assertEquals(2, toList(ctx.getRegisteredInputs()).size());
+ assertEquals(1, toList(ctx.getProcessedResources()).size());
+ ctx.commit();
+
+ // remove input
+ workspace = new DeltaWorkspace();
+ Files.delete(a);
+ workspace.removed.add(DefaultBuildContext.normalize(a));
+ ctx = newBuildContext(workspace);
+ assertEquals(
+ 2, toList(ctx.registerAndProcessInputs(basedir, null, null)).size());
+ assertEquals(2, toList(ctx.getRegisteredInputs()).size());
+ assertEquals(0, toList(ctx.getProcessedResources()).size());
+ ctx.commit();
+ }
+
+ @Test
+ void testResourceStatus() throws Exception {
+ Path basedir = Files.createDirectory(temp.resolve("basedir"));
+
+ DeltaWorkspace workspace;
+ TestBuildContext ctx;
+
+ // initial build
+ newBuildContext().commit();
+
+ // new input
+ workspace = new DeltaWorkspace();
+ Path a = DefaultBuildContext.normalize(Files.createFile(temp.resolve("basedir/a")));
+ workspace.added.add(a);
+ ctx = newBuildContext(workspace);
+ Metadata input = only(ctx.registerInputs(basedir, null, null));
+ assertEquals(Status.NEW, input.getStatus());
+ input.process();
+ ctx.commit();
+
+ // no-change rebuild
+ workspace = new DeltaWorkspace();
+ ctx = newBuildContext(workspace);
+ input = only(ctx.registerInputs(basedir, null, null));
+ assertEquals(Status.UNMODIFIED, input.getStatus());
+ ctx.commit();
+
+ // modified input
+ workspace = new DeltaWorkspace();
+ touch(a);
+ workspace.modified.add(a);
+ ctx = newBuildContext(workspace);
+ input = only(ctx.registerInputs(basedir, null, null));
+ assertEquals(Status.MODIFIED, input.getStatus());
+ input.process();
+ ctx.commit();
+
+ // removed input
+ workspace = new DeltaWorkspace();
+ Files.delete(a);
+ workspace.removed.add(a);
+ ctx = newBuildContext(workspace);
+ assertEquals(1, toList(ctx.registerInputs(basedir, null, null)).size());
+ assertEquals(Status.REMOVED, ctx.getResourceStatus(a));
+ input.process();
+ ctx.commit();
+ }
+
+ @Test
+ void testCarryOverAndCleanup() throws Exception {
+ Files.createDirectory(temp.resolve("basedir"));
+ Path inputdir = Files.createDirectory(temp.resolve("basedir/inputdir"));
+ Path outputdir = Files.createDirectory(temp.resolve("basedir/outputdir"));
+ Path file = Files.createFile(temp.resolve("basedir/inputdir/file.txt"));
+
+ DeltaWorkspace workspace;
+ TestBuildContext ctx;
+ Collection extends DefaultInput> inputs;
+
+ // initial build
+ ctx = newBuildContext();
+ inputs = ctx.registerAndProcessInputs(inputdir, null, null);
+ assertEquals(1, inputs.size());
+ inputs.iterator().next().associateOutput(Files.createFile(temp.resolve("basedir/outputdir/file.out")));
+ ctx.commit();
+
+ // no-change rebuild
+ workspace = new DeltaWorkspace();
+ ctx = newBuildContext(workspace);
+ ctx.registerAndProcessInputs(inputdir, null, null);
+ ctx.commit();
+ assertTrue(Files.exists(outputdir.resolve("file.out")));
+
+ // "delete" input
+ workspace = new DeltaWorkspace();
+ workspace.removed.add(DefaultBuildContext.normalize(file));
+ ctx = newBuildContext(workspace);
+ ctx.registerAndProcessInputs(inputdir, null, null);
+ ctx.commit();
+ assertFalse(Files.exists(outputdir.resolve("file.out")));
+ }
+
+ private T only(Iterable values) {
+ List list = toList(values);
+ assertEquals(1, list.size());
+ return list.get(0);
+ }
+
+ @SuppressWarnings("checkstyle:VisibilityModifier")
+ private static class DeltaWorkspace implements Workspace {
+
+ final Set added = new HashSet<>();
+ final Set modified = new HashSet<>();
+ final Set removed = new HashSet<>();
+
+ @Override
+ public Mode getMode() {
+ return Mode.DELTA;
+ }
+
+ @Override
+ public Workspace escalate() {
+ return new FilesystemWorkspace() {
+ @Override
+ public Mode getMode() {
+ return Mode.ESCALATED;
+ }
+ };
+ }
+
+ @Override
+ public boolean isPresent(Path file) {
+ return Files.exists(file);
+ }
+
+ @Override
+ public boolean isRegularFile(Path file) {
+ return Files.isRegularFile(file);
+ }
+
+ @Override
+ public boolean isDirectory(Path file) {
+ return Files.isDirectory(file);
+ }
+
+ @Override
+ public void deleteFile(Path file) {
+ try {
+ if (Files.exists(file)) {
+ Files.delete(file);
+ }
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ @Override
+ public void processOutput(Path path) {}
+
+ @Override
+ public OutputStream newOutputStream(Path path) {
+ try {
+ return new BufferedOutputStream(Files.newOutputStream(path));
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ @Override
+ public Status getResourceStatus(Path file, FileTime lastModified, long size) {
+ // delta workspace returns resource status compared to the previous build
+ if (added.contains(file)) {
+ return Status.NEW;
+ }
+ if (modified.contains(file)) {
+ return Status.MODIFIED;
+ }
+ if (removed.contains(file)) {
+ return Status.REMOVED;
+ }
+ return Status.UNMODIFIED;
+ }
+
+ @Override
+ public Stream walk(Path basedir) {
+ return Stream.concat(
+ Stream.concat(
+ added.stream().map(p -> new FileState(p, Status.NEW)),
+ modified.stream().map(p -> new FileState(p, Status.MODIFIED))),
+ removed.stream().map(p -> new FileState(p, Status.REMOVED)));
+ }
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/ResourceMessagesTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/ResourceMessagesTest.java
new file mode 100644
index 000000000000..657b6396921d
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/ResourceMessagesTest.java
@@ -0,0 +1,167 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.maven.api.build.context.Severity;
+import org.apache.maven.api.build.context.spi.Message;
+import org.apache.maven.api.build.context.spi.Sink;
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ResourceMessagesTest {
+ /** Temporary directory for tests. */
+ @TempDir
+ private Path temp;
+
+ private TestBuildContext newBuildContext() throws IOException {
+ return new TestBuildContext();
+ }
+
+ @Test
+ void testInputMessages() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+
+ // initial message
+ TestBuildContext context = newBuildContext();
+ DefaultInputMetadata metadata = context.registerInput(inputFile);
+ inputFile = metadata.getPath();
+ DefaultInput input = metadata.process();
+ input.addMessage(0, 0, "message", Severity.WARNING, null);
+ context.commit();
+
+ // the message is retained during no-change rebuild
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ List