Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public ProjectSelector(PluginRegistry registry) {
}

public ProjectCapabilities select(RepositoryFacts facts) {
if (facts.projectType() != null) return selectExplicit(facts);
List<String> selected = new ArrayList<>();
Map<String, List<String>> evidence = new TreeMap<>();
for (PluginDescriptor descriptor : registry.descriptors()) {
Expand All @@ -43,7 +44,7 @@ public ProjectCapabilities select(RepositoryFacts facts) {
.map(registry::descriptor)
.filter(descriptor -> descriptor.kind() == PluginKind.LANGUAGE)
.toList();
for (String path : facts.paths()) {
for (String path : sourcePaths(facts)) {
String extension = extension(path);
List<String> matches = languages.stream()
.filter(descriptor -> descriptor.detection().extensions().contains(extension))
Expand All @@ -62,9 +63,51 @@ public ProjectCapabilities select(RepositoryFacts facts) {
registry.fingerprintFor(selected));
}

private ProjectCapabilities selectExplicit(RepositoryFacts facts) {
PluginDescriptor requested = registry.descriptor(facts.projectType());
TreeSet<String> requestedIds = new TreeSet<>();
requestedIds.add(requested.id());
for (PluginDescriptor descriptor : registry.descriptors()) {
if (descriptor.kind() != PluginKind.LANGUAGE) continue;
if (sourcePaths(facts).stream().anyMatch(path ->
descriptor.detection().extensions().contains(extension(path)))) {
requestedIds.add(descriptor.id());
}
}
List<PluginDescriptor> resolved = registry.resolve(requestedIds);
List<String> selected = resolved.stream().map(PluginDescriptor::id).toList();
Map<String, List<String>> evidence = new TreeMap<>();
for (String pluginId : selected) {
evidence.put(pluginId, new TreeSet<>(List.of(
pluginId.equals(requested.id())
? "manual-project-type:" + requested.id()
: "manual-project-type-dependency:" + requested.id(),
"root:" + (facts.sourceRoot() == null ? "." : facts.sourceRoot())
)).stream().toList());
}
Map<String, List<String>> filePlugins = new TreeMap<>();
List<PluginDescriptor> languages = resolved.stream()
.filter(descriptor -> descriptor.kind() == PluginKind.LANGUAGE)
.toList();
for (String path : sourcePaths(facts)) {
List<String> matches = languages.stream()
.filter(descriptor -> descriptor.detection().extensions().contains(extension(path)))
.map(PluginDescriptor::id)
.toList();
if (!matches.isEmpty()) filePlugins.put(path, matches);
}
return new ProjectCapabilities(
selected,
filePlugins,
evidence,
List.of(),
fingerprint(facts.revision(), selected, filePlugins, evidence),
registry.fingerprintFor(selected));
}

private List<String> match(PluginDescriptor descriptor, RepositoryFacts facts) {
DetectionRules rules = descriptor.detection();
List<String> extensionHits = facts.paths().stream()
List<String> extensionHits = sourcePaths(facts).stream()
.filter(path -> rules.extensions().contains(extension(path)))
.toList();
List<DetectionAlternative> groups = new ArrayList<>();
Expand All @@ -91,54 +134,116 @@ private List<String> match(PluginDescriptor descriptor, RepositoryFacts facts) {
return evidence.stream().limit(MAX_EVIDENCE_PER_PLUGIN).toList();
}

private static List<String> sourcePaths(RepositoryFacts facts) {
if (facts.sourceRoot() == null) return facts.paths();
String prefix = facts.sourceRoot() + "/";
return facts.paths().stream()
.filter(path -> path.equals(facts.sourceRoot()) || path.startsWith(prefix))
.toList();
}

private List<String> matchGroup(DetectionAlternative group, RepositoryFacts facts) {
Set<String> paths = Set.copyOf(facts.paths());
if (!paths.containsAll(group.filesAll())) return null;
if (!group.filesAny().isEmpty() && group.filesAny().stream().noneMatch(paths::contains)) return null;

Map<String, List<String>> allPatternHits = new TreeMap<>();
for (String pattern : group.pathPatternsAll()) {
List<String> hits = facts.paths().stream().filter(path -> PluginGlob.matches(pattern, path)).toList();
if (hits.isEmpty()) return null;
allPatternHits.put(pattern, hits);
List<Set<String>> rootSets = new ArrayList<>();
group.filesAll().forEach(relative -> rootSets.add(suffixRoots(facts.paths(), relative)));
group.contentMarkers().forEach(marker -> rootSets.add(facts.markerContents().entrySet().stream()
.filter(entry -> entry.getValue().contains(marker.contains()))
.flatMap(entry -> suffixRoots(List.of(entry.getKey()), marker.path()).stream())
.collect(java.util.stream.Collectors.toSet())));
Set<String> candidateRoots = new TreeSet<>();
if (!rootSets.isEmpty()) {
candidateRoots.addAll(rootSets.get(0));
rootSets.subList(1, rootSets.size()).forEach(candidateRoots::retainAll);
} else if (!group.filesAny().isEmpty()) {
group.filesAny().forEach(relative -> candidateRoots.addAll(suffixRoots(facts.paths(), relative)));
} else {
candidateRoots.add(facts.sourceRoot() == null ? "" : facts.sourceRoot());
}
Map<String, List<String>> anyPatternHits = new TreeMap<>();
for (String pattern : group.pathPatternsAny()) {
List<String> hits = facts.paths().stream().filter(path -> PluginGlob.matches(pattern, path)).toList();
anyPatternHits.put(pattern, hits);
if (facts.sourceRoot() != null) {
candidateRoots.retainAll(Set.of(facts.sourceRoot()));
}
if (!group.pathPatternsAny().isEmpty()
&& anyPatternHits.values().stream().allMatch(List::isEmpty)) return null;

List<ContentMarker> markerHits = group.contentMarkers().stream()
.filter(marker -> facts.markerContents().containsKey(marker.path()))
.filter(marker -> facts.markerContents().get(marker.path()).contains(marker.contains()))
.toList();
if (markerHits.size() != group.contentMarkers().size()) return null;
Map<ContentPatternMarker, List<String>> patternMarkerHits = new TreeMap<>();
for (ContentPatternMarker marker : group.contentPatternMarkers()) {
List<String> hits = facts.markerContents().entrySet().stream()
.filter(entry -> PluginGlob.matches(marker.pathPattern(), entry.getKey()))
.filter(entry -> entry.getValue().contains(marker.contains()))
.map(Map.Entry::getKey)
.toList();
if (hits.isEmpty()) return null;
patternMarkerHits.put(marker, hits);
for (String root : candidateRoots) {
List<String> filesAll = group.filesAll().stream()
.map(relative -> rooted(root, relative)).toList();
if (!paths.containsAll(filesAll)) continue;
List<String> filesAny = group.filesAny().stream()
.map(relative -> rooted(root, relative)).filter(paths::contains).toList();
if (!group.filesAny().isEmpty() && filesAny.isEmpty()) continue;

Map<String, List<String>> allPatternHits = patternHits(group.pathPatternsAll(), facts.paths(), root);
if (allPatternHits.values().stream().anyMatch(List::isEmpty)) continue;
Map<String, List<String>> anyPatternHits = patternHits(group.pathPatternsAny(), facts.paths(), root);
if (!group.pathPatternsAny().isEmpty()
&& anyPatternHits.values().stream().allMatch(List::isEmpty)) continue;

Map<ContentMarker, String> markerHits = new LinkedHashMap<>();
for (ContentMarker marker : group.contentMarkers()) {
String path = rooted(root, marker.path());
if (!facts.markerContents().getOrDefault(path, "").contains(marker.contains())) break;
markerHits.put(marker, path);
}
if (markerHits.size() != group.contentMarkers().size()) continue;
Map<ContentPatternMarker, List<String>> patternMarkerHits = new TreeMap<>();
for (ContentPatternMarker marker : group.contentPatternMarkers()) {
List<String> hits = facts.markerContents().entrySet().stream()
.filter(entry -> relativeToRoot(entry.getKey(), root) != null)
.filter(entry -> PluginGlob.matches(marker.pathPattern(), relativeToRoot(entry.getKey(), root)))
.filter(entry -> entry.getValue().contains(marker.contains()))
.map(Map.Entry::getKey).toList();
if (hits.isEmpty()) break;
patternMarkerHits.put(marker, hits);
}
if (patternMarkerHits.size() != group.contentPatternMarkers().size()) continue;

TreeSet<String> evidence = new TreeSet<>();
evidence.add("root:" + (root.isEmpty() ? "." : root));
filesAll.forEach(path -> evidence.add("file:" + path));
filesAny.forEach(path -> evidence.add("file:" + path));
for (var entry : allPatternHits.entrySet()) entry.getValue().forEach(path ->
evidence.add("pattern:" + entry.getKey() + ":" + path));
for (var entry : anyPatternHits.entrySet()) entry.getValue().forEach(path ->
evidence.add("pattern:" + entry.getKey() + ":" + path));
markerHits.forEach((marker, path) ->
evidence.add("content:" + path + ":" + marker.contains()));
patternMarkerHits.forEach((marker, hits) -> hits.forEach(path -> evidence.add(
"content-pattern:" + marker.pathPattern() + ":" + path + ":" + marker.contains())));
return List.copyOf(evidence);
}
return null;
}

TreeSet<String> evidence = new TreeSet<>();
group.filesAll().forEach(path -> evidence.add("file:" + path));
group.filesAny().stream().filter(paths::contains).forEach(path -> evidence.add("file:" + path));
for (var entry : allPatternHits.entrySet()) {
entry.getValue().forEach(path -> evidence.add("pattern:" + entry.getKey() + ":" + path));
private static Set<String> suffixRoots(List<String> paths, String relative) {
TreeSet<String> roots = new TreeSet<>();
for (String path : paths) {
if (path.equals(relative)) roots.add("");
else if (path.endsWith("/" + relative)) {
roots.add(path.substring(0, path.length() - relative.length() - 1));
}
}
for (var entry : anyPatternHits.entrySet()) {
entry.getValue().forEach(path -> evidence.add("pattern:" + entry.getKey() + ":" + path));
return roots;
}

private static String rooted(String root, String relative) {
return root.isEmpty() ? relative : root + "/" + relative;
}

private static String relativeToRoot(String path, String root) {
if (root.isEmpty()) return path;
String prefix = root + "/";
return path.startsWith(prefix) ? path.substring(prefix.length()) : null;
}

private static Map<String, List<String>> patternHits(
List<String> patterns, List<String> paths, String root) {
Map<String, List<String>> result = new TreeMap<>();
for (String pattern : patterns) {
result.put(pattern, paths.stream()
.filter(path -> relativeToRoot(path, root) != null)
.filter(path -> PluginGlob.matches(pattern, relativeToRoot(path, root)))
.toList());
}
markerHits.forEach(marker -> evidence.add("content:" + marker.path() + ":" + marker.contains()));
patternMarkerHits.forEach((marker, hits) -> hits.forEach(path -> evidence.add(
"content-pattern:" + marker.pathPattern() + ":" + path + ":" + marker.contains())));
return List.copyOf(evidence);
return result;
}

private String fingerprint(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@
import java.util.Map;
import java.util.TreeMap;

public record RepositoryFacts(String revision, List<String> paths, Map<String, String> markerContents) {
public record RepositoryFacts(
String revision,
List<String> paths,
Map<String, String> markerContents,
String projectType,
String sourceRoot) {
public RepositoryFacts(String revision, List<String> paths, Map<String, String> markerContents) {
this(revision, paths, markerContents, null, null);
}

public RepositoryFacts {
revision = PluginValues.requireNonBlank(revision, "revision");
paths = PluginValues.sortedUnique(paths, "repository paths");
Expand All @@ -26,5 +35,16 @@ public record RepositoryFacts(String revision, List<String> paths, Map<String, S
});
}
markerContents = Collections.unmodifiableMap(new LinkedHashMap<>(normalizedMarkers));
if (projectType != null && (projectType.isBlank() || "auto".equalsIgnoreCase(projectType.trim()))) {
projectType = null;
} else if (projectType != null) {
projectType = PluginValues.requirePluginId(
projectType.trim().toLowerCase(java.util.Locale.ROOT), "project type");
}
if (sourceRoot != null && (sourceRoot.isBlank() || ".".equals(sourceRoot.trim()))) {
sourceRoot = null;
} else if (sourceRoot != null) {
sourceRoot = PluginValues.normalizePath(sourceRoot.trim().replace('\\', '/'));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,82 @@ void selection_matches_the_shared_cross_runtime_projection() throws Exception {
assertThat(selected.filePlugins()).containsEntry(
"app/code/Vendor/Module/Model/Foo.php", List.of("php"));
assertThat(selected.detectionEvidence().get("magento")).containsExactly(
"file:app/etc/config.php", "file:bin/magento", "file:composer.json");
"file:app/etc/config.php", "file:bin/magento", "file:composer.json", "root:.");
assertThat(selected.fingerprint()).isEqualTo(
"sha256:6a888ce52e94cba767c754ff096d29c13637244976edcb97d9a68f44eeb43b10");
"sha256:82da50c6916ad2b50e268523e6226aeaee6f9bb8e76fd868aed5419503946eaf");
}

@Test
void detects_one_coherent_arbitrarily_nested_magento_root() throws Exception {
PluginRegistry registry = new PluginRegistry(
new PluginManifestLoader().loadDescriptors(FIXTURE));
RepositoryFacts facts = new RepositoryFacts(
"abc1234",
List.of(
"magento/src/etc/app/code/Vendor/Module/Model/Foo.php",
"magento/src/etc/app/etc/config.php",
"magento/src/etc/bin/magento",
"magento/src/etc/composer.json"),
Map.of());

ProjectCapabilities selected = new ProjectSelector(registry).select(facts);

assertThat(selected.repositoryPlugins()).containsExactly("php", "magento");
assertThat(selected.detectionEvidence().get("magento"))
.contains("root:magento/src/etc");
}

@Test
void manual_type_bypasses_marker_detection_and_resolves_dependencies() throws Exception {
PluginRegistry registry = new PluginRegistry(
new PluginManifestLoader().loadDescriptors(FIXTURE));
RepositoryFacts facts = new RepositoryFacts(
"abc1234",
List.of("magento/src/etc/app/code/Vendor/Module/Model/Foo.php"),
Map.of(),
"magento",
"magento/src/etc");

ProjectCapabilities selected = new ProjectSelector(registry).select(facts);

assertThat(selected.repositoryPlugins()).containsExactly("php", "magento");
assertThat(selected.detectionEvidence().get("magento")).containsExactly(
"manual-project-type:magento", "root:magento/src/etc");
}

@Test
void source_root_excludes_languages_and_files_outside_the_boundary() throws Exception {
PluginRegistry registry = new PluginRegistry(
new PluginManifestLoader().loadDescriptors(FIXTURE));
List<String> paths = List.of(
"app/etc/config.php",
"bin/magento",
"composer.json",
"packages/store/src/Foo.php",
"tools/Outside.java");
Map<String, String> markerContents = Map.of(
"composer.json", "{\"require\":{\"magento/framework\":\"*\"}}");

ProjectCapabilities automatic = new ProjectSelector(registry).select(
new RepositoryFacts(
"abc1234", paths, markerContents, null, "packages/store"));
ProjectCapabilities explicit = new ProjectSelector(registry).select(
new RepositoryFacts(
"abc1234", paths, markerContents, "magento", "packages/store"));

assertThat(automatic.repositoryPlugins()).containsExactly("php");
assertThat(automatic.filePlugins()).containsOnlyKeys(
"packages/store/src/Foo.php");
assertThat(explicit.repositoryPlugins()).containsExactly("php", "magento");
assertThat(explicit.filePlugins()).containsOnlyKeys(
"packages/store/src/Foo.php");
}

@Test
void source_root_is_canonicalized_like_the_python_contract() {
RepositoryFacts facts = new RepositoryFacts(
"abc1234", List.of(), Map.of(), null, ".\\app/code");

assertThat(facts.sourceRoot()).isEqualTo("app/code");
}
}
19 changes: 19 additions & 0 deletions analysis-plugins/contracts/python/codecrow_plugins/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ class RepositoryFacts:
revision: str
paths: tuple[str, ...]
marker_contents: Mapping[str, str] = field(default_factory=dict)
project_type: str | None = None
source_root: str | None = None

def __post_init__(self) -> None:
_non_blank(self.revision, "revision")
Expand All @@ -295,6 +297,23 @@ def __post_init__(self) -> None:
raise ValueError("marker content must be text")
normalized_markers[path] = content
object.__setattr__(self, "marker_contents", MappingProxyType(normalized_markers))
project_type = (
self.project_type.strip().casefold()
if isinstance(self.project_type, str) and self.project_type.strip()
else None
)
if project_type == "auto":
project_type = None
if project_type is not None:
_plugin_id(project_type)
source_root = (
normalize_path(self.source_root.strip().replace("\\", "/"))
if isinstance(self.source_root, str)
and self.source_root.strip() not in {"", "."}
else None
)
object.__setattr__(self, "project_type", project_type)
object.__setattr__(self, "source_root", source_root)


@dataclass(frozen=True)
Expand Down
Loading
Loading