#!/usr/bin/env node /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { execSync } from 'node:child_process'; import { existsSync, lstatSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; const LINTERS = { actionlint: { check: 'npm list @tktco/node-actionlint --depth=0', installer: 'npm install --save-dev @tktco/node-actionlint', run: ` npx node-actionlint \ -color \ -ignore 'SC2002:' \ -ignore 'SC2016:' \ -ignore 'SC2129:' \ -ignore 'label ".+" is unknown' `, }, shellcheck: { check: 'npm list shellcheck --depth=0', installer: 'npm install --save-dev shellcheck', }, yamllint: { check: 'npm list yaml-lint --depth=0', installer: 'npm install --save-dev yaml-lint', }, }; function getShellScripts() { const allFiles = execSync('git ls-files', { encoding: 'utf-8' }) .split('\n') .filter(Boolean); return allFiles.filter((file) => { if ( file.endsWith('.sh') || file.endsWith('.zsh') || file.endsWith('.bash') ) { return true; } if ( !file.includes('.') && existsSync(file) && !lstatSync(file).isDirectory() ) { try { const content = readFileSync(file, 'utf-8'); const firstLine = content.split('\n')[0]; return ( firstLine.startsWith('#!') && (firstLine.includes('sh') || firstLine.includes('bash') || firstLine.includes('zsh')) ); } catch (_e) { return false; } } return false; }); } function getYamlFiles() { return execSync('git ls-files', { encoding: 'utf-8' }) .split('\n') .filter((file) => file.endsWith('.yaml') || file.endsWith('.yml')); } function runCommand(command, stdio = 'inherit') { try { execSync(command, { stdio, env: process.env, shell: true }); return true; } catch (_e) { return false; } } export function setupLinters() { console.log('Setting up linters...'); for (const linter in LINTERS) { const { check, installer } = LINTERS[linter]; if (!runCommand(check, 'ignore')) { console.log(`Installing ${linter}...`); if (!runCommand(installer)) { console.error( `Failed to install ${linter}. Please install it manually.`, ); process.exit(1); } } } console.log('All required linters are available.'); } export function runESLint() { console.log('\nRunning ESLint...'); if (!runCommand('npm run lint')) { process.exit(1); } } export function runActionlint() { console.log('\nRunning actionlint...'); if (!runCommand(LINTERS.actionlint.run)) { process.exit(1); } } export function runShellcheck() { console.log('\nRunning shellcheck...'); const files = getShellScripts(); if (files.length === 0) { console.log('No shell scripts found.'); return; } const args = [ '--check-sourced', '--enable=all', '--exclude=SC2002,SC2129,SC2310', '--severity=style', '--format=gcc', '--color=never', ...files, ]; const command = `npx shellcheck ${args.join(' ')}`; try { const output = execSync(command, { env: process.env, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], }); console.log( output.replace(/note:/g, 'warning:').replace(/style:/g, 'warning:'), ); } catch (e) { if (e.stdout) { console.log( e.stdout .toString() .replace(/note:/g, 'warning:') .replace(/style:/g, 'warning:'), ); } if (e.stderr) { console.error(e.stderr.toString()); } process.exit(1); } } export function runYamllint() { console.log('\nRunning yamllint...'); const files = getYamlFiles(); if (files.length === 0) { console.log('No YAML files found.'); return; } // Use xargs-like behavior but via Node to avoid shell limits and ensure portability // Passing all files at once is generally supported by yaml-lint if (!runCommand(`npx yaml-lint ${files.join(' ')}`)) { process.exit(1); } } export function runPrettier() { console.log('\nRunning Prettier...'); if (!runCommand('prettier --check .')) { console.log( 'Prettier check failed. Please run "npm run format" to fix formatting issues.', ); process.exit(1); } } export function runSensitiveKeywordLinter() { console.log('\nRunning sensitive keyword linter...'); const SENSITIVE_PATTERN = /gemini-\d+(\.\d+)?/g; const ALLOWED_KEYWORDS = new Set([ 'gemini-3.1', 'gemini-3', 'gemini-3.0', 'gemini-2.5', 'gemini-2.0', 'gemini-1.5', 'gemini-1.0', ]); function getChangedFiles() { const baseRef = process.env.GITHUB_BASE_REF || 'main'; try { // In CI, we often have the remote main already fetched or can diff against it directly // Try to find merge base if possible const mergeBase = execSync( `git merge-base HEAD origin/${baseRef} 2>/dev/null || git merge-base HEAD ${baseRef} 2>/dev/null`, ) .toString() .trim(); if (mergeBase) { return execSync(`git diff --name-only ${mergeBase}..HEAD`) .toString() .trim() .split('\n') .filter(Boolean); } } catch (_error) { // Fall through to other methods } try { console.log('Falling back to diff against HEAD~1'); return execSync(`git diff --name-only HEAD~1..HEAD`) .toString() .trim() .split('\n') .filter(Boolean); } catch (_fallbackError) { console.log('Falling back to all tracked files (slowest)'); try { return execSync('git ls-files') .toString() .trim() .split('\n') .filter(Boolean); } catch (_ultimateError) { console.error('Could not get any files to lint.'); return []; } } } const changedFiles = getChangedFiles(); let violationsFound = false; for (const file of changedFiles) { if (!existsSync(file) || lstatSync(file).isDirectory()) { continue; } const content = readFileSync(file, 'utf-8'); const lines = content.split('\n'); let match; while ((match = SENSITIVE_PATTERN.exec(content)) !== null) { const keyword = match[0]; if (!ALLOWED_KEYWORDS.has(keyword)) { violationsFound = true; const matchIndex = match.index; let lineNum = 0; let charCount = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (charCount + line.length + 1 > matchIndex) { lineNum = i + 1; const colNum = matchIndex - charCount + 1; console.log( `::warning file=${file},line=${lineNum},col=${colNum}::Found sensitive keyword "${keyword}". Please make sure this change is appropriate to submit.`, ); break; } charCount += line.length + 1; // +1 for the newline } } } } if (!violationsFound) { console.log('No sensitive keyword violations found.'); } } function stripJSONComments(json) { return json.replace( /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => (g ? '' : m), ); } export function runTSConfigLinter() { console.log('\nRunning tsconfig linter...'); let files = []; try { // Find all tsconfig.json files under packages/ using a git pathspec files = execSync("git ls-files 'packages/**/tsconfig.json'") .toString() .trim() .split('\n') .filter(Boolean); } catch (e) { console.error('Error finding tsconfig.json files:', e.message); process.exit(1); } let hasError = false; for (const file of files) { const tsconfigPath = join(process.cwd(), file); if (!existsSync(tsconfigPath)) { console.error(`Error: ${tsconfigPath} does not exist.`); hasError = true; continue; } try { const content = readFileSync(tsconfigPath, 'utf-8'); const config = JSON.parse(stripJSONComments(content)); // Check if exclude exists and matches exactly if (config.exclude) { if (!Array.isArray(config.exclude)) { console.error( `Error: ${file} "exclude" must be an array. Found: ${JSON.stringify( config.exclude, )}`, ); hasError = true; } else { const allowedExclude = new Set(['node_modules', 'dist']); const invalidExcludes = config.exclude.filter( (item) => !allowedExclude.has(item), ); if (invalidExcludes.length > 0) { console.error( `Error: ${file} "exclude" contains invalid items: ${JSON.stringify( invalidExcludes, )}. Only "node_modules" and "dist" are allowed.`, ); hasError = true; } } } } catch (error) { console.error(`Error parsing ${tsconfigPath}: ${error.message}`); hasError = true; } } if (hasError) { process.exit(1); } } function main() { const args = process.argv.slice(2); if (args.includes('--setup')) { setupLinters(); } if (args.includes('--eslint')) { runESLint(); } if (args.includes('--actionlint')) { runActionlint(); } if (args.includes('--shellcheck')) { runShellcheck(); } if (args.includes('--yamllint')) { runYamllint(); } if (args.includes('--prettier')) { runPrettier(); } if (args.includes('--sensitive-keywords')) { runSensitiveKeywordLinter(); } if (args.includes('--tsconfig')) { runTSConfigLinter(); } if (args.length === 0) { setupLinters(); runESLint(); runActionlint(); runShellcheck(); runYamllint(); runPrettier(); runSensitiveKeywordLinter(); runTSConfigLinter(); console.log('\nAll linting checks passed!'); } } main();