goldenChat base source add
This commit is contained in:
@@ -0,0 +1,695 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
package ta4jexamples.doc;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.jfree.chart.JFreeChart;
|
||||
import org.ta4j.core.*;
|
||||
import org.ta4j.core.backtest.BarSeriesManager;
|
||||
import org.ta4j.core.criteria.drawdown.MaximumDrawdownCriterion;
|
||||
import org.ta4j.core.criteria.pnl.NetProfitLossCriterion;
|
||||
import org.ta4j.core.indicators.RSIIndicator;
|
||||
import org.ta4j.core.indicators.MACDIndicator;
|
||||
import org.ta4j.core.indicators.averages.EMAIndicator;
|
||||
import org.ta4j.core.indicators.averages.SMAIndicator;
|
||||
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
|
||||
import org.ta4j.core.rules.*;
|
||||
import org.ta4j.core.serialization.ComponentSerialization;
|
||||
import org.ta4j.core.serialization.RuleSerialization;
|
||||
import ta4jexamples.charting.workflow.ChartWorkflow;
|
||||
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
|
||||
|
||||
import java.awt.GraphicsEnvironment;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Utility class to manage README content including chart images, code snippets,
|
||||
* and documentation.
|
||||
*
|
||||
* <p>
|
||||
* This class serves as the single source of truth for README content. It
|
||||
* generates chart images, extracts code snippets from source code using special
|
||||
* comment markers, and synchronizes the README with the latest code examples.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* <strong>How it works:</strong>
|
||||
* <ul>
|
||||
* <li>Code snippets are marked with {@code // START_SNIPPET: snippet-id} and
|
||||
* {@code // END_SNIPPET: snippet-id} comments in the chart generation
|
||||
* methods</li>
|
||||
* <li>The README.md file contains corresponding HTML comment markers:
|
||||
* {@code <!-- START_SNIPPET: snippet-id -->} and
|
||||
* {@code <!-- END_SNIPPET: snippet-id -->}</li>
|
||||
* <li>Run with argument "update-readme" to automatically extract snippets and
|
||||
* update the README:
|
||||
* {@code mvn -pl ta4j-examples exec:java -Dexec.mainClass=ta4jexamples.doc.ReadmeContentManager -Dexec.args=update-readme}</li>
|
||||
* </ul>
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* <strong>Usage:</strong>
|
||||
* <ul>
|
||||
* <li>Generate charts and update README (default):
|
||||
* {@code mvn -pl ta4j-examples exec:java -Dexec.mainClass=ta4jexamples.doc.ReadmeContentManager}</li>
|
||||
* <li>Update README snippets only:
|
||||
* {@code mvn -pl ta4j-examples exec:java -Dexec.mainClass=ta4jexamples.doc.ReadmeContentManager -Dexec.args=update-readme}</li>
|
||||
* <li>View snippets only:
|
||||
* {@code mvn -pl ta4j-examples exec:java -Dexec.mainClass=ta4jexamples.doc.ReadmeContentManager -Dexec.args=snippets}</li>
|
||||
* </ul>
|
||||
* </p>
|
||||
*/
|
||||
public class ReadmeContentManager {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(ReadmeContentManager.class);
|
||||
|
||||
/**
|
||||
* Generates the EMA Crossover chart matching the README quick start example.
|
||||
*
|
||||
* @param outputDir the directory to save the chart image (e.g., "docs/charts"
|
||||
* or ".")
|
||||
* @return the path to the saved chart image, or empty if saving failed
|
||||
*/
|
||||
public static Optional<Path> generateEmaCrossoverChart(String outputDir) {
|
||||
LOG.info("Generating EMA Crossover chart for README...");
|
||||
|
||||
// Load historical price data
|
||||
BarSeries fullSeries = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
|
||||
LOG.info("Loaded {} bars from Bitstamp series", fullSeries.getBarCount());
|
||||
|
||||
// Use a smaller subset for a cleaner chart (last ~150 bars)
|
||||
int totalBars = fullSeries.getBarCount();
|
||||
int startIndex = Math.max(0, totalBars - 150);
|
||||
BarSeries series = fullSeries.getSubSeries(startIndex, totalBars);
|
||||
LOG.info("Using subset: {} bars (indices {} to {})", series.getBarCount(), startIndex, totalBars - 1);
|
||||
|
||||
// Create indicators: calculate moving averages from close prices
|
||||
ClosePriceIndicator close = new ClosePriceIndicator(series);
|
||||
EMAIndicator fastEma = new EMAIndicator(close, 12); // 12-period EMA
|
||||
EMAIndicator slowEma = new EMAIndicator(close, 26); // 26-period EMA
|
||||
|
||||
// Define entry rule: buy when fast EMA crosses above slow EMA (golden cross)
|
||||
Rule entry = new CrossedUpIndicatorRule(fastEma, slowEma);
|
||||
|
||||
// Define exit rule: sell when price gains 3% OR loses 1.5%
|
||||
Rule exit = new StopGainRule(close, 3.0) // take profit at +3%
|
||||
.or(new StopLossRule(close, 1.5)); // or cut losses at -1.5%
|
||||
|
||||
// Combine rules into a strategy
|
||||
Strategy strategy = new BaseStrategy("EMA Crossover", entry, exit);
|
||||
|
||||
// Run the strategy on historical data
|
||||
BarSeriesManager manager = new BarSeriesManager(series);
|
||||
TradingRecord record = manager.run(strategy);
|
||||
|
||||
LOG.info("Strategy executed: {} positions", record.getPositionCount());
|
||||
|
||||
// START_SNIPPET: ema-crossover
|
||||
// Generate simplified chart - just price, indicators, and signals (no subchart)
|
||||
ChartWorkflow chartWorkflow = new ChartWorkflow();
|
||||
JFreeChart chart = chartWorkflow.builder()
|
||||
.withTitle("EMA Crossover Strategy")
|
||||
.withSeries(series) // Price bars (candlesticks)
|
||||
.withIndicatorOverlay(fastEma) // Overlay indicators on price chart
|
||||
.withIndicatorOverlay(slowEma)
|
||||
.withTradingRecordOverlay(record) // Mark entry/exit points with arrows
|
||||
.toChart();
|
||||
chartWorkflow.saveChartImage(chart, series, "ema-crossover-strategy", "output/charts"); // Save as image
|
||||
// END_SNIPPET: ema-crossover
|
||||
|
||||
// Display chart in interactive window (only in non-headless environments)
|
||||
if (!GraphicsEnvironment.isHeadless()) {
|
||||
chartWorkflow.displayChart(chart);
|
||||
}
|
||||
|
||||
// Save chart image
|
||||
Optional<Path> savedPath = chartWorkflow.saveChartImage(chart, series, "ema-crossover-readme", outputDir);
|
||||
|
||||
if (savedPath.isPresent()) {
|
||||
Path relativePath = getRelativePath(savedPath.get());
|
||||
LOG.info("Chart saved to: {}", relativePath);
|
||||
} else {
|
||||
LOG.warn("Failed to save ema-crossover-readme.jpg chart image");
|
||||
}
|
||||
|
||||
return savedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an RSI Strategy chart with RSI indicator in a subchart.
|
||||
* Demonstrates using subcharts for indicators with different scales.
|
||||
*
|
||||
* @param outputDir the directory to save the chart image
|
||||
* @return the path to the saved chart image, or empty if saving failed
|
||||
*/
|
||||
public static Optional<Path> generateRsiStrategyChart(String outputDir) {
|
||||
LOG.info("Generating RSI Strategy chart with subchart for README...");
|
||||
|
||||
// Load historical price data
|
||||
BarSeries fullSeries = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
|
||||
LOG.info("Loaded {} bars from Bitstamp series", fullSeries.getBarCount());
|
||||
|
||||
// Use a smaller subset for a cleaner chart (last ~150 bars)
|
||||
int totalBars = fullSeries.getBarCount();
|
||||
int startIndex = Math.max(0, totalBars - 150);
|
||||
BarSeries series = fullSeries.getSubSeries(startIndex, totalBars);
|
||||
LOG.info("Using subset: {} bars (indices {} to {})", series.getBarCount(), startIndex, totalBars - 1);
|
||||
|
||||
// START_SNIPPET: rsi-strategy
|
||||
// Create indicators
|
||||
ClosePriceIndicator close = new ClosePriceIndicator(series);
|
||||
RSIIndicator rsi = new RSIIndicator(close, 14);
|
||||
|
||||
// RSI strategy: buy when RSI crosses below 30 (oversold), sell when RSI crosses
|
||||
// above 70 (overbought)
|
||||
Rule entry = new CrossedDownIndicatorRule(rsi, 30);
|
||||
Rule exit = new CrossedUpIndicatorRule(rsi, 70);
|
||||
Strategy strategy = new BaseStrategy("RSI Strategy", entry, exit);
|
||||
TradingRecord record = new BarSeriesManager(series).run(strategy);
|
||||
|
||||
ChartWorkflow chartWorkflow = new ChartWorkflow();
|
||||
JFreeChart chart = chartWorkflow.builder()
|
||||
.withTitle("RSI Strategy with Subchart")
|
||||
.withSeries(series) // Price bars (candlesticks)
|
||||
.withTradingRecordOverlay(record) // Mark entry/exit points
|
||||
.withSubChart(rsi) // RSI indicator in separate subchart panel
|
||||
.toChart();
|
||||
// END_SNIPPET: rsi-strategy
|
||||
|
||||
// Save chart image
|
||||
Optional<Path> savedPath = chartWorkflow.saveChartImage(chart, series, "rsi-strategy-readme", outputDir);
|
||||
|
||||
if (savedPath.isPresent()) {
|
||||
Path relativePath = getRelativePath(savedPath.get());
|
||||
LOG.info("Chart saved to: {}", relativePath);
|
||||
} else {
|
||||
LOG.warn("Failed to save rsi-strategy-readme.jpg chart image");
|
||||
}
|
||||
|
||||
return savedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a strategy chart with performance metrics subchart. Demonstrates
|
||||
* visualizing performance analysis criteria over time.
|
||||
*
|
||||
* @param outputDir the directory to save the chart image
|
||||
* @return the path to the saved chart image, or empty if saving failed
|
||||
*/
|
||||
public static Optional<Path> generateStrategyPerformanceChart(String outputDir) {
|
||||
LOG.info("Generating Strategy Performance chart with metrics subchart for README...");
|
||||
|
||||
// Load historical price data
|
||||
BarSeries fullSeries = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
|
||||
LOG.info("Loaded {} bars from Bitstamp series", fullSeries.getBarCount());
|
||||
|
||||
// Use a smaller subset for a cleaner chart (last ~150 bars)
|
||||
int totalBars = fullSeries.getBarCount();
|
||||
int startIndex = Math.max(0, totalBars - 150);
|
||||
BarSeries series = fullSeries.getSubSeries(startIndex, totalBars);
|
||||
LOG.info("Using subset: {} bars (indices {} to {})", series.getBarCount(), startIndex, totalBars - 1);
|
||||
|
||||
// START_SNIPPET: strategy-performance
|
||||
// Create indicators: multiple moving averages
|
||||
ClosePriceIndicator close = new ClosePriceIndicator(series);
|
||||
SMAIndicator sma20 = new SMAIndicator(close, 20);
|
||||
EMAIndicator ema12 = new EMAIndicator(close, 12);
|
||||
|
||||
// Strategy: buy when EMA crosses above SMA, sell when EMA crosses below SMA
|
||||
Rule entry = new CrossedUpIndicatorRule(ema12, sma20);
|
||||
Rule exit = new CrossedDownIndicatorRule(ema12, sma20);
|
||||
Strategy strategy = new BaseStrategy("EMA/SMA Crossover", entry, exit);
|
||||
TradingRecord record = new BarSeriesManager(series).run(strategy);
|
||||
|
||||
ChartWorkflow chartWorkflow = new ChartWorkflow();
|
||||
JFreeChart chart = chartWorkflow.builder()
|
||||
.withTitle("Strategy Performance Analysis")
|
||||
.withSeries(series) // Price bars (candlesticks)
|
||||
.withIndicatorOverlay(sma20) // Overlay SMA on price chart
|
||||
.withIndicatorOverlay(ema12) // Overlay EMA on price chart
|
||||
.withTradingRecordOverlay(record) // Mark entry/exit points
|
||||
.withSubChart(new MaximumDrawdownCriterion(), record) // Performance metric in subchart
|
||||
.toChart();
|
||||
// END_SNIPPET: strategy-performance
|
||||
|
||||
// Save chart image
|
||||
Optional<Path> savedPath = chartWorkflow.saveChartImage(chart, series, "strategy-performance-readme",
|
||||
outputDir);
|
||||
|
||||
if (savedPath.isPresent()) {
|
||||
Path relativePath = getRelativePath(savedPath.get());
|
||||
LOG.info("Chart saved to: {}", relativePath);
|
||||
} else {
|
||||
LOG.warn("Failed to save strategy-performance-readme.jpg chart image");
|
||||
}
|
||||
|
||||
return savedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an advanced multi-indicator strategy chart with multiple subcharts.
|
||||
* Demonstrates full charting capabilities with multiple analysis layers.
|
||||
*
|
||||
* @param outputDir the directory to save the chart image
|
||||
* @return the path to the saved chart image, or empty if saving failed
|
||||
*/
|
||||
public static Optional<Path> generateAdvancedStrategyChart(String outputDir) {
|
||||
LOG.info("Generating Advanced Multi-Indicator Strategy chart for README...");
|
||||
|
||||
// Load historical price data
|
||||
BarSeries fullSeries = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
|
||||
LOG.info("Loaded {} bars from Bitstamp series", fullSeries.getBarCount());
|
||||
|
||||
// Use a smaller subset for a cleaner chart (last ~150 bars)
|
||||
int totalBars = fullSeries.getBarCount();
|
||||
int startIndex = Math.max(0, totalBars - 150);
|
||||
BarSeries series = fullSeries.getSubSeries(startIndex, totalBars);
|
||||
LOG.info("Using subset: {} bars (indices {} to {})", series.getBarCount(), startIndex, totalBars - 1);
|
||||
|
||||
// START_SNIPPET: advanced-strategy
|
||||
// Create indicators
|
||||
ClosePriceIndicator close = new ClosePriceIndicator(series);
|
||||
SMAIndicator sma50 = new SMAIndicator(close, 50);
|
||||
EMAIndicator ema12 = new EMAIndicator(close, 12);
|
||||
MACDIndicator macd = new MACDIndicator(close, 12, 26);
|
||||
RSIIndicator rsi = new RSIIndicator(close, 14);
|
||||
|
||||
// Strategy: buy when EMA crosses above SMA and RSI > 50, sell when EMA crosses
|
||||
// below SMA
|
||||
Rule entry = new CrossedUpIndicatorRule(ema12, sma50).and(new OverIndicatorRule(rsi, 50));
|
||||
Rule exit = new CrossedDownIndicatorRule(ema12, sma50);
|
||||
Strategy strategy = new BaseStrategy("Advanced Multi-Indicator Strategy", entry, exit);
|
||||
TradingRecord record = new BarSeriesManager(series).run(strategy);
|
||||
|
||||
ChartWorkflow chartWorkflow = new ChartWorkflow();
|
||||
JFreeChart chart = chartWorkflow.builder()
|
||||
.withTitle("Advanced Multi-Indicator Strategy")
|
||||
.withSeries(series) // Price bars (candlesticks)
|
||||
.withIndicatorOverlay(sma50) // Overlay SMA on price chart
|
||||
.withIndicatorOverlay(ema12) // Overlay EMA on price chart
|
||||
.withTradingRecordOverlay(record) // Mark entry/exit points
|
||||
.withSubChart(macd) // MACD indicator in subchart
|
||||
.withSubChart(rsi) // RSI indicator in subchart
|
||||
.withSubChart(new NetProfitLossCriterion(), record) // Net profit/loss performance metric
|
||||
.toChart();
|
||||
// END_SNIPPET: advanced-strategy
|
||||
|
||||
// Save chart image
|
||||
Optional<Path> savedPath = chartWorkflow.saveChartImage(chart, series, "advanced-strategy-readme", outputDir);
|
||||
|
||||
if (savedPath.isPresent()) {
|
||||
Path relativePath = getRelativePath(savedPath.get());
|
||||
LOG.info("Chart saved to: {}", relativePath);
|
||||
} else {
|
||||
LOG.warn("Failed to save advanced-strategy-readme.jpg chart image");
|
||||
}
|
||||
|
||||
return savedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the project root directory by searching for README.md file. Starts from
|
||||
* the current working directory and walks up the directory tree.
|
||||
*
|
||||
* @return the project root path, or current directory if not found
|
||||
*/
|
||||
private static Path findProjectRoot() {
|
||||
Path current = Paths.get("").toAbsolutePath().normalize();
|
||||
Path root = current.getRoot();
|
||||
|
||||
// Walk up the directory tree looking for README.md
|
||||
while (current != null && !current.equals(root)) {
|
||||
Path readmePath = current.resolve("README.md");
|
||||
if (Files.exists(readmePath)) {
|
||||
return current;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
|
||||
// If not found, return current working directory as fallback
|
||||
return Paths.get("").toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an absolute path to a relative path from the project root.
|
||||
*
|
||||
* @param absolutePath the absolute path to convert
|
||||
* @return the relative path, or the original path if conversion fails
|
||||
*/
|
||||
private static Path getRelativePath(Path absolutePath) {
|
||||
try {
|
||||
Path projectRoot = findProjectRoot();
|
||||
Path normalized = absolutePath.normalize();
|
||||
if (normalized.startsWith(projectRoot)) {
|
||||
return projectRoot.relativize(normalized);
|
||||
}
|
||||
return normalized;
|
||||
} catch (Exception e) {
|
||||
LOG.warn("Failed to convert path to relative: {}", e.getMessage());
|
||||
return absolutePath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates serialization examples for the README. This method demonstrates
|
||||
* serializing indicators, rules, and strategies to JSON. The examples are
|
||||
* extracted via snippet markers for inclusion in the README.
|
||||
*
|
||||
* @param series the bar series to use for examples
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
public static void generateSerializationExamples(BarSeries series) {
|
||||
// START_SNIPPET: serialize-indicator
|
||||
// Serialize an indicator (RSI) to JSON
|
||||
ClosePriceIndicator close = new ClosePriceIndicator(series);
|
||||
RSIIndicator rsi = new RSIIndicator(close, 14);
|
||||
String rsiJson = rsi.toJson();
|
||||
LOG.info("Output: {}", rsiJson);
|
||||
// Output:
|
||||
// {"type":"RSIIndicator","parameters":{"barCount":14},"components":[{"type":"ClosePriceIndicator"}]}
|
||||
// END_SNIPPET: serialize-indicator
|
||||
|
||||
// START_SNIPPET: serialize-rule
|
||||
// Serialize a rule (AndRule) to JSON
|
||||
Rule rule1 = new OverIndicatorRule(rsi, 50);
|
||||
Rule rule2 = new UnderIndicatorRule(rsi, 80);
|
||||
Rule andRule = new AndRule(rule1, rule2);
|
||||
String ruleJson = ComponentSerialization.toJson(RuleSerialization.describe(andRule));
|
||||
LOG.info("Output: {}", ruleJson);
|
||||
// Output:
|
||||
// {"type":"AndRule","label":"AndRule","components":[{"type":"OverIndicatorRule","label":"OverIndicatorRule","components":[{"type":"RSIIndicator","parameters":{"barCount":14},"components":[{"type":"ClosePriceIndicator"}]}],"parameters":{"threshold":50.0}},{"type":"UnderIndicatorRule","label":"UnderIndicatorRule","components":[{"type":"RSIIndicator","parameters":{"barCount":14},"components":[{"type":"ClosePriceIndicator"}]}],"parameters":{"threshold":80.0}}]}
|
||||
// END_SNIPPET: serialize-rule
|
||||
|
||||
// START_SNIPPET: serialize-strategy
|
||||
// Serialize a strategy (EMA Crossover) to JSON
|
||||
EMAIndicator fastEma = new EMAIndicator(close, 12);
|
||||
EMAIndicator slowEma = new EMAIndicator(close, 26);
|
||||
Rule entry = new CrossedUpIndicatorRule(fastEma, slowEma);
|
||||
Rule exit = new CrossedDownIndicatorRule(fastEma, slowEma);
|
||||
Strategy strategy = new BaseStrategy("EMA Crossover", entry, exit);
|
||||
String strategyJson = strategy.toJson();
|
||||
LOG.info("Output: {}", strategyJson);
|
||||
// Output: {"type":"BaseStrategy","label":"EMA
|
||||
// Crossover","parameters":{"unstableBars":0},"rules":[{"type":"CrossedUpIndicatorRule","label":"entry","components":[{"type":"EMAIndicator","parameters":{"barCount":12},"components":[{"type":"ClosePriceIndicator"}]},{"type":"EMAIndicator","parameters":{"barCount":26},"components":[{"type":"ClosePriceIndicator"}]}]},{"type":"CrossedDownIndicatorRule","label":"exit","components":[{"type":"EMAIndicator","parameters":{"barCount":12},"components":[{"type":"ClosePriceIndicator"}]},{"type":"EMAIndicator","parameters":{"barCount":26},"components":[{"type":"ClosePriceIndicator"}]}]}]}
|
||||
// END_SNIPPET: serialize-strategy
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts code between START_SNIPPET and END_SNIPPET markers from the source
|
||||
* file.
|
||||
*
|
||||
* @param sourceFile the Java source file to read
|
||||
* @param snippetId the snippet identifier (e.g., "ema-crossover")
|
||||
* @return the extracted code snippet, or empty if not found
|
||||
*/
|
||||
public static Optional<String> extractCodeSnippet(Path sourceFile, String snippetId) {
|
||||
try {
|
||||
String content = Files.readString(sourceFile, StandardCharsets.UTF_8);
|
||||
String startMarker = "// START_SNIPPET: " + snippetId;
|
||||
String endMarker = "// END_SNIPPET: " + snippetId;
|
||||
|
||||
int startIndex = content.indexOf(startMarker);
|
||||
if (startIndex == -1) {
|
||||
LOG.warn("Start marker not found for snippet: {}", snippetId);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
int endIndex = content.indexOf(endMarker, startIndex);
|
||||
if (endIndex == -1) {
|
||||
LOG.warn("End marker not found for snippet: {}", snippetId);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Extract code between markers (excluding the markers themselves)
|
||||
int newlineIndex = content.indexOf('\n', startIndex);
|
||||
if (newlineIndex == -1) {
|
||||
LOG.warn("No newline found after start marker for snippet: {}", snippetId);
|
||||
return Optional.empty();
|
||||
}
|
||||
int codeStart = newlineIndex + 1;
|
||||
// Don't trim here - we need to preserve the exact indentation structure
|
||||
// We'll trim only trailing whitespace from the entire snippet after processing
|
||||
String snippet = content.substring(codeStart, endIndex);
|
||||
|
||||
// Remove leading indentation (find minimum indentation and remove it)
|
||||
// Strategy: Find the minimum indentation among lines that have indentation > 0,
|
||||
// but if all lines have 0 indentation, use 0. This handles cases where the
|
||||
// first
|
||||
// line (e.g., a comment) has no indentation but subsequent lines do.
|
||||
String[] lines = snippet.split("\n");
|
||||
int minIndent = Integer.MAX_VALUE;
|
||||
int minIndentIncludingZero = Integer.MAX_VALUE;
|
||||
boolean hasIndentedLines = false;
|
||||
|
||||
for (String line : lines) {
|
||||
if (!line.trim().isEmpty()) {
|
||||
int indent = 0;
|
||||
for (char c : line.toCharArray()) {
|
||||
if (c == ' ') {
|
||||
indent++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
minIndentIncludingZero = Math.min(minIndentIncludingZero, indent);
|
||||
if (indent > 0) {
|
||||
minIndent = Math.min(minIndent, indent);
|
||||
hasIndentedLines = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use the minimum indentation of indented lines if any exist, otherwise use 0
|
||||
int indentToRemove = hasIndentedLines ? minIndent
|
||||
: (minIndentIncludingZero == Integer.MAX_VALUE ? 0 : minIndentIncludingZero);
|
||||
|
||||
// Remove minimum indentation from all lines
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (String line : lines) {
|
||||
if (line.trim().isEmpty()) {
|
||||
result.append("\n");
|
||||
} else {
|
||||
// Ensure we don't go beyond the line length
|
||||
int indentToRemoveForLine = Math.min(indentToRemove, line.length());
|
||||
result.append(line.substring(indentToRemoveForLine)).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Trim only trailing whitespace/newlines, preserve leading structure
|
||||
String resultStr = result.toString();
|
||||
// Remove trailing newlines and whitespace
|
||||
while (resultStr.endsWith("\n") || resultStr.endsWith(" ") || resultStr.endsWith("\r")) {
|
||||
resultStr = resultStr.substring(0, resultStr.length() - 1);
|
||||
}
|
||||
return Optional.of(resultStr);
|
||||
} catch (IOException e) {
|
||||
LOG.error("Error reading source file: {}", e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the README file by replacing code snippets between HTML comment
|
||||
* markers.
|
||||
*
|
||||
* @param readmePath path to the README.md file
|
||||
* @param sourceFile path to the ReadmeContentManager.java file
|
||||
* @return true if update was successful
|
||||
*/
|
||||
public static boolean updateReadmeSnippets(Path readmePath, Path sourceFile) {
|
||||
try {
|
||||
String readmeContent = Files.readString(readmePath, StandardCharsets.UTF_8);
|
||||
String lineSeparator = detectPreferredLineSeparator(readmeContent);
|
||||
String[] snippetIds = { "ema-crossover", "rsi-strategy", "strategy-performance", "advanced-strategy",
|
||||
"serialize-indicator", "serialize-rule", "serialize-strategy" };
|
||||
|
||||
boolean updated = false;
|
||||
for (String snippetId : snippetIds) {
|
||||
Optional<String> snippet = extractCodeSnippet(sourceFile, snippetId);
|
||||
if (snippet.isEmpty()) {
|
||||
LOG.warn("Could not extract snippet: {}", snippetId);
|
||||
continue;
|
||||
}
|
||||
|
||||
String startMarker = "<!-- START_SNIPPET: " + snippetId + " -->";
|
||||
String endMarker = "<!-- END_SNIPPET: " + snippetId + " -->";
|
||||
|
||||
// Find the code block between markers
|
||||
Pattern pattern = Pattern.compile(Pattern.quote(startMarker) + ".*?" + Pattern.quote(endMarker),
|
||||
Pattern.DOTALL);
|
||||
|
||||
String normalizedSnippet = normalizeLineSeparators(snippet.get(), lineSeparator);
|
||||
String replacement = startMarker + lineSeparator + "```java" + lineSeparator + normalizedSnippet
|
||||
+ lineSeparator + "```" + lineSeparator + endMarker;
|
||||
String before = readmeContent;
|
||||
readmeContent = pattern.matcher(readmeContent).replaceAll(Matcher.quoteReplacement(replacement));
|
||||
|
||||
if (!readmeContent.equals(before)) {
|
||||
updated = true;
|
||||
LOG.info("Updated snippet in README: {}", snippetId);
|
||||
} else {
|
||||
LOG.warn("Snippet markers not found in README for: {}", snippetId);
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
Files.writeString(readmePath, readmeContent, StandardCharsets.UTF_8);
|
||||
LOG.info("README updated successfully");
|
||||
return true;
|
||||
} else {
|
||||
LOG.warn("No updates made to README");
|
||||
return false;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.error("Error updating README: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String detectPreferredLineSeparator(String content) {
|
||||
int crlfCount = 0;
|
||||
int lfCount = 0;
|
||||
|
||||
for (int i = 0; i < content.length(); i++) {
|
||||
if (content.charAt(i) == '\n') {
|
||||
if (i > 0 && content.charAt(i - 1) == '\r') {
|
||||
crlfCount++;
|
||||
} else {
|
||||
lfCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (crlfCount >= lfCount && crlfCount > 0) {
|
||||
return "\r\n";
|
||||
}
|
||||
if (lfCount > 0) {
|
||||
return "\n";
|
||||
}
|
||||
return System.lineSeparator();
|
||||
}
|
||||
|
||||
private static String normalizeLineSeparators(String content, String lineSeparator) {
|
||||
String normalized = content.replace("\r\n", "\n").replace("\r", "\n");
|
||||
if ("\n".equals(lineSeparator)) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.replace("\n", lineSeparator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main method to generate all README charts and automatically update README
|
||||
* code snippets.
|
||||
*
|
||||
* <p>
|
||||
* By default, this method generates all chart images and automatically updates
|
||||
* the README with code snippets extracted from the chart generation methods to
|
||||
* keep them in sync.
|
||||
* </p>
|
||||
*
|
||||
* @param args optional arguments: - "update-readme": Only updates README.md
|
||||
* with code snippets (no chart generation) - "snippets": Only
|
||||
* prints all code snippets to log (no chart generation) -
|
||||
* Otherwise: output directory for charts (defaults to
|
||||
* "ta4j-examples/docs/img")
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
if (args.length > 0 && "update-readme".equals(args[0])) {
|
||||
LOG.info("=== Updating README with code snippets ===");
|
||||
Path projectRoot = findProjectRoot();
|
||||
Path sourceFile = projectRoot
|
||||
.resolve("ta4j-examples/src/main/java/ta4jexamples/doc/ReadmeContentManager.java")
|
||||
.normalize();
|
||||
Path readmePath = projectRoot.resolve("README.md").normalize();
|
||||
|
||||
if (!Files.exists(sourceFile)) {
|
||||
LOG.error("Source file not found: {}", sourceFile.toAbsolutePath());
|
||||
return;
|
||||
}
|
||||
if (!Files.exists(readmePath)) {
|
||||
LOG.error("README file not found: {}", readmePath.toAbsolutePath());
|
||||
return;
|
||||
}
|
||||
|
||||
boolean success = updateReadmeSnippets(readmePath, sourceFile);
|
||||
if (success) {
|
||||
LOG.info("=== README update complete ===");
|
||||
} else {
|
||||
LOG.warn("=== README update failed or no changes made ===");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.length > 0 && "snippets".equals(args[0])) {
|
||||
LOG.info("=== Extracting code snippets ===");
|
||||
Path projectRoot = findProjectRoot();
|
||||
Path sourceFile = projectRoot
|
||||
.resolve("ta4j-examples/src/main/java/ta4jexamples/doc/ReadmeContentManager.java")
|
||||
.normalize();
|
||||
String[] snippetIds = { "ema-crossover", "rsi-strategy", "strategy-performance", "advanced-strategy",
|
||||
"serialize-indicator", "serialize-rule", "serialize-strategy" };
|
||||
|
||||
for (String snippetId : snippetIds) {
|
||||
Optional<String> snippet = extractCodeSnippet(sourceFile, snippetId);
|
||||
if (snippet.isPresent()) {
|
||||
LOG.info("\n=== {} Code Snippet ===", snippetId);
|
||||
LOG.info("\n{}", snippet.get());
|
||||
} else {
|
||||
LOG.warn("Could not extract snippet: {}", snippetId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Chart generation requires a display (non-headless environment)
|
||||
if (GraphicsEnvironment.isHeadless()) {
|
||||
LOG.error("Cannot generate charts in headless environment. Chart generation requires a display.");
|
||||
LOG.error("Use 'update-readme' or 'snippets' arguments for headless operations.");
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
String outputDir = args.length > 0 ? args[0] : "ta4j-examples/docs/img";
|
||||
|
||||
LOG.info("=== README Content Manager ===");
|
||||
LOG.info("Output directory: {}", outputDir);
|
||||
|
||||
// Generate all charts
|
||||
generateEmaCrossoverChart(outputDir);
|
||||
generateRsiStrategyChart(outputDir);
|
||||
generateStrategyPerformanceChart(outputDir);
|
||||
generateAdvancedStrategyChart(outputDir);
|
||||
|
||||
LOG.info("=== Chart generation complete ===");
|
||||
|
||||
// Automatically update README with code snippets to keep them in sync
|
||||
LOG.info("=== Updating README with code snippets ===");
|
||||
Path projectRoot = findProjectRoot();
|
||||
Path sourceFile = projectRoot.resolve("ta4j-examples/src/main/java/ta4jexamples/doc/ReadmeContentManager.java")
|
||||
.normalize();
|
||||
Path readmePath = projectRoot.resolve("README.md").normalize();
|
||||
|
||||
if (Files.exists(sourceFile) && Files.exists(readmePath)) {
|
||||
boolean success = updateReadmeSnippets(readmePath, sourceFile);
|
||||
if (success) {
|
||||
LOG.info("=== README updated successfully ===");
|
||||
} else {
|
||||
LOG.warn("=== README update completed (no changes or warnings occurred) ===");
|
||||
}
|
||||
} else {
|
||||
LOG.warn("Could not update README: source file or README not found");
|
||||
LOG.warn("Source: {}, exists: {}", sourceFile.toAbsolutePath(), Files.exists(sourceFile));
|
||||
LOG.warn("README: {}, exists: {}", readmePath.toAbsolutePath(), Files.exists(readmePath));
|
||||
}
|
||||
|
||||
LOG.info("To view code snippets only, run with argument 'snippets'");
|
||||
}
|
||||
}
|
||||
+768
@@ -0,0 +1,768 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
package ta4jexamples.doc;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.sun.source.doctree.DocCommentTree;
|
||||
import com.sun.source.tree.AnnotationTree;
|
||||
import com.sun.source.tree.ClassTree;
|
||||
import com.sun.source.tree.CompilationUnitTree;
|
||||
import com.sun.source.tree.ExpressionTree;
|
||||
import com.sun.source.tree.MethodTree;
|
||||
import com.sun.source.tree.ModifiersTree;
|
||||
import com.sun.source.tree.Tree;
|
||||
import com.sun.source.tree.VariableTree;
|
||||
import com.sun.source.util.DocTrees;
|
||||
import com.sun.source.util.JavacTask;
|
||||
import com.sun.source.util.SourcePositions;
|
||||
import com.sun.source.util.TreePath;
|
||||
import com.sun.source.util.TreePathScanner;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintStream;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import javax.tools.Diagnostic;
|
||||
import javax.tools.DiagnosticCollector;
|
||||
import javax.tools.JavaCompiler;
|
||||
import javax.tools.JavaFileObject;
|
||||
import javax.tools.StandardJavaFileManager;
|
||||
import javax.tools.ToolProvider;
|
||||
|
||||
/**
|
||||
* Scans ta4j source trees for {@code @Deprecated(..., forRemoval = true)}
|
||||
* symbols, reports release lifecycle state, and emits issue plans for release
|
||||
* automation.
|
||||
*
|
||||
* <p>
|
||||
* Typical usage:
|
||||
* {@code mvn -pl ta4j-examples -am compile exec:java -Dexec.mainClass=ta4jexamples.doc.RemovalReadyDeprecationScanner -Dexec.args="--output-json build/report.json --output-md build/report.md"}
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Pass {@code --include-overdue} when a workflow target version should include
|
||||
* every removal version less than or equal to that target. That keeps removal
|
||||
* tracking correct when release numbers jump across major, minor, or patch
|
||||
* positions.
|
||||
* </p>
|
||||
*
|
||||
* @since 0.22.7
|
||||
*/
|
||||
public final class RemovalReadyDeprecationScanner {
|
||||
|
||||
private static final int SCHEMA_VERSION = 1;
|
||||
private static final String AUTOMATION_NAMESPACE = "ta4j:deprecation-removal";
|
||||
private static final String SYMBOL_TRACKING_NAMESPACE = AUTOMATION_NAMESPACE + ":symbol:v1";
|
||||
private static final String GROUPED_FILE_PLAN_KIND = "grouped-file-removal";
|
||||
private static final Gson PRETTY_GSON = new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create();
|
||||
private static final Gson COMPACT_GSON = new GsonBuilder().disableHtmlEscaping().create();
|
||||
private static final Pattern VERSION_RE = Pattern.compile("<version>\\s*([^<]+?)\\s*</version>");
|
||||
private static final Pattern SEMVER_RE = Pattern.compile("\\d+\\.\\d+\\.\\d+");
|
||||
private static final Pattern DECLARATION_VERSION_RE = Pattern.compile(
|
||||
"(?:scheduled\\s+for\\s+removal\\s+in|removal\\s+in)\\s+(\\d+\\.\\d+\\.\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern NOTIFIER_RE = Pattern.compile(
|
||||
"DeprecationNotifier\\.warnOnce\\s*\\([^,]+,\\s*\"([^\"]+)\"(?:\\s*,\\s*\"(\\d+\\.\\d+\\.\\d+)\")?",
|
||||
Pattern.DOTALL);
|
||||
private static final Pattern JAVADOC_REPLACEMENT_RE = Pattern.compile("(?i)\\buse\\s+\\{@link\\s+([^}]+)\\}");
|
||||
private static final Set<String> SKIP_PARTS = Set.of(".agents", ".git", ".idea", "target");
|
||||
|
||||
private RemovalReadyDeprecationScanner() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the CLI scanner.
|
||||
*
|
||||
* @param args command-line arguments
|
||||
* @since 0.22.7
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
int exitCode = run(args, System.out, System.err);
|
||||
if (exitCode != 0) {
|
||||
System.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
static int run(String[] args, PrintStream stdout, PrintStream stderr) {
|
||||
ScanOptions options;
|
||||
try {
|
||||
options = parseArgs(args);
|
||||
} catch (IllegalArgumentException error) {
|
||||
stderr.println("Error: " + error.getMessage());
|
||||
stderr.println(usage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
DeprecationReport report = generateReport(options);
|
||||
writeOutput(options.outputJson(), PRETTY_GSON.toJson(report));
|
||||
writeOutput(options.outputMarkdown(), report.summaryMarkdown());
|
||||
stdout.println(COMPACT_GSON.toJson(summaryJson(report)));
|
||||
if (options.failOnDue() && report.blockingFindingCount() > 0) {
|
||||
stderr.println("Removal-ready deprecation gate failed: " + report.blockingFindingCount()
|
||||
+ " symbol(s) are due or overdue for " + report.removalVersion() + ".");
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
} catch (IOException | IllegalArgumentException error) {
|
||||
stderr.println("Error: " + error.getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> summaryJson(DeprecationReport report) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("schemaVersion", report.schemaVersion());
|
||||
summary.put("automationNamespace", report.automationNamespace());
|
||||
summary.put("snapshotVersion", report.snapshotVersion());
|
||||
summary.put("removalVersion", report.removalVersion());
|
||||
summary.put("issuePlanCount", report.issuePlanCount());
|
||||
summary.put("findingCount", report.findingCount());
|
||||
summary.put("dueFindingCount", report.dueFindingCount());
|
||||
summary.put("overdueFindingCount", report.overdueFindingCount());
|
||||
summary.put("futureFindingCount", report.futureFindingCount());
|
||||
summary.put("unscheduledFindingCount", report.unscheduledFindingCount());
|
||||
summary.put("blockingFindingCount", report.blockingFindingCount());
|
||||
return summary;
|
||||
}
|
||||
|
||||
private static DeprecationReport generateReport(ScanOptions options) throws IOException {
|
||||
ProjectVersion projectVersion = readProjectVersion(options.pomFile());
|
||||
String targetRemovalVersion = options.targetRemovalVersion();
|
||||
if (targetRemovalVersion == null) {
|
||||
if (!projectVersion.version().endsWith("-SNAPSHOT")) {
|
||||
throw new IllegalArgumentException("expected a SNAPSHOT version in " + options.pomFile() + ", found '"
|
||||
+ projectVersion.version() + "'");
|
||||
}
|
||||
targetRemovalVersion = projectVersion.version().substring(0, projectVersion.version().length() - 9);
|
||||
}
|
||||
validateVersion(targetRemovalVersion, "--target-removal-version");
|
||||
String scanTargetRemovalVersion = targetRemovalVersion;
|
||||
|
||||
List<SourceFinding> allFindings = scanSources(options.repoRoot());
|
||||
List<IssuePlan> issuePlans = new ArrayList<>();
|
||||
List<SymbolFinding> unscheduledSymbols = new ArrayList<>();
|
||||
int selectedFindingCount = 0;
|
||||
int dueCount = 0;
|
||||
int overdueCount = 0;
|
||||
int futureCount = 0;
|
||||
int unscheduledCount = 0;
|
||||
|
||||
Map<String, List<SourceFinding>> findingsByIssue = new LinkedHashMap<>();
|
||||
for (SourceFinding finding : allFindings) {
|
||||
if (finding.removalVersion() == null) {
|
||||
unscheduledCount++;
|
||||
unscheduledSymbols.add(finding.toSymbolFinding());
|
||||
continue;
|
||||
}
|
||||
|
||||
int versionComparison = compareVersions(finding.removalVersion(), targetRemovalVersion);
|
||||
if (versionComparison < 0) {
|
||||
overdueCount++;
|
||||
} else if (versionComparison == 0) {
|
||||
dueCount++;
|
||||
} else {
|
||||
futureCount++;
|
||||
}
|
||||
|
||||
boolean selected = versionComparison == 0 || options.includeOverdue() && versionComparison < 0;
|
||||
if (selected) {
|
||||
selectedFindingCount++;
|
||||
String key = finding.removalVersion() + "|" + finding.filePath();
|
||||
findingsByIssue.computeIfAbsent(key, ignored -> new ArrayList<>()).add(finding);
|
||||
}
|
||||
}
|
||||
|
||||
for (Map.Entry<String, List<SourceFinding>> entry : findingsByIssue.entrySet()) {
|
||||
List<SourceFinding> findings = entry.getValue();
|
||||
SourceFinding first = findings.get(0);
|
||||
List<SymbolFinding> symbols = findings.stream()
|
||||
.map(finding -> finding.toSymbolFinding(status(finding.removalVersion(), scanTargetRemovalVersion)))
|
||||
.toList();
|
||||
issuePlans.add(buildIssuePlan(projectVersion.version(), first.removalVersion(), first.filePath(),
|
||||
first.module(), symbols));
|
||||
}
|
||||
|
||||
DeprecationReport partialReport = new DeprecationReport(SCHEMA_VERSION, AUTOMATION_NAMESPACE,
|
||||
OffsetDateTime.now(ZoneOffset.UTC).toString(), options.repoRoot().toString(), projectVersion.version(),
|
||||
targetRemovalVersion, options.targetRemovalVersion() == null ? "snapshot" : "target",
|
||||
options.includeOverdue(), options.failOnDue(), allFindings.size(), selectedFindingCount, dueCount,
|
||||
overdueCount, futureCount, unscheduledCount, dueCount + overdueCount, issuePlans.size(),
|
||||
List.copyOf(issuePlans), List.copyOf(unscheduledSymbols), "");
|
||||
return partialReport.withSummaryMarkdown(renderMarkdown(partialReport));
|
||||
}
|
||||
|
||||
private static List<SourceFinding> scanSources(Path repoRoot) throws IOException {
|
||||
List<Path> javaFiles = iterJavaFiles(repoRoot);
|
||||
if (javaFiles.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
|
||||
if (compiler == null) {
|
||||
throw new IllegalArgumentException("unable to locate the JDK Java compiler");
|
||||
}
|
||||
|
||||
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
|
||||
try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null,
|
||||
StandardCharsets.UTF_8)) {
|
||||
Iterable<? extends JavaFileObject> fileObjects = fileManager.getJavaFileObjectsFromPaths(javaFiles);
|
||||
JavacTask task = (JavacTask) compiler.getTask(null, fileManager, diagnostics,
|
||||
List.of("-proc:none", "-Xlint:none"), null, fileObjects);
|
||||
DocTrees docTrees = DocTrees.instance(task);
|
||||
SourcePositions sourcePositions = docTrees.getSourcePositions();
|
||||
List<SourceFinding> findings = new ArrayList<>();
|
||||
for (CompilationUnitTree unit : task.parse()) {
|
||||
Path sourcePath = sourcePath(unit);
|
||||
Path relativePath = repoRoot.relativize(sourcePath);
|
||||
String sourceText = Files.readString(sourcePath, StandardCharsets.UTF_8);
|
||||
SourceScanner scanner = new SourceScanner(docTrees, sourcePositions, unit, sourceText,
|
||||
relativePath.toString().replace('\\', '/'), moduleName(relativePath));
|
||||
scanner.scan(unit, null);
|
||||
findings.addAll(scanner.findings());
|
||||
}
|
||||
|
||||
List<Diagnostic<? extends JavaFileObject>> errors = diagnostics.getDiagnostics()
|
||||
.stream()
|
||||
.filter(diagnostic -> diagnostic.getKind() == Diagnostic.Kind.ERROR)
|
||||
.toList();
|
||||
if (!errors.isEmpty()) {
|
||||
throw new IllegalArgumentException("unable to parse Java source: " + errors.get(0).getMessage(null));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
}
|
||||
|
||||
private static Path sourcePath(CompilationUnitTree unit) {
|
||||
URI uri = unit.getSourceFile().toUri();
|
||||
return Path.of(uri).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private static String renderMarkdown(DeprecationReport report) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
lines.add("# Removal-ready deprecations for " + report.snapshotVersion());
|
||||
lines.add("");
|
||||
lines.add("- Target removal version: " + report.removalVersion());
|
||||
lines.add("- Scan mode: " + report.scanMode());
|
||||
lines.add("- Planned issues: " + report.issuePlanCount());
|
||||
lines.add("- Removal-ready symbols in planned issues: " + report.findingCount());
|
||||
lines.add("- Due symbols: " + report.dueFindingCount());
|
||||
lines.add("- Overdue symbols: " + report.overdueFindingCount());
|
||||
lines.add("- Future symbols: " + report.futureFindingCount());
|
||||
lines.add("- Unscheduled symbols: " + report.unscheduledFindingCount());
|
||||
lines.add("");
|
||||
|
||||
if (report.issuePlans().isEmpty()) {
|
||||
lines.add("No removal-ready deprecations matched this scan's issue-planning criteria.");
|
||||
} else {
|
||||
for (IssuePlan plan : report.issuePlans()) {
|
||||
lines.add("## " + plan.issueTitle());
|
||||
lines.add("");
|
||||
lines.add("- Module: `" + plan.module() + "`");
|
||||
lines.add("- File: `" + plan.filePath() + "`");
|
||||
lines.add("- Removal version: `" + plan.removalVersion() + "`");
|
||||
lines.add("- Symbols:");
|
||||
for (SymbolFinding symbol : plan.symbols()) {
|
||||
lines.add(" - " + renderSymbol(symbol));
|
||||
}
|
||||
lines.add("");
|
||||
}
|
||||
}
|
||||
|
||||
if (!report.unscheduledSymbols().isEmpty()) {
|
||||
lines.add("");
|
||||
lines.add("## Unscheduled for-removal symbols");
|
||||
lines.add("");
|
||||
for (SymbolFinding symbol : report.unscheduledSymbols()) {
|
||||
lines.add("- `" + symbol.filePath() + "`: " + renderSymbol(symbol));
|
||||
}
|
||||
}
|
||||
return String.join("\n", lines);
|
||||
}
|
||||
|
||||
private static String renderSymbol(SymbolFinding symbol) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
parts.add("`" + symbol.name() + "`");
|
||||
parts.add(symbol.kind());
|
||||
parts.add("line " + symbol.line());
|
||||
parts.add(symbol.status());
|
||||
if (symbol.replacement() != null) {
|
||||
parts.add("use `" + symbol.replacement() + "`");
|
||||
}
|
||||
return parts.stream().reduce((left, right) -> left + ", " + right).orElse("");
|
||||
}
|
||||
|
||||
private static ScanOptions parseArgs(String[] args) {
|
||||
Path repoRoot = Path.of(".").toAbsolutePath().normalize();
|
||||
String pomFile = "pom.xml";
|
||||
String outputJson = null;
|
||||
String outputMarkdown = null;
|
||||
String targetRemovalVersion = null;
|
||||
boolean includeOverdue = false;
|
||||
boolean failOnDue = false;
|
||||
|
||||
for (int index = 0; index < args.length; index++) {
|
||||
String argument = args[index];
|
||||
if ("--repo-root".equals(argument)) {
|
||||
repoRoot = Path.of(requireValue(argument, args, ++index)).toAbsolutePath().normalize();
|
||||
} else if ("--pom-file".equals(argument)) {
|
||||
pomFile = requireValue(argument, args, ++index);
|
||||
} else if ("--output-json".equals(argument)) {
|
||||
outputJson = requireValue(argument, args, ++index);
|
||||
} else if ("--output-md".equals(argument)) {
|
||||
outputMarkdown = requireValue(argument, args, ++index);
|
||||
} else if ("--target-removal-version".equals(argument)) {
|
||||
targetRemovalVersion = requireValue(argument, args, ++index);
|
||||
validateVersion(targetRemovalVersion, argument);
|
||||
} else if ("--include-overdue".equals(argument)) {
|
||||
includeOverdue = true;
|
||||
} else if ("--fail-on-due".equals(argument)) {
|
||||
failOnDue = true;
|
||||
} else {
|
||||
throw new IllegalArgumentException("unknown argument " + argument);
|
||||
}
|
||||
}
|
||||
|
||||
if (outputJson == null || outputMarkdown == null) {
|
||||
throw new IllegalArgumentException("--output-json and --output-md are required");
|
||||
}
|
||||
|
||||
Path resolvedPom = Path.of(pomFile);
|
||||
if (!resolvedPom.isAbsolute()) {
|
||||
resolvedPom = repoRoot.resolve(resolvedPom).normalize();
|
||||
}
|
||||
return new ScanOptions(repoRoot, resolvedPom, Path.of(outputJson), Path.of(outputMarkdown),
|
||||
targetRemovalVersion, includeOverdue, failOnDue);
|
||||
}
|
||||
|
||||
private static String requireValue(String option, String[] args, int valueIndex) {
|
||||
if (valueIndex >= args.length) {
|
||||
throw new IllegalArgumentException(option + " requires a value");
|
||||
}
|
||||
return args[valueIndex];
|
||||
}
|
||||
|
||||
private static ProjectVersion readProjectVersion(Path pomFile) throws IOException {
|
||||
String text = Files.readString(pomFile, StandardCharsets.UTF_8);
|
||||
Matcher matcher = VERSION_RE.matcher(text);
|
||||
if (!matcher.find()) {
|
||||
throw new IllegalArgumentException("unable to find a project version in " + pomFile);
|
||||
}
|
||||
|
||||
return new ProjectVersion(matcher.group(1).trim());
|
||||
}
|
||||
|
||||
private static void validateVersion(String version, String option) {
|
||||
if (!SEMVER_RE.matcher(version).matches()) {
|
||||
throw new IllegalArgumentException(option + " must be major.minor.patch, found '" + version + "'");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Path> iterJavaFiles(Path repoRoot) throws IOException {
|
||||
List<Path> javaFiles = new ArrayList<>();
|
||||
try (Stream<Path> paths = Files.walk(repoRoot)) {
|
||||
paths.filter(Files::isRegularFile)
|
||||
.filter(path -> path.toString().endsWith(".java"))
|
||||
.filter(path -> hasMainSourceMarker(repoRoot.relativize(path)))
|
||||
.filter(path -> !containsSkipPart(repoRoot.relativize(path)))
|
||||
.forEach(javaFiles::add);
|
||||
}
|
||||
javaFiles.sort(Comparator.naturalOrder());
|
||||
return javaFiles;
|
||||
}
|
||||
|
||||
private static boolean containsSkipPart(Path relativePath) {
|
||||
for (Path part : relativePath) {
|
||||
if (SKIP_PARTS.contains(part.toString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasMainSourceMarker(Path relativePath) {
|
||||
List<String> parts = pathParts(relativePath);
|
||||
for (int index = 0; index <= parts.size() - 3; index++) {
|
||||
if ("src".equals(parts.get(index)) && "main".equals(parts.get(index + 1))
|
||||
&& "java".equals(parts.get(index + 2))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String moduleName(Path relativePath) {
|
||||
List<String> parts = pathParts(relativePath);
|
||||
for (int index = 0; index <= parts.size() - 4; index++) {
|
||||
if ("src".equals(parts.get(index + 1)) && "main".equals(parts.get(index + 2))
|
||||
&& "java".equals(parts.get(index + 3))) {
|
||||
return parts.get(index);
|
||||
}
|
||||
}
|
||||
return parts.isEmpty() ? relativePath.toString() : parts.get(0);
|
||||
}
|
||||
|
||||
private static Set<String> removalVersions(String text) {
|
||||
Set<String> versions = new LinkedHashSet<>();
|
||||
Matcher declarationMatcher = DECLARATION_VERSION_RE.matcher(text);
|
||||
while (declarationMatcher.find()) {
|
||||
versions.add(declarationMatcher.group(1));
|
||||
}
|
||||
|
||||
Matcher notifierMatcher = NOTIFIER_RE.matcher(text);
|
||||
while (notifierMatcher.find()) {
|
||||
String version = notifierMatcher.group(2);
|
||||
if (version != null) {
|
||||
versions.add(version);
|
||||
}
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
private static String replacement(String text) {
|
||||
Matcher notifierMatcher = NOTIFIER_RE.matcher(text);
|
||||
if (notifierMatcher.find()) {
|
||||
return notifierMatcher.group(1);
|
||||
}
|
||||
|
||||
Matcher javadocMatcher = JAVADOC_REPLACEMENT_RE.matcher(text);
|
||||
if (javadocMatcher.find()) {
|
||||
return javadocMatcher.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IssuePlan buildIssuePlan(String snapshotVersion, String removalVersion, String relativePath,
|
||||
String module, List<SymbolFinding> symbols) {
|
||||
int symbolCount = symbols.size();
|
||||
String issueTitle = symbolCount == 1
|
||||
? "Remove " + removalVersion + "-ready deprecation: " + symbols.get(0).name()
|
||||
: "Remove " + removalVersion + "-ready deprecations in " + Path.of(relativePath).getFileName();
|
||||
String dedupeKey = removalVersion + ":" + relativePath;
|
||||
String issueMarker = "<!-- ta4j:deprecation-removal version=" + removalVersion + " dedupe=" + dedupeKey
|
||||
+ " -->";
|
||||
String symbolLines = symbols.stream()
|
||||
.map(symbol -> "- " + renderSymbol(symbol))
|
||||
.reduce((left, right) -> left + "\n" + right)
|
||||
.orElse("");
|
||||
String issueBody = String.join("\n",
|
||||
"Release automation detected removal-ready deprecations for `" + snapshotVersion + "`.", "",
|
||||
"Module: `" + module + "`", "File: `" + relativePath + "`", "Removal version: `" + removalVersion + "`",
|
||||
"", "Symbols:", symbolLines, "", "Acceptance checks:",
|
||||
"- remove or migrate the compatibility symbols scheduled for removal in `" + removalVersion + "`",
|
||||
"- update callers, tests, and documentation as needed", "- keep the full build green", "", issueMarker);
|
||||
return new IssuePlan(dedupeKey, GROUPED_FILE_PLAN_KIND, issueMarker, issueTitle, issueBody, removalVersion,
|
||||
module, relativePath, symbolCount, List.copyOf(symbols));
|
||||
}
|
||||
|
||||
private static String status(String removalVersion, String targetRemovalVersion) {
|
||||
int comparison = compareVersions(removalVersion, targetRemovalVersion);
|
||||
if (comparison < 0) {
|
||||
return "overdue";
|
||||
}
|
||||
if (comparison == 0) {
|
||||
return "due";
|
||||
}
|
||||
return "future";
|
||||
}
|
||||
|
||||
private static int compareVersions(String left, String right) {
|
||||
int[] leftParts = versionParts(left);
|
||||
int[] rightParts = versionParts(right);
|
||||
for (int index = 0; index < leftParts.length; index++) {
|
||||
int comparison = Integer.compare(leftParts[index], rightParts[index]);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int[] versionParts(String version) {
|
||||
validateVersion(version, "version");
|
||||
String[] parts = version.split("\\.");
|
||||
try {
|
||||
return new int[] { Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2]) };
|
||||
} catch (NumberFormatException error) {
|
||||
throw new IllegalArgumentException("version components must fit in an integer: '" + version + "'", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeOutput(Path path, String content) throws IOException {
|
||||
Path parent = path.getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static List<String> pathParts(Path path) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (Path part : path) {
|
||||
parts.add(part.toString());
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
private static String usage() {
|
||||
return "Usage: --output-json <path> --output-md <path> [--repo-root <path>] [--pom-file <path>] "
|
||||
+ "[--target-removal-version <major.minor.patch>] [--include-overdue] [--fail-on-due]";
|
||||
}
|
||||
|
||||
private static final class SourceScanner extends TreePathScanner<Void, Void> {
|
||||
|
||||
private final DocTrees docTrees;
|
||||
private final SourcePositions sourcePositions;
|
||||
private final CompilationUnitTree unit;
|
||||
private final String sourceText;
|
||||
private final String filePath;
|
||||
private final String module;
|
||||
private final List<SourceFinding> findings = new ArrayList<>();
|
||||
private final Deque<TypeContext> typeContexts = new ArrayDeque<>();
|
||||
private int methodDepth;
|
||||
|
||||
private SourceScanner(DocTrees docTrees, SourcePositions sourcePositions, CompilationUnitTree unit,
|
||||
String sourceText, String filePath, String module) {
|
||||
this.docTrees = docTrees;
|
||||
this.sourcePositions = sourcePositions;
|
||||
this.unit = unit;
|
||||
this.sourceText = sourceText;
|
||||
this.filePath = filePath;
|
||||
this.module = module;
|
||||
}
|
||||
|
||||
private List<SourceFinding> findings() {
|
||||
return findings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Void visitClass(ClassTree tree, Void unused) {
|
||||
TreePath path = getCurrentPath();
|
||||
CandidateDetails details = candidateDetails(path, tree.getModifiers(), tree, className(tree),
|
||||
className(tree), kind(tree.getKind()), false);
|
||||
Set<String> inheritedVersions = details.forRemoval() ? details.effectiveVersions() : inheritedVersions();
|
||||
typeContexts.push(new TypeContext(className(tree), inheritedVersions));
|
||||
try {
|
||||
if (details.forRemoval()) {
|
||||
findings.add(details.toFinding(filePath, module));
|
||||
}
|
||||
return super.visitClass(tree, unused);
|
||||
} finally {
|
||||
typeContexts.pop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Void visitMethod(MethodTree tree, Void unused) {
|
||||
TreePath path = getCurrentPath();
|
||||
String name = tree.getReturnType() == null ? currentTypeName() : tree.getName().toString();
|
||||
String kind = tree.getReturnType() == null ? "constructor" : "method";
|
||||
CandidateDetails details = candidateDetails(path, tree.getModifiers(), tree, name,
|
||||
methodSignature(tree, name), kind, true);
|
||||
if (details.forRemoval()) {
|
||||
findings.add(details.toFinding(filePath, module));
|
||||
}
|
||||
|
||||
methodDepth++;
|
||||
try {
|
||||
return super.visitMethod(tree, unused);
|
||||
} finally {
|
||||
methodDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Void visitVariable(VariableTree tree, Void unused) {
|
||||
TreePath path = getCurrentPath();
|
||||
if (methodDepth == 0 && isField(path)) {
|
||||
CandidateDetails details = candidateDetails(path, tree.getModifiers(), tree, tree.getName().toString(),
|
||||
tree.getName().toString(), "field", true);
|
||||
if (details.forRemoval()) {
|
||||
findings.add(details.toFinding(filePath, module));
|
||||
}
|
||||
}
|
||||
return super.visitVariable(tree, unused);
|
||||
}
|
||||
|
||||
private CandidateDetails candidateDetails(TreePath path, ModifiersTree modifiers, Tree tree, String name,
|
||||
String signature, String kind, boolean inheritMissingRemovalVersion) {
|
||||
boolean forRemoval = isDeprecatedForRemoval(modifiers);
|
||||
String context = context(path, tree);
|
||||
Set<String> explicitVersions = removalVersions(context);
|
||||
Set<String> effectiveVersions = explicitVersions.isEmpty() && inheritMissingRemovalVersion
|
||||
? inheritedVersions()
|
||||
: explicitVersions;
|
||||
String removalVersion = effectiveVersions.stream().findFirst().orElse(null);
|
||||
String findingStatus = removalVersion == null ? "unscheduled" : "scheduled";
|
||||
int line = declarationLine(tree, name);
|
||||
return new CandidateDetails(forRemoval, name, signature, kind, line, removalVersion, findingStatus,
|
||||
replacement(context), Set.copyOf(effectiveVersions));
|
||||
}
|
||||
|
||||
private String methodSignature(MethodTree tree, String name) {
|
||||
List<String> parameterTypes = new ArrayList<>();
|
||||
for (VariableTree parameter : tree.getParameters()) {
|
||||
parameterTypes.add(parameter.getType().toString());
|
||||
}
|
||||
return name + "(" + String.join(", ", parameterTypes) + ")";
|
||||
}
|
||||
|
||||
private String context(TreePath path, Tree tree) {
|
||||
StringBuilder context = new StringBuilder();
|
||||
DocCommentTree docComment = docTrees.getDocCommentTree(path);
|
||||
if (docComment != null) {
|
||||
context.append(docComment).append('\n');
|
||||
}
|
||||
context.append(sourceFragment(tree));
|
||||
return context.toString();
|
||||
}
|
||||
|
||||
private String sourceFragment(Tree tree) {
|
||||
long start = sourcePositions.getStartPosition(unit, tree);
|
||||
long end = sourcePositions.getEndPosition(unit, tree);
|
||||
if (start < 0 || end < start || end > sourceText.length()) {
|
||||
return tree.toString();
|
||||
}
|
||||
return sourceText.substring((int) start, (int) end);
|
||||
}
|
||||
|
||||
private int declarationLine(Tree tree, String name) {
|
||||
long start = sourcePositions.getStartPosition(unit, tree);
|
||||
if (start < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
String fragment = sourceFragment(tree);
|
||||
int nameIndex = fragment.indexOf(name);
|
||||
if (nameIndex < 0) {
|
||||
nameIndex = 0;
|
||||
}
|
||||
return (int) unit.getLineMap().getLineNumber(start + nameIndex);
|
||||
}
|
||||
|
||||
private boolean isDeprecatedForRemoval(ModifiersTree modifiers) {
|
||||
for (AnnotationTree annotation : modifiers.getAnnotations()) {
|
||||
if (!isDeprecatedAnnotation(annotation)) {
|
||||
continue;
|
||||
}
|
||||
for (ExpressionTree argument : annotation.getArguments()) {
|
||||
String normalized = argument.toString().replaceAll("\\s+", "");
|
||||
if (normalized.equals("forRemoval=true")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isDeprecatedAnnotation(AnnotationTree annotation) {
|
||||
String annotationType = annotation.getAnnotationType().toString();
|
||||
return "Deprecated".equals(annotationType) || "java.lang.Deprecated".equals(annotationType)
|
||||
|| annotationType.endsWith(".Deprecated");
|
||||
}
|
||||
|
||||
private Set<String> inheritedVersions() {
|
||||
return typeContexts.isEmpty() ? Set.of() : typeContexts.peek().inheritedRemovalVersions();
|
||||
}
|
||||
|
||||
private String currentTypeName() {
|
||||
return typeContexts.isEmpty() ? "" : typeContexts.peek().name();
|
||||
}
|
||||
|
||||
private boolean isField(TreePath path) {
|
||||
TreePath parentPath = path.getParentPath();
|
||||
return parentPath != null && parentPath.getLeaf() instanceof ClassTree;
|
||||
}
|
||||
|
||||
private String className(ClassTree tree) {
|
||||
return tree.getSimpleName().toString();
|
||||
}
|
||||
|
||||
private String kind(Tree.Kind treeKind) {
|
||||
return switch (treeKind) {
|
||||
case ANNOTATION_TYPE -> "annotation";
|
||||
case ENUM -> "enum";
|
||||
case INTERFACE -> "interface";
|
||||
case RECORD -> "record";
|
||||
default -> "class";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private record ScanOptions(Path repoRoot, Path pomFile, Path outputJson, Path outputMarkdown,
|
||||
String targetRemovalVersion, boolean includeOverdue, boolean failOnDue) {
|
||||
}
|
||||
|
||||
private record ProjectVersion(String version) {
|
||||
}
|
||||
|
||||
private record TypeContext(String name, Set<String> inheritedRemovalVersions) {
|
||||
}
|
||||
|
||||
private record CandidateDetails(boolean forRemoval, String name, String signature, String kind, int line,
|
||||
String removalVersion, String status, String replacement, Set<String> effectiveVersions) {
|
||||
|
||||
private SourceFinding toFinding(String filePath, String module) {
|
||||
return new SourceFinding(name, signature, kind, line, removalVersion, status, replacement, module,
|
||||
filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private record SourceFinding(String name, String signature, String kind, int line, String removalVersion,
|
||||
String status, String replacement, String module, String filePath) {
|
||||
|
||||
private SymbolFinding toSymbolFinding() {
|
||||
return toSymbolFinding(status);
|
||||
}
|
||||
|
||||
private SymbolFinding toSymbolFinding(String status) {
|
||||
return new SymbolFinding(trackingKey(), name, signature, kind, line, removalVersion, status, replacement,
|
||||
module, filePath);
|
||||
}
|
||||
|
||||
private String trackingKey() {
|
||||
return String.join("|", SYMBOL_TRACKING_NAMESPACE, removalVersion == null ? "unscheduled" : removalVersion,
|
||||
module, filePath, kind, signature);
|
||||
}
|
||||
}
|
||||
|
||||
private record SymbolFinding(String trackingKey, String name, String signature, String kind, int line,
|
||||
String removalVersion, String status, String replacement, String module, String filePath) {
|
||||
}
|
||||
|
||||
private record IssuePlan(String dedupeKey, String planKind, String issueMarker, String issueTitle, String issueBody,
|
||||
String removalVersion, String module, String filePath, int symbolCount, List<SymbolFinding> symbols) {
|
||||
}
|
||||
|
||||
private record DeprecationReport(int schemaVersion, String automationNamespace, String generatedAt, String repoRoot,
|
||||
String snapshotVersion, String removalVersion, String scanMode, boolean includeOverdue, boolean failOnDue,
|
||||
int totalForRemovalCount, int findingCount, int dueFindingCount, int overdueFindingCount,
|
||||
int futureFindingCount, int unscheduledFindingCount, int blockingFindingCount, int issuePlanCount,
|
||||
List<IssuePlan> issuePlans, List<SymbolFinding> unscheduledSymbols, String summaryMarkdown) {
|
||||
|
||||
private DeprecationReport withSummaryMarkdown(String summaryMarkdown) {
|
||||
return new DeprecationReport(schemaVersion, automationNamespace, generatedAt, repoRoot, snapshotVersion,
|
||||
removalVersion, scanMode, includeOverdue, failOnDue, totalForRemovalCount, findingCount,
|
||||
dueFindingCount, overdueFindingCount, futureFindingCount, unscheduledFindingCount,
|
||||
blockingFindingCount, issuePlanCount, issuePlans, unscheduledSymbols, summaryMarkdown);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user