Repository: facebook/react
Pull request: Add --cleanup option to flags script to show groups of flags by status

`yarn flags --cleanup` will categorize flags to make it more clear which ones may need to be cleaned up, experiments checked on, or are blocked by internal rollouts.

Alternative to #31760

<img width="787" alt="Screenshot 2024-12-13 at 2 31 30 PM" src="https://github.com/user-attachments/assets/452aee7e-9caf-4210-a621-53941d59cb2b" />


Changed files:
filename: scripts/flags/flags.js
patch: @@ -62,6 +62,12 @@ const argv = yargs
         'experimental',
       ],
     },
+    cleanup: {
+      describe: 'output flags by cleanup category.',
+      requiresArg: false,
+      type: 'boolean',
+      default: false,
+    },
   }).argv;
 
 // Load ReactFeatureFlags with __NEXT_MAJOR__ replaced with 'next'.
@@ -375,53 +381,86 @@ const FLAG_CONFIG = {
 
 const FLAG_COLUMNS = Object.keys(FLAG_CONFIG);
 
+const INTERNAL_VARIANTS = ['WWW Classic', 'WWW Modern', 'RN FB'];
+const OSS_VARIANTS = [
+  'OSS Next Major',
+  'OSS Canary',
+  'OSS Experimental',
+  'RN OSS',
+  'RN Next Major',
+];
+
 // Build the table with the value for each flag.
-const isDiff = argv.diff != null && argv.diff.length > 1;
-const table = {};
-// eslint-disable-next-line no-for-of-loops/no-for-of-loops
-for (const flag of allFlagsUniqueFlags) {
-  const values = FLAG_COLUMNS.reduce((acc, key) => {
-    acc[key] = FLAG_CONFIG[key](flag);
-    return acc;
-  }, {});
-
-  if (!isDiff) {
-    table[flag] = values;
-    continue;
-  }
+function buildTable(filterFn) {
+  const isDiff = argv.diff != null && argv.diff.length > 1;
+  const table = {};
+  const filteredFlags = filterFn
+    ? allFlagsUniqueFlags.filter(filterFn)
+    : allFlagsUniqueFlags;
+  // eslint-disable-next-line no-for-of-loops/no-for-of-loops
+  for (const flag of filteredFlags) {
+    const values = FLAG_COLUMNS.reduce((acc, key) => {
+      acc[key] = FLAG_CONFIG[key](flag);
+      return acc;
+    }, {});
+
+    if (!isDiff) {
+      table[flag] = values;
+      continue;
+    }
+
+    const subset = argv.diff.map(argToHeader).reduce((acc, key) => {
+      if (key in values) {
+        acc[key] = values[key];
+      }
+      return acc;
+    }, {});
 
-  const subset = argv.diff.map(argToHeader).reduce((acc, key) => {
-    if (key in values) {
-      acc[key] = values[key];
+    if (new Set(Object.values(subset)).size !== 1) {
+      table[flag] = subset;
     }
-    return acc;
-  }, {});
+  }
 
-  if (new Set(Object.values(subset)).size !== 1) {
-    table[flag] = subset;
+  // Sort the table
+  let sorted = table;
+  if (isDiff || argv.sort) {
+    const sortChannel = argToHeader(isDiff ? argv.diff[0] : argv.sort);
+    const sortBy =
+      sortChannel === 'flag'
+        ? ([flagA], [flagB]) => {
+            return flagA.localeCompare(flagB);
+          }
+        : ([, rowA], [, rowB]) => {
+            return rowB[sortChannel]
+              .toString()
+              .localeCompare(rowA[sortChannel]);
+          };
+    sorted = Object.fromEntries(Object.entries(table).sort(sortBy));
   }
+
+  return sorted;
 }
 
-// Sort the table
-let sorted = table;
-if (isDiff || argv.sort) {
-  const sortChannel = argToHeader(isDiff ? argv.diff[0] : argv.sort);
-  const sortBy =
-    sortChannel === 'flag'
-      ? ([flagA], [flagB]) => {
-          return flagA.localeCompare(flagB);
-        }
-      : ([, rowA], [, rowB]) => {
-          return rowB[sortChannel].toString().localeCompare(rowA[sortChannel]);
-        };
-  sorted = Object.fromEntries(Object.entries(table).sort(sortBy));
+function formatTable(tableData) {
+  // left align the flag names.
+  const maxLength = Math.max(
+    ...Object.keys(tableData).map(item => item.length)
+  );
+  const padded = {};
+  Object.keys(tableData).forEach(key => {
+    const newKey = key.padEnd(maxLength, ' ');
+    padded[newKey] = tableData[key];
+  });
+
+  return padded;
 }
 
 if (argv.csv) {
+  const table = buildTable();
   const csvRows = [
     `Flag name, ${FLAG_COLUMNS.join(', ')}`,
     ...Object.keys(table).map(flag => {
-      const row = sorted[flag];
+      const row = table[flag];
       return `${flag}, ${FLAG_COLUMNS.map(col => row[col]).join(', ')}`;
     }),
   ];
@@ -433,16 +472,108 @@ if (argv.csv) {
   });
 }
 
-// left align the flag names.
-const maxLength = Math.max(...Object.keys(sorted).map(item => item.length));
-const padded = {};
-Object.keys(sorted).forEach(key => {
-  const newKey = key.padEnd(maxLength, ' ');
-  padded[newKey] = sorted[key];
-});
+if (argv.cleanup) {
+  const allPassingFlags = [];
+  const allFailingFlags = [];
+  const needsShippedExperimentFlags = [];
+  const earlyExperimentationFlags = [];
+  const internalOnlyFlags = [];
+
+  const diffedFlagColumns =
+    argv.diff[0] != null ? argv.diff.map(argToHeader) : FLAG_COLUMNS;
+
+  // eslint-disable-next-line no-for-of-loops/no-for-of-loops
+  for (const flag of allFlagsUniqueFlags) {
+    const values = diffedFlagColumns.reduce((acc, key) => {
+      acc[key] = FLAG_CONFIG[key](flag);
+      return acc;
+    }, {});
+
+    const uniqueValues = new Set(Object.values(values));
+
+    if (
+      uniqueValues.size === 1 &&
+      (uniqueValues.has('✅') ||
+        typeof uniqueValues.values().next().value === 'number')
+    ) {
+      allPassingFlags.push(flag);
+    }
+
+    if (uniqueValues.size === 1 && uniqueValues.has('❌')) {
+      allFailingFlags.push(flag);
+    }
+
+    const internalVariantValues = INTERNAL_VARIANTS.filter(value =>
+      diffedFlagColumns.includes(value)
+    ).map(v => values[v]);
+    const ossVariantValues = OSS_VARIANTS.filter(value =>
+      diffedFlagColumns.includes(value)
+    ).map(v => values[v]);
+
+    if (
+      internalVariantValues.some(v => v === '✅') &&
+      ossVariantValues.every(v => v === '❌')
+    ) {
+      internalOnlyFlags.push(flag);
+    }
+
+    if (
+      internalVariantValues.some(v => v === '🧪') &&
+      (ossVariantValues.every(v => v === '❌') ||
+        (ossVariantValues.some(v => v === '❌') &&
+          values['OSS Experimental'] === '✅'))
+    ) {
+      earlyExperimentationFlags.push(flag);
+    }
+
+    if (
+      internalVariantValues.some(v => v === '🧪' || v === '❌') &&
+      ossVariantValues.every(v => v === '✅')
+    ) {
+      needsShippedExperimentFlags.push(flag);
+    }
+  }
+
+  if (allPassingFlags.length > 0) {
+    console.log('ALL VARIANTS PASS (✅)');
+    console.table(
+      formatTable(buildTable(flag => allPassingFlags.includes(flag)))
+    );
+  }
+
+  if (allFailingFlags.length > 0) {
+    console.log('ALL VARIANTS FAIL (❌)');
+    console.table(
+      formatTable(buildTable(flag => allFailingFlags.includes(flag)))
+    );
+  }
+
+  if (internalOnlyFlags.length > 0) {
+    console.log('INTERNAL ONLY (✅)');
+    console.table(
+      formatTable(buildTable(flag => internalOnlyFlags.includes(flag)))
+    );
+  }
+
+  if (earlyExperimentationFlags.length > 0) {
+    console.log('WAITING ON RESULTS (🧪)');
+    console.table(
+      formatTable(buildTable(flag => earlyExperimentationFlags.includes(flag)))
+    );
+  }
+
+  if (needsShippedExperimentFlags.length > 0) {
+    console.log('WAITING ON ROLLOUT (🧪)');
+    console.table(
+      formatTable(
+        buildTable(flag => needsShippedExperimentFlags.includes(flag))
+      )
+    );
+  }
+} else {
+  console.table(formatTable(buildTable()));
+}
 
-// print table with formatting
-console.table(padded);
 console.log(`
 Legend:
   ✅ On