mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-02 00:05:39 +08:00
Previously, `bin/lint` always routed files through lefthook, which is only configured for the core repo and fails for files under unbundled `plugins/*` directories. This change detects plugin paths that aren't listed by `script/list_bundled_plugins` and runs `bundle exec stree`/`rubocop` and `pnpm` prettier/eslint/ember-template-lint/stylelint directly inside the plugin's own directory.
406 lines
11 KiB
Ruby
Executable file
406 lines
11 KiB
Ruby
Executable file
#!/usr/bin/env ruby
|
|
# frozen_string_literal: true
|
|
|
|
require "optparse"
|
|
require "open3"
|
|
require "shellwords"
|
|
require "pathname"
|
|
|
|
PROJECT_ROOT = File.expand_path("..", __dir__)
|
|
|
|
# Runs linters directly (bundle exec rubocop/stree, pnpm lint) for files that
|
|
# live outside the core repo (e.g. plugins/*) where lefthook is not available.
|
|
class ExternalLinter
|
|
RUBY_EXTENSIONS = %w[rb rake thor].freeze
|
|
PRETTIER_EXTENSIONS = %w[css scss js gjs cjs mjs].freeze
|
|
ESLINT_EXTENSIONS = %w[js gjs].freeze
|
|
EMBER_TEMPLATE_LINT_EXTENSIONS = %w[gjs].freeze
|
|
STYLELINT_EXTENSIONS = %w[scss].freeze
|
|
|
|
JS_EXTENSIONS =
|
|
(
|
|
PRETTIER_EXTENSIONS + ESLINT_EXTENSIONS + EMBER_TEMPLATE_LINT_EXTENSIONS +
|
|
STYLELINT_EXTENSIONS
|
|
).uniq.freeze
|
|
|
|
def initialize(root, files, fix:, verbose: false)
|
|
@root = root
|
|
@files = files
|
|
@fix = fix
|
|
@verbose = verbose
|
|
end
|
|
|
|
def run
|
|
ruby_files = @files.select { |f| ruby_file?(f) || File.basename(f) == "Gemfile" }
|
|
js_files = @files.select { |f| js_file?(f) }
|
|
|
|
success = true
|
|
success &= run_ruby_linters(ruby_files) if ruby_files.any?
|
|
success &= run_js_linters(js_files) if js_files.any?
|
|
success
|
|
end
|
|
|
|
private
|
|
|
|
def ruby_file?(f)
|
|
RUBY_EXTENSIONS.include?(File.extname(f)[1..])
|
|
end
|
|
|
|
def js_file?(f)
|
|
JS_EXTENSIONS.include?(File.extname(f)[1..])
|
|
end
|
|
|
|
def relative_paths(files)
|
|
files.map { |f| Pathname.new(File.expand_path(f)).relative_path_from(Pathname.new(@root)).to_s }
|
|
end
|
|
|
|
def run_cmd(*cmd)
|
|
puts "Running: #{cmd.shelljoin}" if @verbose
|
|
system(*cmd)
|
|
end
|
|
|
|
def run_ruby_linters(files)
|
|
rel_files = relative_paths(files)
|
|
success = true
|
|
|
|
Dir.chdir(@root) do
|
|
run_cmd("bundle", "install") or abort "bundle install failed in #{@root}"
|
|
|
|
stree_subcmd = @fix ? "write" : "check"
|
|
stree_ok = run_cmd("bundle", "exec", "stree", stree_subcmd, *rel_files)
|
|
success &= stree_ok unless @fix
|
|
|
|
rubocop_args = @fix ? ["--autocorrect"] : []
|
|
rubocop_ok = run_cmd("bundle", "exec", "rubocop", *rubocop_args, *rel_files)
|
|
success &= rubocop_ok unless @fix
|
|
end
|
|
|
|
success
|
|
end
|
|
|
|
def run_js_linters(files)
|
|
by_ext = ->(exts) { files.select { |f| exts.include?(File.extname(f)[1..]) } }
|
|
|
|
prettier_files = by_ext.call(PRETTIER_EXTENSIONS)
|
|
eslint_files = by_ext.call(ESLINT_EXTENSIONS)
|
|
ember_template_lint_files = by_ext.call(EMBER_TEMPLATE_LINT_EXTENSIONS)
|
|
stylelint_files = by_ext.call(STYLELINT_EXTENSIONS)
|
|
|
|
success = true
|
|
|
|
Dir.chdir(@root) do
|
|
run_cmd("pnpm", "i") or abort "pnpm i failed in #{@root}"
|
|
|
|
if prettier_files.any?
|
|
rel = relative_paths(prettier_files)
|
|
args = @fix ? ["--write"] : ["--list-different"]
|
|
ok = run_cmd("pnpm", "prettier", *args, *rel)
|
|
success &= ok unless @fix
|
|
end
|
|
|
|
if eslint_files.any?
|
|
rel = relative_paths(eslint_files)
|
|
args = @fix ? ["--fix"] : ["--quiet"]
|
|
ok = run_cmd("pnpm", "eslint", *args, *rel)
|
|
success &= ok unless @fix
|
|
end
|
|
|
|
if ember_template_lint_files.any?
|
|
rel = relative_paths(ember_template_lint_files)
|
|
args = @fix ? ["--fix"] : []
|
|
ok = run_cmd("pnpm", "ember-template-lint", *args, *rel)
|
|
success &= ok unless @fix
|
|
end
|
|
|
|
if stylelint_files.any?
|
|
rel = relative_paths(stylelint_files)
|
|
args = @fix ? ["--fix"] : []
|
|
ok = run_cmd("pnpm", "stylelint", *args, *rel)
|
|
success &= ok unless @fix
|
|
end
|
|
end
|
|
|
|
success
|
|
end
|
|
end
|
|
|
|
class LefthookLinter
|
|
def initialize(options = {})
|
|
@fix = options[:fix]
|
|
@recent = options[:recent]
|
|
@staged = options[:staged]
|
|
@unstaged = options[:unstaged]
|
|
@wip = options[:wip]
|
|
@files = options[:files] || []
|
|
@verbose = options[:verbose]
|
|
end
|
|
|
|
EVERYTHING = ["ALL FILES"]
|
|
|
|
def run
|
|
files = determine_files
|
|
|
|
if @fix
|
|
run_fix_mode(files)
|
|
else
|
|
run_check_mode(files)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def determine_files
|
|
files =
|
|
if @recent
|
|
recent_files
|
|
elsif @staged
|
|
staged_files
|
|
elsif @unstaged
|
|
unstaged_files
|
|
elsif @wip
|
|
wip_files
|
|
elsif !@files.empty?
|
|
@files
|
|
else
|
|
return EVERYTHING
|
|
end
|
|
|
|
files
|
|
.flat_map do |f|
|
|
path = normalize_relative_path(f)
|
|
if File.directory?(path)
|
|
expanded =
|
|
Dir.glob(File.join(path, "**", "*")).select { |g| File.file?(g) && lintable_file?(g) }
|
|
abort "Error: No lintable files found in directory: #{path}" if expanded.empty?
|
|
expanded
|
|
else
|
|
[path]
|
|
end
|
|
end
|
|
.select { |f| File.file?(f) && lintable_file?(f) }
|
|
.uniq
|
|
end
|
|
|
|
def recent_files
|
|
log_output, status = Open3.capture2("git", "log", "-50", "--name-only", "--pretty=format:")
|
|
return [] unless status.success?
|
|
|
|
log_files = log_output.lines.map(&:strip).reject(&:empty?)
|
|
|
|
tracked_out, _ = Open3.capture2("git", "ls-files")
|
|
untracked_out, _ = Open3.capture2("git", "ls-files", "--others", "--exclude-standard")
|
|
|
|
tracked = Set.new(tracked_out.lines.map(&:strip))
|
|
untracked = Set.new(untracked_out.lines.map(&:strip))
|
|
|
|
candidates = []
|
|
log_files.each { |f| candidates << f if tracked.include?(f) }
|
|
candidates + untracked.to_a
|
|
end
|
|
|
|
def staged_files
|
|
git_output, status = Open3.capture2("git", "diff", "--cached", "--name-only")
|
|
return [] unless status.success?
|
|
git_output.lines.map(&:strip).reject(&:empty?)
|
|
end
|
|
|
|
def unstaged_files
|
|
git_output, status = Open3.capture2("git", "diff", "--name-only")
|
|
return [] unless status.success?
|
|
git_output.lines.map(&:strip).reject(&:empty?)
|
|
end
|
|
|
|
def wip_files
|
|
main_diff_output, _ = Open3.capture2("git", "diff", "main...HEAD", "--name-only")
|
|
main_files = main_diff_output.lines.map(&:strip).reject(&:empty?)
|
|
|
|
main_files + staged_files + unstaged_files
|
|
end
|
|
|
|
def lintable_file?(file)
|
|
# Skip certain directories and files
|
|
if file.include?("node_modules") || file.include?("vendor") || file.include?("tmp") ||
|
|
file.include?(".git") || file == "config/database.yml"
|
|
return false
|
|
end
|
|
|
|
return true if file == "Gemfile"
|
|
|
|
ext = File.extname(file)[1..]
|
|
|
|
# Check for Ruby files in /bin/ directory without extensions
|
|
if ext.nil? || ext.empty?
|
|
if file.start_with?("bin/") && File.file?(file)
|
|
begin
|
|
first_line = File.open(file, &:readline)
|
|
return true if first_line.strip == "#!/usr/bin/env ruby"
|
|
rescue StandardError
|
|
return false
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
# Check if file extension is lintable
|
|
lintable_extensions = %w[rb rake js gjs hbs scss css yml yaml thor]
|
|
lintable_extensions.include?(ext)
|
|
end
|
|
|
|
def run_fix_mode(files)
|
|
if files == EVERYTHING
|
|
puts "Running linters in fix mode on all files" if @verbose
|
|
run_lefthook_command("fix-all", [])
|
|
return
|
|
end
|
|
|
|
if files.empty?
|
|
puts "No files to fix, exiting."
|
|
return
|
|
end
|
|
|
|
core, external = partition_files(files)
|
|
run_lefthook_command("fix-staged", core) if core.any?
|
|
external.each { |root, fs| ExternalLinter.new(root, fs, fix: true, verbose: @verbose).run }
|
|
end
|
|
|
|
def run_check_mode(files)
|
|
if files == EVERYTHING
|
|
puts "Running linters in check mode on all files" if @verbose
|
|
run_lefthook_command("lints", [])
|
|
return
|
|
end
|
|
|
|
if files.empty?
|
|
puts "No files to lint, exiting."
|
|
return
|
|
end
|
|
|
|
core, external = partition_files(files)
|
|
run_lefthook_command("pre-commit", core) if core.any?
|
|
external.each do |root, fs|
|
|
success = ExternalLinter.new(root, fs, fix: false, verbose: @verbose).run
|
|
exit 1 unless success
|
|
end
|
|
end
|
|
|
|
def partition_files(files)
|
|
core_files = []
|
|
external_files = Hash.new { |h, k| h[k] = [] }
|
|
|
|
files.each do |f|
|
|
if (root = external_plugin_root(f))
|
|
external_files[File.join(PROJECT_ROOT, root)] << f
|
|
else
|
|
core_files << f
|
|
end
|
|
end
|
|
|
|
[core_files, external_files]
|
|
end
|
|
|
|
# Returns "plugins/<name>" if the file is in an unbundled plugin directory, else nil.
|
|
def external_plugin_root(file)
|
|
path = normalize_relative_path(file)
|
|
return nil unless path.start_with?("plugins/")
|
|
|
|
parts = path.split("/")
|
|
return nil if parts.length < 2
|
|
|
|
plugin_dir = "plugins/#{parts[1]}"
|
|
bundled_plugins.include?(plugin_dir) ? nil : plugin_dir
|
|
end
|
|
|
|
def bundled_plugins
|
|
@bundled_plugins ||=
|
|
begin
|
|
out, status = Open3.capture2(File.join(PROJECT_ROOT, "script", "list_bundled_plugins"))
|
|
abort "Failed to list bundled plugins" unless status.success?
|
|
Set.new(out.lines.map(&:strip).reject(&:empty?))
|
|
end
|
|
end
|
|
|
|
def run_lefthook_command(hook, files)
|
|
if !files.empty?
|
|
normalized = files.map { |f| normalize_relative_path(f) }
|
|
exec_lefthook(hook, nil, normalized)
|
|
else
|
|
exec_lefthook(hook, nil, files)
|
|
end
|
|
end
|
|
|
|
def exec_lefthook(hook, command, files)
|
|
cmd = ["pnpm", "lefthook", "run", hook]
|
|
cmd << "--command" << command if command
|
|
files.each { |f| cmd << "--file" << f }
|
|
cmd << "--verbose" if @verbose
|
|
|
|
puts "Running: #{cmd.shelljoin}" if @verbose
|
|
exit 1 unless system({ "LEFTHOOK" => "1" }, *cmd)
|
|
end
|
|
|
|
def normalize_relative_path(file)
|
|
cleaned = file.start_with?("./") ? file[2..] : file
|
|
path = Pathname.new(cleaned)
|
|
if path.absolute?
|
|
begin
|
|
path = path.relative_path_from(Pathname.new(PROJECT_ROOT))
|
|
rescue ArgumentError
|
|
# leave as is if it's outside the repo
|
|
end
|
|
end
|
|
path.to_s
|
|
end
|
|
end
|
|
|
|
def parse_options
|
|
options = {}
|
|
|
|
OptionParser
|
|
.new do |parser|
|
|
parser.banner = "Usage: bin/lint [options] [files|directories...]"
|
|
|
|
parser.on("-h", "--help", "Show this help message") do
|
|
puts parser
|
|
puts
|
|
puts "Examples:"
|
|
puts " bin/lint # Lint all files"
|
|
puts " bin/lint --recent # Lint recently changed files"
|
|
puts " bin/lint --staged # Lint only staged files"
|
|
puts " bin/lint --unstaged # Lint only unstaged files"
|
|
puts " bin/lint --wip # Lint staged + unstaged + files changed since main"
|
|
puts " bin/lint --fix app.rb file2.js # Fix specific file/s"
|
|
puts " bin/lint app/models/*.rb # Lint multiple files"
|
|
puts " bin/lint frontend/discourse/app/ # Lint all lintable files in directory"
|
|
puts
|
|
puts "Note: This script now uses lefthook to run linters."
|
|
puts "Check lefthook.yml for linting configuration."
|
|
exit
|
|
end
|
|
|
|
parser.on("-f", "--fix", "Attempt to automatically fix issues") { options[:fix] = true }
|
|
|
|
parser.on("-r", "--recent", "Lint recently changed files (last 50 commits)") do
|
|
options[:recent] = true
|
|
end
|
|
|
|
parser.on("--staged", "Lint only staged files") { options[:staged] = true }
|
|
|
|
parser.on("--unstaged", "Lint only unstaged files") { options[:unstaged] = true }
|
|
|
|
parser.on("--wip", "Lint work-in-progress: staged + unstaged + files changed since main") do
|
|
options[:wip] = true
|
|
end
|
|
|
|
parser.on("-v", "--verbose", "Show verbose output") { options[:verbose] = true }
|
|
end
|
|
.parse!
|
|
|
|
options[:files] = ARGV unless ARGV.empty?
|
|
options
|
|
end
|
|
|
|
if __FILE__ == $0
|
|
options = parse_options
|
|
linter = LefthookLinter.new(options)
|
|
linter.run
|
|
end
|