diff --git a/analysis-plugins/contracts/java/src/main/java/org/rostilos/codecrow/plugins/ProjectSelector.java b/analysis-plugins/contracts/java/src/main/java/org/rostilos/codecrow/plugins/ProjectSelector.java index d420b60e..c4bd1fde 100644 --- a/analysis-plugins/contracts/java/src/main/java/org/rostilos/codecrow/plugins/ProjectSelector.java +++ b/analysis-plugins/contracts/java/src/main/java/org/rostilos/codecrow/plugins/ProjectSelector.java @@ -28,6 +28,7 @@ public ProjectSelector(PluginRegistry registry) { } public ProjectCapabilities select(RepositoryFacts facts) { + if (facts.projectType() != null) return selectExplicit(facts); List selected = new ArrayList<>(); Map> evidence = new TreeMap<>(); for (PluginDescriptor descriptor : registry.descriptors()) { @@ -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 matches = languages.stream() .filter(descriptor -> descriptor.detection().extensions().contains(extension)) @@ -62,9 +63,51 @@ public ProjectCapabilities select(RepositoryFacts facts) { registry.fingerprintFor(selected)); } + private ProjectCapabilities selectExplicit(RepositoryFacts facts) { + PluginDescriptor requested = registry.descriptor(facts.projectType()); + TreeSet 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 resolved = registry.resolve(requestedIds); + List selected = resolved.stream().map(PluginDescriptor::id).toList(); + Map> 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> filePlugins = new TreeMap<>(); + List languages = resolved.stream() + .filter(descriptor -> descriptor.kind() == PluginKind.LANGUAGE) + .toList(); + for (String path : sourcePaths(facts)) { + List 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 match(PluginDescriptor descriptor, RepositoryFacts facts) { DetectionRules rules = descriptor.detection(); - List extensionHits = facts.paths().stream() + List extensionHits = sourcePaths(facts).stream() .filter(path -> rules.extensions().contains(extension(path))) .toList(); List groups = new ArrayList<>(); @@ -91,54 +134,116 @@ private List match(PluginDescriptor descriptor, RepositoryFacts facts) { return evidence.stream().limit(MAX_EVIDENCE_PER_PLUGIN).toList(); } + private static List 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 matchGroup(DetectionAlternative group, RepositoryFacts facts) { Set 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> allPatternHits = new TreeMap<>(); - for (String pattern : group.pathPatternsAll()) { - List hits = facts.paths().stream().filter(path -> PluginGlob.matches(pattern, path)).toList(); - if (hits.isEmpty()) return null; - allPatternHits.put(pattern, hits); + List> 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 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> anyPatternHits = new TreeMap<>(); - for (String pattern : group.pathPatternsAny()) { - List 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 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> patternMarkerHits = new TreeMap<>(); - for (ContentPatternMarker marker : group.contentPatternMarkers()) { - List 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 filesAll = group.filesAll().stream() + .map(relative -> rooted(root, relative)).toList(); + if (!paths.containsAll(filesAll)) continue; + List filesAny = group.filesAny().stream() + .map(relative -> rooted(root, relative)).filter(paths::contains).toList(); + if (!group.filesAny().isEmpty() && filesAny.isEmpty()) continue; + + Map> allPatternHits = patternHits(group.pathPatternsAll(), facts.paths(), root); + if (allPatternHits.values().stream().anyMatch(List::isEmpty)) continue; + Map> anyPatternHits = patternHits(group.pathPatternsAny(), facts.paths(), root); + if (!group.pathPatternsAny().isEmpty() + && anyPatternHits.values().stream().allMatch(List::isEmpty)) continue; + + Map 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> patternMarkerHits = new TreeMap<>(); + for (ContentPatternMarker marker : group.contentPatternMarkers()) { + List 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 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 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 suffixRoots(List paths, String relative) { + TreeSet 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> patternHits( + List patterns, List paths, String root) { + Map> 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( diff --git a/analysis-plugins/contracts/java/src/main/java/org/rostilos/codecrow/plugins/RepositoryFacts.java b/analysis-plugins/contracts/java/src/main/java/org/rostilos/codecrow/plugins/RepositoryFacts.java index dafc1af4..d6d230f1 100644 --- a/analysis-plugins/contracts/java/src/main/java/org/rostilos/codecrow/plugins/RepositoryFacts.java +++ b/analysis-plugins/contracts/java/src/main/java/org/rostilos/codecrow/plugins/RepositoryFacts.java @@ -6,7 +6,16 @@ import java.util.Map; import java.util.TreeMap; -public record RepositoryFacts(String revision, List paths, Map markerContents) { +public record RepositoryFacts( + String revision, + List paths, + Map markerContents, + String projectType, + String sourceRoot) { + public RepositoryFacts(String revision, List paths, Map markerContents) { + this(revision, paths, markerContents, null, null); + } + public RepositoryFacts { revision = PluginValues.requireNonBlank(revision, "revision"); paths = PluginValues.sortedUnique(paths, "repository paths"); @@ -26,5 +35,16 @@ public record RepositoryFacts(String revision, List paths, Map(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('\\', '/')); + } } } diff --git a/analysis-plugins/contracts/java/src/test/java/org/rostilos/codecrow/plugins/ProjectSelectorTest.java b/analysis-plugins/contracts/java/src/test/java/org/rostilos/codecrow/plugins/ProjectSelectorTest.java index 789684ec..c67d6297 100644 --- a/analysis-plugins/contracts/java/src/test/java/org/rostilos/codecrow/plugins/ProjectSelectorTest.java +++ b/analysis-plugins/contracts/java/src/test/java/org/rostilos/codecrow/plugins/ProjectSelectorTest.java @@ -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 paths = List.of( + "app/etc/config.php", + "bin/magento", + "composer.json", + "packages/store/src/Foo.php", + "tools/Outside.java"); + Map 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"); } } diff --git a/analysis-plugins/contracts/python/codecrow_plugins/api.py b/analysis-plugins/contracts/python/codecrow_plugins/api.py index 44a8064d..40e45a94 100644 --- a/analysis-plugins/contracts/python/codecrow_plugins/api.py +++ b/analysis-plugins/contracts/python/codecrow_plugins/api.py @@ -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") @@ -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) diff --git a/analysis-plugins/contracts/python/codecrow_plugins/facts.py b/analysis-plugins/contracts/python/codecrow_plugins/facts.py index 9629262c..c2fd37f0 100644 --- a/analysis-plugins/contracts/python/codecrow_plugins/facts.py +++ b/analysis-plugins/contracts/python/codecrow_plugins/facts.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path from pathlib import PurePosixPath from typing import Iterable @@ -7,10 +8,12 @@ from .api import RepositoryFacts, normalize_path from .registry import PluginRegistry +logger = logging.getLogger(__name__) + def _declared_markers(registry: PluginRegistry): exact = tuple(sorted({ - marker.path + marker for descriptor in registry.descriptors for marker in ( *descriptor.detection.content_markers, @@ -30,63 +33,126 @@ def _declared_markers(registry: PluginRegistry): return exact, patterns +def _under_source_root(path: str, source_root: str | None) -> bool: + return ( + source_root is None + or path == source_root + or path.startswith(source_root + "/") + ) + + +def _matching_markers(path, content, exact_markers, pattern_markers): + matching_exact = { + marker + for marker in exact_markers + if (path == marker.path or path.endswith("/" + marker.path)) + and marker.contains in content + } + matching_patterns = { + marker + for marker in pattern_markers + if PurePosixPath(path).match(marker.path_pattern) + and marker.contains in content + } + return matching_exact, matching_patterns + + def build_repository_facts( repository_root: str | Path, revision: str, paths: Iterable[str | Path], registry: PluginRegistry, *, - max_marker_files: int = 16, max_marker_bytes: int = 262_144, + project_type: str | None = None, + source_root: str | None = None, ) -> RepositoryFacts: """Read only statically declared markers from an already pinned checkout.""" root = Path(repository_root).resolve(strict=True) normalized_paths = tuple(sorted({normalize_path(Path(path).as_posix()) for path in paths})) available = set(normalized_paths) + if project_type and project_type.strip().casefold() != "auto": + return RepositoryFacts( + revision=revision, + paths=normalized_paths, + marker_contents={}, + project_type=project_type, + source_root=source_root, + ) + declared_markers, declared_pattern_markers = _declared_markers(registry) - if len(declared_markers) > max_marker_files: - raise ValueError("declared plugin marker files exceed the host budget") + declared_marker_paths = tuple(sorted({marker.path for marker in declared_markers})) marker_contents: dict[str, str] = {} consumed_bytes = 0 matched_pattern_markers = set() pattern_candidates = tuple( path for path in normalized_paths - if any(PurePosixPath(path).match(marker.path_pattern) for marker in declared_pattern_markers) + if _under_source_root(path, source_root) + and ( + any(PurePosixPath(path).match(marker.path_pattern) for marker in declared_pattern_markers) + or any( + path == marker_path or path.endswith("/" + marker_path) + for marker_path in declared_marker_paths + ) + ) ) - for marker_path in (*declared_markers, *pattern_candidates): - if marker_path not in available: + skipped_for_bytes = 0 + for marker_path in tuple(dict.fromkeys((*declared_marker_paths, *pattern_candidates))): + if ( + marker_path not in available + or not _under_source_root(marker_path, source_root) + ): continue + applicable_exact_markers = { + marker + for marker in declared_markers + if marker_path == marker.path or marker_path.endswith("/" + marker.path) + } applicable_pattern_markers = { marker for marker in declared_pattern_markers if PurePosixPath(marker_path).match(marker.path_pattern) } - if marker_path not in declared_markers and applicable_pattern_markers.issubset(matched_pattern_markers): + if ( + not applicable_exact_markers + and applicable_pattern_markers.issubset(matched_pattern_markers) + ): continue full_path = (root / marker_path).resolve(strict=True) if root not in full_path.parents: raise ValueError("plugin marker escaped the repository root") size = full_path.stat().st_size + if consumed_bytes + size > max_marker_bytes: + skipped_for_bytes += 1 + continue content = full_path.read_text(encoding="utf-8") - matching_pattern_markers = { - marker for marker in applicable_pattern_markers - if marker not in matched_pattern_markers - and marker.contains in content - } - if marker_path not in declared_markers and not matching_pattern_markers: + matching_exact_markers, matching_pattern_markers = _matching_markers( + marker_path, + content, + applicable_exact_markers, + applicable_pattern_markers - matched_pattern_markers, + ) + if not matching_exact_markers and not matching_pattern_markers: continue - if marker_path not in marker_contents and len(marker_contents) >= max_marker_files: - raise ValueError("declared plugin marker files exceed the host budget") - if consumed_bytes + size > max_marker_bytes: - raise ValueError("plugin marker contents exceed the host byte budget") consumed_bytes += len(content.encode("utf-8")) marker_contents[marker_path] = content matched_pattern_markers.update(matching_pattern_markers) + if skipped_for_bytes: + logger.warning( + "Skipped %s plugin marker candidate(s) after reaching the %s-byte " + "content budget; repository indexing will continue with reduced " + "automatic plugin-detection evidence", + skipped_for_bytes, + max_marker_bytes, + ) + return RepositoryFacts( revision=revision, paths=normalized_paths, marker_contents=marker_contents, + project_type=project_type, + source_root=source_root, ) @@ -98,7 +164,6 @@ def overlay_repository_facts( deleted_paths: Iterable[str | Path], registry: PluginRegistry, *, - max_marker_files: int = 16, max_marker_bytes: int = 262_144, ) -> RepositoryFacts: """Apply one exact commit change set to persisted neutral detection facts. @@ -121,7 +186,8 @@ def overlay_repository_facts( + ", ".join(overlap[:10]) ) if updated and repository_root is None: - raise ValueError("updated repository facts require a repository root") + if baseline.project_type is None: + raise ValueError("updated repository facts require a repository root") root = ( Path(repository_root).resolve(strict=True) if repository_root is not None @@ -129,45 +195,82 @@ def overlay_repository_facts( ) paths = (set(baseline.paths) - set(deleted)) | set(updated) + if baseline.project_type is not None: + return RepositoryFacts( + revision=revision, + paths=tuple(sorted(paths)), + marker_contents={}, + project_type=baseline.project_type, + source_root=baseline.source_root, + ) + + declared_markers, declared_pattern_markers = _declared_markers(registry) marker_contents = { path: content - for path, content in baseline.marker_contents.items() + for path, content in sorted(baseline.marker_contents.items()) if path in paths + and _under_source_root(path, baseline.source_root) + and any(_matching_markers( + path, + content, + declared_markers, + declared_pattern_markers, + )) } - declared_markers, declared_pattern_markers = _declared_markers(registry) - exact_markers = set(declared_markers) for marker_path in updated: + if not _under_source_root(marker_path, baseline.source_root): + marker_contents.pop(marker_path, None) + continue + applicable_exact_markers = tuple( + marker + for marker in declared_markers + if marker_path == marker.path or marker_path.endswith("/" + marker.path) + ) applicable_patterns = tuple( marker for marker in declared_pattern_markers if PurePosixPath(marker_path).match(marker.path_pattern) ) - if marker_path not in exact_markers and not applicable_patterns: + if not applicable_exact_markers and not applicable_patterns: continue full_path = (root / marker_path).resolve(strict=True) if root not in full_path.parents: raise ValueError("plugin marker escaped the repository root") content = full_path.read_text(encoding="utf-8") - if ( - marker_path in exact_markers - or any(marker.contains in content for marker in applicable_patterns) - ): + if any(_matching_markers( + marker_path, + content, + applicable_exact_markers, + applicable_patterns, + )): marker_contents[marker_path] = content else: marker_contents.pop(marker_path, None) - if len(marker_contents) > max_marker_files: - raise ValueError("declared plugin marker files exceed the host budget") - consumed_bytes = sum( - len(content.encode("utf-8")) - for content in marker_contents.values() - ) - if consumed_bytes > max_marker_bytes: - raise ValueError("plugin marker contents exceed the host byte budget") + bounded_marker_contents: dict[str, str] = {} + consumed_bytes = 0 + skipped_for_bytes = 0 + for path, content in sorted(marker_contents.items()): + size = len(content.encode("utf-8")) + if consumed_bytes + size > max_marker_bytes: + skipped_for_bytes += 1 + continue + bounded_marker_contents[path] = content + consumed_bytes += size + if skipped_for_bytes: + logger.warning( + "Skipped %s persisted plugin marker file(s) after reaching the " + "%s-byte content budget during incremental update; indexing will " + "continue with reduced automatic plugin-detection evidence", + skipped_for_bytes, + max_marker_bytes, + ) return RepositoryFacts( revision=revision, paths=tuple(sorted(paths)), - marker_contents=marker_contents, + marker_contents=bounded_marker_contents, + project_type=baseline.project_type, + source_root=baseline.source_root, ) diff --git a/analysis-plugins/contracts/python/codecrow_plugins/graphql.py b/analysis-plugins/contracts/python/codecrow_plugins/graphql.py new file mode 100644 index 00000000..129e5fd1 --- /dev/null +++ b/analysis-plugins/contracts/python/codecrow_plugins/graphql.py @@ -0,0 +1,490 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import Mapping + + +_TOKEN = re.compile( + r"(?P\s+)" + r"|(?P\#[^\r\n]*)" + r"|(?P\"\"\"(?:.|\n)*?\"\"\")" + r"|(?P