Skip to content

Commit 21f3384

Browse files
authored
chore: add feature flag check (#5382)
* add feature flag check script * add GitHub workflow * avoid duplicate file tree scanning * apply exclusion to docs flags only * change script to detect feature flags from specific artifact version instead of main branch * remove slack integration * run script for pull requests against main * remove branch filter
1 parent 1d3314b commit 21f3384

File tree

3 files changed

+291
-1
lines changed

3 files changed

+291
-1
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Check Feature Flags
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
7+
jobs:
8+
check:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v6
12+
- name: Set up JDK 21
13+
uses: actions/setup-java@v5
14+
with:
15+
java-version: '21'
16+
distribution: 'temurin'
17+
cache: 'maven'
18+
- name: Check feature flags
19+
run: node scripts/check-feature-flags.js

.github/workflows/format-check.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ name: Format Check
22

33
on:
44
pull_request:
5-
branches: [main]
65

76
concurrency:
87
group: '${{ github.workflow }}-${{ github.ref }}'

scripts/check-feature-flags.js

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
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

Comments
 (0)