mirror of
https://github.com/rife2/bld
synced 2026-08-04 15:07:46 +02:00
Compare commits
11 commits
e6c933eb6a
...
4a7d39ec5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a7d39ec5a | ||
|
|
f2a568a654 | ||
|
|
09418fb979 | ||
|
|
fcb348615e | ||
|
|
124c7695e9 | ||
|
|
736372673e | ||
|
|
025e68e9e6 | ||
|
|
5083ecef78 | ||
|
|
cdd4efa460 | ||
|
|
62c0d4569d | ||
|
|
ec185788a1 |
2
core
2
core
|
|
@ -1 +1 @@
|
|||
Subproject commit 9d73b7bd044df1df94907554c6807c6305ba113e
|
||||
Subproject commit fb2e5022e5c1d125e45227b49076c2b200763986
|
||||
|
|
@ -9,4 +9,4 @@ bld.javaOptions=
|
|||
bld.javacOptions=
|
||||
bld.repositories=MAVEN_CENTRAL,RIFE2_RELEASES,RIFE2_SNAPSHOTS
|
||||
bld.sourceDirectories=core/src/bld/java
|
||||
bld.version=2.4.0-SNAPSHOT
|
||||
bld.version=2.4.0-SNAPSHOT
|
||||
|
|
|
|||
|
|
@ -395,6 +395,7 @@ public class BaseProject extends BuildExecutor {
|
|||
private final CompileOperation compileOperation_ = new CompileOperation();
|
||||
private final DependencyTreeOperation dependencyTreeOperation_ = new DependencyTreeOperation();
|
||||
private final DownloadOperation downloadOperation_ = new DownloadOperation();
|
||||
private final McpOperation mcpOperation_ = new McpOperation();
|
||||
private final PurgeOperation purgeOperation_ = new PurgeOperation();
|
||||
private final PublishOperation publishOperation_ = new PublishOperation();
|
||||
private final RunOperation runOperation_ = new RunOperation();
|
||||
|
|
@ -442,6 +443,16 @@ public class BaseProject extends BuildExecutor {
|
|||
return downloadOperation_;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the project's default MCP operation.
|
||||
*
|
||||
* @return the default MCP operation instance
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public McpOperation mcpOperation() {
|
||||
return mcpOperation_;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the project's default publish operation.
|
||||
*
|
||||
|
|
@ -546,6 +557,18 @@ public class BaseProject extends BuildExecutor {
|
|||
downloadOperation().executeOnce(() -> downloadOperation().fromProject(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard build command, starts an MCP server that exposes the build
|
||||
* commands as tools.
|
||||
*
|
||||
* @since 2.4.0
|
||||
*/
|
||||
@BuildCommand(help = McpHelp.class)
|
||||
public void mcp()
|
||||
throws Exception {
|
||||
mcpOperation().executeOnce(() -> mcpOperation().fromProject(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard build command, purges all unused artifacts from the project.
|
||||
*
|
||||
|
|
@ -910,8 +933,7 @@ public class BaseProject extends BuildExecutor {
|
|||
*/
|
||||
public LocalDependency local(String path) {
|
||||
return new LocalDependency(path);
|
||||
} /**
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a local dependency instance.
|
||||
|
|
@ -920,8 +942,9 @@ public class BaseProject extends BuildExecutor {
|
|||
*
|
||||
* @param path the file system path (absolute or relative to the {@link #workDirectory})
|
||||
* of the local dependency
|
||||
* @since 2.3.1
|
||||
* @since 2.4.0
|
||||
*/
|
||||
|
||||
public LocalDependency local(Path path) {
|
||||
return new LocalDependency(path.toString());
|
||||
}
|
||||
|
|
@ -931,7 +954,7 @@ public class BaseProject extends BuildExecutor {
|
|||
* If the local dependency points to a directory, it will be scanned for jar files.
|
||||
*
|
||||
* @param path the file system path of the local dependency
|
||||
* @since 2.3.1
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public LocalDependency local(File path) {
|
||||
return new LocalDependency(path.getAbsolutePath());
|
||||
|
|
@ -1095,7 +1118,7 @@ public class BaseProject extends BuildExecutor {
|
|||
*
|
||||
* @param path the file system path (absolute or relative to the {@link #workDirectory})
|
||||
* of the local module
|
||||
* @since 2.3.1
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public LocalModule localModule(Path path) {
|
||||
return new LocalModule(path.toString());
|
||||
|
|
@ -1107,7 +1130,7 @@ public class BaseProject extends BuildExecutor {
|
|||
* If the local module points to a directory, it will be scanned for jar files.
|
||||
*
|
||||
* @param path the file system path of the local module
|
||||
* @since 2.3.1
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public LocalModule localModule(File path) {
|
||||
return new LocalModule(path.getAbsolutePath());
|
||||
|
|
@ -2118,6 +2141,22 @@ public class BaseProject extends BuildExecutor {
|
|||
purge();
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the automatic download and purge of the project
|
||||
* dependencies when it's enabled through {@link #autoDownloadPurge()}
|
||||
* and the project isn't offline.
|
||||
* <p>
|
||||
* The dependencies are only refreshed when the dependency cache is
|
||||
* stale, so that repeated invocations are cheap.
|
||||
*
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public void performAutoDownloadPurgeIfEnabled() {
|
||||
if (!offline() && autoDownloadPurge()) {
|
||||
performAutoDownloadPurge();
|
||||
}
|
||||
}
|
||||
|
||||
private void performAutoDownloadPurge() {
|
||||
var resolution = new VersionResolution(properties());
|
||||
var cache = new BldCache(libBldDirectory(), resolution);
|
||||
|
|
@ -2141,6 +2180,12 @@ public class BaseProject extends BuildExecutor {
|
|||
var remainingArguments = new ArrayList<>(List.of(arguments));
|
||||
var auto = remainingArguments.remove(AUTO_DOWNLOAD_PURGE_OPTION);
|
||||
|
||||
if (remainingArguments.contains(BuildExecutor.ARG_USE_STDERR)) {
|
||||
// the build output is sent to standard error so that standard
|
||||
// output stays free for the MCP protocol, done before the
|
||||
// automatic download and purge produces any output
|
||||
System.setOut(System.err);
|
||||
}
|
||||
if (!offline() &&
|
||||
(autoDownloadPurge() || auto)) {
|
||||
performAutoDownloadPurge();
|
||||
|
|
@ -2148,4 +2193,4 @@ public class BaseProject extends BuildExecutor {
|
|||
|
||||
return super.execute(remainingArguments.toArray(new String[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,8 @@ public class BuildExecutor {
|
|||
public static final String LOCAL_PROPERTIES = "local.properties";
|
||||
|
||||
private static final String ARG_OFFLINE = "--offline";
|
||||
static final String ARG_USE_STDERR = "--use-stderr";
|
||||
|
||||
private static final String ARG_HELP1 = "--help";
|
||||
private static final String ARG_HELP2 = "-h";
|
||||
private static final String ARG_HELP3 = "-?";
|
||||
|
|
@ -41,7 +43,7 @@ public class BuildExecutor {
|
|||
private static final String ARG_VERBOSE2 = "-v";
|
||||
|
||||
private final HierarchicalProperties properties_;
|
||||
private List<String> arguments_ = Collections.emptyList();
|
||||
private List<String> arguments_ = new ArrayList<>();
|
||||
private boolean offline_ = false;
|
||||
private boolean verbose_ = false;
|
||||
private Map<String, CommandDefinition> buildCommands_ = null;
|
||||
|
|
@ -138,6 +140,17 @@ public class BuildExecutor {
|
|||
return offline_;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes whether the bld execution is intended to be offline.
|
||||
*
|
||||
* @param offline {@code true} if the execution is intended to be offline;
|
||||
* or {@code false} otherwise
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public void offline(boolean offline) {
|
||||
offline_ = offline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the bld execution should output detailed information
|
||||
* about the operations it performs.
|
||||
|
|
@ -150,6 +163,42 @@ public class BuildExecutor {
|
|||
return verbose_;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes whether the bld execution should output detailed information
|
||||
* about the operations it performs.
|
||||
*
|
||||
* @param verbose {@code true} if the execution is verbose;
|
||||
* or {@code false} otherwise
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public void verbose(boolean verbose) {
|
||||
verbose_ = verbose;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the bld execution prints out the stacktrace for
|
||||
* exceptions.
|
||||
*
|
||||
* @return {@code true} if the stacktrace is printed;
|
||||
* or {@code false} otherwise
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public boolean showStacktrace() {
|
||||
return showStacktrace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes whether the bld execution prints out the stacktrace for
|
||||
* exceptions.
|
||||
*
|
||||
* @param showStacktrace {@code true} if the stacktrace is printed;
|
||||
* or {@code false} otherwise
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public void showStacktrace(boolean showStacktrace) {
|
||||
this.showStacktrace = showStacktrace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the properties uses for bld execution.
|
||||
*
|
||||
|
|
@ -243,12 +292,19 @@ public class BuildExecutor {
|
|||
show_help |= arguments_.removeAll(List.of(ARG_HELP1, ARG_HELP2, ARG_HELP3));
|
||||
showStacktrace = arguments_.removeAll(List.of(ARG_STACKTRACE1, ARG_STACKTRACE2));
|
||||
verbose_ = arguments_.removeAll(List.of(ARG_VERBOSE1, ARG_VERBOSE2));
|
||||
if (arguments_.removeAll(List.of(ARG_USE_STDERR))) {
|
||||
// the build output is sent to standard error so that standard
|
||||
// output stays free for the MCP protocol, which the MCP server
|
||||
// relies on to keep its protocol stream clean
|
||||
System.setOut(System.err);
|
||||
}
|
||||
|
||||
if (show_help) {
|
||||
new HelpOperation(this, Collections.emptyList()).execute();
|
||||
return exitStatus_;
|
||||
}
|
||||
else if (arguments_.isEmpty()) {
|
||||
|
||||
if (arguments_.isEmpty()) {
|
||||
showBldHelp();
|
||||
return exitStatus_;
|
||||
}
|
||||
|
|
@ -441,47 +497,14 @@ public class BuildExecutor {
|
|||
*/
|
||||
public boolean executeCommand(String command)
|
||||
throws Throwable {
|
||||
var matched_command = command;
|
||||
var definition = buildCommands().get(command);
|
||||
|
||||
// try to find an alias
|
||||
if (definition == null) {
|
||||
var aliased_command = buildAliases().get(command);
|
||||
if (aliased_command != null) {
|
||||
matched_command = aliased_command;
|
||||
definition = buildCommands().get(aliased_command);
|
||||
}
|
||||
}
|
||||
|
||||
// try to find a match for the provided command amongst
|
||||
// the ones that are known
|
||||
if (definition == null) {
|
||||
// try to find starting matching options
|
||||
var matches = new ArrayList<>(buildCommands().keySet().stream()
|
||||
.filter(c -> c.toLowerCase().startsWith(command.toLowerCase()))
|
||||
.toList());
|
||||
|
||||
if (matches.isEmpty()) {
|
||||
// try to find fuzzy matching options
|
||||
var fuzzy_regexp = new StringBuilder("^.*");
|
||||
for (var ch : command.toCharArray()) {
|
||||
fuzzy_regexp.append("\\Q");
|
||||
fuzzy_regexp.append(ch);
|
||||
fuzzy_regexp.append("\\E.*");
|
||||
}
|
||||
fuzzy_regexp.append('$');
|
||||
var fuzzy_pattern = Pattern.compile(fuzzy_regexp.toString());
|
||||
matches.addAll(buildCommands().keySet().stream()
|
||||
.filter(c -> fuzzy_pattern.matcher(c.toLowerCase()).matches())
|
||||
.toList());
|
||||
}
|
||||
|
||||
// only proceed if exactly one match was found
|
||||
if (matches.size() == 1) {
|
||||
matched_command = matches.get(0);
|
||||
var matched_command = resolveCommand(command);
|
||||
CommandDefinition definition = null;
|
||||
if (matched_command != null) {
|
||||
if (!matched_command.equals(command) &&
|
||||
!matched_command.equals(buildAliases().get(command))) {
|
||||
System.out.println("Executing matched command: " + matched_command);
|
||||
definition = buildCommands().get(matched_command);
|
||||
}
|
||||
definition = buildCommands().get(matched_command);
|
||||
}
|
||||
|
||||
// execute the command if we found one
|
||||
|
|
@ -507,6 +530,54 @@ public class BuildExecutor {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a command name to the name of the build command that would
|
||||
* be executed for it, taking the command aliases, unique name prefixes
|
||||
* and unique fuzzy matches into account.
|
||||
*
|
||||
* @param command the command name to resolve
|
||||
* @return the name of the build command that would be executed; or
|
||||
* {@code null} when the name couldn't be resolved
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public String resolveCommand(String command) {
|
||||
if (buildCommands().containsKey(command)) {
|
||||
return command;
|
||||
}
|
||||
|
||||
// try to find an alias
|
||||
var aliased_command = buildAliases().get(command);
|
||||
if (aliased_command != null) {
|
||||
return aliased_command;
|
||||
}
|
||||
|
||||
// try to find starting matching options
|
||||
var matches = new ArrayList<>(buildCommands().keySet().stream()
|
||||
.filter(c -> c.toLowerCase().startsWith(command.toLowerCase()))
|
||||
.toList());
|
||||
|
||||
if (matches.isEmpty()) {
|
||||
// try to find fuzzy matching options
|
||||
var fuzzy_regexp = new StringBuilder("^.*");
|
||||
for (var ch : command.toCharArray()) {
|
||||
fuzzy_regexp.append("\\Q");
|
||||
fuzzy_regexp.append(ch);
|
||||
fuzzy_regexp.append("\\E.*");
|
||||
}
|
||||
fuzzy_regexp.append('$');
|
||||
var fuzzy_pattern = Pattern.compile(fuzzy_regexp.toString());
|
||||
matches.addAll(buildCommands().keySet().stream()
|
||||
.filter(c -> fuzzy_pattern.matcher(c.toLowerCase()).matches())
|
||||
.toList());
|
||||
}
|
||||
|
||||
// only resolve if exactly one match was found
|
||||
if (matches.size() == 1) {
|
||||
return matches.get(0);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void showBldHelp() {
|
||||
var help = new HelpOperation(this, arguments());
|
||||
help.executePrintWelcome();
|
||||
|
|
|
|||
|
|
@ -178,6 +178,40 @@ public class DependencyScopes extends LinkedHashMap<Scope, DependencySet> {
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the declared dependencies whose explicit version differs
|
||||
* from the version that a BOM applying to their scope manages them at.
|
||||
* <p>
|
||||
* The declared version is used for the dependency itself, its
|
||||
* transitive dependencies still resolve to the versions that the BOMs
|
||||
* manage. Dependencies whose version is supplied by a
|
||||
* {@code bld.override} property are not reported. Each difference is
|
||||
* reported once.
|
||||
*
|
||||
* @param properties the properties to use to get artifacts
|
||||
* @param retriever the retriever to use to get artifacts
|
||||
* @param repositories the repositories to use for the BOM resolution
|
||||
* @return the version differences between the declared dependencies
|
||||
* and the applicable BOMs
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public List<VersionResolution.DeclaredVersionConflict> declaredVersionConflicts(HierarchicalProperties properties, ArtifactRetriever retriever, List<Repository> repositories) {
|
||||
var result = new ArrayList<VersionResolution.DeclaredVersionConflict>();
|
||||
var seen = new HashSet<String>();
|
||||
for (var entry : entrySet()) {
|
||||
var effective_boms = effectiveBoms(entry.getKey());
|
||||
if (effective_boms.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (var conflict : VersionResolution.resolveDeclaredVersionConflicts(properties, retriever, repositories, effective_boms, entry.getValue())) {
|
||||
if (seen.add(conflict.dependency() + conflict.declaredVersion() + conflict.bom() + conflict.bomVersion())) {
|
||||
result.add(conflict);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the transitive set of dependencies that would be used for the compile scope in a project.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -233,6 +233,78 @@ public class VersionResolution {
|
|||
return conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes a declared dependency whose explicit version differs from
|
||||
* the version that an applicable bill of materials manages it at.
|
||||
* <p>
|
||||
* The declared version is used for the dependency itself, its
|
||||
* transitive dependencies still resolve to the versions that the BOM
|
||||
* manages.
|
||||
*
|
||||
* @param dependency the group and artifact identifiers of the
|
||||
* declared dependency
|
||||
* @param declaredVersion the version the dependency is declared with
|
||||
* @param bom the BOM that manages the dependency, the one
|
||||
* with the highest precedence when several do
|
||||
* @param bomVersion the version the BOM manages the dependency at
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public record DeclaredVersionConflict(String dependency, Version declaredVersion, String bom, Version bomVersion) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the declared dependencies whose explicit version differs from
|
||||
* the version that the provided bills of materials manage them at.
|
||||
* <p>
|
||||
* Dependencies that are declared without a version or whose version is
|
||||
* supplied by a {@code bld.override} property are not reported.
|
||||
*
|
||||
* @param properties the properties to use to get artifacts
|
||||
* @param retriever the retriever to use to get the BOMs
|
||||
* @param repositories the repositories to resolve the BOMs in
|
||||
* @param boms the BOMs to check, in precedence order
|
||||
* @param declared the declared dependencies to check
|
||||
* @return the version differences between the declared dependencies and the BOMs
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public static List<DeclaredVersionConflict> resolveDeclaredVersionConflicts(HierarchicalProperties properties, ArtifactRetriever retriever, List<Repository> repositories, Collection<Bom> boms, Collection<Dependency> declared) {
|
||||
var base = new VersionResolution(properties);
|
||||
var managed_versions = new LinkedHashMap<String, Version>();
|
||||
var managed_boms = new LinkedHashMap<String, String>();
|
||||
if (boms != null) {
|
||||
for (var bom : boms) {
|
||||
var pom = new DependencyResolver(base, retriever, repositories, bom).getMavenPom(bom);
|
||||
for (var managed : pom.getManagedDependencies()) {
|
||||
if (managed.version() != null && !managed.version().isBlank()) {
|
||||
var dependency = managed.convertToDependency();
|
||||
var key = managedKey(dependency);
|
||||
// the first BOM that manages a dependency determines
|
||||
// its version, mirroring the resolution precedence
|
||||
if (managed_versions.putIfAbsent(key, dependency.version()) == null) {
|
||||
managed_boms.put(key, bom.toArtifactString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var conflicts = new ArrayList<DeclaredVersionConflict>();
|
||||
if (declared != null) {
|
||||
for (var dependency : declared) {
|
||||
if (VersionNumber.UNKNOWN.equals(dependency.version()) ||
|
||||
base.versionOverrides_.containsKey(dependency.toArtifactString())) {
|
||||
continue;
|
||||
}
|
||||
var key = managedKey(dependency);
|
||||
var managed_version = managed_versions.get(key);
|
||||
if (managed_version != null && !managed_version.equals(dependency.version())) {
|
||||
conflicts.add(new DeclaredVersionConflict(dependency.toArtifactString(), dependency.version(), managed_boms.get(key), managed_version));
|
||||
}
|
||||
}
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
// builds the identity that dependency management entries are matched
|
||||
// on, mirroring Maven this includes the type and the classifier, the
|
||||
// modular and forced-classpath JAR types match the plain jar entries
|
||||
|
|
|
|||
47
src/main/java/rife/bld/help/McpHelp.java
Normal file
47
src/main/java/rife/bld/help/McpHelp.java
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright 2001-2026 Geert Bevin (gbevin[remove] at uwyn dot com)
|
||||
* Licensed under the Apache License, Version 2.0 (the "License")
|
||||
*/
|
||||
package rife.bld.help;
|
||||
|
||||
import rife.bld.CommandHelp;
|
||||
import rife.tools.StringUtils;
|
||||
|
||||
/**
|
||||
* Provides help for the MCP command.
|
||||
*
|
||||
* @author Geert Bevin (gbevin[remove] at uwyn dot com)
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public class McpHelp implements CommandHelp {
|
||||
public String getSummary() {
|
||||
return "Starts an MCP server that exposes the build commands";
|
||||
}
|
||||
|
||||
public String getDescription(String topic) {
|
||||
return StringUtils.replace("""
|
||||
Starts a Model Context Protocol (MCP) server that exposes the
|
||||
build commands as tools, so that AI agents can drive the build.
|
||||
This is an experimental feature and may still change.
|
||||
|
||||
The server communicates over standard input and output with the
|
||||
MCP stdio transport and runs until its input stream ends. Launch
|
||||
bld with the --use-stderr option to keep standard output free
|
||||
for the protocol while the build starts up.
|
||||
|
||||
The install argument doesn't start the server, it registers the
|
||||
project with an MCP client by writing the standard configuration
|
||||
file inside the project directory:
|
||||
|
||||
install writes .mcp.json (Claude Code and compatible)
|
||||
install cursor writes .cursor/mcp.json
|
||||
install vscode writes .vscode/mcp.json
|
||||
install --print prints the configuration instead of writing it
|
||||
|
||||
Existing configuration files are merged with, other servers are
|
||||
preserved. The registered command launches the wrapper directly
|
||||
through java, so that the same file works on every platform.
|
||||
|
||||
Usage : ${topic} [install [claude | cursor | vscode] [--print]]""", "${topic}", topic);
|
||||
}
|
||||
}
|
||||
|
|
@ -89,6 +89,9 @@ public class DownloadOperation extends AbstractOperation<DownloadOperation> {
|
|||
for (var conflict : dependencies().bomVersionConflicts(properties(), artifactRetriever(), repositories())) {
|
||||
System.out.println(formatBomVersionConflict(conflict));
|
||||
}
|
||||
for (var conflict : dependencies().declaredVersionConflicts(properties(), artifactRetriever(), repositories())) {
|
||||
System.out.println(formatDeclaredVersionConflict(conflict));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -110,6 +113,20 @@ public class DownloadOperation extends AbstractOperation<DownloadOperation> {
|
|||
return message.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a warning message for a declared dependency whose version
|
||||
* differs from the version that a BOM manages it at.
|
||||
*
|
||||
* @param conflict the declared version conflict to format
|
||||
* @return the formatted warning message
|
||||
* @since 2.4.0
|
||||
*/
|
||||
protected static String formatDeclaredVersionConflict(rife.bld.dependencies.VersionResolution.DeclaredVersionConflict conflict) {
|
||||
return "Warning: '" + conflict.dependency() + "' is declared with version " + conflict.declaredVersion() +
|
||||
" while BOM '" + conflict.bom() + "' manages it at " + conflict.bomVersion() +
|
||||
", the declared version is used but transitive dependencies still follow the BOM";
|
||||
}
|
||||
|
||||
/**
|
||||
* Part of the {@link #execute} operation, download the {@code compile} scope artifacts.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ public class HelpOperation {
|
|||
The following bld arguments are supported:
|
||||
|
||||
--offline Works without Internet (only as first argument)
|
||||
--use-stderr Sends the build output to standard error
|
||||
-?, -h, --help Shows the help
|
||||
-D<name>=<value> Sets a JVM system property
|
||||
-s, --stacktrace Prints out the stacktrace for exceptions
|
||||
|
|
|
|||
|
|
@ -561,7 +561,7 @@ public class JpackageOptions extends LinkedHashMap<String, String> {
|
|||
/**
|
||||
* Creates a shortcut for the application.
|
||||
*
|
||||
* @param shortcut {@code true| to create a shortcut, {@code false} otherwise
|
||||
* @param shortcut {@code true} to create a shortcut, {@code false} otherwise
|
||||
* @return this map of options
|
||||
*/
|
||||
public JpackageOptions linuxShortcut(boolean shortcut) {
|
||||
|
|
|
|||
121
src/main/java/rife/bld/operations/McpControl.java
Normal file
121
src/main/java/rife/bld/operations/McpControl.java
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* Copyright 2001-2026 Geert Bevin (gbevin[remove] at uwyn dot com)
|
||||
* Licensed under the Apache License, Version 2.0 (the "License")
|
||||
*/
|
||||
package rife.bld.operations;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The details that a single MCP tool call hands to the build process it
|
||||
* launches.
|
||||
* <p>
|
||||
* The command, its arguments, the flags and the excluded commands are
|
||||
* written to a control file instead of the command line, so they can never
|
||||
* be mistaken for the command's own arguments. The values are escaped so
|
||||
* that any character, including newlines, comes through intact. Only the
|
||||
* MCP code uses this file, the build itself knows nothing about it.
|
||||
*
|
||||
* @author Geert Bevin (gbevin[remove] at uwyn dot com)
|
||||
* @since 2.4.0
|
||||
*/
|
||||
final class McpControl {
|
||||
final String command;
|
||||
final List<String> arguments;
|
||||
final boolean offline;
|
||||
final boolean verbose;
|
||||
final boolean stacktrace;
|
||||
final Set<String> exclusions;
|
||||
|
||||
private McpControl(String command, List<String> arguments, boolean offline, boolean verbose, boolean stacktrace, Set<String> exclusions) {
|
||||
this.command = command;
|
||||
this.arguments = arguments;
|
||||
this.offline = offline;
|
||||
this.verbose = verbose;
|
||||
this.stacktrace = stacktrace;
|
||||
this.exclusions = exclusions;
|
||||
}
|
||||
|
||||
static void write(File file, String command, List<String> arguments,
|
||||
boolean offline, boolean verbose, boolean stacktrace,
|
||||
Collection<String> exclusions)
|
||||
throws IOException {
|
||||
var lines = new ArrayList<String>();
|
||||
lines.add("command " + escape(command));
|
||||
for (var argument : arguments) {
|
||||
lines.add("arg " + escape(argument));
|
||||
}
|
||||
if (offline) {
|
||||
lines.add("offline");
|
||||
}
|
||||
if (verbose) {
|
||||
lines.add("verbose");
|
||||
}
|
||||
if (stacktrace) {
|
||||
lines.add("stacktrace");
|
||||
}
|
||||
for (var exclusion : exclusions) {
|
||||
lines.add("exclude " + escape(exclusion));
|
||||
}
|
||||
Files.write(file.toPath(), lines);
|
||||
}
|
||||
|
||||
static McpControl read(File file)
|
||||
throws IOException {
|
||||
String command = null;
|
||||
var arguments = new ArrayList<String>();
|
||||
var offline = false;
|
||||
var verbose = false;
|
||||
var stacktrace = false;
|
||||
var exclusions = new LinkedHashSet<String>();
|
||||
for (var line : Files.readAllLines(file.toPath())) {
|
||||
if (line.equals("offline")) {
|
||||
offline = true;
|
||||
} else if (line.equals("verbose")) {
|
||||
verbose = true;
|
||||
} else if (line.equals("stacktrace")) {
|
||||
stacktrace = true;
|
||||
} else if (line.startsWith("command ")) {
|
||||
command = unescape(line.substring("command ".length()));
|
||||
} else if (line.startsWith("arg ")) {
|
||||
arguments.add(unescape(line.substring("arg ".length())));
|
||||
} else if (line.startsWith("exclude ")) {
|
||||
exclusions.add(unescape(line.substring("exclude ".length())));
|
||||
}
|
||||
}
|
||||
if (command == null) {
|
||||
throw new IOException("the control file doesn't specify a command");
|
||||
}
|
||||
return new McpControl(command, arguments, offline, verbose, stacktrace, exclusions);
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
return value.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r");
|
||||
}
|
||||
|
||||
private static String unescape(String value) {
|
||||
var result = new StringBuilder();
|
||||
for (var i = 0; i < value.length(); ++i) {
|
||||
var c = value.charAt(i);
|
||||
if (c == '\\' && i + 1 < value.length()) {
|
||||
var next = value.charAt(++i);
|
||||
switch (next) {
|
||||
case 'n' -> result.append('\n');
|
||||
case 'r' -> result.append('\r');
|
||||
case '\\' -> result.append('\\');
|
||||
default -> result.append(next);
|
||||
}
|
||||
} else {
|
||||
result.append(c);
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
1496
src/main/java/rife/bld/operations/McpOperation.java
Normal file
1496
src/main/java/rife/bld/operations/McpOperation.java
Normal file
File diff suppressed because it is too large
Load diff
180
src/main/java/rife/bld/operations/McpToolRunner.java
Normal file
180
src/main/java/rife/bld/operations/McpToolRunner.java
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/*
|
||||
* Copyright 2001-2026 Geert Bevin (gbevin[remove] at uwyn dot com)
|
||||
* Licensed under the Apache License, Version 2.0 (the "License")
|
||||
*/
|
||||
package rife.bld.operations;
|
||||
|
||||
import rife.bld.BaseProject;
|
||||
import rife.bld.BuildExecutor;
|
||||
import rife.bld.operations.exceptions.ExitStatusException;
|
||||
import rife.tools.ExceptionUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* The entry point that runs exactly one build command for an MCP tool
|
||||
* call, in a separate build process.
|
||||
* <p>
|
||||
* It reads the command, its arguments, the flags and the excluded commands
|
||||
* from the control file, creates the build executor, and runs the single
|
||||
* command through its public API. All of this single command handling lives
|
||||
* here in the MCP feature, the build itself stays unaware of it. The runner
|
||||
* is launched with the build executor's class name as its last argument,
|
||||
* any earlier arguments, like the wrapper's {@code --offline} option, are
|
||||
* ignored.
|
||||
*
|
||||
* @author Geert Bevin (gbevin[remove] at uwyn dot com)
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public final class McpToolRunner {
|
||||
/**
|
||||
* The environment variable with the path of the control file that
|
||||
* requests the execution of exactly one command.
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public static final String ENV_CONTROL_FILE = "BLD_MCP_CONTROL_FILE";
|
||||
|
||||
/**
|
||||
* The environment variable with the path of the file that the runner
|
||||
* writes its result to.
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public static final String ENV_STATUS_FILE = "BLD_MCP_STATUS_FILE";
|
||||
|
||||
/**
|
||||
* The outcome that is written when the command couldn't be resolved.
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public static final String STATUS_UNKNOWN_COMMAND = "unknown-command";
|
||||
|
||||
/**
|
||||
* The outcome that is written when the command is excluded.
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public static final String STATUS_EXCLUDED_COMMAND = "excluded-command";
|
||||
|
||||
/**
|
||||
* The outcome that is written when the runner itself couldn't be set
|
||||
* up, for instance when the build executor class couldn't be
|
||||
* instantiated, this is a server failure rather than a tool failure.
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public static final String STATUS_RUNNER_ERROR = "runner-error";
|
||||
|
||||
private McpToolRunner() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a single build command for an MCP tool call.
|
||||
*
|
||||
* @param arguments the build executor class name as the last argument
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public static void main(String[] arguments) {
|
||||
System.exit(run(arguments));
|
||||
}
|
||||
|
||||
private static int run(String[] arguments) {
|
||||
var control_path = System.getenv(ENV_CONTROL_FILE);
|
||||
if (control_path == null) {
|
||||
writeStatus(STATUS_RUNNER_ERROR);
|
||||
System.err.println("ERROR: no MCP control file provided");
|
||||
return ExitStatusException.EXIT_FAILURE;
|
||||
}
|
||||
|
||||
var control_file = new File(control_path);
|
||||
McpControl control;
|
||||
try {
|
||||
control = McpControl.read(control_file);
|
||||
} catch (IOException e) {
|
||||
writeStatus(STATUS_RUNNER_ERROR);
|
||||
System.err.println("ERROR: the MCP control file couldn't be read");
|
||||
return ExitStatusException.EXIT_FAILURE;
|
||||
} finally {
|
||||
// the control file is consumed immediately, so that nested
|
||||
// processes that inherit the environment don't re-enter the
|
||||
// single command execution and don't overwrite its status
|
||||
control_file.delete();
|
||||
}
|
||||
|
||||
if (arguments.length == 0) {
|
||||
writeStatus(STATUS_RUNNER_ERROR);
|
||||
System.err.println("ERROR: no build executor class provided");
|
||||
return ExitStatusException.EXIT_FAILURE;
|
||||
}
|
||||
// the build executor class is instantiated directly through its
|
||||
// no-arg constructor and driven through its public API, its main
|
||||
// method is not invoked, so that a tool call runs exactly the
|
||||
// requested command and nothing else
|
||||
BuildExecutor executor;
|
||||
try {
|
||||
var executor_class = Class.forName(arguments[arguments.length - 1]);
|
||||
executor = (BuildExecutor) executor_class.getDeclaredConstructor().newInstance();
|
||||
} catch (ReflectiveOperationException | ClassCastException e) {
|
||||
writeStatus(STATUS_RUNNER_ERROR);
|
||||
System.err.println("ERROR: the build executor couldn't be instantiated: " + e);
|
||||
return ExitStatusException.EXIT_FAILURE;
|
||||
}
|
||||
|
||||
executor.offline(control.offline);
|
||||
executor.verbose(control.verbose);
|
||||
executor.showStacktrace(control.stacktrace);
|
||||
|
||||
var resolved = executor.resolveCommand(control.command);
|
||||
if (resolved == null) {
|
||||
// the result goes into the status file so that it can never be
|
||||
// confused with a regular exit status of a build command
|
||||
writeStatus(STATUS_UNKNOWN_COMMAND);
|
||||
System.err.println("ERROR: Unknown command '" + control.command + "'");
|
||||
return ExitStatusException.EXIT_FAILURE;
|
||||
}
|
||||
// the exclusions are enforced here, in the freshly compiled build,
|
||||
// so that aliases or commands added to the build sources can't
|
||||
// reach an excluded command either
|
||||
if (control.exclusions.contains(control.command) || control.exclusions.contains(resolved)) {
|
||||
writeStatus(STATUS_EXCLUDED_COMMAND);
|
||||
System.err.println("ERROR: Command '" + control.command + "' is not available");
|
||||
return ExitStatusException.EXIT_FAILURE;
|
||||
}
|
||||
|
||||
// an automatic dependency download and purge that the project is
|
||||
// configured for runs before the command, exactly like it would on
|
||||
// the command line
|
||||
if (executor instanceof BaseProject project) {
|
||||
project.performAutoDownloadPurgeIfEnabled();
|
||||
}
|
||||
|
||||
// the command's arguments come from the control file, they are
|
||||
// never interpreted as additional commands or as global flags
|
||||
executor.arguments().clear();
|
||||
executor.arguments().addAll(control.arguments);
|
||||
try {
|
||||
executor.executeCommand(resolved);
|
||||
} catch (Throwable e) {
|
||||
executor.exitStatus(ExitStatusException.EXIT_FAILURE);
|
||||
System.err.println();
|
||||
if (control.stacktrace) {
|
||||
System.err.println(ExceptionUtils.getExceptionStackTrace(e));
|
||||
} else if (e.getMessage() != null) {
|
||||
System.err.println(e.getMessage());
|
||||
} else {
|
||||
System.err.println(e.getClass().getName());
|
||||
}
|
||||
}
|
||||
return executor.exitStatus();
|
||||
}
|
||||
|
||||
private static void writeStatus(String status) {
|
||||
var status_file = System.getenv(ENV_STATUS_FILE);
|
||||
if (status_file != null) {
|
||||
try {
|
||||
Files.writeString(Path.of(status_file), status);
|
||||
} catch (IOException e) {
|
||||
// the caller falls back to the exit status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -54,7 +54,6 @@ public class PurgeOperation extends AbstractOperation<PurgeOperation> {
|
|||
return;
|
||||
}
|
||||
|
||||
executeReportUncoveredDependencies();
|
||||
executePurgeCompileDependencies();
|
||||
executePurgeProvidedDependencies();
|
||||
executePurgeRuntimeDependencies();
|
||||
|
|
@ -65,24 +64,6 @@ public class PurgeOperation extends AbstractOperation<PurgeOperation> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Part of the {@link #execute} operation, warns about version-less
|
||||
* dependencies that are not covered by a BOM in their scope.
|
||||
*
|
||||
* @since 2.4.0
|
||||
*/
|
||||
protected void executeReportUncoveredDependencies() {
|
||||
if (silent()) {
|
||||
return;
|
||||
}
|
||||
for (var dependency : dependencies().versionlessDependenciesWithoutBom(properties(), artifactRetriever(), repositories())) {
|
||||
System.out.println("Warning: '" + dependency.toArtifactString() + "' isn't covered by a BOM, its latest version will be used");
|
||||
}
|
||||
for (var conflict : dependencies().bomVersionConflicts(properties(), artifactRetriever(), repositories())) {
|
||||
System.out.println(DownloadOperation.formatBomVersionConflict(conflict));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Part of the {@link #execute} operation, purge the {@code compile} scope artifacts.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ public class Wrapper {
|
|||
|
||||
public static final String BUILD_ARGUMENT = "--build";
|
||||
public static final String OFFLINE_ARGUMENT = "--offline";
|
||||
public static final String USE_STDERR_ARGUMENT = "--use-stderr";
|
||||
|
||||
public static final String WRAPPER_PREFIX = "bld-wrapper";
|
||||
public static final String WRAPPER_PROPERTIES = WRAPPER_PREFIX + ".properties";
|
||||
|
|
@ -97,6 +98,11 @@ public class Wrapper {
|
|||
* @since 1.5
|
||||
*/
|
||||
public static void main(String[] arguments) {
|
||||
if (Arrays.asList(arguments).contains(USE_STDERR_ARGUMENT)) {
|
||||
// the wrapper diagnostics are sent to standard error so that
|
||||
// standard output stays free for the MCP protocol
|
||||
System.setOut(System.err);
|
||||
}
|
||||
System.exit(new Wrapper().installAndLaunch(new ArrayList<>(Arrays.asList(arguments))));
|
||||
}
|
||||
|
||||
|
|
|
|||
319
src/test/java/rife/bld/BaseProjectTest.java
Normal file
319
src/test/java/rife/bld/BaseProjectTest.java
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
/*
|
||||
* Copyright 2026 Erik C. Thauvin (https://erik.thauvin.net/)
|
||||
* Licensed under the Apache License, Version 2.0 (the "License")
|
||||
*/
|
||||
package rife.bld;
|
||||
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import rife.bld.dependencies.Scope;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class BaseProjectTest {
|
||||
private static Method ADD_LOCAL_JARS;
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
private BaseProject project;
|
||||
|
||||
@BeforeAll
|
||||
static void initReflection() throws Exception {
|
||||
ADD_LOCAL_JARS = BaseProject.class.getDeclaredMethod("addLocalJars", List.class, String.class);
|
||||
ADD_LOCAL_JARS.setAccessible(true);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void beforeEach() {
|
||||
project = new BaseProject();
|
||||
project.workDirectory = tempDir.toFile();
|
||||
project.createProjectStructure();
|
||||
}
|
||||
|
||||
private void invokeAddLocalJars(List<File> jars, String path) throws Exception {
|
||||
ADD_LOCAL_JARS.invoke(project, jars, path);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("local(Path) and local(File)")
|
||||
class LocalDependencyTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("local(Path) relative should resolve via workDirectory")
|
||||
void localPathRelative() throws Exception {
|
||||
var jar = Files.createFile(tempDir.resolve("a.jar"));
|
||||
var dep = project.local(Path.of("a.jar"));
|
||||
|
||||
assertAll(
|
||||
() -> assertNotNull(dep, "local(Path) should not return null"),
|
||||
() -> assertEquals("a.jar", dep.path(), "relative Path should be kept as-is")
|
||||
);
|
||||
|
||||
project.dependencies().scope(Scope.compile).include(dep);
|
||||
assertTrue(project.compileClasspathJars().stream()
|
||||
.anyMatch(f -> f.getAbsolutePath().equals(jar.toFile().getAbsolutePath())),
|
||||
"compileClasspathJars should resolve relative path against workDirectory");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("local(Path) absolute should be kept absolute")
|
||||
void localPathAbsolute() throws Exception {
|
||||
var jar = Files.createFile(tempDir.resolve("abs.jar"));
|
||||
var dep = project.local(jar);
|
||||
|
||||
assertAll(
|
||||
() -> assertNotNull(dep, "local(Path) absolute should not return null"),
|
||||
() -> assertEquals(jar.toString(), dep.path(), "absolute Path should be kept absolute")
|
||||
);
|
||||
|
||||
project.dependencies().scope(Scope.compile).include(dep);
|
||||
assertTrue(project.compileClasspathJars().stream()
|
||||
.anyMatch(f -> f.getName().equals("abs.jar")),
|
||||
"compileClasspathJars should include absolute Path dependency");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("local(File) relative file")
|
||||
void localFileRelative() {
|
||||
var dep = project.local(new File("b.jar"));
|
||||
|
||||
assertAll(
|
||||
() -> assertNotNull(dep, "local(File) should not return null"),
|
||||
() -> assertTrue(dep.path().endsWith("b.jar"), "should end with file name"),
|
||||
() -> assertTrue(Path.of(dep.path()).isAbsolute(), "File overload uses getAbsolutePath()")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("local(File) absolute file")
|
||||
void localFileAbsolute() throws Exception {
|
||||
var jar = Files.createFile(tempDir.resolve("c.jar"));
|
||||
var dep = project.local(jar.toFile());
|
||||
|
||||
assertAll(
|
||||
() -> assertNotNull(dep, "local(File) absolute should not return null"),
|
||||
() -> assertEquals(jar.toFile().getAbsolutePath(), dep.path(),
|
||||
"should use File.getAbsolutePath()")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("local(File) directory should scan jars excluding sources/javadoc")
|
||||
void localFileDirectory() throws Exception {
|
||||
var dir = Files.createDirectory(tempDir.resolve("libs-dep-filter"));
|
||||
Files.createFile(dir.resolve("one.jar"));
|
||||
Files.createFile(dir.resolve("one-sources.jar"));
|
||||
Files.createFile(dir.resolve("one-JAVADOC.jar"));
|
||||
|
||||
var dep = project.local(dir.toFile());
|
||||
project.dependencies().scope(Scope.compile).include(dep);
|
||||
|
||||
var cp = project.compileClasspathJars();
|
||||
assertAll(
|
||||
() -> assertTrue(cp.stream().anyMatch(
|
||||
f -> f.getName().equals("one.jar")), "regular jar should be included"),
|
||||
() -> assertFalse(cp.stream().anyMatch(f -> f.getName().equals("one-sources.jar")),
|
||||
"sources jar should be excluded (case-insensitive)"),
|
||||
() -> assertFalse(cp.stream().anyMatch(
|
||||
f -> f.getName().toLowerCase().contains("javadoc")),
|
||||
"javadoc jar should be excluded")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("local(Path) and local(String) should behave same")
|
||||
void parityWithStringOverload() throws Exception {
|
||||
Files.createFile(tempDir.resolve("parity.jar"));
|
||||
|
||||
var fromString = project.local("parity.jar");
|
||||
var fromPath = project.local(Path.of("parity.jar"));
|
||||
var fromFile = project.local(new File("parity.jar"));
|
||||
|
||||
assertAll(
|
||||
() -> assertEquals(fromString.path(), fromPath.path(),
|
||||
"String and Path overloads should have same path()"),
|
||||
() -> assertTrue(fromFile.path().endsWith("parity.jar"),
|
||||
"File overload is absolute but ends with same name"),
|
||||
() -> assertTrue(Path.of(fromFile.path()).isAbsolute(),
|
||||
"File overload should be absolute")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("addLocalJars - private implementation")
|
||||
class LocalJarsTest {
|
||||
@Test
|
||||
@DisplayName("should add relative file path")
|
||||
void relativeFile() throws Exception {
|
||||
Files.createFile(tempDir.resolve("my.jar"));
|
||||
var jars = new ArrayList<File>();
|
||||
invokeAddLocalJars(jars, "my.jar");
|
||||
assertEquals(1, jars.size(), "relative file that exists should be added");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should add absolute file path")
|
||||
void absoluteFile() throws Exception {
|
||||
var jar = Files.createFile(tempDir.resolve("abs2.jar"));
|
||||
var jars = new ArrayList<File>();
|
||||
invokeAddLocalJars(jars, jar.toAbsolutePath().toString());
|
||||
assertEquals(1, jars.size(), "absolute file that exists should be added");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should resolve relative to workDirectory")
|
||||
void relativeToWorkDirectory() throws Exception {
|
||||
var sub = Files.createDirectory(tempDir.resolve("sub"));
|
||||
var jar = Files.createFile(sub.resolve("x.jar"));
|
||||
var jars = new ArrayList<File>();
|
||||
invokeAddLocalJars(jars, "sub/x.jar");
|
||||
assertEquals(jar.toFile().getAbsoluteFile(), jars.get(0).getAbsoluteFile(),
|
||||
"should resolve against workDirectory");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should scan directory for jars only")
|
||||
void directoryScansJars() throws Exception {
|
||||
var libDir = Files.createDirectory(tempDir.resolve("libs-jars-scan"));
|
||||
Files.createFile(libDir.resolve("a.jar"));
|
||||
Files.createFile(libDir.resolve("b.jar"));
|
||||
Files.createFile(libDir.resolve("not-a-jar.txt"));
|
||||
var jars = new ArrayList<File>();
|
||||
invokeAddLocalJars(jars, "libs-jars-scan");
|
||||
assertEquals(2, jars.size(), "should only include .jar files");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should exclude -sources and -javadoc jars in directory")
|
||||
void directoryExcludesSourcesAndJavadoc() throws Exception {
|
||||
var libDir = Files.createDirectory(tempDir.resolve("libs2-filter"));
|
||||
Files.createFile(libDir.resolve("foo.jar"));
|
||||
Files.createFile(libDir.resolve("foo-sources.jar"));
|
||||
Files.createFile(libDir.resolve("foo-javadoc.jar"));
|
||||
var jars = new ArrayList<File>();
|
||||
invokeAddLocalJars(jars, "libs2-filter");
|
||||
assertAll(
|
||||
() -> assertEquals(1, jars.size(), "should filter out -sources and -javadoc"),
|
||||
() -> assertEquals("foo.jar", jars.get(0).getName(), "only foo.jar should remain")
|
||||
);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("integration via public classpath methods")
|
||||
class Integration {
|
||||
@Test
|
||||
@DisplayName("compileClasspathJars should include local file dependency")
|
||||
void viaCompileClasspath() throws Exception {
|
||||
var myJar = Files.createFile(tempDir.resolve("custom.jar"));
|
||||
project.dependencies().scope(Scope.compile).include(project.local("custom.jar"));
|
||||
var classpath = project.compileClasspathJars();
|
||||
assertTrue(classpath.stream().anyMatch(
|
||||
f -> f.getAbsolutePath().equals(myJar.toFile().getAbsolutePath())),
|
||||
"compileClasspathJars should include local file dependency");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("verbose branch")
|
||||
class VerboseBranch {
|
||||
@BeforeEach
|
||||
void enableVerbose() {
|
||||
project = new BaseProject() {
|
||||
@Override
|
||||
public boolean verbose() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
project.workDirectory = tempDir.toFile();
|
||||
project.createProjectStructure();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should skip non-existent path without throwing, even verbose")
|
||||
void nonExistentPathSkipped() {
|
||||
var jars = new ArrayList<File>();
|
||||
assertDoesNotThrow(() -> invokeAddLocalJars(jars, "does-not-exist-verbose"),
|
||||
"should not throw for missing path");
|
||||
assertTrue(jars.isEmpty(), "non-existent path should be skipped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("localModule(Path) and localModule(File)")
|
||||
class LocalModuleTest {
|
||||
@Test
|
||||
@DisplayName("localModule(Path) relative")
|
||||
void localModulePathRelative() {
|
||||
var mod = project.localModule(Path.of("mod.jar"));
|
||||
assertAll(
|
||||
() -> assertNotNull(mod, "should not return null"),
|
||||
() -> assertEquals("mod.jar", mod.path(), "relative path kept as-is")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("localModule(Path) absolute")
|
||||
void localModulePathAbsolute() throws Exception {
|
||||
var jar = Files.createFile(tempDir.resolve("modAbs.jar"));
|
||||
var mod = project.localModule(jar);
|
||||
assertAll(
|
||||
() -> assertNotNull(mod, "should not return null"),
|
||||
() -> assertEquals(jar.toString(), mod.path(), "absolute path kept absolute")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("localModule(File) absolute")
|
||||
void localModuleFileAbsolute() throws Exception {
|
||||
var jar = Files.createFile(tempDir.resolve("mod2.jar"));
|
||||
var mod = project.localModule(jar.toFile());
|
||||
assertAll(
|
||||
() -> assertNotNull(mod, "should not return null"),
|
||||
() -> assertEquals(jar.toFile().getAbsolutePath(), mod.path(),
|
||||
"should use getAbsolutePath()")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("localModule(File) directory scan")
|
||||
void localModuleFileDirectory() throws Exception {
|
||||
var dir = Files.createDirectory(tempDir.resolve("mods-filter"));
|
||||
Files.createFile(dir.resolve("m1.jar"));
|
||||
Files.createFile(dir.resolve("m1-javadoc.jar"));
|
||||
|
||||
var mod = project.localModule(dir.toFile());
|
||||
project.dependencies().scope(Scope.compile).include(mod);
|
||||
|
||||
var mp = project.compileModulePathJars();
|
||||
assertAll(
|
||||
() -> assertTrue(mp.stream().anyMatch(f -> f.getName().equals("m1.jar")),
|
||||
"regular jar should be in module path"),
|
||||
() -> assertFalse(mp.stream().anyMatch(f -> f.getName().contains("javadoc")),
|
||||
"javadoc jar should be excluded")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("all three overloads are consistent")
|
||||
void overloadsConsistent() throws Exception {
|
||||
Files.createFile(tempDir.resolve("cons.jar"));
|
||||
|
||||
var s = project.localModule("cons.jar");
|
||||
var p = project.localModule(Path.of("cons.jar"));
|
||||
var f = project.localModule(new File("cons.jar"));
|
||||
|
||||
assertAll(
|
||||
() -> assertEquals(s.path(), p.path(), "String and Path overloads equal"),
|
||||
() -> assertTrue(f.path().endsWith("cons.jar"), "File overload ends with same name")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -506,6 +506,48 @@ public class TestBom {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeclaredVersionConflictsAreDetected() throws Exception {
|
||||
var server = createArtifactServer(Map.of(
|
||||
"bom1:1.0.0", bomPom("bom1", "1.0.0", managed("a", "1.4.0") + managed("b", "3.0.0"))),
|
||||
Map.of());
|
||||
server.start();
|
||||
try {
|
||||
var retriever = ArtifactRetriever.cachingInstance();
|
||||
var repositories = serverRepositories(server);
|
||||
var scopes = new DependencyScopes();
|
||||
scopes.scope(compile)
|
||||
.include(new Bom("com.example", "bom1", new VersionNumber(1, 0, 0)))
|
||||
// declared at a different version than the BOM manages
|
||||
.include(new Dependency("com.example", "a", new VersionNumber(2, 2, 0)))
|
||||
// declared at the same version as the BOM manages
|
||||
.include(new Dependency("com.example", "b", new VersionNumber(3, 0, 0)));
|
||||
scopes.scope(Scope.test)
|
||||
// the same difference through scope composition is
|
||||
// reported once
|
||||
.include(new Dependency("com.example", "a", new VersionNumber(2, 2, 0)))
|
||||
// a version-less dependency takes the BOM version and
|
||||
// isn't a difference
|
||||
.include(new Dependency("com.example", "b"));
|
||||
|
||||
var conflicts = scopes.declaredVersionConflicts(new HierarchicalProperties(), retriever, repositories);
|
||||
assertEquals(1, conflicts.size());
|
||||
var conflict = conflicts.get(0);
|
||||
assertEquals("com.example:a", conflict.dependency());
|
||||
assertEquals(Version.parse("2.2.0"), conflict.declaredVersion());
|
||||
assertEquals("com.example:bom1", conflict.bom());
|
||||
assertEquals(Version.parse("1.4.0"), conflict.bomVersion());
|
||||
|
||||
// a bld.override that supplies the version prevents the report
|
||||
var override_properties = new HierarchicalProperties();
|
||||
override_properties.put(VersionResolution.PROPERTY_OVERRIDE_PREFIX, "com.example:a:2.2.0");
|
||||
assertEquals(List.of(),
|
||||
scopes.declaredVersionConflicts(override_properties, retriever, repositories));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEffectiveBomsComposition() {
|
||||
var compile_bom = new Bom("com.example", "compile-bom", new VersionNumber(1, 0, 0));
|
||||
|
|
|
|||
1248
src/test/java/rife/bld/operations/TestMcpOperation.java
Normal file
1248
src/test/java/rife/bld/operations/TestMcpOperation.java
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue