From e30fe4126b64987cc57a5f300d6ec9b20bf9958d Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Thu, 3 Sep 2026 12:06:27 +0200 Subject: [PATCH 1/6] tool: generate configuration properties reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a jbang script that scans src/main/java for @Value("${...}") and @ConfigurationProperties(...) usages and generates a Markdown reference of every configuration property the server reads — key, default value, source location, and any @ConfigurationProperties prefix with its bound fields. There's no spring-boot-configuration-processor wired into this project, so there's currently no generated list of config properties to reference; this fills that gap without adding a build-time dependency. - server/scripts/src/ConfigPropertiesReport.java — jbang script (pure JDK, no external dependencies), following the existing convention in scripts/src/ (see DependencyOverrideCheck.java) - server/scripts/config-properties-report.sh — thin wrapper, mirrors dependency-override-check.sh Regex-based source scanning (no AST parser) that correctly handles the real edge cases present in this codebase: nested ${outer:${inner:default}} defaults, SpEL-wrapped @Value expressions, string-concatenated @Value args resolved against same-file constants, @ConfigurationProperties prefixes declared via a constant reference (RateLimitProperties.PROPERTY_PREFIX), and @ConfigurationProperties on @Bean-returned collections regardless of annotation order (MirrorConfig). Out of scope: cross-referencing application*.yml/.properties for actually-configured values, and recursing into @ConfigurationProperties nested field types beyond one level (e.g. RemoteScannerProperties's Map keys are operator-chosen at runtime and aren't enumerable from source). Example output: https://gist.github.com/cstamas/f853eec0307f8d5977106bf69f505d29 --- server/scripts/config-properties-report.sh | 13 + .../scripts/src/ConfigPropertiesReport.java | 421 ++++++++++++++++++ 2 files changed, 434 insertions(+) create mode 100755 server/scripts/config-properties-report.sh create mode 100644 server/scripts/src/ConfigPropertiesReport.java diff --git a/server/scripts/config-properties-report.sh b/server/scripts/config-properties-report.sh new file mode 100755 index 000000000..72542355e --- /dev/null +++ b/server/scripts/config-properties-report.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# Lists every Spring config property key read via @Value / @ConfigurationProperties in +# src/main/java, with defaults and source locations. See scripts/src/ConfigPropertiesReport.java. + +set -eu + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +SERVER_ROOT=$( dirname "${SCRIPT_DIR}" ) + +cd "${SERVER_ROOT}" + +jbang scripts/src/ConfigPropertiesReport.java "$@" diff --git a/server/scripts/src/ConfigPropertiesReport.java b/server/scripts/src/ConfigPropertiesReport.java new file mode 100644 index 000000000..bb6af442b --- /dev/null +++ b/server/scripts/src/ConfigPropertiesReport.java @@ -0,0 +1,421 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 25+ + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +// Scans src/main/java for `@Value("${...}")` and `@ConfigurationProperties(...)` usages and prints +// every Spring config property key it finds, with its default (if any) and source location. There +// is no spring-configuration-metadata.json in this project (no annotationProcessor configured), so +// this is a regex-based stand-in - keys/defaults are discovered fresh each run, nothing here is a +// list to keep in sync. +// +// Deliberately not covered: cross-referencing application*.yml/.properties for actually-configured +// values, and recursing into @ConfigurationProperties field types beyond one level (e.g. +// RemoteScannerProperties' Map keys are operator-chosen at runtime and +// aren't enumerable from source). +void main() throws IOException { + var entries = new TreeMap(); + var configPropsClasses = new ArrayList(); + + try (Stream paths = Files.walk(Path.of("src/main/java"))) { + for (Path path : paths.filter(p -> p.toString().endsWith(".java")).toList()) { + var raw = Files.readString(path, StandardCharsets.UTF_8); + var blanked = blankComments(blankStringLiterals(raw)); + var file = path.getFileName().toString(); + scanValueAnnotations(raw, blanked, file, entries); + scanConfigurationProperties(raw, blanked, file, entries, configPropsClasses); + } + } + + System.out.println("# Configuration properties"); + System.out.println(); + System.out.println( + "Generated by `scripts/config-properties-report.sh` from `@Value` and " + + "`@ConfigurationProperties` usages in `src/main/java` - regenerate rather than editing by " + + "hand."); + System.out.println(); + System.out.println(entries.size() + " property keys found."); + System.out.println(); + System.out.println("| Property | Default | Source | Note |"); + System.out.println("|---|---|---|---|"); + for (PropertyEntry entry : entries.values()) { + var defaultCell = entry.defaultValue == null || entry.defaultValue.isEmpty() + ? "-" + : "`" + mdEscape(entry.defaultValue) + "`"; + var sourceCell = + String.join(", ", entry.sources.stream().map(s -> "`" + s + "`").toList()); + var noteCell = entry.note == null ? "" : mdEscape(entry.note); + System.out.println( + "| `" + entry.key + "` | " + defaultCell + " | " + sourceCell + " | " + noteCell + " |"); + } + + if (!configPropsClasses.isEmpty()) { + System.out.println(); + System.out.println("## `@ConfigurationProperties` classes"); + System.out.println(); + System.out.println("| Prefix | Source | Note |"); + System.out.println("|---|---|---|"); + for (ConfigClassEntry entry : configPropsClasses) { + var noteCell = entry.note() == null ? "" : mdEscape(entry.note()); + System.out.println( + "| `" + entry.prefix() + "` | `" + entry.source() + "` | " + noteCell + " |"); + } + } +} + +record PropertyEntry(String key, String defaultValue, Set sources, String note) {} + +record ConfigClassEntry(String prefix, String source, String note) {} + +String mdEscape(String text) { + return text.replace("|", "\\|"); +} + +// ---- @Value("${key:default}") ---- + +Pattern VALUE_ANNOTATION = Pattern.compile("@Value\\s*\\("); +// Deliberately line-bound and avoids a `(?:X|Y)*` alternation group repeated per char - Java's +// regex engine recurses per repetition of that shape, which stack-overflows on long spans (e.g. an +// unbalanced quote inside a javadoc comment matching across a large chunk of the file). +Pattern QUOTED_SEGMENT = Pattern.compile("\"([^\"\\\\\\n]*(?:\\\\.[^\"\\\\\\n]*)*)\""); +// Same quoted-string alternative as QUOTED_SEGMENT, plus a bare CONSTANT_NAME alternative, matched +// in argRaw's actual left-to-right order (see scanValueAnnotations). +Pattern QUOTED_OR_IDENTIFIER = + Pattern.compile("\"([^\"\\\\\\n]*(?:\\\\.[^\"\\\\\\n]*)*)\"|\\b([A-Z][A-Z0-9_]*)\\b"); +// One level of brace nesting, e.g. ${outer:${inner:default}} - covers every nested-default and +// SpEL-wrapped case found in this codebase; the SpEL syntax around it is simply ignored text. +Pattern PLACEHOLDER = Pattern.compile("\\$\\{((?:[^${}]|\\$\\{[^{}]*\\})*)\\}"); + +void scanValueAnnotations( + String raw, String blanked, String file, TreeMap entries) { + var matcher = VALUE_ANNOTATION.matcher(blanked); + while (matcher.find()) { + var close = findMatchingParen(blanked, matcher.end() - 1); + if (close < 0) { + continue; + } + var argRaw = raw.substring(matcher.end(), close); + + // Walk quoted segments and bare CONSTANT identifiers in the order they appear (not as two + // separate passes) so "${key:" + CONST + "}" reconstructs correctly - CONST's own text never + // appears inside a quoted segment, so a later blind replace() on the joined string can't find + // it to substitute. + var joinedBuilder = new StringBuilder(); + var quotedCount = 0; + String note = null; + var partsMatcher = QUOTED_OR_IDENTIFIER.matcher(argRaw); + while (partsMatcher.find()) { + if (partsMatcher.group(1) != null) { + joinedBuilder.append(partsMatcher.group(1)); + quotedCount++; + } else { + var name = partsMatcher.group(2); + var constantValueMatcher = + Pattern.compile(Pattern.quote(name) + "\\s*=\\s*\"([^\"]*)\"").matcher(raw); + if (constantValueMatcher.find()) { + joinedBuilder.append(constantValueMatcher.group(1)); + } else { + note = "unresolved constant: " + name; + } + } + } + if (quotedCount == 0) { + continue; + } + var joined = joinedBuilder.toString(); + + var placeholderMatcher = PLACEHOLDER.matcher(joined); + if (!placeholderMatcher.find()) { + continue; + } + var content = placeholderMatcher.group(1); + var colon = content.indexOf(':'); + var key = colon < 0 ? content : content.substring(0, colon); + var defaultValue = colon < 0 ? null : content.substring(colon + 1); + + var line = lineNumber(raw, matcher.start()); + addEntry(entries, key, defaultValue, file + ":" + line, note); + } +} + +// ---- @ConfigurationProperties(...) ---- + +Pattern CONFIG_PROPS_ANNOTATION = Pattern.compile("@ConfigurationProperties\\s*\\("); +Pattern PREFIX_NAMED = Pattern.compile("prefix\\s*=\\s*\"([^\"]*)\""); +Pattern PREFIX_LITERAL = Pattern.compile("\"([^\"]*)\""); +Pattern PREFIX_NAMED_CONSTANT = Pattern.compile("prefix\\s*=\\s*([A-Za-z_][\\w.]*)"); +Pattern PREFIX_CONSTANT = Pattern.compile("([A-Za-z_][\\w.]*)"); +Pattern NEXT_BEAN_METHOD = Pattern.compile("@Bean\\b"); +Pattern PRECEDING_BEAN = Pattern.compile("@Bean\\s*$"); +Pattern NEXT_CLASS = Pattern.compile("\\bclass\\s+(\\w+)"); +Pattern NEXT_RECORD = Pattern.compile("\\brecord\\s+(\\w+)\\s*\\("); +Set SIMPLE_TYPES = Set.of( + "String", "Boolean", "Integer", "Long", "Double", "Duration", "HttpStatus", "List", "Set", "Map", + "boolean", "int", "long", "double", "float", "short", "byte", "char"); +Pattern MODIFIERS = Pattern.compile("^(?:(?:private|protected|public|final)\\s+)+"); +Pattern TRAILING_NAME = Pattern.compile("(\\w+)$"); + +void scanConfigurationProperties( + String raw, + String blanked, + String file, + TreeMap entries, + List configPropsClasses) { + var matcher = CONFIG_PROPS_ANNOTATION.matcher(blanked); + while (matcher.find()) { + var close = findMatchingParen(blanked, matcher.end() - 1); + if (close < 0) { + continue; + } + var arg = raw.substring(matcher.end(), close); + + String prefix; + String note = null; + var named = PREFIX_NAMED.matcher(arg); + var literal = PREFIX_LITERAL.matcher(arg); + if (named.find()) { + prefix = named.group(1); + } else if (literal.find()) { + prefix = literal.group(1); + } else { + var namedConstant = PREFIX_NAMED_CONSTANT.matcher(arg); + var bareConstant = PREFIX_CONSTANT.matcher(arg); + var expr = namedConstant.find() + ? namedConstant.group(1) + : bareConstant.find() ? bareConstant.group(1) : arg.strip(); + var name = expr.contains(".") ? expr.substring(expr.lastIndexOf('.') + 1) : expr; + var constantValueMatcher = + Pattern.compile(Pattern.quote(name) + "\\s*=\\s*\"([^\"]*)\"").matcher(raw); + if (constantValueMatcher.find()) { + prefix = constantValueMatcher.group(1); + } else { + prefix = expr; + note = "unresolved constant reference"; + } + } + + // @Bean can be stacked either before or after @ConfigurationProperties on the same method + // (MirrorConfig has it first), so check immediately-before as well as forward. + var immediatelyPrecededByBean = + PRECEDING_BEAN.matcher(blanked.substring(0, matcher.start())).find(); + var afterAnnotation = blanked.substring(close + 1); + var beanMatcher = NEXT_BEAN_METHOD.matcher(afterAnnotation); + var classMatcher = NEXT_CLASS.matcher(afterAnnotation); + var recordMatcher = NEXT_RECORD.matcher(afterAnnotation); + var beanAt = beanMatcher.find() ? beanMatcher.start() : Integer.MAX_VALUE; + var classAt = classMatcher.find() ? classMatcher.start() : Integer.MAX_VALUE; + var recordAt = recordMatcher.find() ? recordMatcher.start() : Integer.MAX_VALUE; + + var line = lineNumber(raw, matcher.start()); + var loc = file + ":" + line; + configPropsClasses.add(new ConfigClassEntry(prefix, loc, note)); + + if (immediatelyPrecededByBean || (beanAt < classAt && beanAt < recordAt)) { + addEntry(entries, prefix, null, loc, "@Bean-returned collection, prefix is the bound key"); + } else if (recordAt < classAt) { + var openParen = close + 1 + recordMatcher.end() - 1; + var closeParen = findMatchingParen(blanked, openParen); + if (closeParen > 0) { + for (var field : splitTopLevel(raw.substring(openParen + 1, closeParen), ',')) { + addField(entries, prefix, field.strip(), loc); + } + } + } else if (classAt < Integer.MAX_VALUE) { + var openBrace = afterAnnotation.indexOf('{', classMatcher.end()); + if (openBrace >= 0) { + var absoluteOpenBrace = close + 1 + openBrace; + var closeBrace = findMatchingBrace(blanked, absoluteOpenBrace); + if (closeBrace > 0) { + var bodyBlanked = blanked.substring(absoluteOpenBrace + 1, closeBrace); + for (var chunk : splitTopLevel(bodyBlanked, ';')) { + var trimmed = stripLeadingAnnotations(chunk.strip()).strip(); + // A method's signature never has a top-level '=' before its '(' - a field's + // does only inside its initializer, which addField also strips before + // looking at the declaration, so checking pre-'=' text tells them apart. + var beforeInit = trimmed.split("=", 2)[0]; + if (trimmed.isEmpty() || trimmed.contains("static") || beforeInit.contains("(")) { + continue; + } + addField(entries, prefix, trimmed, loc); + } + } + } + } + } +} + +void addField(TreeMap entries, String prefix, String declaration, String loc) { + // Drop any initializer first ("= Duration.ofMinutes(5)", "= List.of(\"svg\")") - otherwise its + // tokens (which may themselves look like a type-then-identifier pair, e.g. a qualified constant + // reference) get mistaken for the field's own type/name. + var withoutInit = declaration.split("=", 2)[0].strip(); + var nameMatcher = TRAILING_NAME.matcher(withoutInit); + if (!nameMatcher.find()) { + return; + } + var name = nameMatcher.group(1); + // Generic type args can contain top-level spaces after a comma (`Map field`), so + // the type is "everything before the trailing identifier", not a naive whitespace split. + var type = MODIFIERS.matcher(withoutInit.substring(0, nameMatcher.start()).strip()).replaceFirst(""); + if (type.isEmpty()) { + return; + } + var bareType = type.contains("<") ? type.substring(0, type.indexOf('<')) : type; + var note = SIMPLE_TYPES.contains(bareType) ? null : "nested type, see " + bareType + " in this file"; + addEntry(entries, prefix + "." + kebabCase(name), null, loc, note); +} + +String stripLeadingAnnotations(String declaration) { + var result = declaration.strip(); + while (result.startsWith("@")) { + var paren = result.indexOf('('); + var space = result.indexOf(' '); + if (paren >= 0 && (space < 0 || paren < space)) { + var close = findMatchingParen(result, paren); + result = close > 0 ? result.substring(close + 1).strip() : result; + } else if (space > 0) { + result = result.substring(space + 1).strip(); + } else { + break; + } + } + return result; +} + +String kebabCase(String camelCase) { + return camelCase.replaceAll("([a-z0-9])([A-Z])", "$1-$2").toLowerCase(); +} + +void addEntry( + TreeMap entries, String key, String defaultValue, String source, String note) { + var existing = entries.get(key); + if (existing == null) { + var sources = new LinkedHashSet(); + sources.add(source); + entries.put(key, new PropertyEntry(key, defaultValue, sources, note)); + } else { + existing.sources().add(source); + } +} + +// ---- small structural helpers ---- + +/** Java string literals can't contain a raw newline or unescaped quote, so this is a safe, + * non-multiline regex replace - it exists only so brace/paren counting elsewhere isn't fooled by + * literal braces/parens inside a string value (e.g. a default JSON body). */ +String blankStringLiterals(String source) { + return QUOTED_SEGMENT.matcher(source).replaceAll(m -> "\"" + "x".repeat(m.group(1).length()) + "\""); +} + +/** Blanks `//` and `/* */` comments (length- and newline-preserving, so indices/line numbers + * computed against the result still line up with the raw source). Needed for correctness, not just + * robustness: a javadoc sentence containing a literal semicolon (e.g. "OIDC ID token; by default + * ...") would otherwise be mistaken for a field terminator by the class-body field scan below. Runs + * after string-blanking so a `//`/`/*` inside a string's (now blanked) content can't be mistaken for + * a comment start either. */ +String blankComments(String source) { + var result = new StringBuilder(source.length()); + var n = source.length(); + var i = 0; + while (i < n) { + if (i + 1 < n && source.charAt(i) == '/' && source.charAt(i + 1) == '/') { + while (i < n && source.charAt(i) != '\n') { + result.append(' '); + i++; + } + } else if (i + 1 < n && source.charAt(i) == '/' && source.charAt(i + 1) == '*') { + result.append(" "); + i += 2; + while (i + 1 < n && !(source.charAt(i) == '*' && source.charAt(i + 1) == '/')) { + result.append(source.charAt(i) == '\n' ? '\n' : ' '); + i++; + } + if (i + 1 < n) { + result.append(" "); + i += 2; + } + } else { + result.append(source.charAt(i)); + i++; + } + } + return result.toString(); +} + +int findMatchingParen(String text, int openIdx) { + return findMatching(text, openIdx, '(', ')'); +} + +int findMatchingBrace(String text, int openIdx) { + return findMatching(text, openIdx, '{', '}'); +} + +int findMatching(String text, int openIdx, char open, char close) { + var depth = 0; + for (var i = openIdx; i < text.length(); i++) { + var c = text.charAt(i); + if (c == open) { + depth++; + } else if (c == close) { + depth--; + if (depth == 0) { + return i; + } + } + } + return -1; +} + +List splitTopLevel(String text, char separator) { + var result = new ArrayList(); + var depth = 0; + var start = 0; + for (var i = 0; i < text.length(); i++) { + var c = text.charAt(i); + if (c == '<' || c == '(' || c == '{') { + depth++; + } else if (c == '>' || c == ')' || c == '}') { + depth--; + } else if (c == separator && depth == 0) { + result.add(text.substring(start, i)); + start = i + 1; + } + } + if (start < text.length()) { + result.add(text.substring(start)); + } + return result; +} + +int lineNumber(String text, int index) { + var line = 1; + for (var i = 0; i < index; i++) { + if (text.charAt(i) == '\n') { + line++; + } + } + return line; +} From d3afed8adcc37038470022ed6170e2ec79f3cfc9 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Thu, 3 Sep 2026 12:26:05 +0200 Subject: [PATCH 2/6] Make buildSrc Java 25 as well As right now IDEA goes lala --- server/buildSrc/build.gradle | 7 ++++++- server/scripts/src/DependencyOverrideCheck.java | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/server/buildSrc/build.gradle b/server/buildSrc/build.gradle index 978e0bb0f..af81a6288 100644 --- a/server/buildSrc/build.gradle +++ b/server/buildSrc/build.gradle @@ -27,5 +27,10 @@ dependencies { // build.gradle, and the //DEPS versions in scripts/src/{AddBracesFix,ClosingBraceFix}.java. implementation 'org.eclipse.jdt:org.eclipse.jdt.core:3.46.0' // must match the //DEPS version in scripts/src/DependencyOverrideCheck.java - implementation 'org.apache.maven:maven-artifact:3.9.9' + implementation 'org.apache.maven:maven-artifact:3.9.16' +} + +java { + sourceCompatibility = 25 + targetCompatibility = 25 } diff --git a/server/scripts/src/DependencyOverrideCheck.java b/server/scripts/src/DependencyOverrideCheck.java index 23466c44f..22be9607b 100644 --- a/server/scripts/src/DependencyOverrideCheck.java +++ b/server/scripts/src/DependencyOverrideCheck.java @@ -13,7 +13,7 @@ ///usr/bin/env jbang "$0" "$@" ; exit $? //JAVA 25+ -//DEPS org.apache.maven:maven-artifact:3.9.9 +//DEPS org.apache.maven:maven-artifact:3.9.16 import java.io.StringReader; import java.nio.charset.StandardCharsets; From 0604908d784dae4d2796b57160c3852b9cde04f6 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Mon, 7 Sep 2026 14:49:06 +0200 Subject: [PATCH 3/6] fix: address Copilot review findings on config properties report - addEntry() now merges deterministically: whichever occurrence of a duplicate key provides a non-empty default/note wins, instead of the first-scanned occurrence silently shadowing a later one's data - blank text blocks before brace/paren scanning, so braces, parens, and semicolons inside one (e.g. SQL in an @Query) can't throw off the structural scans - record each property's source relative to src/main/java instead of the bare filename, since it can be ambiguous across packages Confirmed unaffected: PropertyEntry/ConfigClassEntry record field access compiles fine here despite being flagged - this is a JEP 445 single-file source script, so the records are nested types of the implicit enclosing class and private field access between them is legal. Co-Authored-By: Claude Sonnet 5 --- .../scripts/src/ConfigPropertiesReport.java | 51 +++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/server/scripts/src/ConfigPropertiesReport.java b/server/scripts/src/ConfigPropertiesReport.java index bb6af442b..4bfcf7e43 100644 --- a/server/scripts/src/ConfigPropertiesReport.java +++ b/server/scripts/src/ConfigPropertiesReport.java @@ -40,11 +40,12 @@ void main() throws IOException { var entries = new TreeMap(); var configPropsClasses = new ArrayList(); - try (Stream paths = Files.walk(Path.of("src/main/java"))) { + var root = Path.of("src/main/java"); + try (Stream paths = Files.walk(root)) { for (Path path : paths.filter(p -> p.toString().endsWith(".java")).toList()) { var raw = Files.readString(path, StandardCharsets.UTF_8); - var blanked = blankComments(blankStringLiterals(raw)); - var file = path.getFileName().toString(); + var blanked = blankComments(blankStringLiterals(blankTextBlocks(raw))); + var file = root.relativize(path).toString(); scanValueAnnotations(raw, blanked, file, entries); scanConfigurationProperties(raw, blanked, file, entries, configPropsClasses); } @@ -316,9 +317,16 @@ void addEntry( var sources = new LinkedHashSet(); sources.add(source); entries.put(key, new PropertyEntry(key, defaultValue, sources, note)); - } else { - existing.sources().add(source); + return; } + existing.sources().add(source); + // Whichever occurrence provides a non-empty default/note wins - source order is file-walk + // order, not meaningful, so the first occurrence shouldn't silently shadow a later one's data. + var mergedDefault = existing.defaultValue() == null || existing.defaultValue().isEmpty() + ? defaultValue + : existing.defaultValue(); + var mergedNote = existing.note() == null ? note : existing.note(); + entries.put(key, new PropertyEntry(key, mergedDefault, existing.sources(), mergedNote)); } // ---- small structural helpers ---- @@ -330,6 +338,39 @@ String blankStringLiterals(String source) { return QUOTED_SEGMENT.matcher(source).replaceAll(m -> "\"" + "x".repeat(m.group(1).length()) + "\""); } +/** Blanks `"""..."""` text blocks (newline-preserving), run before string/comment blanking - a text + * block's content (e.g. SQL in an `@Query`) can contain braces/parens/semicolons that would + * otherwise confuse the brace/paren counting below. Doesn't handle an escaped `\"""` inside a + * block's content; none of this codebase's text blocks need one. */ +String blankTextBlocks(String source) { + var result = new StringBuilder(source.length()); + var n = source.length(); + var i = 0; + while (i < n) { + if (i + 2 < n && source.startsWith("\"\"\"", i)) { + result.append(" "); + i += 3; + while (i + 2 < n && !source.startsWith("\"\"\"", i)) { + result.append(source.charAt(i) == '\n' ? '\n' : ' '); + i++; + } + if (i + 2 < n) { + result.append(" "); + i += 3; + } else { + while (i < n) { + result.append(source.charAt(i) == '\n' ? '\n' : ' '); + i++; + } + } + } else { + result.append(source.charAt(i)); + i++; + } + } + return result.toString(); +} + /** Blanks `//` and `/* */` comments (length- and newline-preserving, so indices/line numbers * computed against the result still line up with the raw source). Needed for correctness, not just * robustness: a javadoc sentence containing a literal semicolon (e.g. "OIDC ID token; by default From 2b40741f61e1421aad17c73f22cb506d09e4f21e Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Mon, 7 Sep 2026 14:59:21 +0200 Subject: [PATCH 4/6] Apply proposed change --- server/buildSrc/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/buildSrc/build.gradle b/server/buildSrc/build.gradle index af81a6288..5d9253ebe 100644 --- a/server/buildSrc/build.gradle +++ b/server/buildSrc/build.gradle @@ -31,6 +31,6 @@ dependencies { } java { - sourceCompatibility = 25 - targetCompatibility = 25 + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 } From 1d9c5903d45a66e06968a58fe81e9de9a1bfd8c5 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Tue, 8 Sep 2026 14:59:36 +0200 Subject: [PATCH 5/6] review: report the properties the reference was leaving out Addresses the review findings, with cstamas's go-ahead. @ConditionalOnProperty is now scanned. Four properties were read only from that annotation and so appeared in no reference at all - ovsx.redis.enabled, ovsx.storage.migration.enabled, ovsx.upstream.proxy.enabled and spring.web.error.path - three of which are operator-facing switches gating whole subsystems. Only prefix/name/value are keys; havingValue and matchIfMissing are not, so the attributes are read by name rather than by taking every string literal in the annotation. A prefix given as a constant from another class (RateLimitConfig) cannot be resolved from the file being scanned, and is reported on stderr and skipped rather than emitted under a made-up key - that one key is documented anyway from the properties class it belongs to. A key that exists only as a nested fallback default was dropped: `${a:${b:}}` declares b as well, which is the pattern AGENTS.md requires of any key that has shipped, so ovsx.token-prefix - the old name of ovsx.access-token.prefix - is exactly what an operator with old configuration comes here to look up. @ConfigurationProperties field initializers were parsed and then discarded. Plain literals are now reported, which fills in four of the empty Default cells; expressions (List.of("svg"), a constant from another class) are still left blank rather than printed as source an operator cannot use. Read from the raw text rather than the blanked copy, which had replaced every string literal with x's - the blanking passes all preserve length, so the two are index-aligned. Both constant lookups were missing identifier boundaries, so FOO could resolve against MY_FOO, and the class-body scan tested contains("static"), which would skip a field named staticPath as though it were a static member. Both were latent - the output is unchanged by either fix - and both fail by omitting a property, which is the direction nobody notices. Scanning the conditionals pushed one key's source list to two dozen entries, so the cell now names three and counts the rest. Co-Authored-By: Claude Opus 5 (1M context) --- .../scripts/src/ConfigPropertiesReport.java | 161 ++++++++++++++++-- 1 file changed, 146 insertions(+), 15 deletions(-) diff --git a/server/scripts/src/ConfigPropertiesReport.java b/server/scripts/src/ConfigPropertiesReport.java index 4bfcf7e43..e0714da4b 100644 --- a/server/scripts/src/ConfigPropertiesReport.java +++ b/server/scripts/src/ConfigPropertiesReport.java @@ -48,6 +48,7 @@ void main() throws IOException { var file = root.relativize(path).toString(); scanValueAnnotations(raw, blanked, file, entries); scanConfigurationProperties(raw, blanked, file, entries, configPropsClasses); + scanConditionalOnProperty(raw, blanked, file, entries); } } @@ -66,8 +67,7 @@ void main() throws IOException { var defaultCell = entry.defaultValue == null || entry.defaultValue.isEmpty() ? "-" : "`" + mdEscape(entry.defaultValue) + "`"; - var sourceCell = - String.join(", ", entry.sources.stream().map(s -> "`" + s + "`").toList()); + var sourceCell = formatSources(entry.sources); var noteCell = entry.note == null ? "" : mdEscape(entry.note); System.out.println( "| `" + entry.key + "` | " + defaultCell + " | " + sourceCell + " | " + noteCell + " |"); @@ -91,12 +91,22 @@ record PropertyEntry(String key, String defaultValue, Set sources, Strin record ConfigClassEntry(String prefix, String source, String note) {} +/** Where a key is read, capped: `ovsx.data.mirror.enabled` is read in two dozen places, and a cell + * listing all of them tells the reader less than one naming a couple and the count. */ +String formatSources(Set sources) { + var shown = sources.stream().limit(MAX_SOURCES).map(s -> "`" + s + "`").toList(); + var remaining = sources.size() - shown.size(); + return String.join(", ", shown) + (remaining > 0 ? " and " + remaining + " more" : ""); +} + String mdEscape(String text) { return text.replace("|", "\\|"); } // ---- @Value("${key:default}") ---- +int MAX_SOURCES = 3; + Pattern VALUE_ANNOTATION = Pattern.compile("@Value\\s*\\("); // Deliberately line-bound and avoids a `(?:X|Y)*` alternation group repeated per char - Java's // regex engine recurses per repetition of that shape, which stack-overflows on long spans (e.g. an @@ -135,7 +145,7 @@ void scanValueAnnotations( } else { var name = partsMatcher.group(2); var constantValueMatcher = - Pattern.compile(Pattern.quote(name) + "\\s*=\\s*\"([^\"]*)\"").matcher(raw); + Pattern.compile("\\b" + Pattern.quote(name) + "\\b\\s*=\\s*\"([^\"]*)\"").matcher(raw); if (constantValueMatcher.find()) { joinedBuilder.append(constantValueMatcher.group(1)); } else { @@ -152,13 +162,25 @@ void scanValueAnnotations( if (!placeholderMatcher.find()) { continue; } - var content = placeholderMatcher.group(1); - var colon = content.indexOf(':'); - var key = colon < 0 ? content : content.substring(0, colon); - var defaultValue = colon < 0 ? null : content.substring(colon + 1); - var line = lineNumber(raw, matcher.start()); - addEntry(entries, key, defaultValue, file + ":" + line, note); + addPlaceholder(entries, placeholderMatcher.group(1), file + ":" + line, note); + } +} + +/** The key and default of one `${...}` body, and of any placeholder nested in that default: a + * property whose default is `${old.key:}` is the fallback pattern AGENTS.md requires of any key that + * has shipped, so the old name is a property in its own right and belongs in the reference. */ +void addPlaceholder(TreeMap entries, String content, String source, String note) { + var colon = content.indexOf(':'); + var key = colon < 0 ? content : content.substring(0, colon); + var defaultValue = colon < 0 ? null : content.substring(colon + 1); + addEntry(entries, key, defaultValue, source, note); + + if (defaultValue != null) { + var nested = PLACEHOLDER.matcher(defaultValue); + while (nested.find()) { + addPlaceholder(entries, nested.group(1), source, "fallback for " + key); + } } } @@ -177,6 +199,9 @@ void scanValueAnnotations( "String", "Boolean", "Integer", "Long", "Double", "Duration", "HttpStatus", "List", "Set", "Map", "boolean", "int", "long", "double", "float", "short", "byte", "char"); Pattern MODIFIERS = Pattern.compile("^(?:(?:private|protected|public|final)\\s+)+"); +Pattern STATIC_MODIFIER = Pattern.compile("\\bstatic\\b"); +// Booleans, numbers (with an optional type suffix or underscores) and null. +Pattern SIMPLE_LITERAL = Pattern.compile("true|false|null|-?[0-9][0-9_]*[LlFfDd]?|-?[0-9.]+[FfDd]?"); Pattern TRAILING_NAME = Pattern.compile("(\\w+)$"); void scanConfigurationProperties( @@ -209,7 +234,7 @@ void scanConfigurationProperties( : bareConstant.find() ? bareConstant.group(1) : arg.strip(); var name = expr.contains(".") ? expr.substring(expr.lastIndexOf('.') + 1) : expr; var constantValueMatcher = - Pattern.compile(Pattern.quote(name) + "\\s*=\\s*\"([^\"]*)\"").matcher(raw); + Pattern.compile("\\b" + Pattern.quote(name) + "\\b\\s*=\\s*\"([^\"]*)\"").matcher(raw); if (constantValueMatcher.find()) { prefix = constantValueMatcher.group(1); } else { @@ -241,7 +266,7 @@ void scanConfigurationProperties( var closeParen = findMatchingParen(blanked, openParen); if (closeParen > 0) { for (var field : splitTopLevel(raw.substring(openParen + 1, closeParen), ',')) { - addField(entries, prefix, field.strip(), loc); + addField(entries, prefix, field.strip(), field, loc); } } } else if (classAt < Integer.MAX_VALUE) { @@ -251,16 +276,24 @@ void scanConfigurationProperties( var closeBrace = findMatchingBrace(blanked, absoluteOpenBrace); if (closeBrace > 0) { var bodyBlanked = blanked.substring(absoluteOpenBrace + 1, closeBrace); + var bodyRaw = raw.substring(absoluteOpenBrace + 1, closeBrace); + // Split on the blanked text so a ';' inside a string or comment cannot end a + // field, but read each chunk's initializer from the raw text - every blanking pass + // preserves length, so the two are index-aligned, and the blanked one has had its + // string literals replaced by 'x' and would report those as the default. + var chunkStart = 0; for (var chunk : splitTopLevel(bodyBlanked, ';')) { + var chunkRaw = bodyRaw.substring(chunkStart, chunkStart + chunk.length()); + chunkStart += chunk.length() + 1; var trimmed = stripLeadingAnnotations(chunk.strip()).strip(); // A method's signature never has a top-level '=' before its '(' - a field's // does only inside its initializer, which addField also strips before // looking at the declaration, so checking pre-'=' text tells them apart. var beforeInit = trimmed.split("=", 2)[0]; - if (trimmed.isEmpty() || trimmed.contains("static") || beforeInit.contains("(")) { + if (trimmed.isEmpty() || STATIC_MODIFIER.matcher(trimmed).find() || beforeInit.contains("(")) { continue; } - addField(entries, prefix, trimmed, loc); + addField(entries, prefix, trimmed, chunkRaw, loc); } } } @@ -268,7 +301,13 @@ void scanConfigurationProperties( } } -void addField(TreeMap entries, String prefix, String declaration, String loc) { +void addField( + TreeMap entries, + String prefix, + String declaration, + String declarationRaw, + String loc +) { // Drop any initializer first ("= Duration.ofMinutes(5)", "= List.of(\"svg\")") - otherwise its // tokens (which may themselves look like a type-then-identifier pair, e.g. a qualified constant // reference) get mistaken for the field's own type/name. @@ -286,7 +325,26 @@ void addField(TreeMap entries, String prefix, String decl } var bareType = type.contains("<") ? type.substring(0, type.indexOf('<')) : type; var note = SIMPLE_TYPES.contains(bareType) ? null : "nested type, see " + bareType + " in this file"; - addEntry(entries, prefix + "." + kebabCase(name), null, loc, note); + // From the raw text, since `declaration` may have had its string literals blanked. + var rawParts = declarationRaw.split("=", 2); + var defaultValue = rawParts.length > 1 ? simpleLiteral(rawParts[1]) : null; + addEntry(entries, prefix + "." + kebabCase(name), defaultValue, loc, note); +} + +/** A field initializer's value where it is a plain literal, which is what can be reported as a + * default without paraphrasing. An expression (`List.of("svg")`, `Duration.ofMinutes(5)`, a constant + * from another class) is left out rather than printed as source the operator cannot use. */ +String simpleLiteral(String initializer) { + var value = initializer.strip(); + if (value.endsWith(";")) { + value = value.substring(0, value.length() - 1).strip(); + } + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + // The literal's value, not its source form, so an embedded quote reads as the operator would + // write it in a yaml file rather than as the escape Java needs. + return value.substring(1, value.length() - 1).replace("\\\"", "\"").replace("\\\\", "\\"); + } + return SIMPLE_LITERAL.matcher(value).matches() ? value : null; } String stripLeadingAnnotations(String declaration) { @@ -329,6 +387,79 @@ void addEntry( entries.put(key, new PropertyEntry(key, mergedDefault, existing.sources(), mergedNote)); } +// ---- @ConditionalOnProperty(...) ---- + +Pattern CONDITIONAL_ANNOTATION = Pattern.compile("@ConditionalOnProperty\\s*\\("); +Pattern STRING_LITERAL = Pattern.compile("\"([^\"]*)\""); + +/** Keys read by {@code @ConditionalOnProperty}, which gate whole beans and so are among the most + * consequential things an operator can set - and are read nowhere else, so without this they appear + * in no reference at all. Only the {@code prefix}/{@code name}/{@code value} attributes are keys; + * {@code havingValue} and {@code matchIfMissing} are not, which is why this reads the attributes by + * name instead of taking every string literal in the annotation. */ +void scanConditionalOnProperty(String raw, String blanked, String file, TreeMap entries) { + var matcher = CONDITIONAL_ANNOTATION.matcher(blanked); + while (matcher.find()) { + var close = findMatchingParen(blanked, matcher.end() - 1); + if (close < 0) { + continue; + } + var arg = raw.substring(matcher.end(), close); + + String prefix = ""; + var prefixExpr = attributeValue(arg, "prefix"); + if (prefixExpr != null) { + var resolved = resolveStringExpression(prefixExpr, raw); + if (resolved == null) { + // Skipped rather than reported under a made-up key. Visible on stderr, because a + // reference that quietly omits a property is the thing this whole script is for. + System.err.println( + "warning: " + file + " has @ConditionalOnProperty(prefix = " + prefixExpr.strip() + + "), which is not resolvable in this file; its keys are omitted"); + continue; + } + prefix = resolved.isEmpty() ? "" : resolved + "."; + } + + var names = attributeValue(arg, "name"); + if (names == null) { + names = attributeValue(arg, "value"); + } + // The single-attribute form, @ConditionalOnProperty("a.b"), names no attribute at all. + if (names == null && !arg.contains("=")) { + names = arg; + } + if (names == null) { + continue; + } + + var line = lineNumber(raw, matcher.start()); + var literal = STRING_LITERAL.matcher(names); + while (literal.find()) { + addEntry(entries, prefix + literal.group(1), null, file + ":" + line, "gates a bean"); + } + } +} + +/** One annotation attribute's value expression, or null when the attribute is absent. */ +String attributeValue(String arg, String attribute) { + var matcher = Pattern + .compile("\\b" + Pattern.quote(attribute) + "\\s*=\\s*(\\{[^}]*\\}|\"[^\"]*\"|[A-Za-z_][\\w.]*)") + .matcher(arg); + return matcher.find() ? matcher.group(1) : null; +} + +/** A string-valued expression: a literal as-is, a constant looked up in the same file. */ +String resolveStringExpression(String expression, String raw) { + var expr = expression.strip(); + if (expr.startsWith("\"")) { + return expr.substring(1, expr.length() - 1); + } + var name = expr.contains(".") ? expr.substring(expr.lastIndexOf('.') + 1) : expr; + var matcher = Pattern.compile("\\b" + Pattern.quote(name) + "\\b\\s*=\\s*\"([^\"]*)\"").matcher(raw); + return matcher.find() ? matcher.group(1) : null; +} + // ---- small structural helpers ---- /** Java string literals can't contain a raw newline or unescaped quote, so this is a safe, From 4cb7d02a12bd7dc43638ec23214bec5a2cf31cd3 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Tue, 8 Sep 2026 15:04:57 +0200 Subject: [PATCH 6/6] fix: declare main the way the other jbang scripts do This was the only script in scripts/src with a no-argument `void main()`; the other five take `String[] args`. jbang deduces the main class, and a version that does not recognise a no-argument instance main fails the script with "no main class deduced, specified nor found in a manifest" rather than anything that points at the cause. Not reproducible on jbang 0.141.0, where both forms run, so this is the portable form rather than a verified fix for one version. It costs nothing and removes the only structural difference from the scripts that do work. Co-Authored-By: Claude Opus 5 (1M context) --- server/scripts/src/ConfigPropertiesReport.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/scripts/src/ConfigPropertiesReport.java b/server/scripts/src/ConfigPropertiesReport.java index e0714da4b..07a57ad4c 100644 --- a/server/scripts/src/ConfigPropertiesReport.java +++ b/server/scripts/src/ConfigPropertiesReport.java @@ -36,7 +36,7 @@ // values, and recursing into @ConfigurationProperties field types beyond one level (e.g. // RemoteScannerProperties' Map keys are operator-chosen at runtime and // aren't enumerable from source). -void main() throws IOException { +void main(String[] args) throws IOException { var entries = new TreeMap(); var configPropsClasses = new ArrayList();