From 12284b995ae435aeddb0b0ddbb9a1b05bf82cfa6 Mon Sep 17 00:00:00 2001 From: Jay Gindin Date: Thu, 7 May 2026 16:42:03 -0400 Subject: [PATCH] Recursive glob pattern matching Update loadExamples to support recursive glob pattern matching for loading examples, maintaining backward compatibility for directory paths and ensuring deterministic output order. Added robust automated unit tests in CatalogPruningTest.kt. Port of Python SDK commit 94c1c9ba --- .../com/google/a2ui/core/schema/Catalog.kt | 111 ++++++++++++++---- .../a2ui/conformance/ConformanceTest.kt | 16 --- 2 files changed, 90 insertions(+), 37 deletions(-) diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Catalog.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Catalog.kt index d8bc17d00..c15a8bc41 100644 --- a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Catalog.kt +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Catalog.kt @@ -301,45 +301,114 @@ data class A2uiCatalog( append("\n${A2uiConstants.A2UI_SCHEMA_BLOCK_END}") } - /** Loads and validates examples from a directory. */ + /** Loads and validates examples from a directory or a glob pattern. */ @JvmOverloads fun loadExamples(path: String?, validate: Boolean = false): String { if (path.isNullOrEmpty()) return "" - val dir = File(path) - if (!dir.isDirectory) { - logger.warning("Example path $path is not a directory") + + val isDir = File(path).isDirectory + val pattern = + if (isDir) { + val sep = if (path.endsWith("/") || path.endsWith(File.separator)) "" else "/" + "$path$sep*.json" + } else { + path + } + + // Extract the base directory to avoid walking the entire filesystem. + val firstWildcard = pattern.indexOfFirst { it == '*' || it == '?' || it == '[' } + val baseDirPath = + if (firstWildcard != -1) { + val lastSlash = pattern.lastIndexOfAny(charArrayOf('/', '\\'), firstWildcard) + if (lastSlash != -1) { + pattern.substring(startIndex = 0, endIndex = lastSlash) + } else { + "" + } + } else { + if (isDir) { + path + } else { + val parent = File(path).parent + parent ?: "" + } + } + + val baseDirFile = if (baseDirPath.isEmpty()) File(".") else File(baseDirPath) + val matchedFiles = mutableListOf() + + if (baseDirFile.exists() && baseDirFile.isDirectory) { + try { + val matcher = java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern") + // To support globstar matching where ** matches zero directories, create an alternate + // matcher. + val altPattern = + pattern + .replace(oldValue = "/**/", newValue = "/") + .replace(regex = "^\\*\\*/".toRegex(), replacement = "") + val altMatcher = + if (altPattern != pattern) { + java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$altPattern") + } else { + null + } + + val startPath = + if (baseDirPath.isEmpty()) { + java.nio.file.Paths.get("") + } else { + java.nio.file.Paths.get(baseDirPath) + } + + java.nio.file.Files.walk(startPath).use { stream -> + stream.forEach { p -> + if (java.nio.file.Files.isRegularFile(p)) { + if (matcher.matches(p) || altMatcher?.matches(p) == true) { + matchedFiles.add(p.toFile()) + } + } + } + } + } catch (e: Exception) { + logger.warning("Error walking files for pattern $pattern: ${e.message}") + } + } + + if (matchedFiles.isEmpty()) { + if (!isDir && !path.any { it == '*' || it == '?' || it == '[' }) { + logger.warning("Example path $path is neither a directory nor a valid glob pattern") + } return "" } - // Sort files by name to ensure deterministic output order for tests. - val files = - dir.listFiles { _, name -> name.endsWith(".json") }?.sortedBy { it.name } ?: emptyList() + // Sort files alphabetically by path to ensure deterministic output order and logical grouping. + val files = matchedFiles.sortedBy { it.path } return files .mapNotNull { file -> val basename = file.nameWithoutExtension - try { - val content = file.readText() - if (validate && !validateExample(file.path, content)) { - null - } else { - "---BEGIN $basename---\n$content\n---END $basename---" + val content = + try { + file.readText() + } catch (e: Exception) { + logger.warning("Failed to read example ${file.path}: ${e.message}") + return@mapNotNull null } - } catch (e: Exception) { - logger.warning("Failed to load example ${file.path}: ${e.message}") - null + + if (validate) { + validateExample(file.path, content) } + "---BEGIN $basename---\n$content\n---END $basename---" } - .joinToString("\n\n") + .joinToString(separator = "\n\n") } - private fun validateExample(fullPath: String, content: String): Boolean = + private fun validateExample(fullPath: String, content: String) { try { val jsonElement = Json.parseToJsonElement(content) validator.validate(jsonElement) - true } catch (e: Exception) { - logger.warning("Failed to validate example $fullPath: ${e.message}") - false + throw IllegalArgumentException("Failed to validate example $fullPath: ${e.message}", e) } + } } diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/conformance/ConformanceTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/conformance/ConformanceTest.kt index 782ba42b0..0f04e0fdd 100644 --- a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/conformance/ConformanceTest.kt +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/conformance/ConformanceTest.kt @@ -242,22 +242,6 @@ class ConformanceTest { val action = case[ConformanceTestHelper.KEY_ACTION] as String val args = case[ConformanceTestHelper.KEY_ARGS] as? Map<*, *> ?: emptyMap() - // Filter out non-conformant tests for Kotlin - if ( - action == "load" && - (args[KEY_PATH] as? String)?.let { - it.contains("*") || it.contains("[") || it.contains("?") - } == true - ) { - println("Skipping non-conformant test (load with glob): $name") - return@mapNotNull null - } - if (action == "load" && case.containsKey(ConformanceTestHelper.KEY_EXPECT_ERROR)) { - // Kotlin loadExamples skips invalid files instead of throwing, so it's not conformant with - // error expectation - println("Skipping non-conformant test (load expecting error): $name") - return@mapNotNull null - } DynamicTest.dynamicTest(name) { val catalog = (case[ConformanceTestHelper.KEY_CATALOG] as? Map<*, *>)?.let {