Skip to content

Commit ab505d5

Browse files
committed
feat: add opt-in diagnostics and formatted inspection reports
1 parent 9c74748 commit ab505d5

18 files changed

Lines changed: 684 additions & 14 deletions

‎README.md‎

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ _Inspired by the established [ArchUnit](https://www.archunit.org/) project, but
3131
implemented and not affiliated with ArchUnit. This is not a drop-in replacement._
3232

3333
[Quickstart](#-five-minute-quickstart) · [Use cases](#-use-cases) ·
34-
[Capabilities](#-capabilities) · [Reports](#-reports) ·
34+
[Capabilities](#-capabilities) · [Inspection](#-logging-and-inspection) · [Reports](#-reports) ·
3535
[Example repository](#-independent-example-repository) ·
3636
[User guide](docs/USER_GUIDE.md) · [CLI reference](docs/CLI_REFERENCE.md) ·
3737
[Documentation](https://lukasniessen.github.io/ArchUnitJava/) ·
@@ -225,6 +225,104 @@ normal workflow and API entry point; the [CLI reference](docs/CLI_REFERENCE.md)
225225
configuration key, command, format, and exit code. The internal architecture and ownership rules
226226
are described in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
227227

228+
## 🔎 Logging and inspection
229+
230+
Logging is **off by default** and scoped to an individual check or analysis. Choose
231+
`ERROR`, `WARN`, `INFO`, or `DEBUG`; higher verbosity includes all lower levels.
232+
Logging does not change rule outcomes, violation identities, evidence, or machine-readable reports.
233+
234+
Given an `ArchitectureRule rule`, enable detailed inspection and a formatted console report:
235+
236+
```java
237+
import dev.archunitjava.diagnostics.AnalysisLogging;
238+
import dev.archunitjava.diagnostics.LogLevel;
239+
import dev.archunitjava.execution.CheckOptions;
240+
import dev.archunitjava.report.ConsoleColor;
241+
import dev.archunitjava.report.ConsoleResultRenderer;
242+
import dev.archunitjava.report.ResultReport;
243+
import java.util.List;
244+
245+
var logging = AnalysisLogging.text(LogLevel.DEBUG, System.err, ConsoleColor.AUTO);
246+
var options = CheckOptions.builder().logging(logging).build();
247+
var result = rule.check(options);
248+
249+
System.out.print(ConsoleResultRenderer.renderPretty(
250+
ResultReport.of(List.of(result)), ConsoleColor.AUTO));
251+
```
252+
253+
| Level | What you see |
254+
| --- | --- |
255+
| `OFF` | Nothing; the default |
256+
| `ERROR` | Failed or incomplete rules, execution failures, and error diagnostics |
257+
| `WARN` | Errors plus skipped rules and import or rule warnings |
258+
| `INFO` | Warnings plus rule start/completion, import and graph counts, and analysis summaries |
259+
| `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 |
260+
261+
Use the same observer for the complete bytecode pipeline:
262+
263+
```java
264+
import dev.archunitjava.cli.CliAnalyzer;
265+
import dev.archunitjava.cli.CliConfigurationLoader;
266+
267+
var configuration = CliConfigurationLoader.load(configurationFile, approvedRoot);
268+
var analysis = new CliAnalyzer().analyze(configuration, logging);
269+
```
270+
271+
The direct Java analyzer overload enables logging without changing CLI configuration keys or
272+
stdout report formats. For independently constructed models, graphs, and metric reports,
273+
`AnalysisInspection.imports(importResult, logging)`,
274+
`AnalysisInspection.graph(graph, logging)`, and
275+
`AnalysisInspection.metrics(samples, logging)` inspect existing immutable data without
276+
re-importing classes or reevaluating rules. Dependency-rule checks automatically explain their
277+
projected graph, selected subjects, and self/external dependency filtering. Metric-threshold
278+
checks automatically show both passing and failing samples at `DEBUG`.
279+
280+
Example plain-text output (ANSI highlighting is added on supported terminals):
281+
282+
```text
283+
[INFO] EXTRACT | Import complete {externalTypes=1, resources=2, types=2}
284+
[INFO] PROJECT | Graph ready {edges=2, nodes=3}
285+
[DEBUG] ASSERT | Selection {role=origins, rule=..., selected=1, selector=...}
286+
[ERROR] REPORT | Rule complete {rule=..., status=FAILED, violations=1}
287+
288+
ARCHUNITJAVA | Architecture report
289+
=====================================
290+
[FAIL:POLICY] API boundary (...)
291+
- ... [ERROR] dependency.forbidden
292+
at classes/api/A.class
293+
Because: internal implementation must remain private
294+
295+
Summary: PASSED=0 FAILED=1 SKIPPED=0 INCOMPLETE=0
296+
```
297+
298+
Pretty reports use a heading, highlighted status lines, indented evidence, the rule rationale,
299+
and a status summary. `ConsoleColor.AUTO` uses ANSI only when a terminal is detected;
300+
`NEVER` always produces plain text and `ALWAYS` explicitly enables ANSI for a caller that
301+
knows its destination supports it. `NO_COLOR`, `CI`, and `TERM=dumb` suppress ANSI in
302+
every mode. Existing `ConsoleResultRenderer.render(...)` overloads keep their original
303+
plain-text output and limits; `renderPretty` retains the same evidence and diagnostic limits.
304+
305+
### Save logs as CI artifacts or collect structured events
306+
307+
```java
308+
import java.nio.charset.StandardCharsets;
309+
import java.nio.file.Files;
310+
311+
try (var writer = Files.newBufferedWriter(logFile, StandardCharsets.UTF_8)) {
312+
var fileLogging = AnalysisLogging.text(LogLevel.DEBUG, writer); // plain text
313+
rule.check(CheckOptions.builder().logging(fileLogging).build());
314+
}
315+
```
316+
317+
`AnalysisLogging.of(LogLevel.DEBUG, event -> ...)` accepts a custom sink. Each immutable
318+
`AnalysisEvent` has a level, pipeline stage, message, and sorted detail map. Text output
319+
escapes target-controlled line breaks, terminal controls, and formatting characters.
320+
Debug events are not truncated, so stream them to a writer for large models; the library
321+
does not retain an event buffer. Disabled levels do not evaluate detail suppliers.
322+
Sink runtime exceptions, including writer failures, are isolated from evaluation; fatal VM
323+
errors are not swallowed. Callers own sink concurrency, flushing, and file lifetime.
324+
There is no global logger, implicit log file, or implicit console output.
325+
228326
## 📊 Reports
229327

230328
Rule results can be rendered as:

‎src/main/java/dev/archunitjava/cli/CliAnalyzer.java‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package dev.archunitjava.cli;
22

33
import dev.archunitjava.importer.ClassFileInput;
4+
import dev.archunitjava.diagnostics.AnalysisInspection;
5+
import dev.archunitjava.diagnostics.AnalysisLogging;
6+
import dev.archunitjava.diagnostics.LogLevel;
47
import dev.archunitjava.importer.ClassPathImportResolver;
58
import dev.archunitjava.importer.ImportResolutionResult;
69
import dev.archunitjava.importer.InputDiagnosticCode;
@@ -18,15 +21,31 @@
1821
public final class CliAnalyzer {
1922
public CliAnalysisResult analyze(CliConfiguration configuration) {
2023
CliConfiguration config = Objects.requireNonNull(configuration, "configuration");
24+
return analyze(config, config.checkOptions().logging());
25+
}
26+
27+
/** Analyze with an explicit observer without changing the configuration or result schema. */
28+
public CliAnalysisResult analyze(CliConfiguration configuration, AnalysisLogging logging) {
29+
CliConfiguration config = Objects.requireNonNull(configuration, "configuration");
30+
AnalysisLogging log = Objects.requireNonNull(logging, "logging");
31+
var options = log == config.checkOptions().logging() ? config.checkOptions()
32+
: config.checkOptions().toBuilder().logging(log).build();
33+
log.emit(LogLevel.INFO, "EXTRACT", "Import started", () -> Map.of("inputs", "" + config.inputs().size()));
2134
var imports = new ClassPathImportResolver().resolve(config.inputs().stream()
2235
.map(ClassFileInput::path).toList());
36+
AnalysisInspection.imports(imports, log);
2337
var graph = CliGraphBuilder.build(imports.model());
38+
AnalysisInspection.graph(graph, log);
2439
var failures = importFailures(imports);
2540
var results = CliRuleFactory.createRules(config, imports.model(), graph).stream()
26-
.map(rule -> rule.check(config.checkOptions()))
41+
.map(rule -> rule.check(options))
2742
.map(result -> withImportDiagnostics(result, failures,
2843
config.checkOptions().allowIncompleteAnalysis()))
2944
.toList();
45+
if (!failures.isEmpty()) results.forEach(result -> AnalysisInspection.result(result, log));
46+
log.emit(failures.isEmpty() ? LogLevel.INFO : LogLevel.WARN, "REPORT", "Analysis complete", () -> Map.of("rules", "" + results.size(),
47+
"passed", "" + results.stream().filter(RuleResult::passed).count(),
48+
"importFailures", "" + failures.size()));
3049
return new CliAnalysisResult(imports, graph, ResultReport.of(results));
3150
}
3251

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package dev.archunitjava.diagnostics;
2+
3+
import java.util.Collections;
4+
import java.util.Map;
5+
import java.util.Objects;
6+
import java.util.TreeMap;
7+
8+
/** Structured observation, separate from rule results and their diagnostic schema. */
9+
public record AnalysisEvent(LogLevel level, String stage, String message, Map<String, String> details) {
10+
public AnalysisEvent {
11+
Objects.requireNonNull(level, "level");
12+
Objects.requireNonNull(stage, "stage");
13+
Objects.requireNonNull(message, "message");
14+
TreeMap<String, String> sorted = new TreeMap<>();
15+
Objects.requireNonNull(details, "details").forEach((key, value) ->
16+
sorted.put(Objects.requireNonNull(key), Objects.requireNonNull(value)));
17+
details = Collections.unmodifiableMap(sorted);
18+
}
19+
/** Returns a sorted defensive snapshot; callers never receive the stored map. */
20+
@Override
21+
public Map<String, String> details() {
22+
return Collections.unmodifiableMap(new TreeMap<>(details));
23+
}
24+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package dev.archunitjava.diagnostics;
2+
3+
import dev.archunitjava.graph.DependencyGraph;
4+
import dev.archunitjava.importer.ImportResolutionResult;
5+
import dev.archunitjava.metrics.MetricSample;
6+
import dev.archunitjava.result.RuleResult;
7+
import java.util.Collection;
8+
import java.util.Map;
9+
10+
/** Read-only inspection of existing immutable data; never parses, loads or reevaluates target code. */
11+
public final class AnalysisInspection {
12+
private AnalysisInspection() {}
13+
14+
public static void imports(ImportResolutionResult imports, AnalysisLogging log) {
15+
if (log.level() == LogLevel.OFF) return;
16+
log.emit(LogLevel.INFO, "EXTRACT", "Import complete", () -> Map.of(
17+
"types", "" + imports.model().types().size(), "externalTypes", "" + imports.externalTypes().size(),
18+
"resources", "" + imports.assembly().selections().size()));
19+
if (log.enabled(LogLevel.DEBUG)) {
20+
imports.assembly().selections().forEach(selection -> log.emit(LogLevel.DEBUG, "EXTRACT",
21+
"Selected resource", () -> Map.of("resource", selection.winner().name(),
22+
"precedence", "" + selection.winner().precedence())));
23+
imports.importedTypes().forEach(type -> log.emit(LogLevel.DEBUG, "EXTRACT", "Imported type",
24+
() -> Map.of("type", type.winner().binaryName(), "scope", type.lookupScope(),
25+
"shadowedDefinitions", "" + type.shadowedDefinitions().size())));
26+
imports.externalTypes().forEach(type -> log.emit(LogLevel.DEBUG, "EXTRACT", "External type",
27+
() -> Map.of("type", type.name().binaryName())));
28+
}
29+
imports.assembly().diagnostics().forEach(diagnostic -> log.emit(switch (diagnostic.code()) {
30+
case DUPLICATE_INPUT, DUPLICATE_RESOURCE, MULTI_RELEASE_ENTRY_IGNORED,
31+
RESOURCE_EXCLUDED, SYMLINK_SKIPPED -> LogLevel.DEBUG;
32+
default -> LogLevel.WARN;
33+
}, "EXTRACT",
34+
"Input selection diagnostic", () -> Map.of("code", diagnostic.code().name(),
35+
"input", diagnostic.input(), "context", diagnostic.context().toString())));
36+
imports.diagnostics().forEach(diagnostic -> log.emit(LogLevel.WARN, "EXTRACT",
37+
"Import resolution diagnostic", () -> Map.of("diagnostic", diagnostic.toString())));
38+
imports.model().classFileDiagnostics().forEach(diagnostic -> log.emit(LogLevel.ERROR, "EXTRACT",
39+
"Class file diagnostic", () -> Map.of("diagnostic", diagnostic.toString())));
40+
imports.model().diagnostics().forEach(diagnostic -> log.emit(LogLevel.WARN, "EXTRACT",
41+
"Model diagnostic", () -> Map.of("diagnostic", diagnostic.toString())));
42+
}
43+
44+
public static void graph(DependencyGraph graph, AnalysisLogging log) {
45+
log.emit(LogLevel.INFO, "PROJECT", "Graph ready", () -> Map.of(
46+
"nodes", "" + graph.nodes().size(), "edges", "" + graph.edges().size()));
47+
if (!log.enabled(LogLevel.DEBUG)) return;
48+
graph.nodes().forEach(node -> log.emit(LogLevel.DEBUG, "PROJECT", "Graph node",
49+
() -> Map.of("node", node.id().stableKey())));
50+
graph.edges().forEach(edge -> {
51+
log.emit(LogLevel.DEBUG, "PROJECT", "Dependency edge", () -> Map.of(
52+
"origin", edge.origin().stableKey(), "target", edge.target().stableKey(),
53+
"kind", edge.kind().name(), "evidence", "" + edge.evidence().size()));
54+
edge.evidence().forEach(evidence -> log.emit(LogLevel.DEBUG, "PROJECT", "Dependency evidence",
55+
() -> Map.of("origin", edge.origin().stableKey(), "target", edge.target().stableKey(),
56+
"evidence", evidence.toString())));
57+
});
58+
}
59+
60+
public static void metrics(Collection<MetricSample> samples, AnalysisLogging log) {
61+
if (!log.enabled(LogLevel.DEBUG)) return;
62+
samples.stream().sorted().forEach(sample -> log.emit(LogLevel.DEBUG, "ASSERT", "Metric sample",
63+
() -> Map.of("subject", sample.subject().stableKey(), "metric", sample.metric().name(),
64+
"value", sample.amount().stableValue(), "unit", sample.amount().unit().name())));
65+
}
66+
67+
public static void result(RuleResult result, AnalysisLogging log) {
68+
if (log.level() == LogLevel.OFF) return;
69+
LogLevel level = switch (result.status()) {
70+
case PASSED -> LogLevel.INFO;
71+
case FAILED, INCOMPLETE -> LogLevel.ERROR;
72+
case SKIPPED -> LogLevel.WARN;
73+
};
74+
log.emit(level, "REPORT", "Rule complete", () -> Map.of("rule", result.ruleId(),
75+
"status", result.status().name(), "violations", "" + result.violations().size()));
76+
result.diagnostics().forEach(diagnostic -> log.emit(
77+
switch (diagnostic.severity()) {
78+
case ERROR -> LogLevel.ERROR;
79+
case WARNING -> LogLevel.WARN;
80+
case INFO -> LogLevel.INFO;
81+
},
82+
"REPORT", "Rule diagnostic", () -> Map.of("rule", result.ruleId(),
83+
"code", diagnostic.code(), "context", diagnostic.context().toString())));
84+
if (!log.enabled(LogLevel.DEBUG)) return;
85+
result.violations().forEach(violation -> {
86+
log.emit(LogLevel.DEBUG, "REPORT", "Violation", () -> Map.of("rule", result.ruleId(),
87+
"id", violation.id().value(), "code", violation.code(),
88+
"subjects", violation.subjects().toString(), "attributes", violation.attributes().toString()));
89+
violation.evidence().forEach(evidence -> log.emit(LogLevel.DEBUG, "REPORT", "Violation evidence",
90+
() -> Map.of("id", violation.id().value(), "evidence", evidence.toString())));
91+
});
92+
}
93+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package dev.archunitjava.diagnostics;
2+
3+
import dev.archunitjava.report.ConsoleColor;
4+
import dev.archunitjava.report.ConsoleText;
5+
import java.io.IOException;
6+
import java.io.UncheckedIOException;
7+
import java.util.Map;
8+
import java.util.Objects;
9+
import java.util.function.Consumer;
10+
import java.util.function.Supplier;
11+
12+
/**
13+
* Explicit per-check observer; no global logger, files, timestamps or implicit console writes.
14+
* Sink runtime failures are isolated so logging cannot replace a rule result or execution error.
15+
* Callers own sink lifetime, flushing and concurrency. Fatal VM errors are not intercepted.
16+
*/
17+
public final class AnalysisLogging {
18+
private static final AnalysisLogging DISABLED = new AnalysisLogging(LogLevel.OFF, event -> {});
19+
private final LogLevel level;
20+
private final Consumer<AnalysisEvent> sink;
21+
22+
private AnalysisLogging(LogLevel level, Consumer<AnalysisEvent> sink) {
23+
this.level = Objects.requireNonNull(level, "level");
24+
this.sink = Objects.requireNonNull(sink, "sink");
25+
}
26+
27+
public static AnalysisLogging disabled() { return DISABLED; }
28+
29+
public static AnalysisLogging of(LogLevel level, Consumer<AnalysisEvent> sink) {
30+
return new AnalysisLogging(level, sink);
31+
}
32+
33+
/** Plain text is the conservative default for appendable destinations such as files. */
34+
public static AnalysisLogging text(LogLevel level, Appendable destination) {
35+
return text(level, destination, ConsoleColor.NEVER);
36+
}
37+
38+
public static AnalysisLogging text(LogLevel level, Appendable destination, ConsoleColor color) {
39+
Objects.requireNonNull(destination, "destination");
40+
boolean useColor = Objects.requireNonNull(color, "color").enabled();
41+
return of(level, event -> {
42+
try {
43+
destination.append(format(event, useColor));
44+
} catch (IOException failure) {
45+
throw new UncheckedIOException(failure);
46+
}
47+
});
48+
}
49+
50+
public LogLevel level() { return level; }
51+
public boolean enabled(LogLevel eventLevel) { return level.includes(eventLevel); }
52+
53+
public void emit(LogLevel eventLevel, String stage, String message, Supplier<Map<String, String>> details) {
54+
if (!enabled(eventLevel)) return;
55+
try {
56+
sink.accept(new AnalysisEvent(eventLevel, stage, message, details.get()));
57+
} catch (RuntimeException ignored) {
58+
// Observability is best effort and never alters architecture semantics.
59+
}
60+
}
61+
62+
public static String format(AnalysisEvent event, boolean color) {
63+
String code = switch (event.level()) {
64+
case ERROR -> "31";
65+
case WARN -> "33";
66+
case INFO -> "36";
67+
case DEBUG, OFF -> "2";
68+
};
69+
return ConsoleText.style("[" + event.level() + "]", code, color) + " "
70+
+ ConsoleText.sanitize(event.stage()) + " | " + ConsoleText.sanitize(event.message())
71+
+ (event.details().isEmpty() ? "" : " " + ConsoleText.sanitize(event.details().toString())) + "\n";
72+
}
73+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package dev.archunitjava.diagnostics;
2+
3+
/** Per-operation verbosity. DEBUG includes every emitted event; OFF emits nothing. */
4+
public enum LogLevel {
5+
OFF, ERROR, WARN, INFO, DEBUG;
6+
7+
public boolean includes(LogLevel eventLevel) {
8+
return eventLevel != OFF && ordinal() >= eventLevel.ordinal();
9+
}
10+
}

0 commit comments

Comments
 (0)