/* * Copyright (C) 2024 - present Instructure, Inc. * * This file is part of Canvas. * * Canvas is free software: you can redistribute it and/or modify it under * the terms of the GNU Affero General Public License as published by the Free * Software Foundation, version 3 of the License. * * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more * details. * * You should have received a copy of the GNU Affero General Public License along * with this program. If not, see . */ const {execSync, exec} = require('child_process') const util = require('util') const path = require('path') const {ESLint} = require('eslint') const pluginReactCompiler = require('eslint-plugin-react-compiler') const execAsync = util.promisify(exec) const projectRoot = path.resolve(__dirname, '..') const colors = { reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', gray: '\x1b[90m', bold: '\x1b[1m', white: '\x1b[37m', } function colorize(color, text) { return `${colors[color]}${text}${colors.reset}` } function bold(text) { return colorize('bold', text) } // Parse command line arguments function parseArgs() { const args = process.argv.slice(2) const options = { sections: [], verbose: false, help: false, } for (let i = 0; i < args.length; i++) { const arg = args[i] switch (arg) { case '-h': case '--help': options.help = true break case '-v': case '--verbose': options.verbose = true break case '-s': case '--section': if (i + 1 < args.length) { // Split by comma to support multiple sections const sectionArg = args[i + 1] options.sections = sectionArg.split(',').map(s => s.trim()) i++ // Skip the next argument since we used it } break } } return options } function printHelp() { console.log(` Usage: node techdebt_stats.js [options] Options: -h, --help Show this help message -v, --verbose Show all files instead of just examples -s, --section Show only specific section(s), comma-separated (e.g., skipped,proptypes) Available sections: skipped - Skipped tests proptypes - PropTypes usage defaultprops - DefaultProps usage handlebars - Handlebars files jquery - jQuery imports reactdom - ReactDOM.render files class - React class components javascript - JavaScript files typescript - TypeScript suppressions outdated - Outdated packages react-compiler - React Compiler Rule Violations `) process.exit(0) } const normalizePath = filePath => filePath.replace(/\/+/g, '/') // Helper function to get random examples function getRandomExamples(files, count = 3) { if (files.length === 0) return [] if (files.length <= count) return files.map(normalizePath) const shuffled = [...files].sort(() => 0.5 - Math.random()) return shuffled.slice(0, count).map(normalizePath) } async function getMatchingFiles(searchPattern, verbose = false) { try { const {stdout} = await execAsync( `git ls-files "ui/" "packages/" | grep -E "${searchPattern}"`, {cwd: projectRoot}, ) return stdout.trim().split('\n').filter(Boolean) } catch (error) { return [] } } async function getGrepMatchingFiles(filePattern, grepPattern, verbose = false) { try { const cmd = `git ls-files "ui/" "packages/" | grep -E "${filePattern}" | xargs grep -l "${grepPattern}"` const {stdout} = await execAsync(cmd, {cwd: projectRoot}) return stdout.trim().split('\n').filter(Boolean) } catch (error) { return [] } } async function countAndShowFiles(searchPattern, description, verbose = false) { try { const files = await getMatchingFiles(searchPattern, verbose) const fileCount = files.length if (fileCount > 0) { console.log(colorize('yellow', `- ${description}: ${bold(fileCount)}`)) if (verbose) { files.sort().forEach(file => { console.log(colorize('gray', ` ${file}`)) }) } else { const examples = getRandomExamples(files, 3) examples.forEach(file => { console.log(colorize('gray', ` Example: ${file}`)) }) } } else { console.log(colorize('yellow', `- ${description}: ${colorize('green', 'None')}`)) } } catch (error) { console.error(colorize('red', `Error searching for ${description}: ${error.message}`)) } } async function countTsSuppressions(type, verbose = false) { try { const {stdout} = await execAsync( `git ls-files "ui/" | grep -E "\\.(ts|tsx)$" | xargs grep -l "@${type}" | wc -l`, {cwd: projectRoot}, ) return Number.parseInt(stdout.trim(), 10) } catch (error) { console.error(colorize('red', `Error counting @${type}: ${error.message}`)) return 0 } } async function getRandomTsSuppressionFiles(type, verbose = false) { try { const {stdout} = await execAsync( `git ls-files "ui/" | grep -E "\\.(ts|tsx)$" | xargs grep -l "@${type}"`, {cwd: projectRoot}, ) const files = stdout.trim().split('\n').filter(Boolean) return getRandomExamples(files, 3) } catch (error) { console.error(colorize('red', `Error finding @${type} examples: ${error.message}`)) } return [] } async function showTsSuppressionStats(type, verbose = false) { const count = await countTsSuppressions(type, verbose) console.log(colorize('yellow', `- Total files with @${type}: ${bold(count)}`)) if (count > 0) { const files = await getGrepMatchingFiles('\\.(ts|tsx)$', `@${type}`, verbose) if (verbose) { files.sort().forEach(file => { console.log(colorize('gray', ` ${file}`)) }) } else { const examples = await getRandomTsSuppressionFiles(type, verbose) examples.forEach(file => { console.log(colorize('gray', ` Example: ${file}`)) }) } } } async function countJqueryImports(verbose = false) { try { const cmd = 'git ls-files "ui/" | grep -E "\\.(js|jsx|ts|tsx)$" | ' + 'xargs grep -l "from [\'\\"]jquery[\'\\"]"' const {stdout} = await execAsync(cmd, {cwd: projectRoot}) return Number.parseInt(stdout.trim().split('\n').filter(Boolean).length, 10) } catch (error) { console.error(colorize('red', `Error counting jQuery imports: ${error.message}`)) return 0 } } async function getRandomJqueryImportFiles(verbose = false) { try { const cmd = 'git ls-files "ui/" | grep -E "\\.(js|jsx|ts|tsx)$" | ' + 'xargs grep -l "from [\'\\"]jquery[\'\\"]"' const {stdout} = await execAsync(cmd, {cwd: projectRoot}) const files = stdout.trim().split('\n').filter(Boolean) return getRandomExamples(files, 3) } catch (error) { console.error(colorize('red', `Error finding jQuery import examples: ${error.message}`)) } return [] } async function showJqueryImportStats(verbose = false) { const count = await countJqueryImports(verbose) console.log(colorize('yellow', `- Files with jQuery imports: ${bold(count)}`)) if (count > 0) { if (verbose) { const cmd = 'git ls-files "ui/" "packages/" | grep -E "\\.(js|jsx|ts|tsx)$" | ' + 'xargs grep -l "\\$\\|\\bjQuery\\b\\|\\bimport.*jquery\\b"' const {stdout} = await execAsync(cmd, {cwd: projectRoot}) const files = stdout.trim().split('\n').filter(Boolean) files.sort().forEach(file => { console.log(colorize('gray', ` ${file}`)) }) } else { const examples = await getRandomJqueryImportFiles(verbose) examples.forEach(file => { console.log(colorize('gray', ` Example: ${file}`)) }) } } } async function countSkippedTests(verbose = false) { try { let itSkipFiles = [] let describeSkipFiles = [] try { const {stdout: itSkipStdout} = await execAsync( `git ls-files "ui/" "packages/" | xargs grep -l 'it\\.skip(' 2>/dev/null || true`, {cwd: projectRoot}, ) itSkipFiles = itSkipStdout.trim().split('\n').filter(Boolean) } catch (e) { // grep returns exit code 1 when no matches found } try { const {stdout: describeSkipStdout} = await execAsync( `git ls-files "ui/" "packages/" | xargs grep -l 'describe\\.skip(' 2>/dev/null || true`, {cwd: projectRoot}, ) describeSkipFiles = describeSkipStdout.trim().split('\n').filter(Boolean) } catch (e) { // grep returns exit code 1 when no matches found } // Combine and deduplicate files const allFiles = [...new Set([...itSkipFiles, ...describeSkipFiles])] const fileCount = allFiles.length if (fileCount > 0) { console.log(colorize('yellow', `- Total files with skipped tests: ${bold(fileCount)}`)) if (verbose) { allFiles.sort().forEach(file => { console.log(colorize('gray', ` ${file}`)) }) } else { const examples = getRandomExamples(allFiles, 3) examples.forEach(file => { console.log(colorize('gray', ` Example: ${file}`)) }) } } else { console.log( colorize('yellow', `- Total files with skipped tests: ${colorize('green', 'None')}`), ) } } catch (error) { if (error.code === 1 && !error.stdout) { // grep returns exit code 1 when no matches are found console.log( colorize('yellow', `- Total files with skipped tests: ${colorize('green', 'None')}`), ) } else { console.error(colorize('red', `Error counting skipped test files: ${error.message}`)) } } } async function checkOutdatedPackages(verbose = false) { try { const output = execSync('npm outdated --json', { cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8', }).toString() handleOutdatedPackages(output, verbose) } catch (error) { // npm outdated exits with code 1 when it finds outdated packages // This is expected behavior, so we should still try to parse the output if (error.stdout) { handleOutdatedPackages(error.stdout, verbose) } else { console.error(colorize('red', `Error running npm outdated: ${error.message}`)) if (error.stderr) { console.error(colorize('red', `stderr: ${error.stderr}`)) } if (error.status) { console.error(colorize('red', `exit code: ${error.status}`)) } if (error.signal) { console.error(colorize('red', `signal: ${error.signal}`)) } } } } function handleOutdatedPackages(output, verbose = false) { if (output.trim()) { const outdatedData = JSON.parse(output) const majorOutdated = [] for (const packageName in outdatedData) { const pkg = outdatedData[packageName] // Skip if we don't have all the version information if (!pkg.current || !pkg.latest) continue const currentMajor = Number.parseInt((pkg.current || '0').split('.')[0], 10) const latestMajor = Number.parseInt((pkg.latest || '0').split('.')[0], 10) if (!Number.isNaN(currentMajor) && !Number.isNaN(latestMajor) && latestMajor > currentMajor) { majorOutdated.push({ packageName, current: pkg.current, wanted: pkg.wanted || pkg.current, latest: pkg.latest, }) } } if (majorOutdated.length > 0) { console.log( colorize('yellow', `- Packages outdated by major version: ${bold(majorOutdated.length)}`), ) if (verbose) { majorOutdated.forEach(pkg => { console.log( colorize( 'gray', ` ${pkg.packageName} (current: ${pkg.current}, wanted: ${pkg.wanted}, latest: ${pkg.latest})`, ), ) }) } else { // Fix: Pass the formatted string directly, not the object const examples = getRandomExamples( majorOutdated.map( pkg => `${pkg.packageName} (current: ${pkg.current}, wanted: ${pkg.wanted}, latest: ${pkg.latest})`, ), 3, ) examples.forEach(example => { console.log(colorize('gray', ` Example: ${example}`)) }) } } else { console.log( colorize('yellow', `- Packages outdated by major version: ${colorize('green', 'None')}`), ) } } else { console.log( colorize('yellow', `- Packages outdated by major version: ${colorize('green', 'None')}`), ) } } async function countReactDomRenderFiles(verbose = false) { try { // Find files containing ReactDOM.render const {stdout} = await execAsync( `git ls-files "ui/" "packages/" | xargs grep -l "ReactDOM.render"`, {cwd: projectRoot}, ) const files = stdout.trim().split('\n').filter(Boolean) const fileCount = files.length if (fileCount > 0) { console.log(colorize('yellow', `- Total files with ReactDOM.render: ${bold(fileCount)}`)) if (verbose) { files.sort().forEach(file => { console.log(colorize('gray', ` ${file}`)) }) } else { const examples = getRandomExamples(files, 3) examples.forEach(file => { console.log(colorize('gray', ` Example: ${file}`)) }) } } else { console.log( colorize('yellow', `- Total files with ReactDOM.render: ${colorize('green', 'None')}`), ) } } catch (error) { if (error.code === 1 && !error.stdout) { // grep returns exit code 1 when no matches are found console.log( colorize('yellow', `- Total files with ReactDOM.render: ${colorize('green', 'None')}`), ) } else { console.error(colorize('red', `Error counting ReactDOM.render files: ${error.message}`)) } } } async function countReactClassComponentFiles(verbose = false) { try { let reactComponentFiles = [] let componentFiles = [] // Find files containing class components using both patterns try { const {stdout: reactComponentStdout} = await execAsync( `git ls-files "ui/" "packages/" | xargs grep -l "extends React.Component" 2>/dev/null || true`, {cwd: projectRoot}, ) reactComponentFiles = reactComponentStdout.trim().split('\n').filter(Boolean) } catch (e) { // grep returns exit code 1 when no matches found } try { const {stdout: componentStdout} = await execAsync( `git ls-files "ui/" "packages/" | xargs grep -l "extends Component" 2>/dev/null || true`, {cwd: projectRoot}, ) componentFiles = componentStdout.trim().split('\n').filter(Boolean) } catch (e) { // grep returns exit code 1 when no matches found } // Combine and deduplicate files const allFiles = [...new Set([...reactComponentFiles, ...componentFiles])] const fileCount = allFiles.length if (fileCount > 0) { console.log(colorize('yellow', `- Total files with class components: ${bold(fileCount)}`)) if (verbose) { allFiles.sort().forEach(file => { console.log(colorize('gray', ` ${file}`)) }) } else { const examples = getRandomExamples(allFiles, 3) examples.forEach(file => { console.log(colorize('gray', ` Example: ${file}`)) }) } } else { console.log( colorize('yellow', `- Total files with class components: ${colorize('green', 'None')}`), ) } } catch (error) { if (error.code === 1 && !error.stdout) { // grep returns exit code 1 when no matches are found console.log( colorize('yellow', `- Total files with class components: ${colorize('green', 'None')}`), ) } else { console.error(colorize('red', `Error counting class component files: ${error.message}`)) } } } async function countPropTypesFiles(verbose = false) { try { const cmd = 'git ls-files "ui/" "packages/" | grep -E "\\.(js|jsx|ts|tsx)$" | ' + 'xargs grep -l "\\.propTypes\\s*="' const {stdout} = await execAsync(cmd, {cwd: projectRoot}) return Number.parseInt(stdout.trim().split('\n').filter(Boolean).length, 10) } catch (error) { console.error(colorize('red', `Error counting PropTypes files: ${error.message}`)) return 0 } } async function getRandomPropTypesFiles(verbose = false) { try { const cmd = 'git ls-files "ui/" "packages/" | grep -E "\\.(js|jsx|ts|tsx)$" | ' + 'xargs grep -l "\\.propTypes\\s*="' const {stdout} = await execAsync(cmd, {cwd: projectRoot}) const files = stdout.trim().split('\n').filter(Boolean) return getRandomExamples(files, 3) } catch (error) { console.error(colorize('red', `Error finding PropTypes examples: ${error.message}`)) } return [] } async function showPropTypesStats(verbose = false) { const count = await countPropTypesFiles(verbose) console.log(colorize('yellow', `- Files with PropTypes: ${bold(count)}`)) if (count > 0) { if (verbose) { const cmd = 'git ls-files "ui/" "packages/" | grep -E "\\.(js|jsx)$" | ' + 'xargs grep -l "PropTypes\\."' const {stdout} = await execAsync(cmd, {cwd: projectRoot}) const files = stdout.trim().split('\n').filter(Boolean) files.sort().forEach(file => { console.log(colorize('gray', ` ${file}`)) }) } else { const examples = await getRandomPropTypesFiles(verbose) examples.forEach(file => { console.log(colorize('gray', ` Example: ${file}`)) }) } } } async function countDefaultPropsFiles(verbose = false) { try { const cmd = 'git ls-files "ui/" "packages/" | grep -E "\\.(js|jsx|ts|tsx)$" | ' + 'xargs grep -l "\\.defaultProps\\s*="' const {stdout} = await execAsync(cmd, {cwd: projectRoot}) return Number.parseInt(stdout.trim().split('\n').filter(Boolean).length, 10) } catch (error) { console.error(colorize('red', `Error counting defaultProps files: ${error.message}`)) return 0 } } async function getRandomDefaultPropsFiles(verbose = false) { try { const cmd = 'git ls-files "ui/" "packages/" | grep -E "\\.(js|jsx|ts|tsx)$" | ' + 'xargs grep -l "\\.defaultProps\\s*="' const {stdout} = await execAsync(cmd, {cwd: projectRoot}) const files = stdout.trim().split('\n').filter(Boolean) return getRandomExamples(files, 3) } catch (error) { console.error(colorize('red', `Error finding defaultProps examples: ${error.message}`)) } return [] } async function showDefaultPropsStats(verbose = false) { const count = await countDefaultPropsFiles(verbose) console.log(colorize('yellow', `- Files with defaultProps: ${bold(count)}`)) if (count > 0) { if (verbose) { const cmd = 'git ls-files "ui/" "packages/" | grep -E "\\.(js|jsx)$" | ' + 'xargs grep -l "\\.defaultProps\\s*="' const {stdout} = await execAsync(cmd, {cwd: projectRoot}) const files = stdout.trim().split('\n').filter(Boolean) files.sort().forEach(file => { console.log(colorize('gray', ` ${file}`)) }) } else { const examples = await getRandomDefaultPropsFiles(verbose) examples.forEach(file => { console.log(colorize('gray', ` Example: ${file}`)) }) } } } // Add loading indicator helpers function startSpinner(message) { const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] let i = 0 process.stdout.write('\r' + frames[0] + ' ' + message) return setInterval(() => { i = (i + 1) % frames.length process.stdout.write('\r' + frames[i] + ' ' + message) }, 80) } function stopSpinner(interval) { clearInterval(interval) process.stdout.write('\r\x1b[K') // Clear the line } function createReactCompilerESLint() { return new ESLint({ cache: true, baseConfig: { plugins: { 'react-compiler': pluginReactCompiler, }, rules: { 'react-compiler/react-compiler': 'warn', }, }, }) } async function countReactCompilerViolations() { try { const spinner = startSpinner('Analyzing files in `ui/` for react-compiler violations...') const eslint = createReactCompilerESLint() const results = await eslint.lintFiles(['ui/**/*.{js,jsx,ts,tsx}']) stopSpinner(spinner) const violations = results.flatMap(result => result.messages.filter(msg => msg.ruleId === 'react-compiler/react-compiler'), ) return violations.length } catch (error) { console.error(colorize('red', `Error counting react-compiler violations: ${error.message}`)) return 0 } } async function getRandomReactCompilerViolationFiles() { try { const spinner = startSpinner('Finding files with react-compiler violations...') const eslint = createReactCompilerESLint() const results = await eslint.lintFiles(['ui/**/*.{js,jsx,ts,tsx}']) stopSpinner(spinner) const filesWithViolations = results .filter(result => result.messages.some(msg => msg.ruleId === 'react-compiler/react-compiler')) .map(result => { const message = result.messages.find(msg => msg.ruleId === 'react-compiler/react-compiler') return ( normalizePath(path.relative(projectRoot, result.filePath)) + `:${message.line}:${message.column}` ) }) if (filesWithViolations.length === 0) { return [] } return getRandomExamples(filesWithViolations, 3) } catch (error) { console.error( colorize('red', `Error finding react-compiler violation examples: ${error.message}`), ) return [] } } async function showReactCompilerViolationStats(verbose = false) { const count = await countReactCompilerViolations() console.log(colorize('yellow', `- Files with react-compiler violations: ${bold(count)}`)) if (count > 0) { if (verbose) { const spinner = startSpinner('Getting detailed list of react-compiler violations...') const eslint = createReactCompilerESLint() const results = await eslint.lintFiles(['ui/**/*.{js,jsx,ts,tsx}']) stopSpinner(spinner) const filesWithViolations = results .filter(result => result.messages.some(msg => msg.ruleId === 'react-compiler/react-compiler'), ) .map(result => { const message = result.messages.find( msg => msg.ruleId === 'react-compiler/react-compiler', ) return normalizePath( `${path.relative(projectRoot, result.filePath)}:${message.line}:${message.column}`, ) }) .sort() filesWithViolations.forEach(file => { console.log(colorize('gray', ` ${file}`)) }) } else { const examples = await getRandomReactCompilerViolationFiles() if (examples.length > 0) { examples.forEach(file => { console.log(colorize('gray', ` Example: ${file}`)) }) } else { console.log(colorize('green', `No violations found!`)) } } } } function getSectionTitle(section) { const titles = { class: ['React Class Component Files', '(convert to function components)'], defaultprops: ['DefaultProps Usage', '(use default parameters/TypeScript defaults)'], handlebars: ['Handlebars Files', '(convert to React)'], javascript: ['JavaScript Files', '(convert to TypeScript)'], jquery: ['JQuery Imports', '(use native DOM)'], outdated: ['Outdated Packages', ''], proptypes: ['PropTypes Usage', '(use TypeScript interfaces/types)'], reactCompiler: ['React Compiler Rule Violations', ''], reactdom: ['ReactDOM.render Files', '(convert to createRoot)'], skipped: ['Skipped Tests', '(fix or remove)'], typescript: ['TypeScript Suppressions', ''], } const [title, note] = titles[section] || [section, ''] return note ? colorize('white', bold(title)) + ' ' + colorize('gray', note) : colorize('white', bold(title)) } async function printDashboard() { try { const options = parseArgs() if (options.help) { printHelp() } console.log(bold(colorize('green', '\nTech Debt Summary\n'))) const selectedSections = options.sections const verbose = options.verbose if (selectedSections.length === 0 || selectedSections.includes('skipped')) { console.log(getSectionTitle('skipped')) await countSkippedTests(verbose) console.log() } if (selectedSections.length === 0 || selectedSections.includes('reactdom')) { console.log(getSectionTitle('reactdom')) await countReactDomRenderFiles(verbose) console.log() } if (selectedSections.length === 0 || selectedSections.includes('defaultprops')) { console.log(getSectionTitle('defaultprops')) await showDefaultPropsStats(verbose) console.log() } if (selectedSections.length === 0 || selectedSections.includes('handlebars')) { console.log(getSectionTitle('handlebars')) await countAndShowFiles('\\.handlebars$', 'Total Handlebars files', verbose) console.log() } if (selectedSections.length === 0 || selectedSections.includes('class')) { console.log(getSectionTitle('class')) await countReactClassComponentFiles(verbose) console.log() } if (selectedSections.length === 0 || selectedSections.includes('proptypes')) { console.log(getSectionTitle('proptypes')) await showPropTypesStats(verbose) console.log() } if (selectedSections.length === 0 || selectedSections.includes('jquery')) { console.log(getSectionTitle('jquery')) await showJqueryImportStats(verbose) console.log() } if (selectedSections.length === 0 || selectedSections.includes('javascript')) { console.log(getSectionTitle('javascript')) await countAndShowFiles('\\.(js|jsx)$', 'Total JavaScript files', verbose) console.log() } if (selectedSections.length === 0 || selectedSections.includes('typescript')) { console.log(getSectionTitle('typescript')) await showTsSuppressionStats('ts-nocheck', verbose) await showTsSuppressionStats('ts-ignore', verbose) await showTsSuppressionStats('ts-expect-error', verbose) console.log() } if (selectedSections.length === 0 || selectedSections.includes('outdated')) { console.log(getSectionTitle('outdated')) await checkOutdatedPackages(verbose) console.log() } if (selectedSections.includes('react-compiler')) { console.log(getSectionTitle('reactCompiler')) await showReactCompilerViolationStats(verbose) } } catch (error) { console.error(colorize('red', `Error: ${error.message}`)) process.exit(1) } } printDashboard().catch(error => { console.error(colorize('red', `Error: ${error.message}`)) process.exit(1) })