From ab505d524ffa6aaf7bf5f6d19beada939bee4c2d Mon Sep 17 00:00:00 2001 From: Lukas Niessen Date: Wed, 30 Sep 2026 18:22:04 +0200 Subject: [PATCH] feat: add opt-in diagnostics and formatted inspection reports --- README.md | 100 ++++++++++++- .../dev/archunitjava/cli/CliAnalyzer.java | 21 ++- .../diagnostics/AnalysisEvent.java | 24 ++++ .../diagnostics/AnalysisInspection.java | 93 ++++++++++++ .../diagnostics/AnalysisLogging.java | 73 ++++++++++ .../archunitjava/diagnostics/LogLevel.java | 10 ++ .../archunitjava/execution/CheckOptions.java | 20 ++- .../metrics/MetricThresholdRules.java | 13 +- .../dev/archunitjava/report/ConsoleColor.java | 21 +++ .../report/ConsoleResultRenderer.java | 39 +++++ .../dev/archunitjava/report/ConsoleText.java | 23 +++ .../archunitjava/rules/ArchitectureRules.java | 29 +++- .../archunitjava/rules/DependencyRules.java | 18 +++ .../dev/archunitjava/rules/RuleTerminal.java | 8 ++ .../dev/archunitjava/cli/CliRunnerTest.java | 31 ++++ .../diagnostics/AnalysisLoggingTest.java | 134 ++++++++++++++++++ .../report/ResultRenderersTest.java | 20 +++ .../rules/DependencyRulesTest.java | 21 +++ 18 files changed, 684 insertions(+), 14 deletions(-) create mode 100644 src/main/java/dev/archunitjava/diagnostics/AnalysisEvent.java create mode 100644 src/main/java/dev/archunitjava/diagnostics/AnalysisInspection.java create mode 100644 src/main/java/dev/archunitjava/diagnostics/AnalysisLogging.java create mode 100644 src/main/java/dev/archunitjava/diagnostics/LogLevel.java create mode 100644 src/main/java/dev/archunitjava/report/ConsoleColor.java create mode 100644 src/main/java/dev/archunitjava/report/ConsoleText.java create mode 100644 src/test/java/dev/archunitjava/diagnostics/AnalysisLoggingTest.java diff --git a/README.md b/README.md index 96b73c5..1651b43 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ _Inspired by the established [ArchUnit](https://www.archunit.org/) project, but implemented and not affiliated with ArchUnit. This is not a drop-in replacement._ [Quickstart](#-five-minute-quickstart) · [Use cases](#-use-cases) · -[Capabilities](#-capabilities) · [Reports](#-reports) · +[Capabilities](#-capabilities) · [Inspection](#-logging-and-inspection) · [Reports](#-reports) · [Example repository](#-independent-example-repository) · [User guide](docs/USER_GUIDE.md) · [CLI reference](docs/CLI_REFERENCE.md) · [Documentation](https://lukasniessen.github.io/ArchUnitJava/) · @@ -225,6 +225,104 @@ normal workflow and API entry point; the [CLI reference](docs/CLI_REFERENCE.md) configuration key, command, format, and exit code. The internal architecture and ownership rules are described in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). +## 🔎 Logging and inspection + +Logging is **off by default** and scoped to an individual check or analysis. Choose +`ERROR`, `WARN`, `INFO`, or `DEBUG`; higher verbosity includes all lower levels. +Logging does not change rule outcomes, violation identities, evidence, or machine-readable reports. + +Given an `ArchitectureRule rule`, enable detailed inspection and a formatted console report: + +```java +import dev.archunitjava.diagnostics.AnalysisLogging; +import dev.archunitjava.diagnostics.LogLevel; +import dev.archunitjava.execution.CheckOptions; +import dev.archunitjava.report.ConsoleColor; +import dev.archunitjava.report.ConsoleResultRenderer; +import dev.archunitjava.report.ResultReport; +import java.util.List; + +var logging = AnalysisLogging.text(LogLevel.DEBUG, System.err, ConsoleColor.AUTO); +var options = CheckOptions.builder().logging(logging).build(); +var result = rule.check(options); + +System.out.print(ConsoleResultRenderer.renderPretty( + ResultReport.of(List.of(result)), ConsoleColor.AUTO)); +``` + +| Level | What you see | +| --- | --- | +| `OFF` | Nothing; the default | +| `ERROR` | Failed or incomplete rules, execution failures, and error diagnostics | +| `WARN` | Errors plus skipped rules and import or rule warnings | +| `INFO` | Warnings plus rule start/completion, import and graph counts, and analysis summaries | +| `DEBUG` | All of the above plus selected resources/types, external types, graph nodes/edges and evidence, rule selectors and counts, dependency filter decisions, metric samples/thresholds, and every violation with its evidence | + +Use the same observer for the complete bytecode pipeline: + +```java +import dev.archunitjava.cli.CliAnalyzer; +import dev.archunitjava.cli.CliConfigurationLoader; + +var configuration = CliConfigurationLoader.load(configurationFile, approvedRoot); +var analysis = new CliAnalyzer().analyze(configuration, logging); +``` + +The direct Java analyzer overload enables logging without changing CLI configuration keys or +stdout report formats. For independently constructed models, graphs, and metric reports, +`AnalysisInspection.imports(importResult, logging)`, +`AnalysisInspection.graph(graph, logging)`, and +`AnalysisInspection.metrics(samples, logging)` inspect existing immutable data without +re-importing classes or reevaluating rules. Dependency-rule checks automatically explain their +projected graph, selected subjects, and self/external dependency filtering. Metric-threshold +checks automatically show both passing and failing samples at `DEBUG`. + +Example plain-text output (ANSI highlighting is added on supported terminals): + +```text +[INFO] EXTRACT | Import complete {externalTypes=1, resources=2, types=2} +[INFO] PROJECT | Graph ready {edges=2, nodes=3} +[DEBUG] ASSERT | Selection {role=origins, rule=..., selected=1, selector=...} +[ERROR] REPORT | Rule complete {rule=..., status=FAILED, violations=1} + +ARCHUNITJAVA | Architecture report +===================================== +[FAIL:POLICY] API boundary (...) + - ... [ERROR] dependency.forbidden + at classes/api/A.class + Because: internal implementation must remain private + +Summary: PASSED=0 FAILED=1 SKIPPED=0 INCOMPLETE=0 +``` + +Pretty reports use a heading, highlighted status lines, indented evidence, the rule rationale, +and a status summary. `ConsoleColor.AUTO` uses ANSI only when a terminal is detected; +`NEVER` always produces plain text and `ALWAYS` explicitly enables ANSI for a caller that +knows its destination supports it. `NO_COLOR`, `CI`, and `TERM=dumb` suppress ANSI in +every mode. Existing `ConsoleResultRenderer.render(...)` overloads keep their original +plain-text output and limits; `renderPretty` retains the same evidence and diagnostic limits. + +### Save logs as CI artifacts or collect structured events + +```java +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +try (var writer = Files.newBufferedWriter(logFile, StandardCharsets.UTF_8)) { + var fileLogging = AnalysisLogging.text(LogLevel.DEBUG, writer); // plain text + rule.check(CheckOptions.builder().logging(fileLogging).build()); +} +``` + +`AnalysisLogging.of(LogLevel.DEBUG, event -> ...)` accepts a custom sink. Each immutable +`AnalysisEvent` has a level, pipeline stage, message, and sorted detail map. Text output +escapes target-controlled line breaks, terminal controls, and formatting characters. +Debug events are not truncated, so stream them to a writer for large models; the library +does not retain an event buffer. Disabled levels do not evaluate detail suppliers. +Sink runtime exceptions, including writer failures, are isolated from evaluation; fatal VM +errors are not swallowed. Callers own sink concurrency, flushing, and file lifetime. +There is no global logger, implicit log file, or implicit console output. + ## 📊 Reports Rule results can be rendered as: diff --git a/src/main/java/dev/archunitjava/cli/CliAnalyzer.java b/src/main/java/dev/archunitjava/cli/CliAnalyzer.java index 7ecfca2..8b35015 100644 --- a/src/main/java/dev/archunitjava/cli/CliAnalyzer.java +++ b/src/main/java/dev/archunitjava/cli/CliAnalyzer.java @@ -1,6 +1,9 @@ package dev.archunitjava.cli; import dev.archunitjava.importer.ClassFileInput; +import dev.archunitjava.diagnostics.AnalysisInspection; +import dev.archunitjava.diagnostics.AnalysisLogging; +import dev.archunitjava.diagnostics.LogLevel; import dev.archunitjava.importer.ClassPathImportResolver; import dev.archunitjava.importer.ImportResolutionResult; import dev.archunitjava.importer.InputDiagnosticCode; @@ -18,15 +21,31 @@ public final class CliAnalyzer { public CliAnalysisResult analyze(CliConfiguration configuration) { CliConfiguration config = Objects.requireNonNull(configuration, "configuration"); + return analyze(config, config.checkOptions().logging()); + } + + /** Analyze with an explicit observer without changing the configuration or result schema. */ + public CliAnalysisResult analyze(CliConfiguration configuration, AnalysisLogging logging) { + CliConfiguration config = Objects.requireNonNull(configuration, "configuration"); + AnalysisLogging log = Objects.requireNonNull(logging, "logging"); + var options = log == config.checkOptions().logging() ? config.checkOptions() + : config.checkOptions().toBuilder().logging(log).build(); + log.emit(LogLevel.INFO, "EXTRACT", "Import started", () -> Map.of("inputs", "" + config.inputs().size())); var imports = new ClassPathImportResolver().resolve(config.inputs().stream() .map(ClassFileInput::path).toList()); + AnalysisInspection.imports(imports, log); var graph = CliGraphBuilder.build(imports.model()); + AnalysisInspection.graph(graph, log); var failures = importFailures(imports); var results = CliRuleFactory.createRules(config, imports.model(), graph).stream() - .map(rule -> rule.check(config.checkOptions())) + .map(rule -> rule.check(options)) .map(result -> withImportDiagnostics(result, failures, config.checkOptions().allowIncompleteAnalysis())) .toList(); + if (!failures.isEmpty()) results.forEach(result -> AnalysisInspection.result(result, log)); + log.emit(failures.isEmpty() ? LogLevel.INFO : LogLevel.WARN, "REPORT", "Analysis complete", () -> Map.of("rules", "" + results.size(), + "passed", "" + results.stream().filter(RuleResult::passed).count(), + "importFailures", "" + failures.size())); return new CliAnalysisResult(imports, graph, ResultReport.of(results)); } diff --git a/src/main/java/dev/archunitjava/diagnostics/AnalysisEvent.java b/src/main/java/dev/archunitjava/diagnostics/AnalysisEvent.java new file mode 100644 index 0000000..79a9129 --- /dev/null +++ b/src/main/java/dev/archunitjava/diagnostics/AnalysisEvent.java @@ -0,0 +1,24 @@ +package dev.archunitjava.diagnostics; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +/** Structured observation, separate from rule results and their diagnostic schema. */ +public record AnalysisEvent(LogLevel level, String stage, String message, Map details) { + public AnalysisEvent { + Objects.requireNonNull(level, "level"); + Objects.requireNonNull(stage, "stage"); + Objects.requireNonNull(message, "message"); + TreeMap sorted = new TreeMap<>(); + Objects.requireNonNull(details, "details").forEach((key, value) -> + sorted.put(Objects.requireNonNull(key), Objects.requireNonNull(value))); + details = Collections.unmodifiableMap(sorted); + } + /** Returns a sorted defensive snapshot; callers never receive the stored map. */ + @Override + public Map details() { + return Collections.unmodifiableMap(new TreeMap<>(details)); + } +} diff --git a/src/main/java/dev/archunitjava/diagnostics/AnalysisInspection.java b/src/main/java/dev/archunitjava/diagnostics/AnalysisInspection.java new file mode 100644 index 0000000..70dc32e --- /dev/null +++ b/src/main/java/dev/archunitjava/diagnostics/AnalysisInspection.java @@ -0,0 +1,93 @@ +package dev.archunitjava.diagnostics; + +import dev.archunitjava.graph.DependencyGraph; +import dev.archunitjava.importer.ImportResolutionResult; +import dev.archunitjava.metrics.MetricSample; +import dev.archunitjava.result.RuleResult; +import java.util.Collection; +import java.util.Map; + +/** Read-only inspection of existing immutable data; never parses, loads or reevaluates target code. */ +public final class AnalysisInspection { + private AnalysisInspection() {} + + public static void imports(ImportResolutionResult imports, AnalysisLogging log) { + if (log.level() == LogLevel.OFF) return; + log.emit(LogLevel.INFO, "EXTRACT", "Import complete", () -> Map.of( + "types", "" + imports.model().types().size(), "externalTypes", "" + imports.externalTypes().size(), + "resources", "" + imports.assembly().selections().size())); + if (log.enabled(LogLevel.DEBUG)) { + imports.assembly().selections().forEach(selection -> log.emit(LogLevel.DEBUG, "EXTRACT", + "Selected resource", () -> Map.of("resource", selection.winner().name(), + "precedence", "" + selection.winner().precedence()))); + imports.importedTypes().forEach(type -> log.emit(LogLevel.DEBUG, "EXTRACT", "Imported type", + () -> Map.of("type", type.winner().binaryName(), "scope", type.lookupScope(), + "shadowedDefinitions", "" + type.shadowedDefinitions().size()))); + imports.externalTypes().forEach(type -> log.emit(LogLevel.DEBUG, "EXTRACT", "External type", + () -> Map.of("type", type.name().binaryName()))); + } + imports.assembly().diagnostics().forEach(diagnostic -> log.emit(switch (diagnostic.code()) { + case DUPLICATE_INPUT, DUPLICATE_RESOURCE, MULTI_RELEASE_ENTRY_IGNORED, + RESOURCE_EXCLUDED, SYMLINK_SKIPPED -> LogLevel.DEBUG; + default -> LogLevel.WARN; + }, "EXTRACT", + "Input selection diagnostic", () -> Map.of("code", diagnostic.code().name(), + "input", diagnostic.input(), "context", diagnostic.context().toString()))); + imports.diagnostics().forEach(diagnostic -> log.emit(LogLevel.WARN, "EXTRACT", + "Import resolution diagnostic", () -> Map.of("diagnostic", diagnostic.toString()))); + imports.model().classFileDiagnostics().forEach(diagnostic -> log.emit(LogLevel.ERROR, "EXTRACT", + "Class file diagnostic", () -> Map.of("diagnostic", diagnostic.toString()))); + imports.model().diagnostics().forEach(diagnostic -> log.emit(LogLevel.WARN, "EXTRACT", + "Model diagnostic", () -> Map.of("diagnostic", diagnostic.toString()))); + } + + public static void graph(DependencyGraph graph, AnalysisLogging log) { + log.emit(LogLevel.INFO, "PROJECT", "Graph ready", () -> Map.of( + "nodes", "" + graph.nodes().size(), "edges", "" + graph.edges().size())); + if (!log.enabled(LogLevel.DEBUG)) return; + graph.nodes().forEach(node -> log.emit(LogLevel.DEBUG, "PROJECT", "Graph node", + () -> Map.of("node", node.id().stableKey()))); + graph.edges().forEach(edge -> { + log.emit(LogLevel.DEBUG, "PROJECT", "Dependency edge", () -> Map.of( + "origin", edge.origin().stableKey(), "target", edge.target().stableKey(), + "kind", edge.kind().name(), "evidence", "" + edge.evidence().size())); + edge.evidence().forEach(evidence -> log.emit(LogLevel.DEBUG, "PROJECT", "Dependency evidence", + () -> Map.of("origin", edge.origin().stableKey(), "target", edge.target().stableKey(), + "evidence", evidence.toString()))); + }); + } + + public static void metrics(Collection samples, AnalysisLogging log) { + if (!log.enabled(LogLevel.DEBUG)) return; + samples.stream().sorted().forEach(sample -> log.emit(LogLevel.DEBUG, "ASSERT", "Metric sample", + () -> Map.of("subject", sample.subject().stableKey(), "metric", sample.metric().name(), + "value", sample.amount().stableValue(), "unit", sample.amount().unit().name()))); + } + + public static void result(RuleResult result, AnalysisLogging log) { + if (log.level() == LogLevel.OFF) return; + LogLevel level = switch (result.status()) { + case PASSED -> LogLevel.INFO; + case FAILED, INCOMPLETE -> LogLevel.ERROR; + case SKIPPED -> LogLevel.WARN; + }; + log.emit(level, "REPORT", "Rule complete", () -> Map.of("rule", result.ruleId(), + "status", result.status().name(), "violations", "" + result.violations().size())); + result.diagnostics().forEach(diagnostic -> log.emit( + switch (diagnostic.severity()) { + case ERROR -> LogLevel.ERROR; + case WARNING -> LogLevel.WARN; + case INFO -> LogLevel.INFO; + }, + "REPORT", "Rule diagnostic", () -> Map.of("rule", result.ruleId(), + "code", diagnostic.code(), "context", diagnostic.context().toString()))); + if (!log.enabled(LogLevel.DEBUG)) return; + result.violations().forEach(violation -> { + log.emit(LogLevel.DEBUG, "REPORT", "Violation", () -> Map.of("rule", result.ruleId(), + "id", violation.id().value(), "code", violation.code(), + "subjects", violation.subjects().toString(), "attributes", violation.attributes().toString())); + violation.evidence().forEach(evidence -> log.emit(LogLevel.DEBUG, "REPORT", "Violation evidence", + () -> Map.of("id", violation.id().value(), "evidence", evidence.toString()))); + }); + } +} diff --git a/src/main/java/dev/archunitjava/diagnostics/AnalysisLogging.java b/src/main/java/dev/archunitjava/diagnostics/AnalysisLogging.java new file mode 100644 index 0000000..075a12e --- /dev/null +++ b/src/main/java/dev/archunitjava/diagnostics/AnalysisLogging.java @@ -0,0 +1,73 @@ +package dev.archunitjava.diagnostics; + +import dev.archunitjava.report.ConsoleColor; +import dev.archunitjava.report.ConsoleText; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Explicit per-check observer; no global logger, files, timestamps or implicit console writes. + * Sink runtime failures are isolated so logging cannot replace a rule result or execution error. + * Callers own sink lifetime, flushing and concurrency. Fatal VM errors are not intercepted. + */ +public final class AnalysisLogging { + private static final AnalysisLogging DISABLED = new AnalysisLogging(LogLevel.OFF, event -> {}); + private final LogLevel level; + private final Consumer sink; + + private AnalysisLogging(LogLevel level, Consumer sink) { + this.level = Objects.requireNonNull(level, "level"); + this.sink = Objects.requireNonNull(sink, "sink"); + } + + public static AnalysisLogging disabled() { return DISABLED; } + + public static AnalysisLogging of(LogLevel level, Consumer sink) { + return new AnalysisLogging(level, sink); + } + + /** Plain text is the conservative default for appendable destinations such as files. */ + public static AnalysisLogging text(LogLevel level, Appendable destination) { + return text(level, destination, ConsoleColor.NEVER); + } + + public static AnalysisLogging text(LogLevel level, Appendable destination, ConsoleColor color) { + Objects.requireNonNull(destination, "destination"); + boolean useColor = Objects.requireNonNull(color, "color").enabled(); + return of(level, event -> { + try { + destination.append(format(event, useColor)); + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } + }); + } + + public LogLevel level() { return level; } + public boolean enabled(LogLevel eventLevel) { return level.includes(eventLevel); } + + public void emit(LogLevel eventLevel, String stage, String message, Supplier> details) { + if (!enabled(eventLevel)) return; + try { + sink.accept(new AnalysisEvent(eventLevel, stage, message, details.get())); + } catch (RuntimeException ignored) { + // Observability is best effort and never alters architecture semantics. + } + } + + public static String format(AnalysisEvent event, boolean color) { + String code = switch (event.level()) { + case ERROR -> "31"; + case WARN -> "33"; + case INFO -> "36"; + case DEBUG, OFF -> "2"; + }; + return ConsoleText.style("[" + event.level() + "]", code, color) + " " + + ConsoleText.sanitize(event.stage()) + " | " + ConsoleText.sanitize(event.message()) + + (event.details().isEmpty() ? "" : " " + ConsoleText.sanitize(event.details().toString())) + "\n"; + } +} diff --git a/src/main/java/dev/archunitjava/diagnostics/LogLevel.java b/src/main/java/dev/archunitjava/diagnostics/LogLevel.java new file mode 100644 index 0000000..6d570c6 --- /dev/null +++ b/src/main/java/dev/archunitjava/diagnostics/LogLevel.java @@ -0,0 +1,10 @@ +package dev.archunitjava.diagnostics; + +/** Per-operation verbosity. DEBUG includes every emitted event; OFF emits nothing. */ +public enum LogLevel { + OFF, ERROR, WARN, INFO, DEBUG; + + public boolean includes(LogLevel eventLevel) { + return eventLevel != OFF && ordinal() >= eventLevel.ordinal(); + } +} diff --git a/src/main/java/dev/archunitjava/execution/CheckOptions.java b/src/main/java/dev/archunitjava/execution/CheckOptions.java index e47fbaa..9494b76 100644 --- a/src/main/java/dev/archunitjava/execution/CheckOptions.java +++ b/src/main/java/dev/archunitjava/execution/CheckOptions.java @@ -1,6 +1,7 @@ package dev.archunitjava.execution; import java.util.Objects; +import dev.archunitjava.diagnostics.AnalysisLogging; /** Immutable policies applied uniformly by architecture-rule terminals. */ public final class CheckOptions { @@ -8,10 +9,12 @@ public final class CheckOptions { private final EmptySelectionPolicy emptySelectionPolicy; private final boolean allowIncompleteAnalysis; + private final AnalysisLogging logging; private CheckOptions(Builder builder) { emptySelectionPolicy = builder.emptySelectionPolicy; allowIncompleteAnalysis = builder.allowIncompleteAnalysis; + logging = builder.logging; } /** Returns the deterministic strict defaults used by {@link Checkable#check()}. */ @@ -40,16 +43,22 @@ public boolean allowIncompleteAnalysis() { return allowIncompleteAnalysis; } + /** Per-check observations, disabled by default and independent of rule-result diagnostics. */ + public AnalysisLogging logging() { + return logging; + } + @Override public boolean equals(Object other) { return other instanceof CheckOptions options && emptySelectionPolicy == options.emptySelectionPolicy - && allowIncompleteAnalysis == options.allowIncompleteAnalysis; + && allowIncompleteAnalysis == options.allowIncompleteAnalysis + && logging.equals(options.logging); } @Override public int hashCode() { - return Objects.hash(emptySelectionPolicy, allowIncompleteAnalysis); + return Objects.hash(emptySelectionPolicy, allowIncompleteAnalysis, logging); } @Override @@ -62,12 +71,14 @@ public String toString() { public static final class Builder { private EmptySelectionPolicy emptySelectionPolicy = EmptySelectionPolicy.FAIL; private boolean allowIncompleteAnalysis; + private AnalysisLogging logging = AnalysisLogging.disabled(); private Builder() {} private Builder(CheckOptions options) { emptySelectionPolicy = options.emptySelectionPolicy; allowIncompleteAnalysis = options.allowIncompleteAnalysis; + logging = options.logging; } public Builder allowEmptySelection(boolean value) { @@ -88,5 +99,10 @@ public Builder allowIncompleteAnalysis(boolean value) { public CheckOptions build() { return new CheckOptions(this); } + + public Builder logging(AnalysisLogging value) { + logging = Objects.requireNonNull(value, "logging"); + return this; + } } } diff --git a/src/main/java/dev/archunitjava/metrics/MetricThresholdRules.java b/src/main/java/dev/archunitjava/metrics/MetricThresholdRules.java index 33b656e..ea03e11 100644 --- a/src/main/java/dev/archunitjava/metrics/MetricThresholdRules.java +++ b/src/main/java/dev/archunitjava/metrics/MetricThresholdRules.java @@ -1,6 +1,8 @@ package dev.archunitjava.metrics; import dev.archunitjava.result.Diagnostic; +import dev.archunitjava.diagnostics.AnalysisInspection; +import dev.archunitjava.diagnostics.LogLevel; import dev.archunitjava.result.RuleMetadata; import dev.archunitjava.result.RuleResult; import dev.archunitjava.result.Violation; @@ -41,13 +43,18 @@ public static ArchitectureRule enforce( + ':' + limit.limit().stableValue() + ':' + limit.limit().unit(); String description = limit.metric() + " must be " + limit.comparison() + ' ' + limit.limit().stableValue() + ' ' + limit.limit().unit(); - return ArchitectureRules.define(identity, description, (metadata, options) -> - RuleTerminal.evaluate( + return ArchitectureRules.define(identity, description, (metadata, options) -> { + AnalysisInspection.metrics(values, options.logging()); + options.logging().emit(LogLevel.DEBUG, "ASSERT", "Metric threshold", () -> Map.of( + "metric", limit.metric().name(), "comparison", limit.comparison().name(), + "limit", limit.limit().stableValue(), "unit", limit.limit().unit().name())); + return RuleTerminal.evaluate( metadata, options, new SelectorDescription("subjects measured for " + limit.metric()), values.size(), - diagnostics -> evaluate(metadata, values, limit, diagnostics))); + diagnostics -> evaluate(metadata, values, limit, diagnostics)); + }); } private static RuleResult evaluate( diff --git a/src/main/java/dev/archunitjava/report/ConsoleColor.java b/src/main/java/dev/archunitjava/report/ConsoleColor.java new file mode 100644 index 0000000..3a2e429 --- /dev/null +++ b/src/main/java/dev/archunitjava/report/ConsoleColor.java @@ -0,0 +1,21 @@ +package dev.archunitjava.report; + +import java.util.Map; +import java.util.Objects; + +/** ANSI policy. NO_COLOR, CI and TERM=dumb suppress color, including ALWAYS. */ +public enum ConsoleColor { + NEVER, AUTO, ALWAYS; + + public boolean enabled() { + return enabled(System.console() != null, System.getenv()); + } + + /** Explicit terminal context for embedded applications and deterministic rendering. */ + public boolean enabled(boolean terminal, Map environment) { + Objects.requireNonNull(environment, "environment"); + if (this == NEVER || environment.containsKey("NO_COLOR") + || environment.containsKey("CI") || "dumb".equals(environment.get("TERM"))) return false; + return this == ALWAYS || terminal; + } +} diff --git a/src/main/java/dev/archunitjava/report/ConsoleResultRenderer.java b/src/main/java/dev/archunitjava/report/ConsoleResultRenderer.java index a8b7b43..3ab2189 100644 --- a/src/main/java/dev/archunitjava/report/ConsoleResultRenderer.java +++ b/src/main/java/dev/archunitjava/report/ConsoleResultRenderer.java @@ -4,6 +4,8 @@ import dev.archunitjava.result.RuleResult; import dev.archunitjava.result.Violation; import java.util.Objects; +import java.util.List; +import java.util.Map; /** Bounded, deterministic plain-text output for people and terminal CI logs. */ public final class ConsoleResultRenderer { @@ -29,6 +31,43 @@ public static String render(ResultReport report, ResultRenderLimits limits) { return out.toString(); } + /** Opt-in formatted report; the existing render overloads retain their plain-text contract. */ + public static String renderPretty(ResultReport report, ConsoleColor color) { + return renderPretty(report, ResultRenderLimits.defaults(), color, System.console() != null, System.getenv()); + } + + /** Renders with explicit limits and terminal context; labels are always escaped before styling. */ + public static String renderPretty(ResultReport report, ResultRenderLimits limits, + ConsoleColor color, boolean terminal, Map environment) { + Objects.requireNonNull(report, "report"); + Objects.requireNonNull(limits, "limits"); + boolean useColor = Objects.requireNonNull(color, "color").enabled(terminal, environment); + StringBuilder out = new StringBuilder(ConsoleText.style("ARCHUNITJAVA | Architecture report", "1;36", useColor)) + .append('\n').append("=====================================\n"); + for (RuleResult result : report.results()) { + String plain = render(ResultReport.of(List.of(result)), limits); + int newline = plain.indexOf('\n'); + String code = switch (result.status()) { + case PASSED -> "1;32"; + case FAILED, INCOMPLETE -> "1;31"; + case SKIPPED -> "1;33"; + }; + out.append(ConsoleText.style(ConsoleText.sanitize(plain.substring(0, newline)), code, useColor)) + .append('\n'); + plain.substring(newline + 1).lines().forEach(line -> + out.append(ConsoleText.sanitize(line)).append('\n')); + result.metadata().rationale().ifPresent(reason -> out.append(" Because: ") + .append(ConsoleText.sanitize(reason)).append('\n')); + out.append('\n'); + } + out.append("Summary:"); + for (var status : dev.archunitjava.result.RuleStatus.values()) { + out.append(' ').append(status).append('=') + .append(report.results().stream().filter(result -> result.status() == status).count()); + } + return out.append('\n').toString(); + } + private static String marker(RuleResult result) { return switch (result.status()) { case PASSED -> "[PASS]"; diff --git a/src/main/java/dev/archunitjava/report/ConsoleText.java b/src/main/java/dev/archunitjava/report/ConsoleText.java new file mode 100644 index 0000000..aa4f70d --- /dev/null +++ b/src/main/java/dev/archunitjava/report/ConsoleText.java @@ -0,0 +1,23 @@ +package dev.archunitjava.report; + +/** Single-line terminal escaping shared by human-readable reports and logging. */ +public final class ConsoleText { + private ConsoleText() {} + + public static String sanitize(String value) { + StringBuilder out = new StringBuilder(value.length()); + value.codePoints().forEach(codePoint -> { + if (codePoint == '\n') out.append("\\n"); + else if (codePoint == '\r') out.append("\\r"); + else if (codePoint == '\t') out.append("\\t"); + else if (Character.isISOControl(codePoint) || Character.getType(codePoint) == Character.FORMAT + || codePoint == 0x2028 || codePoint == 0x2029) out.append('?'); + else out.appendCodePoint(codePoint); + }); + return out.toString(); + } + + public static String style(String text, String ansiCode, boolean color) { + return color ? "\u001b[" + ansiCode + "m" + text + "\u001b[0m" : text; + } +} diff --git a/src/main/java/dev/archunitjava/rules/ArchitectureRules.java b/src/main/java/dev/archunitjava/rules/ArchitectureRules.java index 31ab328..ef88208 100644 --- a/src/main/java/dev/archunitjava/rules/ArchitectureRules.java +++ b/src/main/java/dev/archunitjava/rules/ArchitectureRules.java @@ -1,11 +1,14 @@ package dev.archunitjava.rules; import dev.archunitjava.execution.CheckOptions; +import dev.archunitjava.diagnostics.AnalysisInspection; +import dev.archunitjava.diagnostics.LogLevel; import dev.archunitjava.result.RuleMetadata; import dev.archunitjava.result.RuleResult; import dev.archunitjava.result.Severity; import java.util.Collection; import java.util.Objects; +import java.util.Map; /** Factory for immutable rule values; later rule DSLs share this metadata behavior. */ public final class ArchitectureRules { @@ -27,14 +30,26 @@ private record ImmutableArchitectureRule(RuleMetadata metadata, RuleEvaluation e @Override public RuleResult check(CheckOptions options) { - RuleResult result = Objects.requireNonNull( - evaluation.evaluate(metadata, Objects.requireNonNull(options, "options")), - "rule result"); - if (!result.metadata().equals(metadata)) { - throw new IllegalStateException( - "Rule evaluation returned metadata other than the supplied immutable value"); + Objects.requireNonNull(options, "options"); + var log = options.logging(); + log.emit(LogLevel.INFO, "ASSERT", "Rule started", () -> Map.of( + "rule", metadata.semanticIdentity(), "description", metadata.displayName())); + log.emit(LogLevel.DEBUG, "ASSERT", "Check policies", () -> Map.of( + "emptySelection", options.emptySelectionPolicy().name(), + "allowIncompleteAnalysis", "" + options.allowIncompleteAnalysis())); + try { + RuleResult result = Objects.requireNonNull(evaluation.evaluate(metadata, options), "rule result"); + if (!result.metadata().equals(metadata)) { + throw new IllegalStateException( + "Rule evaluation returned metadata other than the supplied immutable value"); + } + AnalysisInspection.result(result, log); + return result; + } catch (RuntimeException failure) { + log.emit(LogLevel.ERROR, "ASSERT", "Rule execution failed", () -> Map.of( + "rule", metadata.semanticIdentity(), "exception", failure.getClass().getName())); + throw failure; } - return result; } @Override diff --git a/src/main/java/dev/archunitjava/rules/DependencyRules.java b/src/main/java/dev/archunitjava/rules/DependencyRules.java index 0526634..495f78f 100644 --- a/src/main/java/dev/archunitjava/rules/DependencyRules.java +++ b/src/main/java/dev/archunitjava/rules/DependencyRules.java @@ -1,6 +1,8 @@ package dev.archunitjava.rules; import dev.archunitjava.execution.CheckOptions; +import dev.archunitjava.diagnostics.AnalysisInspection; +import dev.archunitjava.diagnostics.LogLevel; import dev.archunitjava.graph.DependencyEdge; import dev.archunitjava.graph.DependencyEvidence; import dev.archunitjava.graph.DependencyGraph; @@ -111,6 +113,12 @@ private static RuleResult evaluate( CheckOptions options, Domain domain, DependencyRuleSpec spec) { + AnalysisInspection.graph(domain.graph, options.logging()); + options.logging().emit(LogLevel.DEBUG, "ASSERT", "Selected subjects", () -> Map.of( + "origins", domain.origins.toString(), "targets", domain.targets.toString())); + options.logging().emit(LogLevel.DEBUG, "ASSERT", "Dependency policies", () -> Map.of( + "mode", spec.mode().name(), "self", spec.selfDependencies().name(), + "external", spec.externalDependencies().name())); return RuleTerminal.evaluate( metadata, options, @@ -134,6 +142,16 @@ private static RuleResult ordinary( List diagnostics = new ArrayList<>(terminalDiagnostics); boolean externalFailure = false; for (DependencyEdge edge : domain.graph.edges()) { + if (options.logging().enabled(LogLevel.DEBUG)) { + String decision = !domain.origins.contains(edge.origin()) ? "origin not selected" + : spec.selfDependencies() == SelfDependencyPolicy.IGNORE && edge.origin().equals(edge.target()) + ? "self dependency ignored" + : !domain.known.contains(edge.target()) && spec.externalDependencies() == ExternalDependencyPolicy.IGNORE + ? "external dependency ignored" : "evaluated"; + options.logging().emit(LogLevel.DEBUG, "ASSERT", "Dependency filter", () -> Map.of( + "origin", edge.origin().stableKey(), "target", edge.target().stableKey(), + "decision", decision, "targetSelected", "" + domain.targets.contains(edge.target()))); + } if (!domain.origins.contains(edge.origin())) continue; if (spec.selfDependencies() == SelfDependencyPolicy.IGNORE && edge.origin().equals(edge.target())) continue; diff --git a/src/main/java/dev/archunitjava/rules/RuleTerminal.java b/src/main/java/dev/archunitjava/rules/RuleTerminal.java index 6b0fb75..7f649e9 100644 --- a/src/main/java/dev/archunitjava/rules/RuleTerminal.java +++ b/src/main/java/dev/archunitjava/rules/RuleTerminal.java @@ -1,6 +1,7 @@ package dev.archunitjava.rules; import dev.archunitjava.execution.CheckOptions; +import dev.archunitjava.diagnostics.LogLevel; import dev.archunitjava.execution.EmptySelectionPolicy; import dev.archunitjava.result.Diagnostic; import dev.archunitjava.result.RuleMetadata; @@ -46,6 +47,13 @@ public static RuleResult evaluate( .thenComparing(value -> value.selector().text())) .toList(); Objects.requireNonNull(ordinaryEvaluation, "ordinaryEvaluation"); + if (options.logging().enabled(LogLevel.DEBUG)) { + selections.stream().sorted(java.util.Comparator.comparing(RuleSelection::role) + .thenComparing(value -> value.selector().text())).forEach(selection -> + options.logging().emit(LogLevel.DEBUG, "ASSERT", "Selection", () -> Map.of( + "rule", metadata.semanticIdentity(), "role", selection.role(), + "selector", selection.selector().text(), "selected", "" + selection.selectedCount()))); + } if (empty.isEmpty() || options.emptySelectionPolicy() == EmptySelectionPolicy.ALLOW) { return requireMetadata(metadata, ordinaryEvaluation.create(List.of())); } diff --git a/src/test/java/dev/archunitjava/cli/CliRunnerTest.java b/src/test/java/dev/archunitjava/cli/CliRunnerTest.java index 37d2944..fcde9ae 100644 --- a/src/test/java/dev/archunitjava/cli/CliRunnerTest.java +++ b/src/test/java/dev/archunitjava/cli/CliRunnerTest.java @@ -49,6 +49,37 @@ void fixture() throws IOException { """); } + @Test + void debugInspectionPreservesRealBytecodeAnalysisAndStructuredResults() { + CliConfiguration config = CliConfigurationLoader.load(configuration, root); + var expected = new CliAnalyzer().analyze(config); + var events = new java.util.ArrayList(); + var actual = new CliAnalyzer().analyze(config, dev.archunitjava.diagnostics.AnalysisLogging.of( + dev.archunitjava.diagnostics.LogLevel.DEBUG, events::add)); + assertEquals(expected.results(), actual.results()); + assertEquals(expected.graph(), actual.graph()); + assertEquals(ResultJsonRenderer.render(expected.results()), ResultJsonRenderer.render(actual.results())); + for (String message : java.util.List.of("Import started", "Selected resource", "Imported type", + "External type", "Graph ready", "Graph node", "Dependency edge", "Selection", + "Dependency filter", "Violation", "Analysis complete")) { + assertTrue(events.stream().anyMatch(event -> event.message().equals(message)), message); + } + } + + @Test + void incompleteImportLogsTheFinalAdjustedResult() throws IOException { + Files.write(classes.resolve("Broken.class"), new byte[] {0, 1, 2, 3}); + CliConfiguration config = CliConfigurationLoader.load(configuration, root); + var events = new java.util.ArrayList(); + var actual = new CliAnalyzer().analyze(config, dev.archunitjava.diagnostics.AnalysisLogging.of( + dev.archunitjava.diagnostics.LogLevel.DEBUG, events::add)); + assertEquals(new CliAnalyzer().analyze(config).results(), actual.results()); + assertEquals("INCOMPLETE", events.stream().filter(event -> event.message().equals("Rule complete")) + .toList().getLast().details().get("status")); + assertTrue(events.stream().anyMatch(event -> event.message().equals("Class file diagnostic") + && event.level() == dev.archunitjava.diagnostics.LogLevel.ERROR)); + } + @Test void checkUsesTheSameResultsAsThePublicJavaApi() { CliConfiguration config = CliConfigurationLoader.load(configuration, root); diff --git a/src/test/java/dev/archunitjava/diagnostics/AnalysisLoggingTest.java b/src/test/java/dev/archunitjava/diagnostics/AnalysisLoggingTest.java new file mode 100644 index 0000000..52642e6 --- /dev/null +++ b/src/test/java/dev/archunitjava/diagnostics/AnalysisLoggingTest.java @@ -0,0 +1,134 @@ +package dev.archunitjava.diagnostics; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.archunitjava.execution.CheckOptions; +import dev.archunitjava.graph.TypeId; +import dev.archunitjava.metrics.MetricAmount; +import dev.archunitjava.metrics.MetricName; +import dev.archunitjava.metrics.MetricSample; +import dev.archunitjava.metrics.MetricThreshold; +import dev.archunitjava.metrics.MetricThresholdRules; +import dev.archunitjava.report.ConsoleColor; +import dev.archunitjava.report.ResultJsonRenderer; +import dev.archunitjava.report.ResultReport; +import dev.archunitjava.result.RuleResult; +import dev.archunitjava.rules.ArchitectureRules; +import dev.archunitjava.rules.RuleTerminal; +import dev.archunitjava.selector.SelectorDescription; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AnalysisLoggingTest { + @Test + void levelsFilterBeforeEvaluatingLazyDetails() { + for (LogLevel level : LogLevel.values()) { + List events = new ArrayList<>(); + var log = AnalysisLogging.of(level, events::add); + for (LogLevel emitted : LogLevel.values()) { + log.emit(emitted, "ASSERT", "event", () -> { + assertTrue(level.includes(emitted)); + return Map.of(); + }); + } + assertEquals(level.ordinal(), events.size(), level.name()); + assertTrue(events.stream().allMatch(event -> level.includes(event.level()))); + } + AnalysisLogging.disabled().emit(LogLevel.DEBUG, "ASSERT", "disabled", + () -> { throw new AssertionError("Disabled logging evaluated a supplier"); }); + } + + @Test + void loggingPreservesRuleResultsAndTheOriginalExecutionException() { + var rule = ArchitectureRules.define("selection", "Inspect selection", (metadata, options) -> + RuleTerminal.evaluate(metadata, options, new SelectorDescription("nothing"), 0, + diagnostics -> RuleResult.passed(metadata, diagnostics))); + String expected = ResultJsonRenderer.render(ResultReport.of(List.of(rule.check()))); + for (LogLevel level : LogLevel.values()) { + List events = new ArrayList<>(); + var options = CheckOptions.builder().logging(AnalysisLogging.of(level, events::add)).build(); + assertEquals(expected, ResultJsonRenderer.render(ResultReport.of(List.of(rule.check(options))))); + assertSame(options.logging(), options.toBuilder().build().logging()); + if (level == LogLevel.DEBUG) { + assertTrue(events.stream().anyMatch(event -> event.message().equals("Selection") + && event.details().get("selected").equals("0"))); + assertTrue(events.stream().anyMatch(event -> event.level() == LogLevel.ERROR)); + } + } + var failingSink = CheckOptions.builder().logging(AnalysisLogging.of(LogLevel.DEBUG, + event -> { throw new IllegalStateException("closed log"); })).build(); + assertEquals(rule.check(), rule.check(failingSink)); + var original = new IllegalStateException("evaluation failed"); + var failingRule = ArchitectureRules.define("failure", "Fails to execute", + (metadata, options) -> { throw original; }); + assertSame(original, assertThrows(IllegalStateException.class, () -> failingRule.check(failingSink))); + } + + @Test + void metricInspectionIncludesPassingAndFailingSamplesWithoutChangingViolations() { + MetricName metric = MetricName.values()[0]; + List samples = List.of( + new MetricSample(TypeId.ofBinaryName("example.Pass"), metric, MetricAmount.of(1, metric.unit())), + new MetricSample(TypeId.ofBinaryName("example.Fail"), metric, MetricAmount.of(3, metric.unit()))); + var rule = MetricThresholdRules.enforce(samples, + MetricThreshold.atMost(metric, MetricAmount.of(2, metric.unit()))); + List events = new ArrayList<>(); + var actual = rule.check(CheckOptions.builder().logging(AnalysisLogging.of(LogLevel.DEBUG, events::add)).build()); + assertEquals(rule.check(), actual); + assertEquals(1, actual.violations().size()); + assertEquals(2, events.stream().filter(event -> event.message().equals("Metric sample")).count()); + assertTrue(events.stream().anyMatch(event -> event.message().equals("Metric threshold"))); + } + + @Test + void structuredEventsAreImmutableAndTextCannotInjectTerminalControlSequences() { + Map details = new HashMap<>(Map.of("z", "last", "a", "first\n\u001b[31m\u202e")); + var event = new AnalysisEvent(LogLevel.WARN, "EXTRACT", "bad\rlabel", details); + details.clear(); + assertEquals(List.of("a", "z"), new ArrayList<>(event.details().keySet())); + assertThrows(UnsupportedOperationException.class, () -> event.details().put("x", "y")); + String plain = AnalysisLogging.format(event, false); + assertTrue(plain.contains("bad\\rlabel")); + assertTrue(plain.contains("first\\n?[31m?")); + assertFalse(plain.contains("\u001b")); + assertEquals(1, plain.chars().filter(c -> c == '\n').count()); + String colored = AnalysisLogging.format(event, true); + assertTrue(colored.startsWith("\u001b[33m[WARN]\u001b[0m")); + assertEquals(plain, colored.replaceAll("\u001b\\[[0-9;]*m", "")); + } + + @Test + void appendableFailureCannotChangeACheckAndTextDefaultsToPlain() { + var writer = new java.io.Writer() { + @Override public void write(char[] buffer, int offset, int length) throws java.io.IOException { + throw new java.io.IOException("disk full"); + } + @Override public void flush() {} + @Override public void close() {} + }; + var rule = ArchitectureRules.define("pass", "Pass", (metadata, options) -> RuleResult.passed(metadata)); + assertEquals(rule.check(), rule.check(CheckOptions.builder() + .logging(AnalysisLogging.text(LogLevel.DEBUG, writer)).build())); + StringBuilder out = new StringBuilder(); + rule.check(CheckOptions.builder().logging(AnalysisLogging.text(LogLevel.INFO, out)).build()); + assertTrue(out.toString().contains("[INFO] ASSERT | Rule started")); + assertFalse(out.toString().contains("\u001b")); + } + + @Test + void colorHonorsTerminalAndExplicitEnvironmentOptOuts() { + assertFalse(ConsoleColor.AUTO.enabled(false, Map.of())); + assertTrue(ConsoleColor.AUTO.enabled(true, Map.of())); + assertTrue(ConsoleColor.ALWAYS.enabled(false, Map.of())); + assertFalse(ConsoleColor.NEVER.enabled(true, Map.of())); + for (ConsoleColor color : ConsoleColor.values()) { + assertFalse(color.enabled(true, Map.of("NO_COLOR", ""))); + assertFalse(color.enabled(true, Map.of("CI", "true"))); + assertFalse(color.enabled(true, Map.of("TERM", "dumb"))); + } + } +} + diff --git a/src/test/java/dev/archunitjava/report/ResultRenderersTest.java b/src/test/java/dev/archunitjava/report/ResultRenderersTest.java index e792e28..cf6a7a7 100644 --- a/src/test/java/dev/archunitjava/report/ResultRenderersTest.java +++ b/src/test/java/dev/archunitjava/report/ResultRenderersTest.java @@ -22,6 +22,26 @@ import org.junit.jupiter.api.Test; final class ResultRenderersTest { + @Test + void prettyReportsAddBoundedLayoutAndOptionalColorWithoutChangingPlainReports() { + ResultReport report = report(); + ResultRenderLimits limits = new ResultRenderLimits(1, 0, 1); + String plainBefore = ConsoleResultRenderer.render(report, limits); + String pretty = ConsoleResultRenderer.renderPretty(report, limits, ConsoleColor.NEVER, true, Map.of()); + String colored = ConsoleResultRenderer.renderPretty(report, limits, ConsoleColor.AUTO, true, Map.of()); + assertEquals(pretty, colored.replaceAll("\u001b\\[[0-9;]*m", "")); + assertTrue(colored.contains("\u001b[1;32m[PASS]")); + assertTrue(pretty.contains("ARCHUNITJAVA | Architecture report")); + assertTrue(pretty.contains("Summary: PASSED=1 FAILED=1 SKIPPED=1 INCOMPLETE=1")); + assertTrue(pretty.contains("Because: Keep <safe> & reliable")); + assertTrue(pretty.contains("... 2 more evidence")); + assertEquals(pretty, ConsoleResultRenderer.renderPretty(report, limits, ConsoleColor.ALWAYS, + true, Map.of("NO_COLOR", ""))); + assertEquals(pretty, ConsoleResultRenderer.renderPretty(report, limits, ConsoleColor.AUTO, + false, Map.of())); + assertEquals(plainBefore, ConsoleResultRenderer.render(report, limits)); + } + @Test void jsonIsVersionedLosslessDeterministicAndUtf8() { ResultReport report = report(); diff --git a/src/test/java/dev/archunitjava/rules/DependencyRulesTest.java b/src/test/java/dev/archunitjava/rules/DependencyRulesTest.java index 6f5739a..04975b1 100644 --- a/src/test/java/dev/archunitjava/rules/DependencyRulesTest.java +++ b/src/test/java/dev/archunitjava/rules/DependencyRulesTest.java @@ -64,6 +64,27 @@ void buildFixture() throws IOException { .build(); } + @Test + void loggingExplainsSelectionAndFiltersWithoutChangingPolicyResults() { + var rule = DependencyRules.types(model, graph, binary("api.A"), binary("api.B"), + DependencyRuleSpec.noDependencies() + .withExternalDependencies(ExternalDependencyPolicy.IGNORE)); + var expected = rule.check(); + for (var level : dev.archunitjava.diagnostics.LogLevel.values()) { + var events = new java.util.ArrayList(); + var options = CheckOptions.builder().logging( + dev.archunitjava.diagnostics.AnalysisLogging.of(level, events::add)).build(); + assertEquals(expected, rule.check(options)); + if (level == dev.archunitjava.diagnostics.LogLevel.DEBUG) { + assertTrue(events.stream().anyMatch(event -> event.message().equals("Selected subjects"))); + assertTrue(events.stream().anyMatch(event -> event.message().equals("Dependency filter") + && event.details().get("decision").equals("self dependency ignored"))); + assertTrue(events.stream().anyMatch(event -> event.message().equals("Dependency evidence"))); + assertTrue(events.stream().anyMatch(event -> event.message().equals("Violation evidence"))); + } + } + } + @Test void noOnlyAnyAndRequiredPoliciesShareEvidencePreservingResults() { TypeSelector a = binary("api.A");