From 6fe71ea9bb7970d0710d6a1243c7e39b27bdf66c Mon Sep 17 00:00:00 2001 From: Iwan Eising Date: Tue, 25 Aug 2026 22:19:44 +0400 Subject: [PATCH] feat(api-detector-core): quiet default detector console output to stage headers Throttled per-batch scan progress now logs at INFO (visible only with --info) instead of LIFECYCLE, and a new DetectorStageReporter emits the always-visible "[N/M] stage" headers, so default output shows one line per major phase instead of a stream of batch counters. --- .../core/console/DetectorStageReporter.java | 51 +++++++++++++++++ .../core/console/ScanProgressReporter.java | 56 +++++++++++-------- .../console/DetectorStageReporterTest.java | 37 ++++++++++++ .../core/console/RecordingLogger.java | 29 ++++++++-- .../console/ScanProgressReporterTest.java | 14 ++--- 5 files changed, 151 insertions(+), 36 deletions(-) create mode 100644 api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/console/DetectorStageReporter.java create mode 100644 api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/DetectorStageReporterTest.java diff --git a/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/console/DetectorStageReporter.java b/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/console/DetectorStageReporter.java new file mode 100644 index 0000000..4ec249a --- /dev/null +++ b/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/console/DetectorStageReporter.java @@ -0,0 +1,51 @@ +package com.arc_e_tect.gradle.detector.core.console; + +import org.gradle.api.logging.Logger; + +/** + * Emits the always-visible, top-level "[N/M] stage" header lines that mark the major phases of a + * detector plugin run (controller scanning, OpenAPI endpoint collection, verification-evidence + * scanning, ...), e.g. {@code "Doppelganger API Detector: [1/3] Scanning @RestController + * classes..."}. + * + *

Always emitted at {@code LIFECYCLE} level - unlike the per-item detail a {@link + * ScanProgressReporter} reports for the work inside each stage, which is {@code INFO}-only and + * thus hidden unless the build is run with {@code --info}. Pairing the two keeps default output to + * one line per stage plus a completion summary, while still letting {@code --info} reveal + * per-batch progress within a stage.

+ */ +public final class DetectorStageReporter { + + private final Logger logger; + private final String pluginLabel; + private final int totalStages; + + private int currentStage; + + /** + * Creates a reporter for a plugin run with a known, fixed number of stages. + * + * @param logger the logger stage headers are emitted to, at {@code LIFECYCLE} level + * @param pluginLabel short label identifying the plugin, e.g. {@code "Doppelganger API Detector"} + * @param totalStages the total number of stages the run will report + */ + public DetectorStageReporter(Logger logger, String pluginLabel, int totalStages) { + this.logger = logger; + this.pluginLabel = pluginLabel; + this.totalStages = totalStages; + } + + /** + * Advances to the next stage and emits its header line, e.g. {@code "Doppelganger API + * Detector: [2/3] Collecting OpenAPI endpoints..."}. Calling this more times than {@code + * totalStages} keeps counting upward rather than clamping - callers are expected to call it + * exactly {@code totalStages} times, once per stage, in order. + * + * @param description short description of the stage being entered, without a trailing + * ellipsis or punctuation, e.g. {@code "Collecting OpenAPI endpoints"} + */ + public void stage(String description) { + currentStage++; + logger.lifecycle(pluginLabel + ": [" + currentStage + "/" + totalStages + "] " + description + "..."); + } +} diff --git a/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporter.java b/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporter.java index 541c95d..c4ae9e3 100644 --- a/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporter.java +++ b/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporter.java @@ -6,9 +6,9 @@ import java.util.function.LongSupplier; /** - * Emits periodic, low-overhead {@code LIFECYCLE}-level status lines for a long-running scan loop - * (controller scanning, OpenAPI {@code $ref} resolution, verification-evidence scanning, ...), so - * a consumer watching the build knows the task is still alive and roughly how far along it is. + * Emits periodic, low-overhead status lines for a long-running scan loop (controller scanning, + * OpenAPI {@code $ref} resolution, verification-evidence scanning, ...), so a consumer watching + * the build knows the task is still alive and roughly how far along it is. * *

Deliberately built only on the public {@link Logger} API - one plain line per status update, * never overwriting a line in place - rather than Gradle's internal, unsupported rich-console @@ -17,12 +17,14 @@ * changed shape across versions and offers no compatibility guarantee. This is a deliberate * trade-off in exchange for forward/backward compatibility, not an oversight.

* - *

A status line is emitted every {@code everyNItems} items or every - * {@code everySeconds} seconds since the last emission, whichever comes first - never on every - * single item, and never silent for more than {@code everySeconds} regardless of how many items - * are processed in between. {@link #complete()} always emits a final summary line, even if the - * most recent {@link #step()} landed inside the throttle window - the final line is never - * suppressed by throttling.

+ *

{@link #step()} lines are emitted at {@code INFO} level - visible only with {@code --info} - + * every {@code everyNItems} items or every {@code everySeconds} seconds since the + * last emission, whichever comes first - never on every single item, and never silent for more + * than {@code everySeconds} regardless of how many items are processed in between. {@link + * #complete()} always emits its final summary line at {@code LIFECYCLE} level, visible by default, + * regardless of whether the most recent {@link #step()} landed inside the throttle window - the + * final line is never suppressed by throttling. Pair this with a {@code DetectorStageReporter} for + * the always-visible top-level "[N/M] stage" headers a plugin task prints around each phase.

*/ public final class ScanProgressReporter { @@ -50,7 +52,8 @@ public final class ScanProgressReporter { * every {@value #DEFAULT_EVERY_SECONDS} seconds) and the real wall-clock time source. Prefer * {@link #determinate(Logger, String, int)} or {@link #indeterminate(Logger, String)}. * - * @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level + * @param logger the logger status lines are emitted to - {@link #step} at {@code INFO} + * level, {@link #complete()} at {@code LIFECYCLE} level * @param phaseLabel short label identifying the scan phase, e.g. {@code "Scanning @RestController classes"} * @param total the total number of items expected, or a negative number for an * indeterminate-total scan (the total isn't known ahead of time) @@ -62,7 +65,8 @@ public ScanProgressReporter(Logger logger, String phaseLabel, int total) { /** * Creates a reporter with an explicit throttle and the real wall-clock time source. * - * @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level + * @param logger the logger status lines are emitted to - {@link #step} at {@code INFO} + * level, {@link #complete()} at {@code LIFECYCLE} level * @param phaseLabel short label identifying the scan phase * @param total the total number of items expected, or a negative number for an * indeterminate-total scan @@ -78,7 +82,8 @@ public ScanProgressReporter(Logger logger, String phaseLabel, int total, int eve * Creates a reporter with an explicit throttle and time source, for use by tests that need to * control elapsed time without a real {@code Thread.sleep}. * - * @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level + * @param logger the logger status lines are emitted to - {@link #step} at {@code INFO} + * level, {@link #complete()} at {@code LIFECYCLE} level * @param phaseLabel short label identifying the scan phase * @param total the total number of items expected, or a negative number for an * indeterminate-total scan @@ -103,7 +108,8 @@ public ScanProgressReporter(Logger logger, String phaseLabel, int total, int eve * Creates a reporter for a scan whose total item count is known ahead of time. Emitted lines * include a running fraction and percentage, e.g. {@code "Scanning @RestController classes: 150/438 (34%)"}. * - * @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level + * @param logger the logger status lines are emitted to - {@link #step} at {@code INFO} + * level, {@link #complete()} at {@code LIFECYCLE} level * @param phaseLabel short label identifying the scan phase * @param total the total number of items expected; {@code 0} is valid and not an error * @return a new determinate-mode reporter, using the default throttle @@ -116,7 +122,8 @@ public static ScanProgressReporter determinate(Logger logger, String phaseLabel, * Creates a reporter for a scan whose total item count isn't known ahead of time. Emitted * lines report only a running count, e.g. {@code "Resolving OpenAPI documents: 27 processed so far"}. * - * @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level + * @param logger the logger status lines are emitted to - {@link #step} at {@code INFO} + * level, {@link #complete()} at {@code LIFECYCLE} level * @param phaseLabel short label identifying the scan phase * @return a new indeterminate-mode reporter, using the default throttle */ @@ -125,16 +132,18 @@ public static ScanProgressReporter indeterminate(Logger logger, String phaseLabe } /** - * Records one item processed, emitting a status line if the throttle window has elapsed. - * Equivalent to {@link #step(String)} with no detail. + * Records one item processed, emitting an {@code INFO}-level status line - visible only with + * {@code --info} - if the throttle window has elapsed. Equivalent to {@link #step(String)} + * with no detail. */ public void step() { step(null); } /** - * Records one item processed, emitting a status line - with {@code detail} appended, when - * given - if the throttle window has elapsed. + * Records one item processed, emitting an {@code INFO}-level status line - visible only with + * {@code --info}, with {@code detail} appended when given - if the throttle window has + * elapsed. * * @param detail short description of the current item, appended to the line only when this * call itself results in an emission; or {@code null}/blank for no detail @@ -145,17 +154,18 @@ public void step(String detail) { boolean dueByCount = (count - lastEmittedCount) >= everyNItems; boolean dueByTime = (now - lastEmittedNanos) >= everyNanos; if (dueByCount || dueByTime) { - logger.lifecycle(progressLine(detail)); + logger.info(progressLine(detail)); lastEmittedCount = count; lastEmittedNanos = now; } } /** - * Emits a final summary line unconditionally, regardless of throttling state - the last - * {@link #step()} may have landed inside the throttle window, but this line is never - * suppressed. Safe to call on a reporter that never had {@link #step()} called at all (the - * {@code 0}-items-processed case). + * Emits a final summary line unconditionally at {@code LIFECYCLE} level - visible by default, + * regardless of throttling state. The last {@link #step()} may have landed inside the throttle + * window (or never fired at all, since its lines are {@code INFO}-only), but this line is + * never suppressed. Safe to call on a reporter that never had {@link #step()} called at all + * (the {@code 0}-items-processed case). */ public void complete() { logger.lifecycle(phaseLabel + ": done, " + count + " item(s)"); diff --git a/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/DetectorStageReporterTest.java b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/DetectorStageReporterTest.java new file mode 100644 index 0000000..a160d76 --- /dev/null +++ b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/DetectorStageReporterTest.java @@ -0,0 +1,37 @@ +package com.arc_e_tect.gradle.detector.core.console; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("DetectorStageReporter") +class DetectorStageReporterTest { + + private final RecordingLogger logger = new RecordingLogger(); + + @Test + @DisplayName("emits a numbered header line at LIFECYCLE level for each stage, in order") + void emitsNumberedHeaderLineForEachStage() { + DetectorStageReporter reporter = new DetectorStageReporter(logger, "Doppelganger API Detector", 3); + + reporter.stage("Scanning @RestController classes"); + reporter.stage("Collecting OpenAPI endpoints"); + reporter.stage("Scanning Spring RestDocs verification evidence"); + + assertThat(logger.lifecycleMessages()).containsExactly( + "Doppelganger API Detector: [1/3] Scanning @RestController classes...", + "Doppelganger API Detector: [2/3] Collecting OpenAPI endpoints...", + "Doppelganger API Detector: [3/3] Scanning Spring RestDocs verification evidence..."); + } + + @Test + @DisplayName("never emits at INFO level") + void neverEmitsAtInfoLevel() { + DetectorStageReporter reporter = new DetectorStageReporter(logger, "Shadow API Detector", 1); + + reporter.stage("Scanning @RestController classes"); + + assertThat(logger.infoMessages()).isEmpty(); + } +} diff --git a/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/RecordingLogger.java b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/RecordingLogger.java index 5c99fc1..bf6b33e 100644 --- a/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/RecordingLogger.java +++ b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/RecordingLogger.java @@ -9,12 +9,14 @@ /** * Hand-written {@link Logger} test double that records every message passed to - * {@link #lifecycle(String)}/{@link #lifecycle(String, Object...)} and no-ops everything else - - * this codebase's tests use hand-written fakes rather than a mocking framework. + * {@link #lifecycle(String)}/{@link #lifecycle(String, Object...)} and + * {@link #info(String)}/{@link #info(String, Object...)}, and no-ops everything else - this + * codebase's tests use hand-written fakes rather than a mocking framework. */ class RecordingLogger implements Logger { private final List lifecycleMessages = new ArrayList<>(); + private final List infoMessages = new ArrayList<>(); /** Creates a new {@code RecordingLogger}. */ RecordingLogger() {} @@ -28,6 +30,15 @@ List lifecycleMessages() { return lifecycleMessages; } + /** + * Every message passed to {@code info(...)}, in call order. + * + * @return the recorded info messages + */ + List infoMessages() { + return infoMessages; + } + @Override public void lifecycle(String message) { lifecycleMessages.add(message); @@ -163,11 +174,13 @@ public void debug(Marker marker, String msg, Throwable t) {} @Override public boolean isInfoEnabled() { - return false; + return true; } @Override - public void info(String msg) {} + public void info(String msg) { + infoMessages.add(msg); + } @Override public void info(String format, Object arg) {} @@ -176,10 +189,14 @@ public void info(String format, Object arg) {} public void info(String format, Object arg1, Object arg2) {} @Override - public void info(String format, Object... arguments) {} + public void info(String format, Object... arguments) { + infoMessages.add(format); + } @Override - public void info(String msg, Throwable t) {} + public void info(String msg, Throwable t) { + infoMessages.add(msg); + } @Override public boolean isInfoEnabled(Marker marker) { diff --git a/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporterTest.java b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporterTest.java index 9864042..58ca151 100644 --- a/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporterTest.java +++ b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporterTest.java @@ -23,7 +23,7 @@ void emitsNothingBeforeItemCountThrottleIsReached() { reporter.step(); } - assertThat(logger.lifecycleMessages()).isEmpty(); + assertThat(logger.infoMessages()).isEmpty(); } @Test @@ -36,7 +36,7 @@ void emitsStatusLineOnceItemCountThrottleIsReached() { reporter.step(); } - assertThat(logger.lifecycleMessages()).containsExactly("Scanning: 5/100 (5%)"); + assertThat(logger.infoMessages()).containsExactly("Scanning: 5/100 (5%)"); } @Test @@ -49,7 +49,7 @@ void doesNotEmitAgainUntilAnotherFullItemCountWindow() { reporter.step(); } - assertThat(logger.lifecycleMessages()).hasSize(1); + assertThat(logger.infoMessages()).hasSize(1); } @Test @@ -62,7 +62,7 @@ void emitsNothingBeforeElapsedTimeThrottleIsReached() { nanos.set(1_000_000_000L); // 1 second, less than the 2-second throttle reporter.step(); - assertThat(logger.lifecycleMessages()).isEmpty(); + assertThat(logger.infoMessages()).isEmpty(); } @Test @@ -75,7 +75,7 @@ void emitsStatusLineOnceElapsedTimeThrottleIsReached() { nanos.set(2_000_000_000L); // exactly 2 seconds reporter.step(); - assertThat(logger.lifecycleMessages()).containsExactly("Scanning: 1/100 (1%)"); + assertThat(logger.infoMessages()).containsExactly("Scanning: 1/100 (1%)"); } @Test @@ -110,7 +110,7 @@ void indeterminateModeReportsRunningCount() { reporter.step(); } - assertThat(logger.lifecycleMessages()).containsExactly("Resolving: 3 processed so far"); + assertThat(logger.infoMessages()).containsExactly("Resolving: 3 processed so far"); } @Test @@ -133,7 +133,7 @@ void stepWithDetailAppendsDetailToEmittedLine() { reporter.step("UserController.java"); - assertThat(logger.lifecycleMessages()).containsExactly("Scanning: 1/100 (1%) - UserController.java"); + assertThat(logger.infoMessages()).containsExactly("Scanning: 1/100 (1%) - UserController.java"); } private LongSupplier fixedClock(long value) {