Files
superset2/superset-frontend/scripts/check-custom-rules.js
2026-02-22 21:27:37 -08:00

685 lines
18 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Custom rule checker for Superset-specific linting patterns
* Runs as a separate check without needing custom binaries
*/
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
// ANSI color codes
const RED = '\x1B[31m';
const YELLOW = '\x1B[33m';
const RESET = '\x1B[0m';
let errorCount = 0;
let warningCount = 0;
/**
* Check if a node has an eslint-disable comment
*/
function hasEslintDisable(path, ruleName = 'theme-colors/no-literal-colors') {
const { node, parent } = path;
// Check leadingComments on the node itself
if (node.leadingComments) {
const hasDisable = node.leadingComments.some(
comment =>
(comment.value.includes('eslint-disable-next-line') ||
comment.value.includes('eslint-disable')) &&
comment.value.includes(ruleName),
);
if (hasDisable) return true;
}
// Check leadingComments on parent nodes (for expressions in assignments, etc.)
if (parent && parent.leadingComments) {
const hasDisable = parent.leadingComments.some(
comment =>
(comment.value.includes('eslint-disable-next-line') ||
comment.value.includes('eslint-disable')) &&
comment.value.includes(ruleName),
);
if (hasDisable) return true;
}
// Check if parent is a statement with leading comments
let current = path;
while (current.parent) {
current = current.parent;
if (current.node && current.node.leadingComments) {
const hasDisable = current.node.leadingComments.some(
comment =>
(comment.value.includes('eslint-disable-next-line') ||
comment.value.includes('eslint-disable')) &&
comment.value.includes(ruleName),
);
if (hasDisable) return true;
}
}
return false;
}
/**
* Check for literal color values (hex, rgb, rgba)
*/
function checkNoLiteralColors(ast, filepath) {
const colorPatterns = [
/^#[0-9A-Fa-f]{3,6}$/, // Hex colors
/^rgb\(/, // RGB colors
/^rgba\(/, // RGBA colors
];
traverse(ast, {
StringLiteral(path) {
const { value } = path.node;
if (colorPatterns.some(pattern => pattern.test(value))) {
// Check if this line has an eslint-disable comment
if (hasEslintDisable(path)) {
return; // Skip this violation
}
// eslint-disable-next-line no-console
console.error(
`${RED}${RESET} ${filepath}: Literal color "${value}" found. Use theme colors instead.`,
);
errorCount += 1;
}
},
// Check styled-components template literals
TemplateLiteral(path) {
path.node.quasis.forEach(quasi => {
const value = quasi.value.raw;
// Look for CSS color properties
if (
value.match(
/(?:color|background|border-color|outline-color):\s*(#[0-9A-Fa-f]{3,6}|rgb|rgba)/,
)
) {
// Check if this line has an eslint-disable comment
if (hasEslintDisable(path)) {
return; // Skip this violation
}
// eslint-disable-next-line no-console
console.error(
`${RED}${RESET} ${filepath}: Literal color in styled component. Use theme colors instead.`,
);
errorCount += 1;
}
});
},
});
}
/**
* Check for FontAwesome icon usage
*/
function checkNoFaIcons(ast, filepath) {
traverse(ast, {
ImportDeclaration(path) {
const source = path.node.source.value;
if (source.includes('@fortawesome') || source.includes('font-awesome')) {
// eslint-disable-next-line no-console
console.error(
`${RED}${RESET} ${filepath}: FontAwesome import detected. Use @superset-ui/core/components/Icons instead.`,
);
errorCount += 1;
}
},
JSXAttribute(path) {
if (path.node.name.name === 'className') {
const { value } = path.node;
if (
value &&
value.type === 'StringLiteral' &&
value.value.includes('fa-')
) {
// eslint-disable-next-line no-console
console.error(
`${RED}${RESET} ${filepath}: FontAwesome class detected. Use Icons component instead.`,
);
errorCount += 1;
}
}
},
});
}
/**
* Check for improper i18n template usage
*/
function checkI18nTemplates(ast, filepath) {
traverse(ast, {
CallExpression(path) {
const { callee } = path.node;
// Check for t() or tn() functions
if (
callee.type === 'Identifier' &&
(callee.name === 't' || callee.name === 'tn')
) {
const args = path.node.arguments;
if (args.length > 0 && args[0].type === 'TemplateLiteral') {
const templateLiteral = args[0];
if (templateLiteral.expressions.length > 0) {
// eslint-disable-next-line no-console
console.error(
`${RED}${RESET} ${filepath}: Template variables in t() function. Use parameterized messages instead.`,
);
errorCount += 1;
}
}
}
},
});
}
/**
* Props that should contain translated strings
*/
const TRANSLATABLE_PROPS = new Set([
'title',
'placeholder',
'label',
'alt',
'aria-label',
'aria-placeholder',
'aria-roledescription',
'aria-valuetext',
]);
/**
* Props that should NOT be checked for translation
*/
const IGNORED_PROPS = new Set([
'className',
'id',
'name',
'type',
'role',
'href',
'src',
'key',
'data-test',
'data-testid',
'htmlFor',
'target',
'rel',
'method',
'action',
'pattern',
'accept',
'autoComplete',
'inputMode',
'lang',
'dir',
'xmlns',
'viewBox',
'd',
'fill',
'stroke',
'transform',
'style',
'dangerouslySetInnerHTML',
]);
/**
* SQL keywords and technical terms that should not be translated
*/
const TECHNICAL_TERMS = new Set([
// SQL keywords
'SELECT',
'FROM',
'WHERE',
'HAVING',
'GROUP',
'ORDER',
'BY',
'JOIN',
'LEFT',
'RIGHT',
'INNER',
'OUTER',
'FULL',
'CROSS',
'ON',
'AND',
'OR',
'NOT',
'IN',
'EXISTS',
'BETWEEN',
'LIKE',
'IS',
'NULL',
'TRUE',
'FALSE',
'ASC',
'DESC',
'LIMIT',
'OFFSET',
'UNION',
'ALL',
'DISTINCT',
'AS',
'CASE',
'WHEN',
'THEN',
'ELSE',
'END',
'CAST',
'CONVERT',
// SQL date functions (common in Superset)
'DATETIME',
'DATEADD',
'DATETRUNC',
'LASTDAY',
'HOLIDAY',
'DATE',
'TIME',
'TIMESTAMP',
'YEAR',
'MONTH',
'DAY',
'HOUR',
'MINUTE',
'SECOND',
'WEEK',
// Data types
'JSON',
'XML',
'CSV',
'INT',
'INTEGER',
'FLOAT',
'DOUBLE',
'VARCHAR',
'CHAR',
'TEXT',
'BOOLEAN',
'BOOL',
'BIGINT',
'SMALLINT',
'DECIMAL',
// Technical abbreviations
'SQL',
'API',
'URL',
'URI',
'HTML',
'CSS',
'JS',
'TS',
'ID',
'UUID',
'HTTP',
'HTTPS',
'GET',
'POST',
'PUT',
'DELETE',
'PATCH',
// Error/status indicators that are typically not translated
'OK',
'ERROR',
'WARNING',
'INFO',
'DEBUG',
'N/A',
'TBD',
]);
/**
* Check if a string looks like it needs translation
* Returns false for technical strings, identifiers, etc.
*/
function needsTranslation(value) {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
// Empty or whitespace-only strings don't need translation
if (!trimmed) return false;
// Single characters don't need translation
if (trimmed.length === 1) return false;
// Pure numbers don't need translation
if (/^-?\d+\.?\d*$/.test(trimmed)) return false;
// Punctuation-only strings don't need translation
if (/^[^\w\s]+$/.test(trimmed)) return false;
// URLs and paths don't need translation
if (/^(https?:\/\/|\/|\.\/|\.\.\/)/.test(trimmed)) return false;
// File extensions don't need translation
if (/^\.\w+$/.test(trimmed)) return false;
// CSS-like values (colors, sizes, etc.)
if (
/^(#[0-9a-f]+|\d+(%|px|em|rem|vh|vw|pt|cm|mm|in)|none|auto|inherit|initial|unset)$/i.test(
trimmed,
)
)
return false;
// camelCase or PascalCase identifiers (likely code, not user text)
if (/^[a-z][a-zA-Z0-9]*$/.test(trimmed) && /[A-Z]/.test(trimmed))
return false;
// snake_case or SCREAMING_SNAKE_CASE identifiers
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed) && trimmed.includes('_'))
return false;
// kebab-case identifiers (CSS classes, data attributes)
if (/^[a-z][a-z0-9-]*$/.test(trimmed) && trimmed.includes('-')) return false;
// Looks like a variable or placeholder pattern
if (/^[%$]\w+$/.test(trimmed) || /^\{\{?\w+\}?\}$/.test(trimmed))
return false;
// Very short strings (2-3 chars) that are all lowercase are likely codes
if (trimmed.length <= 3 && /^[a-z]+$/.test(trimmed)) return false;
// Single lowercase words up to 10 chars are often icon names or technical terms
// (e.g., "check", "stop", "down", "empty", "starred")
if (/^[a-z]+$/.test(trimmed) && trimmed.length <= 10) return false;
// Code fragments (contains = or other code syntax)
if (/[=<>{}[\]]/.test(trimmed)) return false;
// ALL_CAPS words are usually technical terms (SQL keywords, constants)
if (/^[A-Z][A-Z0-9]*$/.test(trimmed)) return false;
// Known technical terms
if (TECHNICAL_TERMS.has(trimmed.toUpperCase())) return false;
// Code-like patterns: function calls, SQL syntax examples
if (/^[a-zA-Z]+\s*\([^)]*\)/.test(trimmed)) return false;
// SQL-like syntax: "SELECT * FROM", "GROUP BY", etc.
if (/^(SELECT|FROM|WHERE|GROUP|ORDER|HAVING)\s/i.test(trimmed)) return false;
// Date format patterns (strftime, moment.js, etc.)
if (/^%[YymdHMSjWwUzZ%-]+$/.test(trimmed)) return false;
if (/^%[a-zA-Z][/%\-a-zA-Z]*$/.test(trimmed)) return false;
// Format patterns with slashes/dashes (e.g., YYYY-MM-DD, mm/dd/yyyy)
if (/^[YMDHhms/\-:. ]+$/i.test(trimmed) && /[YMDHhms]{2,}/i.test(trimmed))
return false;
// Strings ending with colon followed by technical content are often labels
// But if it's just "Label:" with a space-containing label, it might need translation
// Strings that are likely user-visible text (contains spaces or is a readable word)
return (
/\s/.test(trimmed) || /^[A-Z][a-z]+/.test(trimmed) || trimmed.length > 3
);
}
/**
* Check if a JSX expression is wrapped in t() or tn()
*/
function isWrappedInTranslation(node) {
if (!node) return false;
// Direct t() or tn() call
if (node.type === 'CallExpression') {
const { callee } = node;
if (
callee.type === 'Identifier' &&
(callee.name === 't' || callee.name === 'tn')
) {
return true;
}
}
// JSX expression container with t() call
if (node.type === 'JSXExpressionContainer') {
return isWrappedInTranslation(node.expression);
}
return false;
}
/**
* Check for untranslated user-facing strings
*/
function checkUntranslatedStrings(ast, filepath) {
traverse(ast, {
// Check JSX attributes for untranslated strings
JSXAttribute(path) {
const attrName =
path.node.name.name ||
(path.node.name.namespace
? `${path.node.name.namespace.name}:${path.node.name.name.name}`
: null);
if (!attrName) return;
// Skip ignored props
if (IGNORED_PROPS.has(attrName)) return;
// Skip data-* and aria-* props that aren't in our translatable list
if (attrName.startsWith('data-') && !TRANSLATABLE_PROPS.has(attrName))
return;
if (attrName.startsWith('aria-') && !TRANSLATABLE_PROPS.has(attrName))
return;
// Only check props that should be translated
if (!TRANSLATABLE_PROPS.has(attrName)) return;
const { value } = path.node;
// String literal value
if (value && value.type === 'StringLiteral') {
if (needsTranslation(value.value)) {
if (hasEslintDisable(path, 'i18n/no-untranslated-string')) {
return;
}
// eslint-disable-next-line no-console
console.error(
`${RED}${RESET} ${filepath}: Untranslated string in "${attrName}" prop: "${value.value}". Wrap with t().`,
);
errorCount += 1;
}
}
// JSX expression that's not a t() call
if (value && value.type === 'JSXExpressionContainer') {
const { expression } = value;
if (
expression.type === 'StringLiteral' &&
needsTranslation(expression.value)
) {
if (!isWrappedInTranslation(value)) {
if (hasEslintDisable(path, 'i18n/no-untranslated-string')) {
return;
}
// eslint-disable-next-line no-console
console.error(
`${RED}${RESET} ${filepath}: Untranslated string in "${attrName}" prop: "${expression.value}". Wrap with t().`,
);
errorCount += 1;
}
}
}
},
// Check JSX text content
JSXText(path) {
const text = path.node.value.trim();
if (needsTranslation(text)) {
if (hasEslintDisable(path, 'i18n/no-untranslated-string')) {
return;
}
// eslint-disable-next-line no-console
console.error(
`${RED}${RESET} ${filepath}: Untranslated JSX text: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}". Wrap with {t('...')}.`,
);
errorCount += 1;
}
},
});
}
/**
* Process a single file
*/
function processFile(filepath) {
const code = fs.readFileSync(filepath, 'utf8');
try {
const ast = parser.parse(code, {
sourceType: 'module',
plugins: ['jsx', 'typescript', 'decorators-legacy'],
attachComments: true,
});
// Run all checks
checkNoLiteralColors(ast, filepath);
checkNoFaIcons(ast, filepath);
checkI18nTemplates(ast, filepath);
checkUntranslatedStrings(ast, filepath);
} catch (error) {
// eslint-disable-next-line no-console
console.warn(
`${YELLOW}${RESET} Could not parse ${filepath}: ${error.message}`,
);
warningCount += 1;
}
}
/**
* Main function
*/
function main() {
const args = process.argv.slice(2);
let files = args;
// Define ignore patterns once
const ignorePatterns = [
/\.test\./,
/\.spec\./,
/\/test\//,
/\/tests\//,
/\/storybook\//,
/^\.storybook\//, // .storybook directory at root
/\.stories\./,
/\/demo\//,
/\/examples\//,
/\/color\/colorSchemes\//,
/\/cypress\//,
/\/cypress-base\//,
/\/esm\//,
/\/lib\//,
/\/dist\//,
/plugins\/legacy-/, // Legacy plugins can have old color patterns
/\/vendor\//, // Third-party vendor code
/spec\/fixtures\//, // Test fixtures
/theme\/exampleThemes/, // Theme examples legitimately have colors
/\/color\/utils/, // Color utility functions legitimately work with colors
/\/theme\/utils/, // Theme utility functions legitimately work with colors
/packages\/superset-ui-core\/src\/color\/index\.ts/, // Core brand color constants
];
// If no files specified, check all
if (files.length === 0) {
files = glob.sync('src/**/*.{ts,tsx,js,jsx}', {
ignore: [
'**/*.test.*',
'**/*.spec.*',
'**/test/**',
'**/tests/**',
'**/node_modules/**',
'**/storybook/**',
'**/*.stories.*',
'**/demo/**',
'**/examples/**',
'**/color/colorSchemes/**', // Color scheme definitions legitimately contain colors
'**/cypress/**',
'**/cypress-base/**',
'**/esm/**', // Build artifacts
'**/lib/**', // Build artifacts
'**/dist/**', // Build artifacts
'plugins/legacy-*/**', // Legacy plugins
'**/vendor/**',
'spec/fixtures/**',
'**/theme/exampleThemes/**',
'**/color/utils/**',
'**/theme/utils/**',
'packages/superset-ui-core/src/color/index.ts', // Core brand color constants
],
});
} else {
// Filter to only JS/TS files and remove superset-frontend prefix
files = files
.filter(f => /\.(ts|tsx|js|jsx)$/.test(f))
.map(f => f.replace(/^superset-frontend\//, ''))
.filter(f => !ignorePatterns.some(pattern => pattern.test(f)));
}
if (files.length === 0) {
// eslint-disable-next-line no-console
console.log('No files to check.');
return;
}
// eslint-disable-next-line no-console
console.log(`Checking ${files.length} files for Superset custom rules...\\n`);
files.forEach(file => {
// Resolve the file path
const resolvedPath = path.resolve(file);
if (fs.existsSync(resolvedPath)) {
processFile(resolvedPath);
} else if (fs.existsSync(file)) {
processFile(file);
}
});
// eslint-disable-next-line no-console
console.log(`\\n${errorCount} errors, ${warningCount} warnings`);
if (errorCount > 0) {
process.exit(1);
}
}
// Run if called directly
if (require.main === module) {
main();
}
module.exports = {
checkNoLiteralColors,
checkNoFaIcons,
checkI18nTemplates,
checkUntranslatedStrings,
};