Compare commits

..

11 commits

Author SHA1 Message Date
Erik C. Thauvin 4a7d39ec5a
Merge f2a568a654 into 09418fb979 2026-07-18 02:48:44 +00:00
Geert Bevin f2a568a654
Merge branch 'main' into main 2026-07-17 22:48:41 -04:00
Geert Bevin 09418fb979 Clarify that the MCP durationMs covers the whole tool call process
Some checks are pending
bld-ci / build (./bld, 17, macos-latest, false) (push) Waiting to run
bld-ci / build (./bld, 21, macos-latest, false) (push) Waiting to run
bld-ci / build (./bld, 25, macos-latest, false) (push) Waiting to run
bld-ci / build (./bld, 26, macos-latest, false) (push) Waiting to run
bld-ci / build (.\bld.bat, 17, windows-latest, false) (push) Waiting to run
bld-ci / build (.\bld.bat, 21, windows-latest, false) (push) Waiting to run
bld-ci / build (.\bld.bat, 25, windows-latest, false) (push) Waiting to run
bld-ci / build (.\bld.bat, 26, windows-latest, false) (push) Waiting to run
bld-ci / build (unittests, password, unittests, mariadb:10.9, mysql:8, gvenzl/oracle-free:latest, gvenzl/oracle-xe:18-slim, postgres:15, ./bld, 17, ubuntu-latest, true) (push) Waiting to run
bld-ci / build (unittests, password, unittests, mariadb:10.9, mysql:8, gvenzl/oracle-free:latest, gvenzl/oracle-xe:18-slim, postgres:15, ./bld, 21, ubuntu-latest, true) (push) Waiting to run
bld-ci / build (unittests, password, unittests, mariadb:10.9, mysql:8, gvenzl/oracle-free:latest, gvenzl/oracle-xe:18-slim, postgres:15, ./bld, 25, ubuntu-latest, true) (push) Waiting to run
bld-ci / build (unittests, password, unittests, mariadb:10.9, mysql:8, gvenzl/oracle-free:latest, gvenzl/oracle-xe:18-slim, postgres:15, ./bld, 26, ubuntu-latest, true) (push) Waiting to run
javadocs-pages / deploy (push) Waiting to run
2026-07-17 22:46:26 -04:00
Geert Bevin fcb348615e Add the mcp install argument that registers the server with MCP clients 2026-07-17 22:43:51 -04:00
Geert Bevin 124c7695e9 Add structured content with execution metadata to MCP tool results 2026-07-17 21:28:18 -04:00
Erik C. Thauvin 736372673e
Add tests coverage for local/localModule, Path/File overloads and addLocalJars filtering 2026-07-17 18:21:29 -07:00
Geert Bevin 025e68e9e6 Stream MCP tool call output as log message notifications 2026-07-17 20:27:20 -04:00
Geert Bevin 5083ecef78 Omit unconfigured values from the MCP project resource 2026-07-17 19:42:23 -04:00
Geert Bevin cdd4efa460 Fix javadoc typo and updated RIFE2/core 2026-07-17 17:47:09 -04:00
Geert Bevin 62c0d4569d Add experimental WIP MCP support that serves the build commands as tools 2026-07-17 17:28:29 -04:00
Geert Bevin ec185788a1 Warn when declared versions differ from applicable BOMs and only report in download 2026-07-17 17:18:33 -04:00
18 changed files with 3750 additions and 70 deletions

2
core

@ -1 +1 @@
Subproject commit 9d73b7bd044df1df94907554c6807c6305ba113e
Subproject commit fb2e5022e5c1d125e45227b49076c2b200763986

View file

@ -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

View file

@ -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]));
}
}
}

View file

@ -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();

View file

@ -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.
*

View file

@ -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

View 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);
}
}

View file

@ -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.
*

View file

@ -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

View file

@ -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) {

View 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();
}
}

File diff suppressed because it is too large Load diff

View 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
}
}
}
}

View file

@ -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.
*

View file

@ -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))));
}

View 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")
);
}
}
}

View file

@ -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));

File diff suppressed because it is too large Load diff