ContentHasher.java

1
// SPDX-License-Identifier: Apache-2.0
2
// Copyright 2026 Egothor
3
// Copyright 2026 Accenture
4
package org.egothor.methodatlas.command;
5
6
import java.nio.charset.StandardCharsets;
7
import java.nio.file.Path;
8
import java.nio.file.Paths;
9
import java.security.MessageDigest;
10
import java.security.NoSuchAlgorithmException;
11
import java.util.HexFormat;
12
import java.util.List;
13
14
/**
15
 * Pure utility helpers for content fingerprints and scan-root path prefixes.
16
 *
17
 * <p>
18
 * Two unrelated-but-small concerns live here because both are stateless,
19
 * dependency-free, and named-focused. Pulling them into one
20
 * single-responsibility utility class keeps each concern visible without
21
 * the cost of constructor injection — both methods are pure functions
22
 * mandated by their specifications (SHA-256 for hashing, OS-native
23
 * relativisation for the prefix), so there is nothing to substitute:
24
 * </p>
25
 * <ul>
26
 *   <li>{@link #hashClass(String)} — computes a SHA-256 fingerprint of the
27
 *       canonical pretty-printed AST text of a class. This is the value
28
 *       exposed as {@code content_hash} in CSV / SARIF output and used as the
29
 *       cache key by {@link org.egothor.methodatlas.AiResultCache}.</li>
30
 *   <li>{@link #filePrefix(List)} — derives the forward-slashed path prefix
31
 *       used in GitHub Actions workflow annotations and SARIF location URIs,
32
 *       relativised to the current working directory so that paths resolve
33
 *       to inline positions in PR diffs.</li>
34
 * </ul>
35
 *
36
 * <p>
37
 * Both methods are pure functions and therefore exposed as {@code static}.
38
 * Test code calls them directly with handcrafted inputs; no dependency
39
 * injection is needed because there is nothing to substitute — the SHA-256
40
 * implementation is mandated by the Java SE specification and the path
41
 * relativisation has only one correct answer.
42
 * </p>
43
 *
44
 * @see ScanOrchestrator
45
 * @since 1.0.0
46
 */
47
public final class ContentHasher {
48
49
    private ContentHasher() {
50
        // Utility class; instantiation is prevented to make the static
51
        // intent obvious to callers and to satisfy PMD.
52
    }
53
54
    /**
55
     * Computes a SHA-256 content fingerprint of a class source string.
56
     *
57
     * <p>
58
     * The input is expected to be the canonical AST text of the class — for
59
     * Java this is the JavaParser pretty-printed form, which normalises
60
     * whitespace and comments so that semantically equivalent classes that
61
     * differ only in formatting produce identical hashes. The output is
62
     * suitable for incremental scanning, AI-cache lookups, and audit
63
     * traceability across two pipeline stages.
64
     * </p>
65
     *
66
     * <p>
67
     * Algorithm: SHA-256 (FIPS 180-4) applied to the UTF-8 bytes of
68
     * {@code classSource}. Time complexity is {@code O(n)} in the source
69
     * size. The result is a 64-character lowercase hexadecimal string.
70
     * </p>
71
     *
72
     * @param classSource canonical pretty-printed form of the class
73
     *                    declaration; must not be {@code null}
74
     * @return 64-character lowercase hexadecimal SHA-256 digest; never
75
     *         {@code null}, never empty
76
     * @throws IllegalStateException if SHA-256 is unavailable — never in
77
     *                               practice, because SHA-256 is mandated by
78
     *                               the Java SE specification
79
     */
80
    public static String hashClass(String classSource) {
81
        try {
82
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
83
            byte[] bytes = digest.digest(classSource.getBytes(StandardCharsets.UTF_8));
84 1 1. hashClass : replaced return value with "" for org/egothor/methodatlas/command/ContentHasher::hashClass → KILLED
            return HexFormat.of().formatHex(bytes);
85
        } catch (NoSuchAlgorithmException e) {
86
            throw new IllegalStateException("SHA-256 not available", e);
87
        }
88
    }
89
90
    /**
91
     * Derives the forward-slashed path prefix used in GitHub Actions
92
     * workflow annotations and SARIF location URIs.
93
     *
94
     * <p>
95
     * The first configured scan root is relativised against the current
96
     * working directory and converted to forward slashes. A trailing slash
97
     * is appended unless the prefix is empty. The resulting string is
98
     * concatenated with the per-method relative path to produce annotation
99
     * paths that GitHub resolves to inline positions in the PR diff
100
     * (for example {@code src/test/java/com/acme/AuthTest.java}).
101
     * </p>
102
     *
103
     * <p>
104
     * When {@code roots} is empty the returned prefix is the empty string,
105
     * which produces unprefixed annotation paths — appropriate when no scan
106
     * root was configured because the caller is operating on the current
107
     * directory directly.
108
     * </p>
109
     *
110
     * <p>
111
     * On Windows, scan roots that resolve to a different drive than the
112
     * current working directory cannot be relativised. The method falls
113
     * back to the absolute path of the root in that case rather than
114
     * throwing.
115
     * </p>
116
     *
117
     * @param roots configured scan roots; must not be {@code null}; may be
118
     *              empty
119
     * @return forward-slash path ending with {@code /}, or the empty string
120
     *         when {@code roots} is empty
121
     */
122
    public static String filePrefix(List<Path> roots) {
123 2 1. filePrefix : removed conditional - replaced equality check with false → KILLED
2. filePrefix : removed conditional - replaced equality check with true → KILLED
        if (roots.isEmpty()) {
124
            return "";
125
        }
126
        Path root = roots.get(0).toAbsolutePath().normalize();
127
        String prefix;
128
        try {
129
            Path cwd = Paths.get("").toAbsolutePath();
130
            prefix = cwd.relativize(root).toString().replace('\\', '/');
131
        } catch (IllegalArgumentException e) {
132
            // Different drive on Windows — fall back to the absolute path.
133
            prefix = root.toString().replace('\\', '/');
134
        }
135 4 1. filePrefix : removed conditional - replaced equality check with true → SURVIVED
2. filePrefix : removed conditional - replaced equality check with true → SURVIVED
3. filePrefix : removed conditional - replaced equality check with false → KILLED
4. filePrefix : removed conditional - replaced equality check with false → KILLED
        if (!prefix.isEmpty() && !prefix.endsWith("/")) {
136
            prefix += "/";
137
        }
138 1 1. filePrefix : replaced return value with "" for org/egothor/methodatlas/command/ContentHasher::filePrefix → KILLED
        return prefix;
139
    }
140
}

Mutations

84

1.1
Location : hashClass
Killed by : org.egothor.methodatlas.command.ContentHasherTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.ContentHasherTest]/[method:hashClass_returnsLowercaseHex64Characters()]
replaced return value with "" for org/egothor/methodatlas/command/ContentHasher::hashClass → KILLED

123

1.1
Location : filePrefix
Killed by : org.egothor.methodatlas.command.ContentHasherTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.ContentHasherTest]/[method:filePrefix_emptyList_returnsEmptyString()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : filePrefix
Killed by : org.egothor.methodatlas.command.ContentHasherTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.ContentHasherTest]/[method:filePrefix_relativeRoot_endsWithSlash(java.nio.file.Path)]
removed conditional - replaced equality check with true → KILLED

135

1.1
Location : filePrefix
Killed by : org.egothor.methodatlas.command.ContentHasherTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.ContentHasherTest]/[method:filePrefix_relativeRoot_endsWithSlash(java.nio.file.Path)]
removed conditional - replaced equality check with false → KILLED

2.2
Location : filePrefix
Killed by : org.egothor.methodatlas.command.ContentHasherTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.ContentHasherTest]/[method:filePrefix_relativeRoot_endsWithSlash(java.nio.file.Path)]
removed conditional - replaced equality check with false → KILLED

3.3
Location : filePrefix
Killed by : none
removed conditional - replaced equality check with true → SURVIVED
Covering tests

4.4
Location : filePrefix
Killed by : none
removed conditional - replaced equality check with true → SURVIVED Covering tests

138

1.1
Location : filePrefix
Killed by : org.egothor.methodatlas.command.ContentHasherTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.ContentHasherTest]/[method:filePrefix_relativeRoot_endsWithSlash(java.nio.file.Path)]
replaced return value with "" for org/egothor/methodatlas/command/ContentHasher::filePrefix → KILLED

Active mutators

Tests examined


Report generated by PIT 1.22.1