Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/) ·
Expand Down Expand Up @@ -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:
Expand Down
21 changes: 20 additions & 1 deletion src/main/java/dev/archunitjava/cli/CliAnalyzer.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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));
}

Expand Down
24 changes: 24 additions & 0 deletions src/main/java/dev/archunitjava/diagnostics/AnalysisEvent.java
Original file line number Diff line number Diff line change
@@ -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<String, String> details) {
public AnalysisEvent {
Objects.requireNonNull(level, "level");
Objects.requireNonNull(stage, "stage");
Objects.requireNonNull(message, "message");
TreeMap<String, String> 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<String, String> details() {
return Collections.unmodifiableMap(new TreeMap<>(details));
}
}
93 changes: 93 additions & 0 deletions src/main/java/dev/archunitjava/diagnostics/AnalysisInspection.java
Original file line number Diff line number Diff line change
@@ -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<MetricSample> 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())));
});
}
}
73 changes: 73 additions & 0 deletions src/main/java/dev/archunitjava/diagnostics/AnalysisLogging.java
Original file line number Diff line number Diff line change
@@ -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<AnalysisEvent> sink;

private AnalysisLogging(LogLevel level, Consumer<AnalysisEvent> 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<AnalysisEvent> 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<Map<String, String>> 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";
}
}
10 changes: 10 additions & 0 deletions src/main/java/dev/archunitjava/diagnostics/LogLevel.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading
Loading