|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +import { execSync } from 'node:child_process'; |
| 4 | +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; |
| 5 | +import { tmpdir } from 'node:os'; |
| 6 | +import { dirname, join } from 'node:path'; |
| 7 | +import { fileURLToPath } from 'node:url'; |
| 8 | + |
| 9 | +const REPOS = [ |
| 10 | + { name: 'flow', url: 'https://github.com/vaadin/flow.git', artifact: 'com.vaadin:flow-server' }, |
| 11 | + { |
| 12 | + name: 'flow-components', |
| 13 | + url: 'https://github.com/vaadin/flow-components.git', |
| 14 | + artifact: 'com.vaadin:vaadin-flow-components-base', |
| 15 | + }, |
| 16 | +]; |
| 17 | + |
| 18 | +// Feature flags to exclude from both undocumented and stale checks |
| 19 | +const EXCLUDED_FLAGS = new Set(['copilotExperimentalFeatures']); |
| 20 | + |
| 21 | +const SPI_SERVICE = 'META-INF/services/com.vaadin.experimental.FeatureFlagProvider'; |
| 22 | + |
| 23 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 24 | +const PROJECT_DIR = join(__dirname, '..'); |
| 25 | +const ARTICLE_PATH = join( |
| 26 | + __dirname, |
| 27 | + '..', |
| 28 | + 'articles', |
| 29 | + 'flow', |
| 30 | + 'configuration', |
| 31 | + 'feature-flags.adoc' |
| 32 | +); |
| 33 | + |
| 34 | +function resolveVersions() { |
| 35 | + console.log('Resolving dependency versions...'); |
| 36 | + const tree = execSync('mvn dependency:tree -DoutputType=text', { |
| 37 | + encoding: 'utf-8', |
| 38 | + cwd: PROJECT_DIR, |
| 39 | + stdio: ['pipe', 'pipe', 'pipe'], |
| 40 | + }); |
| 41 | + const versions = {}; |
| 42 | + for (const repo of REPOS) { |
| 43 | + const match = new RegExp( |
| 44 | + `${repo.artifact.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:jar:([^:]+):` |
| 45 | + ).exec(tree); |
| 46 | + if (!match) { |
| 47 | + throw new Error(`Could not resolve version for ${repo.artifact} from Maven dependency tree`); |
| 48 | + } |
| 49 | + // eslint-disable-next-line @typescript-eslint/prefer-destructuring |
| 50 | + versions[repo.name] = match[1]; |
| 51 | + console.log(` ${repo.artifact} -> ${match[1]}`); |
| 52 | + } |
| 53 | + return versions; |
| 54 | +} |
| 55 | + |
| 56 | +function cloneRepo(url, tag, dest) { |
| 57 | + try { |
| 58 | + execSync(`git clone --bare --single-branch --branch ${tag} --depth 1 ${url} ${dest}`, { |
| 59 | + stdio: 'pipe', |
| 60 | + }); |
| 61 | + } catch (e) { |
| 62 | + throw new Error(`Failed to clone ${url} (tag: ${tag}): ${e.stderr?.toString().trim()}`); |
| 63 | + } |
| 64 | +} |
| 65 | + |
| 66 | +function gitShow(repoPath, path) { |
| 67 | + try { |
| 68 | + return execSync(`git --git-dir=${repoPath} show HEAD:${path}`, { |
| 69 | + encoding: 'utf-8', |
| 70 | + stdio: ['pipe', 'pipe', 'pipe'], |
| 71 | + }); |
| 72 | + } catch (_e) { |
| 73 | + return null; |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +const treeCache = new Map(); |
| 78 | + |
| 79 | +function gitFindFiles(repoPath, pathSuffix) { |
| 80 | + if (!treeCache.has(repoPath)) { |
| 81 | + try { |
| 82 | + const tree = execSync(`git --git-dir=${repoPath} ls-tree -r --name-only HEAD`, { |
| 83 | + encoding: 'utf-8', |
| 84 | + stdio: ['pipe', 'pipe', 'pipe'], |
| 85 | + }) |
| 86 | + .split('\n') |
| 87 | + .filter(Boolean); |
| 88 | + treeCache.set(repoPath, tree); |
| 89 | + } catch { |
| 90 | + treeCache.set(repoPath, []); |
| 91 | + } |
| 92 | + } |
| 93 | + return treeCache.get(repoPath).filter((l) => l.endsWith(pathSuffix)); |
| 94 | +} |
| 95 | + |
| 96 | +// Find all SPI service files and return the provider class names listed in them |
| 97 | +function findProviderClasses(repoPath) { |
| 98 | + const spiFiles = gitFindFiles(repoPath, SPI_SERVICE); |
| 99 | + const classes = []; |
| 100 | + for (const spiFile of spiFiles) { |
| 101 | + // Skip test resources |
| 102 | + if (spiFile.includes('src/test/')) continue; |
| 103 | + const content = gitShow(repoPath, spiFile); |
| 104 | + if (!content) continue; |
| 105 | + for (const line of content.split('\n')) { |
| 106 | + const trimmed = line.trim(); |
| 107 | + if (trimmed && !trimmed.startsWith('#')) { |
| 108 | + classes.push(trimmed); |
| 109 | + } |
| 110 | + } |
| 111 | + } |
| 112 | + return classes; |
| 113 | +} |
| 114 | + |
| 115 | +// Convert a FQCN to a source file path suffix, e.g. |
| 116 | +// com.vaadin.experimental.CoreFeatureFlagProvider -> com/vaadin/experimental/CoreFeatureFlagProvider.java |
| 117 | +function fqcnToPathSuffix(fqcn) { |
| 118 | + return `${fqcn.replace(/\./g, '/')}.java`; |
| 119 | +} |
| 120 | + |
| 121 | +// Find the full path for a Java class in the repo tree |
| 122 | +function resolveClassPath(repoPath, fqcn) { |
| 123 | + const suffix = fqcnToPathSuffix(fqcn); |
| 124 | + const matches = gitFindFiles(repoPath, suffix); |
| 125 | + // Prefer src/main/java over test sources |
| 126 | + return matches.find((m) => m.includes('src/main/java')) ?? matches[0]; |
| 127 | +} |
| 128 | + |
| 129 | +// Extract feature flag IDs from a provider's Java source |
| 130 | +function extractFeatureIds(source) { |
| 131 | + // Strip inline comments to avoid interference with parsing |
| 132 | + const cleaned = source.replace(/\/\/.*$/gm, ''); |
| 133 | + // Build a map of string constants (e.g. FEATURE_FLAG_ID = "aiComponents") |
| 134 | + const constantsMap = {}; |
| 135 | + const constRegex = /static\s+final\s+String\s+(\w+)\s*=\s*"([^"]+)"/g; |
| 136 | + let m; |
| 137 | + while ((m = constRegex.exec(cleaned)) !== null) { |
| 138 | + // eslint-disable-next-line @typescript-eslint/prefer-destructuring |
| 139 | + constantsMap[m[1]] = m[2]; |
| 140 | + } |
| 141 | + |
| 142 | + // Find all new Feature(...) calls and extract the second argument (the ID) |
| 143 | + // Collapse whitespace so multi-line constructors become single-line |
| 144 | + const collapsed = cleaned.replace(/\s+/g, ' '); |
| 145 | + const featureRegex = /new\s+Feature\(\s*"[^"]*"\s*,\s*(?:"([^"]+)"|(\w+))\s*,/g; |
| 146 | + const ids = []; |
| 147 | + while ((m = featureRegex.exec(collapsed)) !== null) { |
| 148 | + if (m[1]) { |
| 149 | + ids.push(m[1]); |
| 150 | + } else if (m[2] && constantsMap[m[2]]) { |
| 151 | + ids.push(constantsMap[m[2]]); |
| 152 | + } else if (m[2]) { |
| 153 | + console.warn(` Warning: unresolved constant "${m[2]}"`); |
| 154 | + } |
| 155 | + } |
| 156 | + return ids; |
| 157 | +} |
| 158 | + |
| 159 | +function extractRepoFeatureFlags(repoPath, repoName) { |
| 160 | + const providerClasses = findProviderClasses(repoPath); |
| 161 | + if (providerClasses.length === 0) { |
| 162 | + console.warn(` Warning: no SPI service files found in ${repoName}`); |
| 163 | + return []; |
| 164 | + } |
| 165 | + |
| 166 | + const allIds = []; |
| 167 | + for (const fqcn of providerClasses) { |
| 168 | + const filePath = resolveClassPath(repoPath, fqcn); |
| 169 | + if (!filePath) { |
| 170 | + console.warn(` Warning: could not find source for ${fqcn}`); |
| 171 | + continue; |
| 172 | + } |
| 173 | + const source = gitShow(repoPath, filePath); |
| 174 | + if (!source) { |
| 175 | + console.warn(` Warning: could not read ${filePath}`); |
| 176 | + continue; |
| 177 | + } |
| 178 | + const ids = extractFeatureIds(source); |
| 179 | + for (const id of ids) { |
| 180 | + console.log(` ${fqcn} -> ${id}`); |
| 181 | + } |
| 182 | + allIds.push(...ids); |
| 183 | + } |
| 184 | + return allIds; |
| 185 | +} |
| 186 | + |
| 187 | +function parseArticleFlags(adoc) { |
| 188 | + const ids = new Set(); |
| 189 | + const regex = /^`([^`]+)`::$/gm; |
| 190 | + let match; |
| 191 | + while ((match = regex.exec(adoc)) !== null) { |
| 192 | + ids.add(match[1]); |
| 193 | + } |
| 194 | + return ids; |
| 195 | +} |
| 196 | + |
| 197 | +function readArticle() { |
| 198 | + return readFileSync(ARTICLE_PATH, 'utf-8'); |
| 199 | +} |
| 200 | + |
| 201 | +function main() { |
| 202 | + const tmpDir = mkdtempSync(join(tmpdir(), 'vaadin-feature-flags-')); |
| 203 | + const cleanup = () => { |
| 204 | + try { |
| 205 | + rmSync(tmpDir, { recursive: true, force: true }); |
| 206 | + } catch {} |
| 207 | + }; |
| 208 | + process.on('exit', cleanup); |
| 209 | + process.on('SIGINT', () => { |
| 210 | + cleanup(); |
| 211 | + process.exit(2); |
| 212 | + }); |
| 213 | + |
| 214 | + // Resolve versions from Maven dependency tree |
| 215 | + const versions = resolveVersions(); |
| 216 | + |
| 217 | + // Clone repos and extract feature flags |
| 218 | + console.log('Cloning repos...'); |
| 219 | + const allRepoFlags = []; |
| 220 | + for (const repo of REPOS) { |
| 221 | + const tag = versions[repo.name]; |
| 222 | + const dest = join(tmpDir, `${repo.name}.git`); |
| 223 | + cloneRepo(repo.url, tag, dest); |
| 224 | + console.log(` ${repo.name} (${tag}):`); |
| 225 | + const flags = extractRepoFeatureFlags(dest, repo.name); |
| 226 | + console.log(` ${repo.name}: found ${flags.length} feature flag(s)`); |
| 227 | + allRepoFlags.push(...flags); |
| 228 | + } |
| 229 | + |
| 230 | + if (allRepoFlags.length === 0) { |
| 231 | + console.warn('Warning: No feature flags found in repos. This may indicate a parsing issue.'); |
| 232 | + } |
| 233 | + |
| 234 | + // Read and parse article |
| 235 | + console.log(`Reading article from ${ARTICLE_PATH}...`); |
| 236 | + const adoc = readArticle(); |
| 237 | + const articleFlags = parseArticleFlags(adoc); |
| 238 | + for (const id of EXCLUDED_FLAGS) { |
| 239 | + articleFlags.delete(id); |
| 240 | + } |
| 241 | + console.log(` article: found ${articleFlags.size} feature flag(s)`); |
| 242 | + |
| 243 | + // Compare |
| 244 | + const repoFlagSet = new Set(allRepoFlags); |
| 245 | + const undocumented = allRepoFlags.filter((id) => !articleFlags.has(id)); |
| 246 | + const stale = [...articleFlags].filter((id) => !repoFlagSet.has(id)); |
| 247 | + let failed = false; |
| 248 | + |
| 249 | + if (undocumented.length > 0) { |
| 250 | + console.error(`\nUndocumented feature flags (${undocumented.length}):`); |
| 251 | + for (const id of undocumented) { |
| 252 | + console.error(` - ${id}`); |
| 253 | + } |
| 254 | + failed = true; |
| 255 | + } |
| 256 | + |
| 257 | + if (stale.length > 0) { |
| 258 | + console.error(`\nStale feature flags in article (${stale.length}):`); |
| 259 | + for (const id of stale) { |
| 260 | + console.error(` - ${id}`); |
| 261 | + } |
| 262 | + failed = true; |
| 263 | + } |
| 264 | + |
| 265 | + if (failed) { |
| 266 | + process.exit(1); |
| 267 | + } |
| 268 | + |
| 269 | + console.log('\nAll feature flags are in sync.'); |
| 270 | +} |
| 271 | + |
| 272 | +main(); |
0 commit comments