mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 05:42:36 +08:00
We've been using basic type-checking via JSDoc for some time. This commit allows us to author proper `.ts`/`.gts` files, and use the full typescript syntax. Initially, only d-button and a single chat file are migrated, as proof of functionality. In future, we may migrate more files, and consider making our tsconfig more strict.
414 lines
12 KiB
Ruby
Executable file
Vendored
414 lines
12 KiB
Ruby
Executable file
Vendored
#!/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 ts gts mts cts].freeze
|
|
ESLINT_EXTENSIONS = %w[js gjs ts gts mts cts].freeze
|
|
STYLELINT_EXTENSIONS = %w[scss].freeze
|
|
|
|
JS_EXTENSIONS = (PRETTIER_EXTENSIONS + ESLINT_EXTENSIONS + STYLELINT_EXTENSIONS).uniq.freeze
|
|
|
|
attr_reader :results
|
|
|
|
def initialize(root, files, fix:, verbose: false)
|
|
@root = root
|
|
@files = files.map { |f| File.expand_path(f) }
|
|
@fix = fix
|
|
@verbose = verbose
|
|
@results = []
|
|
end
|
|
|
|
def run
|
|
ruby_files = @files.select { |f| ruby_file?(f) || File.basename(f) == "Gemfile" }
|
|
js_files = @files.select { |f| js_file?(f) }
|
|
|
|
run_ruby_linters(ruby_files) if ruby_files.any?
|
|
run_js_linters(js_files) if js_files.any?
|
|
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 run_cmd(*cmd)
|
|
puts "Running: #{cmd.shelljoin} (cwd: #{@root})" if @verbose
|
|
system(*cmd, chdir: @root)
|
|
end
|
|
|
|
def run_linter(name, *cmd)
|
|
puts "[bin/lint] Running #{name}..."
|
|
@results << [name, run_cmd(*cmd)]
|
|
end
|
|
|
|
def run_ruby_linters(files)
|
|
puts "[bin/lint] Installing bundler dependencies in #{@root}..."
|
|
run_cmd("bundle", "install") or abort "bundle install failed in #{@root}"
|
|
|
|
stree_subcmd = @fix ? "write" : "check"
|
|
run_linter("stree", "bundle", "exec", "stree", stree_subcmd, *files)
|
|
|
|
rubocop_args = @fix ? ["--autocorrect"] : []
|
|
run_linter("rubocop", "bundle", "exec", "rubocop", *rubocop_args, *files)
|
|
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)
|
|
stylelint_files = by_ext.call(STYLELINT_EXTENSIONS)
|
|
|
|
pnpm = %w[pnpm --ignore-workspace]
|
|
puts "[bin/lint] Installing pnpm dependencies in #{@root}..."
|
|
run_cmd(*pnpm, "i") or abort "pnpm i failed in #{@root}"
|
|
|
|
if prettier_files.any?
|
|
args = @fix ? ["--write"] : ["--list-different"]
|
|
run_linter("prettier", *pnpm, "prettier", *args, *prettier_files)
|
|
end
|
|
|
|
if eslint_files.any?
|
|
args = @fix ? ["--fix"] : ["--quiet"]
|
|
run_linter("eslint", *pnpm, "eslint", *args, *eslint_files)
|
|
end
|
|
|
|
if stylelint_files.any?
|
|
args = @fix ? ["--fix"] : []
|
|
run_linter("stylelint", *pnpm, "stylelint", *args, *stylelint_files)
|
|
end
|
|
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]
|
|
@results = []
|
|
end
|
|
|
|
EVERYTHING = ["ALL FILES"]
|
|
|
|
# In --staged mode we want to lint exactly what is in the index, so we use the
|
|
# pre-commit/fix-staged hooks (which read the staged blobs). Every other mode
|
|
# operates on what is currently on disk, so it routes to the lint-files/fix-files
|
|
# hooks: those are not named pre-commit/pre-push, so lefthook does not hide
|
|
# unstaged changes and the linters see the working-tree content.
|
|
def check_hook
|
|
@staged ? "pre-commit" : "lint-files"
|
|
end
|
|
|
|
def fix_hook
|
|
@staged ? "fix-staged" : "fix-files"
|
|
end
|
|
|
|
def run
|
|
files = determine_files
|
|
|
|
if @fix
|
|
run_fix_mode(files)
|
|
else
|
|
run_check_mode(files)
|
|
end
|
|
|
|
print_summary
|
|
exit 1 if @results.any? { |_, ok| !ok }
|
|
end
|
|
|
|
def print_summary
|
|
failed = @results.reject { |_, ok| ok }
|
|
if failed.empty?
|
|
puts "[bin/lint] All lints passed"
|
|
else
|
|
failed.each { |name, _| puts "[bin/lint] #{name} failed" }
|
|
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 ts gts mts cts 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_hook, core) if core.any?
|
|
external.each do |root, fs|
|
|
linter = ExternalLinter.new(root, fs, fix: true, verbose: @verbose)
|
|
linter.run
|
|
collect_external_results(root, linter)
|
|
end
|
|
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(check_hook, core) if core.any?
|
|
external.each do |root, fs|
|
|
linter = ExternalLinter.new(root, fs, fix: false, verbose: @verbose)
|
|
linter.run
|
|
collect_external_results(root, linter)
|
|
end
|
|
end
|
|
|
|
def collect_external_results(root, linter)
|
|
rel = Pathname.new(root).relative_path_from(Pathname.new(PROJECT_ROOT)).to_s
|
|
linter.results.each { |name, ok| @results << ["#{name} (#{rel})", ok] }
|
|
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 "[bin/lint] Running core linters via lefthook..."
|
|
puts "Running: #{cmd.shelljoin}" if @verbose
|
|
@results << ["core linters", 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
|