GitHubAnnotationsEmitter.java

package org.egothor.methodatlas.emit;

import java.io.PrintWriter;
import java.util.List;

import org.egothor.methodatlas.TagAiDrift;
import org.egothor.methodatlas.TestMethodSink;
import org.egothor.methodatlas.ai.AiMethodSuggestion;

/**
 * Emits GitHub Actions workflow commands for inline PR annotations.
 *
 * <p>Only security-relevant methods produce output. Each method becomes one
 * {@code ::notice} or {@code ::warning} line that GitHub Actions intercepts
 * and renders as an inline annotation on the PR diff:</p>
 *
 * <ul>
 *   <li>{@code ::warning} — when {@code ai_interaction_score >= 0.8}: the test
 *       only verifies that methods were called, not what they returned.</li>
 *   <li>{@code ::notice} — otherwise: a well-formed security test worth
 *       reviewing.</li>
 * </ul>
 *
 * <p>The file path in each annotation is constructed as
 * {@code <filePrefix><fqcn-as-path>.java}, where {@code filePrefix} is
 * derived from the first configured scan root (e.g. {@code src/test/java/}).
 * This produces paths like {@code src/test/java/com/acme/AuthTest.java},
 * which GitHub resolves to the correct inline position in the PR diff for
 * standard Maven / Gradle source layouts.</p>
 *
 * <p>This mode does not require a GitHub Advanced Security licence, unlike
 * SARIF upload via the {@code upload-sarif} action.</p>
 *
 * @see TestMethodSink
 */
public final class GitHubAnnotationsEmitter implements TestMethodSink {

    /** Interaction score at or above which a security test is flagged as a potential placebo. */
    public static final double PLACEBO_THRESHOLD = 0.8;

    private final PrintWriter out;
    private final String filePrefix;

    /**
     * @param out        writer that receives the annotation lines
     * @param filePrefix prefix prepended to the FQCN-derived file path,
     *                   including a trailing slash (e.g. {@code "src/test/java/"});
     *                   empty string when the scan root is already the repo root
     */
    public GitHubAnnotationsEmitter(PrintWriter out, String filePrefix) {
        this.out = out;
        this.filePrefix = filePrefix;
    }

    @Override
    @SuppressWarnings("PMD.UseObjectForClearerAPI")
    public void record(String fqcn, String method, int beginLine, int loc, String contentHash,
            List<String> tags, String displayName, AiMethodSuggestion suggestion) {
        String filePath = filePrefix + fqcn.replace('.', '/') + ".java";

        if (displayName != null && displayName.isEmpty()) {
            out.println(formatCommand("notice", filePath, beginLine,
                    "@DisplayName(\"\") on " + fqcn + "#" + method,
                    "@DisplayName(\"\") declares an empty display name — "
                            + "the test will appear unnamed in reports, obscuring the audit trail. "
                            + "Replace with a meaningful description, "
                            + "e.g. @DisplayName(\"Verifies that ...\")."));
        }

        if (suggestion == null || !suggestion.securityRelevant()) {
            return;
        }

        boolean isPlacebo = suggestion.interactionScore() >= PLACEBO_THRESHOLD;
        String level = isPlacebo ? "warning" : "notice";

        String title = suggestion.displayName() != null && !suggestion.displayName().isBlank()
                ? suggestion.displayName()
                : fqcn + "#" + method;

        TagAiDrift drift = TagAiDrift.compute(tags, suggestion);
        String message = buildMessage(suggestion, isPlacebo, drift);

        out.println(formatCommand(level, filePath, beginLine, title, message));
    }

    @SuppressWarnings("PMD.NPathComplexity")
    private static String buildMessage(AiMethodSuggestion suggestion, boolean isPlacebo, TagAiDrift drift) {
        StringBuilder sb = new StringBuilder(512);

        if (suggestion.displayName() != null && !suggestion.displayName().isBlank()) {
            sb.append("Suggested @DisplayName: \"").append(suggestion.displayName()).append('"');
        }
        if (!suggestion.tags().isEmpty()) {
            appendSep(sb);
            sb.append("Suggested @Tag: ").append(String.join(", ", suggestion.tags()));
        }
        if (suggestion.reason() != null && !suggestion.reason().isBlank()) {
            appendSep(sb);
            String reason = suggestion.reason().strip();
            sb.append("Reason: ").append(reason);
            if (!reason.endsWith(".")) {
                sb.append('.');
            }
        }
        if (isPlacebo) {
            appendSep(sb);
            sb.append("Interaction score ")
              .append(String.format("%.1f", suggestion.interactionScore()))
              .append(": assertions only verify method calls, not output values or state");
        }
        if (drift == TagAiDrift.TAG_ONLY) {
            appendSep(sb);
            sb.append("Drift: @Tag(\"security\") present but AI disagrees — annotation may be stale");
        } else if (drift == TagAiDrift.AI_ONLY) {
            appendSep(sb);
            sb.append("Drift: AI classifies as security-relevant but no @Tag(\"security\") in source");
        }
        if (sb.length() == 0) {
            sb.append("Security test");
        }
        return sb.toString();
    }

    private static void appendSep(StringBuilder sb) {
        if (sb.length() > 0) {
            sb.append(" · ");
        }
    }

    /**
     * Formats a GitHub Actions workflow command line.
     */
    @SuppressWarnings("PMD.UseObjectForClearerAPI")
    public static String formatCommand(String level, String filePath, int beginLine,
            String title, String message) {
        StringBuilder cmd = new StringBuilder(128);
        cmd.append("::").append(level).append(" file=").append(escapeParam(filePath));
        if (beginLine > 0) {
            cmd.append(",line=").append(beginLine);
        }
        if (title != null && !title.isEmpty()) {
            cmd.append(",title=").append(escapeParam(title));
        }
        cmd.append("::").append(escapeMessage(message));
        return cmd.toString();
    }

    /**
     * Encodes characters that would break a GitHub workflow command parameter value.
     */
    public static String escapeParam(String value) {
        return value
                .replace("%", "%25")
                .replace("\r", "%0D")
                .replace("\n", "%0A")
                .replace(":", "%3A")
                .replace(",", "%2C");
    }

    /**
     * Encodes characters that would break a GitHub workflow command message.
     */
    public static String escapeMessage(String value) {
        return value
                .replace("%", "%25")
                .replace("\r", "%0D")
                .replace("\n", "%0A");
    }
}