LocationEvaluator.java
/*
* SPDX-License-Identifier: Apache-2.0
* Copyright 2026 Nikolas Charalambidis
*/
package dev.nichar.facepalm.engine.evaluator;
import dev.nichar.facepalm.FacepalmConfig;
import dev.nichar.facepalm.engine.FileContext;
import dev.nichar.facepalm.engine.Finding;
import jakarta.annotation.Nonnull;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
/**
* Evaluates findings based on their filesystem location.
* Elevates risk for production paths and de-prioritizes test or mock directories.
*
* <p>
* Markers are matched against the path relative to the scan root, independent of the OS separator:
* <ul>
* <li>A marker with only letters and digits (e.g. {@code test}) matches a whole word of a path segment, split at
* non-alphanumeric characters and camelCase boundaries, optionally in plural: {@code src/test/},
* {@code __tests__}, {@code FooTest.java} and {@code app.spec.ts} match, {@code latest} and {@code contest} do
* not.</li>
* <li>Any other marker (e.g. {@code src/main/}, {@code .env}) matches at a segment start: {@code .env} matches
* {@code .env} and {@code .env.local}, {@code src/main/} matches {@code src/main/java}.</li>
* </ul>
*/
@Named
@Singleton
class LocationEvaluator implements FindingEvaluator {
/**
* Splits a path segment into words: at runs of non-alphanumeric characters ({@code app.spec.ts},
* {@code __tests__}) and at camelCase boundaries ({@code FooTest} becomes {@code Foo}, {@code Test}).
*/
private static final Pattern WORD_BOUNDARY = Pattern.compile("[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])");
/**
* Identifies word markers (letters and digits only, e.g. {@code test}), which match whole words of a segment.
* Markers with other characters (e.g. {@code src/main/}, {@code .env}) match at a segment start instead.
*/
private static final Pattern ALPHANUMERIC = Pattern.compile("[A-Za-z0-9]+");
@Inject
private FacepalmConfig config;
@Override
public void evaluate(@Nonnull final Finding finding, @Nonnull final FileContext context) {
final var segments = segments(context.getRelativePath());
final var conf = config.getEvaluators();
// Increase risk for findings in production-critical paths.
if (conf.getProdPathMarkers().stream().anyMatch(marker -> matches(segments, marker))) {
finding.log("Production Path Marker", 20, 0);
}
// Reduce risk for findings in test or mock environments.
if (conf.getTestPathMarkers().stream().anyMatch(marker -> matches(segments, marker))) {
finding.log("Test/Mock Path Marker", -30, -20);
}
}
@Nonnull
private static List<String> segments(@Nonnull final Path relativePath) {
return StreamSupport.stream(relativePath.spliterator(), false)
.map(Path::toString)
.collect(Collectors.toList());
}
static boolean matches(@Nonnull final List<String> segments, @Nonnull final String marker) {
final var lowerMarker = marker.toLowerCase(Locale.ROOT);
if (ALPHANUMERIC.matcher(lowerMarker).matches()) {
return segments.stream()
.flatMap(segment -> Arrays.stream(WORD_BOUNDARY.split(segment)))
.map(word -> word.toLowerCase(Locale.ROOT))
.anyMatch(word -> word.equals(lowerMarker) || word.equals(lowerMarker + "s"));
}
final var lowerSegments = new ArrayList<String>();
segments.forEach(segment -> lowerSegments.add(segment.toLowerCase(Locale.ROOT)));
final var path = "/" + String.join("/", lowerSegments);
final var needle = lowerMarker.startsWith("/") ? lowerMarker : "/" + lowerMarker;
return path.contains(needle);
}
}