JsonEmitter.java
package org.egothor.methodatlas.emit;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;
import org.egothor.methodatlas.ai.AiMethodSuggestion;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
/**
* Buffers test method records and serializes them as a flat JSON array when
* {@link #flush(PrintWriter)} is called.
*
* <p>
* Each element of the array is a JSON object whose fields mirror the CSV
* columns produced by {@link OutputEmitter}, with the following differences:
* </p>
* <ul>
* <li>{@code tags} and {@code ai_tags} are JSON arrays rather than
* semicolon-separated strings</li>
* <li>Numeric fields ({@code loc}, {@code ai_interaction_score},
* {@code ai_confidence}) are JSON numbers</li>
* <li>{@code ai_security_relevant} is a JSON boolean</li>
* <li>Optional columns ({@code source_root}, {@code content_hash},
* {@code ai_*}, {@code tag_ai_drift}) are omitted from each object
* when the corresponding flag is not enabled</li>
* </ul>
*
* <p>
* All records are accumulated in memory via {@link #record} and the complete
* JSON array is serialized to the writer in a single {@link #flush} call.
* </p>
*
* <p>
* This class implements {@link TestMethodSink} so it can be passed directly to
* the orchestration layer. The {@link TestMethodSink#record} implementation
* calls {@link #record(String, String, int, int, String, List, String, AiMethodSuggestion, String)}
* with a {@code null} source root; callers that know the scan root (such as
* {@code JsonCommand} in the application layer) should call the extended
* method directly.
* </p>
*
* @see OutputEmitter
* @see TestMethodSink
*/
public final class JsonEmitter implements TestMethodSink, RecordEmitter {
private final boolean aiEnabled;
private final boolean confidenceEnabled;
private final boolean contentHashEnabled;
private final boolean driftDetect;
private final boolean emitSourceRoot;
private final List<MethodRecord> records = new ArrayList<>();
/**
* Creates a new JSON emitter.
*
* @param aiEnabled whether AI enrichment columns should be included
* @param confidenceEnabled whether the {@code ai_confidence} field should be
* included; only meaningful when {@code aiEnabled} is
* {@code true}
* @param contentHashEnabled whether the {@code content_hash} field should be
* included
* @param driftDetect whether the {@code tag_ai_drift},
* {@code tags_added}, and {@code tags_removed}
* fields should be included; only meaningful when
* {@code aiEnabled} is {@code true}
* @param emitSourceRoot whether the {@code source_root} field should be
* included
*/
public JsonEmitter(boolean aiEnabled, boolean confidenceEnabled,
boolean contentHashEnabled, boolean driftDetect, boolean emitSourceRoot) {
this.aiEnabled = aiEnabled;
this.confidenceEnabled = confidenceEnabled;
this.contentHashEnabled = contentHashEnabled;
this.driftDetect = driftDetect;
this.emitSourceRoot = emitSourceRoot;
}
/**
* Buffers a single test method record (without a source root).
*
* <p>
* Delegates to
* {@link #record(String, String, int, int, String, List, String, AiMethodSuggestion, String)}
* with {@code sourceRoot=null}. Callers that know the scan root should use
* the extended form directly.
* </p>
*/
@Override
@SuppressWarnings("PMD.UseObjectForClearerAPI")
public void record(String fqcn, String method, int beginLine, int loc, String contentHash,
List<String> tags, String displayName, AiMethodSuggestion suggestion) {
record(fqcn, method, beginLine, loc, contentHash, tags, displayName, suggestion, null);
}
/**
* Buffers a single test method record including the scan root.
*
* @param fqcn fully qualified class name
* @param method test method name
* @param beginLine first line of the method declaration (1-based)
* @param loc inclusive line count of the method declaration
* @param contentHash SHA-256 fingerprint of the enclosing class source, or
* {@code null} when {@code -content-hash} is not enabled
* @param tags source-level tags extracted from the method
* @param displayName text from an existing display-name annotation, or an
* empty string/null if absent
* @param suggestion AI suggestion for the method, or {@code null} if none
* @param sourceRoot CWD-relative path of the scan root that produced this
* record, or {@code null} when {@code -emit-source-root} is
* not enabled
*/
@SuppressWarnings("PMD.UseObjectForClearerAPI")
public void record(String fqcn, String method, int beginLine, int loc, String contentHash,
List<String> tags, String displayName, AiMethodSuggestion suggestion, String sourceRoot) {
String resolvedSourceRoot = emitSourceRoot ? sourceRoot : null;
String resolvedContentHash = contentHashEnabled ? contentHash : null;
Boolean aiSecurityRelevant = null;
String aiDisplayName = null;
List<String> aiTags = null;
String aiReason = null;
Double aiInteractionScore = null;
Double aiConfidence = null;
String tagAiDrift = null;
String tagsAdded = null;
String tagsRemoved = null;
if (aiEnabled) {
if (suggestion != null) {
aiSecurityRelevant = suggestion.securityRelevant();
aiDisplayName = suggestion.displayName();
aiTags = suggestion.tags() != null ? List.copyOf(suggestion.tags()) : List.of();
aiReason = suggestion.reason();
aiInteractionScore = suggestion.interactionScore();
aiConfidence = confidenceEnabled ? suggestion.confidence() : null;
DriftFields drift = driftFields(tags, suggestion);
tagAiDrift = drift.drift();
tagsAdded = drift.added();
tagsRemoved = drift.removed();
} else {
aiTags = List.of();
}
}
records.add(new MethodRecord(
fqcn,
method,
loc,
tags == null ? List.of() : List.copyOf(tags),
displayName != null ? displayName : "",
resolvedSourceRoot,
resolvedContentHash,
aiSecurityRelevant,
aiDisplayName,
aiTags,
aiReason,
aiInteractionScore,
aiConfidence,
tagAiDrift,
tagsAdded,
tagsRemoved));
}
/**
* Computes the drift-detection fields ({@code tag_ai_drift}, {@code tags_added},
* {@code tags_removed}) for a method with an AI suggestion.
*
* <p>
* Extracted from {@link #record} to keep that method's branching complexity
* within the project's PMD limits. Returns {@link DriftFields#EMPTY} (all
* {@code null}, so the fields are omitted from the JSON) when drift detection
* is disabled.
* </p>
*
* @param tags source-level tags of the method, may be {@code null}
* @param suggestion non-null AI suggestion for the method
* @return the three drift fields, or {@link DriftFields#EMPTY} when disabled
*/
private DriftFields driftFields(List<String> tags, AiMethodSuggestion suggestion) {
if (!driftDetect) {
return DriftFields.EMPTY;
}
String drift = TagAiDrift.compute(tags != null ? tags : List.of(), suggestion).toValue();
return new DriftFields(drift,
TagAiDrift.tagDifference(tags, suggestion.tags()),
TagAiDrift.tagDifference(suggestion.tags(), tags));
}
/** Triple of the drift-detection fields, or all {@code null} when disabled. */
private record DriftFields(String drift, String added, String removed) {
private static final DriftFields EMPTY = new DriftFields(null, null, null);
}
/**
* Serializes all buffered records as a JSON array and writes the result to
* {@code out}.
*
* @param out writer that receives the JSON output
* @throws IllegalStateException if JSON serialization fails or the
* underlying stream reports a write error
*/
public void flush(PrintWriter out) {
JsonMapper mapper = JsonMapper.builder()
.configure(SerializationFeature.INDENT_OUTPUT, true)
.build();
try {
out.println(mapper.writeValueAsString(records));
} catch (JacksonException e) {
throw new IllegalStateException("Failed to serialize JSON output", e);
}
// PrintWriter swallows write errors; surface them so a truncated JSON
// file (e.g. on a full disk) is never reported as a successful run.
if (out.checkError()) {
throw new IllegalStateException(
"Failed to write JSON output: the underlying stream reported an error");
}
}
// -------------------------------------------------------------------------
// Internal record POJO
// -------------------------------------------------------------------------
/**
* Immutable value object representing one serialized test method.
* Fields annotated with {@link JsonInclude}{@code (NON_NULL)} are omitted
* from the JSON output when their value is {@code null}.
*/
@JsonInclude(Include.NON_NULL)
private record MethodRecord(
@JsonProperty("fqcn") String fqcn,
@JsonProperty("method") String method,
@JsonProperty("loc") int loc,
@JsonProperty("tags") List<String> tags,
@JsonProperty("display_name") String displayName,
@JsonProperty("source_root") String sourceRoot,
@JsonProperty("content_hash") String contentHash,
@JsonProperty("ai_security_relevant") Boolean aiSecurityRelevant,
@JsonProperty("ai_display_name") String aiDisplayName,
@JsonProperty("ai_tags") List<String> aiTags,
@JsonProperty("ai_reason") String aiReason,
@JsonProperty("ai_interaction_score") Double aiInteractionScore,
@JsonProperty("ai_confidence") Double aiConfidence,
@JsonProperty("tag_ai_drift") String tagAiDrift,
@JsonProperty("tags_added") String tagsAdded,
@JsonProperty("tags_removed") String tagsRemoved) {
}
}