DetectCredentialsStage.java

package org.egothor.methodatlas.command;

import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
import org.egothor.methodatlas.ai.CredentialTriageVerdict;
import org.egothor.methodatlas.api.CredentialCandidate;
import org.egothor.methodatlas.api.CredentialDetector;
import org.egothor.methodatlas.api.CredentialScanUnit;
import org.egothor.methodatlas.emit.CredentialFinding;

/**
 * Runs the deterministic detectors over the scan units, attaches best-effort
 * enclosing-method attribution, and produces {@link CredentialFinding}s. AI triage
 * (added later) enriches the findings afterwards; this stage leaves the triage
 * fields {@code null}.
 *
 * @since 4.1.0
 */
public final class DetectCredentialsStage {

    private final List<CredentialDetector> detectors;
    private final Map<Path, List<MethodRange>> attribution;

    /**
     * Creates a stage.
     *
     * @param detectors   loaded detectors; never {@code null}
     * @param attribution map of absolute file path to method ranges for
     *                    enclosing-method attribution; never {@code null}
     */
    public DetectCredentialsStage(List<CredentialDetector> detectors, Map<Path, List<MethodRange>> attribution) {
        this.detectors = List.copyOf(detectors);
        this.attribution = Map.copyOf(attribution);
    }

    /**
     * Detects and builds findings for the supplied units, in unit-then-source order.
     *
     * @param units units to scan; never {@code null}
     * @return findings; never {@code null}
     */
    @SuppressWarnings("PMD.CloseResource") // detectors are owned and closed by the caller, not by this stage
    public List<CredentialFinding> run(List<CredentialScanUnit> units) {
        List<CredentialFinding> findings = new ArrayList<>();
        for (CredentialScanUnit unit : units) {
            for (CredentialDetector detector : detectors) {
                for (CredentialCandidate c : detector.detect(unit)) {
                    findings.add(new CredentialFinding(c, unit.filePath(), unit.fqcn(),
                            methodFor(unit.filePath(), c.beginLine()), null, null, null));
                }
            }
        }
        return findings;
    }

    private String methodFor(Path file, int line) {
        List<MethodRange> ranges = attribution.get(file);
        if (ranges == null) {
            return null;
        }
        for (MethodRange r : ranges) {
            if (line >= r.beginLine() && line <= r.endLine()) {
                return r.method();
            }
        }
        return null;
    }

    /**
     * Merges LLM triage verdicts into a per-class finding list by candidate index.
     * Findings without a matching verdict are returned unchanged (their triage
     * fields stay {@code null}).
     *
     * @param findings deterministic findings for one class, in candidate-index order;
     *                 never {@code null}
     * @param verdicts verdicts keyed by candidate index; never {@code null}
     * @return findings with triage fields populated where a verdict exists; never {@code null}
     */
    public static List<CredentialFinding> mergeVerdicts(List<CredentialFinding> findings,
            Map<Integer, CredentialTriageVerdict> verdicts) {
        return IntStream.range(0, findings.size())
                .mapToObj(i -> applyVerdict(findings.get(i), verdicts.get(i)))
                .toList();
    }

    private static CredentialFinding applyVerdict(CredentialFinding finding, CredentialTriageVerdict verdict) {
        if (verdict == null) {
            return finding;
        }
        return new CredentialFinding(finding.candidate(), finding.filePath(), finding.fqcn(), finding.method(),
                verdict.credibilityScore(), verdict.endpoint(), verdict.rationale());
    }

    /**
     * A method's line span, used for attribution.
     *
     * @param method    simple method name
     * @param beginLine one-based first line
     * @param endLine   one-based last line
     * @since 4.1.0
     */
    public record MethodRange(String method, int beginLine, int endLine) {
    }
}