diff --git a/build-tools/pom.xml b/build-tools/pom.xml new file mode 100644 index 0000000000..b8c52f1a3b --- /dev/null +++ b/build-tools/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + org.eclipse.rdf4j + rdf4j + 5.2.1-SNAPSHOT + + rdf4j-build-tools + maven-plugin + RDF4J: Build Tools + + 3.13.1 + false + + + + org.apache.maven + maven-plugin-api + 3.9.6 + provided + + + org.apache.maven + maven-model + 3.9.6 + provided + + + org.apache.maven + maven-artifact + 3.9.6 + provided + + + org.apache.maven.plugin-tools + maven-plugin-annotations + ${maven.plugin.annotations.version} + provided + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + + org.apache.maven.plugins + maven-plugin-plugin + 3.13.1 + + rdf4j-build + + + + + descriptor + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 11 + 11 + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + + diff --git a/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/CheckNewFilesCopyrightMojo.java b/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/CheckNewFilesCopyrightMojo.java new file mode 100644 index 0000000000..b778e9f562 --- /dev/null +++ b/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/CheckNewFilesCopyrightMojo.java @@ -0,0 +1,62 @@ +/******************************************************************************* + * Copyright (c) 2025 Eclipse RDF4J contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Distribution License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + ******************************************************************************/ + +package org.eclipse.rdf4j.buildtools.license; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +@Mojo(name = "check-new-files", defaultPhase = LifecyclePhase.VERIFY, threadSafe = true) +public class CheckNewFilesCopyrightMojo extends AbstractMojo { + + private static final List DEFAULT_INCLUDES = List.of("**/*.java", "**/*.kt", "**/*.kts", "**/*.scala", + "**/*.groovy", "**/*.xml", "**/*.xsd", "**/*.xsl", "**/*.properties", "**/*.sh"); + + @Parameter(defaultValue = "${project.basedir}", readonly = true, required = true) + private File baseDirectory; + + @Parameter + private List includes; + + @Parameter + private List excludes; + + @Parameter(property = "copyrightCheck.baseRef") + private String baseReference; + + @Override + public void execute() throws MojoExecutionException { + Path root = baseDirectory.toPath(); + GitService gitService = new GitCommandService(root, baseReference); + List effectiveIncludes = includes == null || includes.isEmpty() ? DEFAULT_INCLUDES + : new ArrayList<>(includes); + List effectiveExcludes = excludes == null ? List.of() : new ArrayList<>(excludes); + + NewFileCopyrightChecker checker = new NewFileCopyrightChecker(root, gitService, effectiveIncludes, + effectiveExcludes); + try { + checker.check(); + } catch (CopyrightCheckException e) { + throw new MojoExecutionException(e.getMessage(), e); + } catch (IOException e) { + throw new MojoExecutionException("Failed to execute git copyright validation", e); + } + } +} diff --git a/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/CopyrightCheckException.java b/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/CopyrightCheckException.java new file mode 100644 index 0000000000..0948101196 --- /dev/null +++ b/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/CopyrightCheckException.java @@ -0,0 +1,20 @@ +/******************************************************************************* + * Copyright (c) 2025 Eclipse RDF4J contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Distribution License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + ******************************************************************************/ + +package org.eclipse.rdf4j.buildtools.license; + +public class CopyrightCheckException extends Exception { + + public CopyrightCheckException(String message) { + super(message); + } + +} diff --git a/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/GitCommandService.java b/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/GitCommandService.java new file mode 100644 index 0000000000..0aac48b2a3 --- /dev/null +++ b/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/GitCommandService.java @@ -0,0 +1,172 @@ +/******************************************************************************* + * Copyright (c) 2025 Eclipse RDF4J contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Distribution License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + ******************************************************************************/ + +package org.eclipse.rdf4j.buildtools.license; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Year; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +public class GitCommandService implements GitService { + + private final Path repositoryRoot; + private final String baseReference; + + public GitCommandService(Path repositoryRoot, String baseReference) { + this.repositoryRoot = repositoryRoot.toAbsolutePath().normalize(); + this.baseReference = baseReference; + } + + @Override + public Map findNewFilesWithCreationYear() throws IOException { + Optional mergeBase = determineMergeBase(); + List newFiles = listNewFiles(mergeBase); + Map result = new LinkedHashMap<>(); + + for (String file : newFiles) { + if (file.isBlank()) { + continue; + } + Path relative = Path.of(file.trim()); + int year = findCreationYear(relative); + result.put(relative, year); + } + + return result; + } + + private Optional determineMergeBase() throws IOException { + Optional reference = resolveBaseReference(); + if (reference.isPresent()) { + GitResult mergeBase = runGit(false, "merge-base", "HEAD", reference.get()); + if (mergeBase.isSuccess() && !mergeBase.lines().isEmpty()) { + return Optional.of(mergeBase.lines().get(0)); + } + } + + GitResult parent = runGit(false, "rev-parse", "HEAD^"); + if (parent.isSuccess() && !parent.lines().isEmpty()) { + return Optional.of(parent.lines().get(0)); + } + + return Optional.empty(); + } + + private Optional resolveBaseReference() throws IOException { + if (baseReference != null && !baseReference.isBlank()) { + return Optional.of(baseReference); + } + + GitResult upstream = runGit(false, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"); + if (upstream.isSuccess() && !upstream.lines().isEmpty()) { + return Optional.of(upstream.lines().get(0)); + } + + for (String candidate : List.of("origin/main", "origin/master", "main", "master")) { + GitResult verify = runGit(false, "rev-parse", "--verify", candidate); + if (verify.isSuccess()) { + return Optional.of(candidate); + } + } + + return Optional.empty(); + } + + private List listNewFiles(Optional mergeBase) throws IOException { + GitResult diff; + if (mergeBase.isPresent()) { + diff = runGit(false, "diff", "--name-only", "--diff-filter=A", mergeBase.get() + "..HEAD"); + } else { + diff = runGit(false, "show", "--name-only", "--diff-filter=A", "--pretty=format:", "HEAD"); + } + + if (!diff.isSuccess()) { + return List.of(); + } + + return diff.lines(); + } + + private int findCreationYear(Path relativePath) throws IOException { + String gitPath = relativePath.toString().replace('\\', '/'); + GitResult log = runGit(false, "log", "--date=format:%Y", "--diff-filter=A", "--follow", "--format=%ad", "--", + gitPath); + if (log.isSuccess() && !log.lines().isEmpty()) { + return parseYear(log.lines().get(0)); + } + return Year.now().getValue(); + } + + private GitResult runGit(boolean failOnError, String... arguments) throws IOException { + List command = new ArrayList<>(arguments.length + 1); + command.add("git"); + command.addAll(List.of(arguments)); + + ProcessBuilder builder = new ProcessBuilder(command); + builder.directory(repositoryRoot.toFile()); + builder.redirectErrorStream(true); + Process process = builder.start(); + + String output; + try (InputStream inputStream = process.getInputStream()) { + output = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + process.destroyForcibly(); + throw e; + } + + try { + int exitCode = process.waitFor(); + if (exitCode != 0 && failOnError) { + throw new IOException( + "Git command failed: " + String.join(" ", command) + System.lineSeparator() + output); + } + return new GitResult(exitCode, output); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while executing git command", e); + } + } + + private int parseYear(String value) { + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return Year.now().getValue(); + } + } + + private static final class GitResult { + private final int exitCode; + private final String output; + + private GitResult(int exitCode, String output) { + this.exitCode = exitCode; + this.output = output; + } + + boolean isSuccess() { + return exitCode == 0; + } + + List lines() { + return output.lines().map(String::trim).filter(line -> !line.isEmpty()).collect(Collectors.toList()); + } + } +} diff --git a/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/GitService.java b/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/GitService.java new file mode 100644 index 0000000000..9264cf1f1c --- /dev/null +++ b/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/GitService.java @@ -0,0 +1,21 @@ +/******************************************************************************* + * Copyright (c) 2025 Eclipse RDF4J contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Distribution License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + ******************************************************************************/ + +package org.eclipse.rdf4j.buildtools.license; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Map; + +public interface GitService { + + Map findNewFilesWithCreationYear() throws IOException; +} diff --git a/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/NewFileCopyrightChecker.java b/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/NewFileCopyrightChecker.java new file mode 100644 index 0000000000..6bd712a64c --- /dev/null +++ b/build-tools/src/main/java/org/eclipse/rdf4j/buildtools/license/NewFileCopyrightChecker.java @@ -0,0 +1,142 @@ +/******************************************************************************* + * Copyright (c) 2025 Eclipse RDF4J contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Distribution License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + ******************************************************************************/ + +package org.eclipse.rdf4j.buildtools.license; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class NewFileCopyrightChecker { + + private static final Pattern COPYRIGHT_PATTERN = Pattern.compile("Copyright \\(c\\) (\\d{4})"); + private static final int HEADER_SCAN_LIMIT = 80; + + private final Path repositoryRoot; + private final GitService gitService; + private final List includeMatchers; + private final List excludeMatchers; + + public NewFileCopyrightChecker(Path repositoryRoot, GitService gitService, List includes, + List excludes) { + this.repositoryRoot = repositoryRoot.toAbsolutePath().normalize(); + this.gitService = gitService; + this.includeMatchers = compileMatchers(includes); + this.excludeMatchers = compileMatchers(excludes); + } + + public void check() throws IOException, CopyrightCheckException { + Map newFiles = gitService.findNewFilesWithCreationYear(); + List violations = new ArrayList<>(); + + for (Map.Entry entry : newFiles.entrySet()) { + Path relativePath = toRelative(entry.getKey()); + if (!shouldCheck(relativePath)) { + continue; + } + + Path file = resolve(relativePath); + if (!Files.exists(file) || !Files.isRegularFile(file)) { + continue; + } + + Integer detectedYear = findCopyrightYear(file); + if (detectedYear == null) { + violations.add(String.format("Missing copyright header in %s (expected %d)", + relativePath, entry.getValue())); + continue; + } + + if (!detectedYear.equals(entry.getValue())) { + violations.add(String.format("File %s has copyright year %d but expected %d", + relativePath, detectedYear, entry.getValue())); + } + } + + if (!violations.isEmpty()) { + String message = violations.stream().collect(Collectors.joining(System.lineSeparator())); + throw new CopyrightCheckException(message); + } + } + + private Path resolve(Path path) { + if (path.isAbsolute()) { + return path.normalize(); + } + return repositoryRoot.resolve(path).normalize(); + } + + private Path toRelative(Path path) { + Path normalized = path.normalize(); + if (normalized.isAbsolute()) { + if (normalized.startsWith(repositoryRoot)) { + return repositoryRoot.relativize(normalized); + } + return normalized; + } + return normalized; + } + + private boolean shouldCheck(Path path) { + if (!excludeMatchers.isEmpty()) { + for (PathMatcher matcher : excludeMatchers) { + if (matcher.matches(path)) { + return false; + } + } + } + + if (includeMatchers.isEmpty()) { + return true; + } + + for (PathMatcher matcher : includeMatchers) { + if (matcher.matches(path)) { + return true; + } + } + + return false; + } + + private static List compileMatchers(List patterns) { + if (patterns == null || patterns.isEmpty()) { + return List.of(); + } + return patterns.stream() + .filter(pattern -> pattern != null && !pattern.isEmpty()) + .map(pattern -> FileSystems.getDefault().getPathMatcher("glob:" + pattern)) + .collect(Collectors.toList()); + } + + private Integer findCopyrightYear(Path file) throws IOException { + try (BufferedReader reader = Files.newBufferedReader(file)) { + String line; + int lines = 0; + while ((line = reader.readLine()) != null && lines++ < HEADER_SCAN_LIMIT) { + Matcher matcher = COPYRIGHT_PATTERN.matcher(line); + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)); + } + } + } + return null; + } +} diff --git a/build-tools/src/test/java/org/eclipse/rdf4j/buildtools/license/NewFileCopyrightCheckerTest.java b/build-tools/src/test/java/org/eclipse/rdf4j/buildtools/license/NewFileCopyrightCheckerTest.java new file mode 100644 index 0000000000..6d6eb611f0 --- /dev/null +++ b/build-tools/src/test/java/org/eclipse/rdf4j/buildtools/license/NewFileCopyrightCheckerTest.java @@ -0,0 +1,81 @@ +/******************************************************************************* + * Copyright (c) 2025 Eclipse RDF4J contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Distribution License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + ******************************************************************************/ + +package org.eclipse.rdf4j.buildtools.license; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class NewFileCopyrightCheckerTest { + + @TempDir + Path tempDir; + + @Test + void passesWhenAllHeadersMatch() throws Exception { + String contents = String.join("\n", + "/*******************************************************************************", + " * Copyright (c) 2024 Eclipse RDF4J contributors, Aduna, and others.", + " *", + " * SPDX-License-Identifier: BSD-3-Clause", + " ******************************************************************************/", + "package example;", + ""); + Path file = createFile("src/main/java/Test.java", contents); + + GitService git = () -> Map.of(tempDir.relativize(file), 2024); + NewFileCopyrightChecker checker = new NewFileCopyrightChecker(tempDir, git, List.of("**/*.java"), List.of()); + + assertDoesNotThrow(checker::check); + } + + @Test + void failsWhenHeaderMissing() throws Exception { + Path file = createFile("src/main/java/TestMissing.java", "package example;\n"); + + GitService git = () -> Map.of(tempDir.relativize(file), 2024); + NewFileCopyrightChecker checker = new NewFileCopyrightChecker(tempDir, git, List.of("**/*.java"), List.of()); + + assertThrows(CopyrightCheckException.class, checker::check); + } + + @Test + void failsWhenYearDoesNotMatch() throws Exception { + String contents = String.join("\n", + "/*******************************************************************************", + " * Copyright (c) 2022 Eclipse RDF4J contributors, Aduna, and others.", + " ******************************************************************************/", + "package example;", + ""); + Path file = createFile("src/main/java/TestWrongYear.java", contents); + + GitService git = () -> Map.of(tempDir.relativize(file), 2024); + NewFileCopyrightChecker checker = new NewFileCopyrightChecker(tempDir, git, List.of("**/*.java"), List.of()); + + assertThrows(CopyrightCheckException.class, checker::check); + } + + private Path createFile(String relative, String contents) throws IOException { + Path file = tempDir.resolve(relative); + Files.createDirectories(file.getParent()); + Files.writeString(file, contents); + return file; + } +} diff --git a/pom.xml b/pom.xml index 44e2322d41..a677d7a7b2 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ assembly-descriptors + build-tools core tools spring-components @@ -875,6 +876,21 @@ + + org.eclipse.rdf4j + rdf4j-build-tools + ${project.version} + false + + + verify-new-files + verify + + check-new-files + + + + org.apache.maven.plugins maven-war-plugin