Skip to content

Build report: structured JSON report with per-mojo log capture - #12695

Open
gnodet wants to merge 1 commit into
feature/logging-foundationfrom
feature/build-report
Open

gnodet wants to merge 1 commit into
feature/logging-foundationfrom
feature/build-report

Conversation

@gnodet

@gnodet gnodet commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Part 2 of the logging feature chain (depends on #12694 — logging foundation).

Adds a structured build report that captures per-module and per-mojo execution results, timing, log events, and failures as a JSON file at the end of every build. Also adds BuildEnvironment to the Maven API, exposed via Session.buildEnvironment() and frozen in BuildReport.environment().

What's in this PR

Layer Files Description
API BuildReport, BuildStatus, ModuleReport, MojoReport, FailureReport Immutable interfaces for the report data model
API BuildEnvironment New interface in o.a.m.api capturing the invocation context
API Session.buildEnvironment() Live access to build environment for plugins
Impl DefaultBuildReport, DefaultModuleReport, DefaultMojoReport, DefaultFailureReport, DefaultBuildEnvironment Record implementations
Collector BuildReportCollector EventSpy that tracks lifecycle events and captures log output via LogEventSink, routing to mojo/module/build-level buffers
Writer BuildReportJsonWriter Zero-dependency JSON serializer with stable field order
Tests 3 test classes, 17+ tests Unit tests for collector, JSON writer, and integration

Key design decisions

  • EventSpy pattern: BuildReportCollector is a @Named @Singleton that extends AbstractEventSpy, discovered automatically — no wiring changes needed
  • Thread-based log routing: Uses ConcurrentHashMap<Long, String> (thread ID → mojo/project key) to associate log events with the correct scope in parallel builds
  • Dual sink architecture: Uses LogEventSink (4-arg) independently from the existing LogSink (5-arg) used by ProjectBuildLogAppender — no interference with console output
  • Atomic writes: Reports are written to a temp file, then atomic-moved into place with a timestamped filename and a build-report-latest.json symlink
  • Defensive: onSessionEnded wraps report generation in try-catch so report failures never crash the build

BuildEnvironment

BuildEnvironment (in o.a.m.api) captures the full invocation context at SessionStarted:

  • Goals, user properties (sensitive keys redacted: password, token, secret, passphrase, apikey), curated system info (OS, JVM, Maven home, available processors)
  • Local repository path, active profiles (explicit -P only), selected projects (-pl), resume-from (-rf)
  • Reactor failure behavior, offline, update-snapshots, no-transfer-progress, batch mode, thread count

Session.buildEnvironment() gives plugins live access to the same data. BuildReport.environment() carries a frozen snapshot in the JSON output.

Also fixes a gap in MavenExecutionRequest: noTransferProgress was consumed by MavenInvoker to pick a TransferListener but never stored on the request. Added isNoTransferProgress() / setNoTransferProgress().

What's NOT in this PR (deferred to later PRs)

PR chain

# PR Feature
1 #12694 Logging foundation
2 This PR Build report + BuildEnvironment
3 #13180 Console modes
4 #12698 Warning mode + diagnostics
5 #12699 mvnlog viewer
6 #12702 Structured problems pipeline
7 #12714 TRACE level migration

Test plan

  • 17+ new unit/integration tests pass
  • Sensitive user property keys are redacted in BuildEnvironment
  • Full Maven test suite passes (same results as base branch)
  • CI validation

@gnodet
gnodet force-pushed the feature/logging-foundation branch from bae1db9 to 5a5af1e Compare August 8, 2026 01:19
@gnodet
gnodet force-pushed the feature/build-report branch from 602fffb to 056dcf0 Compare August 8, 2026 01:23
gnodet added a commit that referenced this pull request Aug 8, 2026
Add the --console CLI flag with four output modes:
- plain: compact one-line-per-module output for CI
- rich: JLine status bar with live reactor progress
- verbose: full mojo-level output (current default)
- machine: JSON lines for piping to external tools

Part 3 of the #12572 split (depends on build report PR #12695).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gnodet
gnodet force-pushed the feature/build-report branch from 056dcf0 to a1ee585 Compare August 8, 2026 05:35
@gnodet
gnodet force-pushed the feature/logging-foundation branch from e64db31 to de8044a Compare August 8, 2026 12:14
@gnodet
gnodet force-pushed the feature/build-report branch from a1ee585 to 0f31ce2 Compare August 8, 2026 12:14
gnodet added a commit that referenced this pull request Aug 8, 2026
Add the --console CLI flag with four output modes:
- plain: compact one-line-per-module output for CI
- rich: JLine status bar with live reactor progress
- verbose: full mojo-level output (current default)
- machine: JSON lines for piping to external tools

Part 3 of the #12572 split (depends on build report PR #12695).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
gnodet added a commit that referenced this pull request Aug 8, 2026
Add the --console CLI flag with four output modes:
- plain: compact one-line-per-module output for CI
- rich: JLine status bar with live reactor progress
- verbose: full mojo-level output (current default)
- machine: JSON lines for piping to external tools

Part 3 of the #12572 split (depends on build report PR #12695).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gnodet
gnodet force-pushed the feature/build-report branch from 8083c89 to 170bd72 Compare August 8, 2026 19:35
gnodet added a commit that referenced this pull request Aug 8, 2026
Add the --console CLI flag with four output modes:
- plain: compact one-line-per-module output for CI
- rich: JLine status bar with live reactor progress
- verbose: full mojo-level output (current default)
- machine: JSON lines for piping to external tools

Part 3 of the #12572 split (depends on build report PR #12695).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gnodet
gnodet force-pushed the feature/build-report branch from 170bd72 to af0cc72 Compare August 8, 2026 21:48
gnodet added a commit that referenced this pull request Aug 8, 2026
Add the --console CLI flag with four output modes:
- plain: compact one-line-per-module output for CI
- rich: JLine status bar with live reactor progress
- verbose: full mojo-level output (current default)
- machine: JSON lines for piping to external tools

Part 3 of the #12572 split (depends on build report PR #12695).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gnodet
gnodet force-pushed the feature/logging-foundation branch from 8baa65a to 02ac855 Compare August 9, 2026 08:11
@gnodet
gnodet force-pushed the feature/build-report branch from af0cc72 to 3658983 Compare August 9, 2026 08:11
@gnodet
gnodet marked this pull request as ready for review August 9, 2026 08:11

@gnodet gnodet left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Well-designed build report feature with clean API/impl separation and comprehensive tests. A few issues noted below.

Also noted:

  • The architecture is solid: clean API interfaces in maven-api-core, record-based implementations in maven-core, EventSpy pattern for automatic discovery, thread-based log routing for parallel-build safety, atomic file writes with symlink swap.
  • The PR correctly depends on PR #12694 (logging foundation) — should not be merged until #12694 lands.
  • No test for the captureLogEvent routing logic (mojo-level vs module-level vs build-level buffers). This is the core routing mechanism and warrants at least one test exercising the dispatch.

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

Claude Code on behalf of gnodet

* Maximum number of log events captured per scope (mojo, module, or build).
* Beyond this, events are dropped and a truncation notice is appended.
*/
static final int MAX_LOG_EVENTS_PER_SCOPE = 500;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The Javadoc says "Beyond this, events are dropped and a truncation notice is appended" but captureLogEvent silently drops events without appending any truncation notice. Either append a synthetic LogEvent indicating truncation (e.g. with level WARN and message "... N events truncated"), or update the Javadoc to say events are silently dropped.

sb.append('}');
}

private static void writeModule(StringBuilder sb, ModuleReport module, int indent) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The hasMore parameter is unused (annotated @SuppressWarnings("unused")) and all call sites pass true. If trailing comma control is no longer needed, the parameter should be removed to reduce confusion.

Suggested change
private static void writeModule(StringBuilder sb, ModuleReport module, int indent) {
private static void writeNullableField(StringBuilder sb, int indent, String key, String value) {

@gnodet gnodet left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Well-designed build report feature with clean API/impl separation, solid thread safety, and comprehensive tests. A few issues worth addressing:

Confirmed findings (verified independently):

  1. [Medium] BuildReportCollector.java — The Javadoc on MAX_LOG_EVENTS_PER_SCOPE states "events are dropped and a truncation notice is appended," but captureLogEvent silently drops events without ever appending a truncation notice. Either implement the truncation notice (e.g., append a synthetic LogEvent like "... N events truncated") or correct the Javadoc to say events are silently dropped.

  2. [Low] BuildReportJsonWriter.java — The writeNullableField method has an unused boolean hasMore parameter annotated with @SuppressWarnings("unused"). The parameter is never read and the method always emits a trailing comma regardless. Remove it to avoid confusion.

  3. [Low] BuildReportJsonWriter.javawriteProblem uses sb.lastIndexOf(",\n") to remove trailing commas (searches entire buffer backwards), while writeLogEvent uses the dedicated removeTrailingComma helper (checks only last two characters). Use removeTrailingComma consistently — it's safer since lastIndexOf could theoretically match an earlier ,\n if future refactors change field order.

This review was generated by an AI agent (Claude Code) and may contain inaccuracies. Please verify all suggestions before applying.

Claude Code on behalf of Guillaume Nodet

/**
* Maximum number of log events captured per scope (mojo, module, or build).
* Beyond this, events are dropped and a truncation notice is appended.
*/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Medium] The Javadoc here says "events are dropped and a truncation notice is appended," but captureLogEvent (below) silently drops events when the limit is reached — no truncation notice is ever appended.

Either implement the truncation notice (e.g., append a synthetic LogEvent like "... N events truncated") or correct this Javadoc to say events are silently dropped.

}

private static void writeNullableField(
StringBuilder sb, int indent, String key, String value, @SuppressWarnings("unused") boolean hasMore) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Low] The hasMore parameter is annotated @SuppressWarnings("unused") and is indeed never read — the method always emits a trailing comma regardless of its value. Consider removing it:

Suggested change
StringBuilder sb, int indent, String key, String value, @SuppressWarnings("unused") boolean hasMore) {
private static void writeNullableField(StringBuilder sb, int indent, String key, String value) {

gnodet added a commit to gnodet/maven that referenced this pull request Aug 16, 2026
@gnodet gnodet added this to the 4.1.0 milestone Aug 23, 2026

@gnodet gnodet left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Well-structured addition of a JSON build report feature with clean API design, good test coverage (17 tests), and defensive error handling. The zero-dependency JSON writer is appropriate for Maven's philosophy. A few design items:

Medium severity:

  1. No opt-out mechanism (BuildReportCollector.java): The collector is unconditionally active for every build — every mvn invocation writes a JSON file to disk with no system property to disable it. Consider adding -Dmaven.build.report.skip=true for environments where this is undesirable (read-only filesystems, embedded invocations, CI runners).

  2. MojoSkipped events not handled (BuildReportCollector.java): ExecutionEvent.Type.MojoSkipped (fired e.g. when a mojo requires online mode but Maven is offline) silently vanishes from the report. Inconsistent with ProjectSkipped which IS handled. Skipped mojos should be tracked with BuildStatus.SKIPPED.

Low severity:

  1. Failure timestamp inaccuracy (BuildReportCollector.java line 1110): failureTimestamp is set to MonotonicClock.now() at report-assembly time, not at actual failure time. The mojo's timing data does capture the real timing — worth documenting in the FailureReport.timestamp() Javadoc.

  2. Inconsistent trailing comma removal (BuildReportJsonWriter.java): writeProblem uses sb.lastIndexOf(",\n") which searches backwards through the entire buffer, while writeLogEvent uses the more robust removeTrailingComma(sb) which checks only the end. Consider using removeTrailingComma consistently.

  3. Unused hasMore parameter (BuildReportJsonWriter.java line 1553): Annotated @SuppressWarnings("unused") and never referenced. Either use it to control comma behavior or remove it.

  4. ~70 lines of duplicated test helpers: BuildReportCollectorTest and BuildReportIntegrationTest share identical createProject, createSession, createMojoExecution, and createEvent methods. Consider extracting to a shared test utility.

The API design (immutable interfaces in maven-api-core, record implementations in maven-core, @Experimental markers) follows Maven's established patterns. Thread safety approach is sound. The atomic file write + symlink pattern is well-implemented with proper fallbacks.


This review was generated by an AI agent (Claude Code) and may contain inaccuracies. Please verify all suggestions before applying.

Claude Code on behalf of Guillaume Nodet

@gnodet
gnodet force-pushed the feature/build-report branch from 3658983 to c51ee87 Compare August 28, 2026 09:28
@gnodet
gnodet force-pushed the feature/logging-foundation branch from 02ac855 to 812a842 Compare August 28, 2026 09:28
Comment thread api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java Outdated
* @since 4.1.0
*/
default long sequenceNumber() {
return -1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Medium, RERAISED] Javadoc contract violation — says "always non-negative" but default returns -1.

Line 161: @return the sequence number, always non-negative

Line 165: return -1; // violates the stated contract

This mismatch was raised in the prior review and remains unaddressed in the squash. The default value of -1 is used by DefaultLogEvent's convenience constructor (tests, programmatic construction) — so -1 is a legitimate sentinel meaning "not assigned".

Fix the Javadoc: change to @return the sequence number, or {@code -1} if not available.

}

@Override
public void mojoSkipped(ExecutionEvent event) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Medium, RERAISED] mojoSkipped() still does not call setMojoId(null) — mojo ID leaked on skip.

The base branch called ProjectBuildLogAppender.setMojoId(null) in mojoSkipped() (mirroring mojoSucceeded and mojoFailed). This PR removed that call.

With this gap: if a mojo is skipped, the MOJO_ID thread-local is never cleared. Any subsequent log event on that thread (e.g. from a later mojo startup or a project-level log) will be misrouted into the skipped mojo's scope in the build report.

mojoSucceeded and mojoFailed both correctly call setMojoId(null) (lines 132, 139). mojoSkipped must do the same:

@Override
public void mojoSkipped(ExecutionEvent event) {
    setMdc(event);
    ProjectBuildLogAppender.setMojoId(null);  // missing
    delegate.mojoSkipped(event);
}

Comment thread api/maven-api-core/src/main/java/org/apache/maven/api/Session.java Outdated

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review of squash commit (7d1e5cb6) — six issues carried forward, none resolved.

Resolved in this squash:

  • DefaultLog.withMetadata() StackWalker guard correctly restored via hasReportCapture()
  • projectId() / mojoId() added to LogEvent API and populated in ProjectBuildLogAppender
  • FORKING_MOJO_ID thread-local added, fork restore logic mirrors the project ID pattern ✅

Still unresolved (6 findings):

  1. [High] Log.javaisTraceEnabled() and all 5 trace() overloads lose default, breaking any plugin that implements Log (binary-incompatible; AbstractMethodError at runtime).
  2. [Medium] BuildReportCollector.javaMAX_LOG_EVENTS_PER_SCOPE Javadoc says "a truncation notice is appended" but none is ever appended.
  3. [Medium] LoggingExecutionListener.javamojoSkipped() does not call setMojoId(null); mojo ID leaks to subsequent events on the same thread.
  4. [Medium] Session.java — Javadoc says buildEnvironment() returns "the same object recorded in the structured build report" but DefaultSession constructs a fresh DefaultBuildEnvironment on every call.
  5. [Medium] LogEvent.javasequenceNumber() Javadoc says "always non-negative" but default returns -1.
  6. [Medium] BuildEnvironment.java — "What is not yet captured" section still lists batchMode and noTransferProgress even though both are now exposed.
  7. [Low] BuildReportJsonWriter.java — dead hasMore parameter with @SuppressWarnings("unused").

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review of squashed commit 7d1e5cb — new: projectId/mojoId on LogEvent, FORKING_MOJO_ID support, hasReportCapture guard, fork-restore logic, quiet-mode JUL fix.

Four findings remain — two reraises, two new.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

default boolean isTraceEnabled() {
return false;
}
boolean isTraceEnabled();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[High, RERAISED] isTraceEnabled() and all six trace methods changed from default to abstract — binary-incompatible breaking change.

In feature/logging-foundation (the base branch), all six methods had default implementations:

default boolean isTraceEnabled() { return false; }
default void trace(CharSequence content) {}
default void trace(CharSequence content, Throwable error) {}
default void trace(Throwable error) {}
default void trace(Supplier<String> content) {}
default void trace(Supplier<String> content, Throwable error) {}

This PR makes them all abstract. Any existing class that implements Log and relied on those defaults — third-party plugin frameworks, mocking adapters, test doubles — will fail with AbstractMethodError at runtime without a recompile.

Note: the Javadoc on trace(Supplier<String>) (line 75–81) still says "The default implementation is a no-op for backward compatibility" — but there is no longer a default.

The base branch intentionally provided default {} bodies to allow incremental adoption. Either keep them as defaults, or add a clear migration note explaining why the break is intentional and what implementors must do.

@Override
public void mojoSkipped(ExecutionEvent event) {
setMdc(event);
delegate.mojoSkipped(event);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Medium] Two issues in mojoSkipped and fork mojo ID lifecycle.

1. mojoSkipped still does not clear the mojo ID (RERAISED).

mojoSucceeded (line 132) and mojoFailed (line 139) both call ProjectBuildLogAppender.setMojoId(null). mojoSkipped does not. When a mojo is skipped (e.g. maven-surefire-plugin with -DskipTests), the MDC and MOJO_ID ThreadLocal are left pointing at the skipped mojo. Any log emitted by the next lifecycle phase on the same thread will be misrouted in the build report.

Suggested change
delegate.mojoSkipped(event);
public void mojoSkipped(ExecutionEvent event) {
setMdc(event);
ProjectBuildLogAppender.setMojoId(null);
delegate.mojoSkipped(event);
}

2. FORKING_MOJO_ID restore path is dead code (new).

setForkingMojoId() was added to ProjectBuildLogAppender and the restore logic was wired into setMojoId(null) — but setForkingMojoId() is never called in this squashed commit. The calls from forkStarted/forkSucceeded/forkFailed were removed. FORKING_MOJO_ID will always be null, making the restore branch in setMojoId() permanently dead.

Either restore the calls in LoggingExecutionListener.forkStarted() / forkSucceeded() / forkFailed(), or remove the dead FORKING_MOJO_ID ThreadLocal and setForkingMojoId() method entirely.

@gnodet
gnodet force-pushed the feature/build-report branch from 7d1e5cb to bd0bdf3 Compare September 18, 2026 21:53

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review of squash commit bd0bdf3d.

Resolved since prior reviews:

  • Log.java trace methods restored to default — binary-compat issue fixed ✅
  • LoggingExecutionListener.mojoSkipped() now calls setMojoId(null) — mojo ID leak fixed ✅
  • LogEvent.sequenceNumber() Javadoc now correctly says or {@code -1} if unavailable

Still unresolved (4 findings): see inline comments.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

Comment thread api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java Outdated
Comment thread api/maven-api-core/src/main/java/org/apache/maven/api/Session.java Outdated
@gnodet
gnodet force-pushed the feature/build-report branch from bd0bdf3 to ee2e6c4 Compare September 18, 2026 21:59

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review of squashed commit bd0bdf3d. New content: BuildEnvironment API with batchMode/noTransferProgress, BuildReport/ModuleReport/MojoReport hierarchy, BuildReportCollector (EventSpy), BuildReportJsonWriter, comprehensive tests.

Five issues. Four are reraises that remain unaddressed after multiple rounds; one is new.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

Comment thread api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java Outdated
Comment thread api/maven-api-core/src/main/java/org/apache/maven/api/Session.java Outdated

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review of commit ee2e6c44 — import cleanup in ApiRunner.java only. Four findings from prior review still unresolved.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review of squash commit ee2e6c44 — import cleanup in ApiRunner.java only.

The four findings from the prior review remain unaddressed.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

Comment thread api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java Outdated
Comment thread api/maven-api-core/src/main/java/org/apache/maven/api/Session.java Outdated
Comment thread api/maven-api-core/src/main/java/org/apache/maven/api/Session.java Outdated
@gnodet
gnodet force-pushed the feature/build-report branch from ee2e6c4 to df6ed07 Compare September 18, 2026 22:14

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review of squash commit df6ed07b.

All 9 prior findings resolved — full tally below. One new finding in this commit.

Resolved since prior reviews:

  • Log.java trace methods — default keyword restored ✅
  • LoggingExecutionListener.mojoSkipped()setMojoId(null) added ✅
  • LogEvent.sequenceNumber() Javadoc — fixed to "or -1 if unavailable" ✅
  • BuildEnvironment.java stale Javadoc — batchMode/noTransferProgress removed from "not yet captured" ✅
  • BuildReportCollector truncation Javadoc — fixed to "silently dropped" ✅
  • BuildReportJsonWriter.writeNullableField dead hasMore parameter — removed ✅
  • writeProblem now uses removeTrailingComma consistently ✅
  • Session.java "same object" Javadoc claim — removed ✅
  • FORKING_MOJO_ID ThreadLocal — now properly set in forkStarted and cleared in forkSucceeded/forkFailed

One new finding: see inline comment.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

private final MavenRepositorySystem mavenRepositorySystem;
private final RuntimeInformation runtimeInformation;
private final Map<String, Project> allProjects = new ConcurrentHashMap<>();
private BuildEnvironment buildEnvironment;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Medium] Non-volatile lazy-init field on a @ThreadSafe class — data race.

Session is annotated @ThreadSafe. DefaultSession.buildEnvironment is a plain private field with a non-atomic lazy-init pattern:

if (buildEnvironment == null) {
    buildEnvironment = BuildReportCollector.buildEnvironment(getMavenSession());
}
return buildEnvironment;

Under the Java Memory Model, a write to a non-volatile field by thread A is not guaranteed to be visible to thread B. Two threads calling session.buildEnvironment() concurrently before the field is initialized can each see null, each compute a distinct DefaultBuildEnvironment, and each write their own — leaving the field in a state where different threads see different instances. This is exactly why BuildReportCollector correctly declares its counterpart field private volatile BuildEnvironment buildEnvironment.

Note: for this to be data-race-free, either use volatile (JMM guarantees visibility) or synchronize. Since DefaultBuildEnvironment is an immutable record, multiple initializations produce equivalent objects — but the racy read/write still constitutes a data race under JMM, which is undefined behavior regardless of whether the result is logically correct.

Suggested change
private BuildEnvironment buildEnvironment;
private volatile BuildEnvironment buildEnvironment;

- Add BuildReport, ModuleReport, MojoReport, FailureReport, BuildStatus to maven-api-core
- Add BuildEnvironment to Session API and BuildReport
- Add BuildReportCollector (maven-core): per-mojo log capture via ProjectBuildLogAppender
- Add BuildReportJsonWriter: serializes structured report to JSON file
- Add noTransferProgress to MavenExecutionRequest and BuildEnvironment
- Remove DefaultLogTest (replaced by BuildReportCollectorTest)
@gnodet
gnodet force-pushed the feature/build-report branch from df6ed07 to 7eaa538 Compare September 18, 2026 22:23

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review of squash commit df6ed07b.

Resolved since prior review (ee2e6c44):

  • Stale Javadoc (batchMode/noTransferProgress listed as "not yet captured") — removed ✅
  • Session.buildEnvironment() Javadoc "same object" false claim — removed ✅
  • Truncation notice Javadoc ("appended""silently dropped") ✅
  • Dead hasMore parameter with @SuppressWarnings("unused") — removed ✅
  • writeProblem lastIndexOf inconsistency → now uses removeTrailingComma
  • DefaultSession.buildEnvironment() re-allocates on every call — lazy-init caching added ✅

One new finding in this commit:

This review was generated by an AI agent, Hermès on behalf of @gnodet.

private final MavenRepositorySystem mavenRepositorySystem;
private final RuntimeInformation runtimeInformation;
private final Map<String, Project> allProjects = new ConcurrentHashMap<>();
private BuildEnvironment buildEnvironment;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Medium] Missing volatile on lazily-initialized field — data race in a @ThreadSafe class.

DefaultSession implements Session, which carries @ThreadSafe. The new buildEnvironment field is lazily initialized with a plain check-then-act:

if (buildEnvironment == null) {
    buildEnvironment = BuildReportCollector.buildEnvironment(getMavenSession());
}
return buildEnvironment;

Without volatile, the JMM offers no happens-before guarantee between the write in thread A and the read in thread B. Thread B may observe a stale null and re-compute, or (worse) observe a partially-constructed DefaultBuildEnvironment record. Note that BuildReportCollector itself uses private volatile BuildEnvironment buildEnvironment on its own caching field — the same pattern, done correctly.

Fix: add volatile.

Suggested change
private BuildEnvironment buildEnvironment;
private volatile BuildEnvironment buildEnvironment;

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review of commit 7eaa538877 — two changes: volatile on buildEnvironment in DefaultSession, two new LogSink tests in MavenSimpleLoggerTest.

All prior findings resolved:

  1. DefaultSession.buildEnvironment field is now volatile — visibility guaranteed. The lazy-init check-then-act (if (buildEnvironment == null) { buildEnvironment = ... }) is a benign race: buildEnvironment() is a pure factory reading from a frozen MavenExecutionRequest, so two threads computing simultaneously produce equivalent values. Functionally correct.

  2. MAX_LOG_EVENTS_PER_SCOPE Javadoc now says "silently dropped to prevent unbounded memory growth" — no longer claims a truncation notice is appended.

  3. ✅ Dead hasMore parameter removed from writeNullableField — all call sites updated to 4-arg form.

New tests (MavenSimpleLoggerTest): writeRoutesToSinkWhenInstalled and writeRoutesThrowableToSink correctly verify that LogSink receives the level, logger name, clean message, formatted string (with throwable rendering), and the original Throwable. Both tests clean up the sink in finally blocks. No issues.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review of squash commit 7eaa5388.

Resolved since prior reviews:

  • MAX_LOG_EVENTS_PER_SCOPE Javadoc now says "silently dropped" ✅
  • Dead hasMore parameter in writeNullableField removed ✅
  • batchMode/noTransferProgress removed from "What is not yet captured" Javadoc ✅
  • Session.buildEnvironment() Javadoc no longer claims "same object as in report" ✅
  • writeProblem uses indexed loop with explicit comma — lastIndexOf gone ✅
  • buildEnvironment field is now volatile in both BuildReportCollector and DefaultSession

Two findings remain.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@Override
public BuildEnvironment buildEnvironment() {
if (buildEnvironment == null) {
buildEnvironment = BuildReportCollector.buildEnvironment(getMavenSession());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Medium] Single-check lazy-init on volatile field — still racy under concurrent access.

volatile prevents the visibility problem, but two threads can still both observe buildEnvironment == null and both enter the body, constructing two separate DefaultBuildEnvironment instances (each reading system properties independently). DefaultSession is @ThreadSafe, so this is a contract violation.

The straightforward fix is double-checked locking:

Suggested change
buildEnvironment = BuildReportCollector.buildEnvironment(getMavenSession());
if (buildEnvironment == null) {
synchronized (this) {
if (buildEnvironment == null) {
buildEnvironment = BuildReportCollector.buildEnvironment(getMavenSession());
}
}

Alternatively — since BuildReportCollector.onSessionStarted() already initialises its own volatile BuildEnvironment field at session start, the fallback here only fires before the first event fires. If that timing is intentional (e.g. for ApiRunner / standalone mode), the DCL approach above is the right fix. If it isn't intentional, the fallback can simply call BuildReportCollector.buildEnvironment() once and cache via DCL.

minimal.trace(() -> "no-op");
minimal.trace(() -> "no-op", new RuntimeException());
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Medium] DefaultLogTest.java deleted — coverage regression with no replacement.

This file is on the base branch (feature/logging-foundation) and contains 6 tests:

  • warnWithSupplierAndThrowableDelegatesToWarn — regression guard for the warn(Supplier, Throwable)logger.info() bug
  • logApiMetadataIsClearedAfterCall — verifies no metadata leaks across log calls
  • traceMethodsDelegateToSlf4jTrace — verifies the trace() delegation path
  • traceIsNoOpWhenDisabled — verifies trace is suppressed when trace level is off
  • childCreatesSubLogger — verifies Log.child() prefix behaviour
  • defaultTraceIsDisabled — verifies Log implementors don't get AbstractMethodError from trace defaults (the exact binary-compat regression guard raised in earlier reviews)

None of these are replaced by BuildReportCollectorTest, BuildReportJsonWriterTest, or BuildReportIntegrationTest. If the tests are now redundant because the base branch covers them, they should still remain on the base branch — but if this PR removes them from the merge chain, the guard is gone. Restore DefaultLogTest.java or move the tests to BuildReportCollectorTest.

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review of squashed commit 7eaa538877 — all prior findings resolved.

Resolved since last review (ee2e6c44):

  • MAX_LOG_EVENTS_PER_SCOPE Javadoc now says "silently dropped" — no longer claims a truncation notice is appended ✅
  • Dead hasMore parameter removed from BuildReportJsonWriter; removeTrailingComma() used consistently throughout including writeProblem()
  • mojoSkipped() now calls ProjectBuildLogAppender.setMojoId(null) — mojo ID no longer leaked on skip ✅
  • DefaultSession.buildEnvironment field is now volatile with lazy initialization — no longer re-allocates on every call ✅
  • BuildEnvironment class-level Javadoc updated — batchMode/noTransferProgress no longer listed under "not yet captured" ✅
  • LogEvent.sequenceNumber() Javadoc now says "or -1 if unavailable" — no longer contradicts the default return value ✅
  • Session.buildEnvironment() Javadoc no longer claims the returned object "is the same object recorded in the build report" ✅
  • ApiRunner.java anonymous BuildEnvironment implementation is complete including noTransferProgress() and batchMode()

No new findings. The design is solid: EventSpy pattern, thread-based log routing with ConcurrentHashMap, atomic file writes, and comprehensive test coverage (17+ tests). Approved.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants