Skip to content
Standard Ruby no Apple Silicon e no Docker: um linter, dois runners

Standard Ruby no Apple Silicon e no Docker: um linter, dois runners

TL;DR

O Standard é o RuboCop com um conjunto de regras fixo, debatido pela comunidade, que encerra as brigas sobre estilo. Adicione com standard-rails, fixe ruby_version no .standard.yml e adote com --fix e --generate-todo. Um pequeno script bin/standardrb roda nativo no Apple Silicon e no Docker nas demais máquinas; com um único lockfile, os dois dão o mesmo resultado.

O Standard dá aos times Ruby um linter e formatador sem nada para discutir. Veja o que ele faz, como ele se apoia no RuboCop e um pequeno script que o executa nativamente no Apple Silicon e no Docker em todo o resto, com resultados idênticos.

Publicado em 11 min de leitura

Também disponível em English

Compartilhar

Todo time Ruby já teve essa discussão. Aspas simples ou duplas. Vírgulas finais. Alinhamento de hashes. Alguém ajusta o .rubocop.yml do seu jeito, outra pessoa reverte uma regra no PR seguinte, e o linter vira fonte de churn em vez de rede de segurança.

O Standard encerra essa conversa ao se recusar a tê-la. Este post cobre o que a gem oferece, como ela funciona por dentro e um setup prático para times cujo app Rails roda no Docker Compose: lint nativo em Macs com Apple Silicon, fallback para o container em todo o resto e a mesma resposta nos dois casos.

O que é o Standard

O Standard (gem standard, executável standardrb) é um linter e formatador Ruby mantido pela Test Double desde 2018. Ele traz a filosofia do StandardJS para o Ruby: um único conjunto de regras, e você não pode configurá-lo.

Por baixo dos panos, é o RuboCop mais o rubocop-performance, com o estado (ativo ou não) e as configurações de cada cop já decididos para você. Esse conjunto de regras é fruto de anos de debate na comunidade, e novas versões saem mais ou menos todo mês para acompanhar os novos cops do RuboCop.

O argumento a favor não é que as escolhas do Standard sejam as melhores possíveis. É que o valor de um linter vem de usá-lo de forma consistente, não de como ele está ajustado, e um conjunto fixo de regras elimina uma categoria inteira de comentários de review e de churn de configuração.

Recursos que valem a pena conhecer

  • Lint e formatação em uma só ferramenta. standardrb reporta as ofensas. standardrb --fix aplica as autocorreções consideradas seguras. standardrb --fix-unsafely aplica também as que podem mudar o comportamento, então rode com a working tree limpa e deixe seus testes julgarem.
  • Tarefas Rake. Adicione require "standard/rake" ao seu Rakefile para ter rake standard, rake standard:fix e rake standard:fix_unsafely. Flags de CLI passam pela variável de ambiente STANDARDOPTS.
  • Válvulas de escape sem guerra de configuração. Desative um cop em uma linha ou bloco com # standard:disable Cop/Name (e # standard:enable), ignore arquivos inteiros ou globs, ou ignore cops específicos para um glob no .standard.yml.
  • Adoção incremental. standardrb --generate-todo gera um .standard_todo.yml com as ofensas existentes por arquivo. Código novo segue o conjunto completo de regras enquanto você reduz o backlog.
  • Plugins. standard-rails e standard-sorbet adicionam cops específicos de framework, e você pode escrever os seus com a gem lint_roller. Plugins adicionam regras; não podem afrouxar as do Standard.
  • extend_config. Aponte o Standard para arquivos YAML comuns do RuboCop para incluir cops extras (de uma gem como a betterlint, por exemplo) sem empacotar um plugin.
  • Suporte a editores via LSP. O Standard inclui um add-on para o Ruby LSP que carrega automaticamente quando a gem está no seu Gemfile, com pull diagnostics, formatação e code actions de quick fix. Para editores que não usam o Ruby LSP, standardrb --lsp inicia um language server independente.
  • Qualquer formatter do RuboCop. --format progress, --format json ou --format github para anotações inline no GitHub Actions.

Como funciona

O Standard não faz fork do RuboCop. Cada execução segue mais ou menos este roteiro:

  1. Lê as opções. Flags de CLI mais o .standard.yml, que o Standard encontra subindo a partir do diretório atual.
  2. Monta uma configuração do RuboCop em memória. O conjunto base de regras do Standard entra primeiro, depois seus plugins padrão (standard-custom e standard-performance), depois os plugins que você listar e, por fim, quaisquer arquivos de extend_config. A configuração é first-in-wins: depois que uma camada configura um cop, as camadas seguintes não podem alterá-lo. É assim que o Standard continua "inconfigurável" e, ainda assim, extensível.
  3. Resolve o Ruby alvo. ruby_version vira o TargetRubyVersion do RuboCop. Se você não definir, ele é inferido de arquivos como .ruby-version, .tool-versions, Gemfile.lock ou um gemspec.
  4. Aplica os ignores. Os ignores padrão, seus globs de ignore: e o arquivo de todo.
  5. Passa o controle para o RuboCop. Cops, autocorreção, cache de resultados e formatters são todos do RuboCop.

Três consequências práticas:

  • Atualizar o Standard atualiza o RuboCop e o conjunto de regras juntos. Seu Gemfile.lock fixa os dois, então você faz o bump no seu ritmo.
  • Com o mesmo lockfile, o mesmo .standard.yml e o mesmo Ruby alvo, os resultados são determinísticos, não importa onde o Standard rode. É essa propriedade que torna segura a divisão entre host e Docker mais abaixo.
  • Qualquer ferramenta que entende a saída do RuboCop já entende a do Standard.

Configurando em um app Rails

# Gemfile
group :development, :test do
  gem "standard", require: false
  gem "standard-rails", require: false
end
# .standard.yml
ruby_version: 3.4        # match .ruby-version; pinning removes any host vs. container ambiguity
parallel: true
format: progress
plugins:
  - standard-rails       # Rails version is usually detected; pin with target_rails_version if needed
ignore:
  - "db/schema.rb"
  - "db/*_schema.rb"
  - "bin/**/*"
  - "vendor/**/*"
  - "node_modules/**/*"
  - "tmp/**/*"
# Rakefile
require "standard/rake"

Se o app veio de um rails new no Rails 7.2 ou posterior, ele já vem com rubocop-rails-omakase. Para trocar, remova essa gem, o .rubocop.yml e o bin/rubocop, e atualize a etapa de lint no CI. Adote em dois passos:

bundle exec standardrb --fix            # take every safe autocorrect
bundle exec standardrb --generate-todo  # park whatever is left

Faça o commit disso separado do trabalho de features para que o diff continue revisável.

Nativo no Apple Silicon, Docker em todo o resto

Esta é a situação que esta seção resolve. O app roda no Docker Compose, e rodar o lint via docker compose run funciona em qualquer máquina. Mas cada invocação sobe um container e lê o projeto pelo compartilhamento de arquivos do Docker Desktop. Isso não é problema no CI e é tolerável de vez em quando, mas fica lento em um hook de pre-commit.

Em Macs com Apple Silicon, um Ruby nativo é rápido de instalar com mise, rbenv ou asdf. Gems nativas comuns como a pg publicam builds pré-compilados para arm64-darwin. Ali, rodar o Standard no host é rápido e barato de manter.

Em Macs Intel, estações Linux ou qualquer máquina sem um Ruby correspondente no host, o container continua sendo o caminho confiável.

Como os dois caminhos leem o mesmo Gemfile.lock e o mesmo .standard.yml, eles produzem os mesmos resultados. A única decisão é qual runner usar, e um script pode tomar essa decisão.

Preparando o host

# Install the Ruby the image uses
mise install ruby@"$(cat .ruby-version)"   # or rbenv/asdf

# Make sure container-oriented Bundler settings are not leaking onto the host
env | grep BUNDLE_               # unset BUNDLE_PATH, BUNDLE_APP_CONFIG, BUNDLE_DEPLOYMENT, BUNDLE_WITHOUT
cat .bundle/config 2>/dev/null   # remove container paths such as /usr/local/bundle

bundle install
bundle exec standardrb

Quatro pegadinhas aparecem quando o lockfile só foi resolvido dentro do Linux:

  • Plataformas do lockfile. A primeira instalação no host adiciona arm64-darwin ao Gemfile.lock, e o modo frozen ou deployment se recusa a fazer isso. Adicione a plataforma uma vez e faça commit: bundle lock --add-platform arm64-darwin.
  • vendor/bundle compartilhado. Se o container instala gems em vendor/bundle dentro do bind mount, não instale no mesmo caminho no host. As extensões nativas de Linux e de Darwin vão se sobrescrever. Deixe o host usar o gem home padrão do seu gerenciador de versões.
  • Gems nativas. Desde a 1.6, a pg publica gems pré-compiladas por plataforma com a libpq embutida, então o host geralmente não precisa do PostgreSQL do Homebrew. Gems sem builds binários, como a mysql2, ainda precisam das bibliotecas cliente instaladas localmente, mesmo que o lint nunca as carregue.
  • Terminais com Rosetta. Se o seu app de terminal está com a opção "Abrir com o Rosetta" ativada, uname -m reporta x86_64 em um Mac com Apple Silicon. O script abaixo leva isso em conta, mas um terminal nativo continua sendo o melhor padrão.

O script runner

Salve isto como bin/standardrb e rode chmod +x nele. (Se algum dia você rodar bundle binstubs standard, não deixe que ele sobrescreva este arquivo.)

#!/bin/sh
# Run Standard natively on Apple Silicon, in Docker everywhere else.
# Override with STANDARDRB_RUNNER=local|docker.
set -eu

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"

is_apple_silicon() {
  [ "$(uname -s)" = "Darwin" ] || return 1
  # Native arm64 shell
  [ "$(uname -m)" = "arm64" ] && return 0
  # x86_64 shell translated by Rosetta, which only exists on Apple Silicon
  [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = "1" ] && return 0
  # Hardware flag as a last check
  [ "$(sysctl -n hw.optional.arm64 2>/dev/null || echo 0)" = "1" ]
}

local_ready() {
  command -v bundle >/dev/null 2>&1 && bundle check >/dev/null 2>&1
}

run_local() {
  exec bundle exec standardrb "$@"
}

run_docker() {
  exec docker compose run --rm -T app bundle exec standardrb "$@"
}

case "${STANDARDRB_RUNNER:-auto}" in
  local)  run_local "$@" ;;
  docker) run_docker "$@" ;;
  auto)   ;;
  *)      echo "STANDARDRB_RUNNER must be auto, local or docker" >&2; exit 64 ;;
esac

if is_apple_silicon; then
  if local_ready; then
    run_local "$@"
  fi
  echo "standardrb: host bundle not ready (run bundle install); using Docker" >&2
fi

run_docker "$@"

Uso:

bin/standardrb                            # lint
bin/standardrb --fix                      # autocorrect
bin/standardrb app/models/user.rb         # specific files
STANDARDRB_RUNNER=docker bin/standardrb   # force the container, e.g. to compare runners

Como ele decide:

  • Detecção de Apple Silicon. Ele verifica três coisas, nesta ordem: um shell arm64 nativo, um shell traduzido pelo Rosetta (sysctl.proc_translated igual a 1) e, por fim, a flag de hardware hw.optional.arm64. Verificar só o uname -m erra em terminais com Rosetta, e a flag de hardware sozinha pode não estar visível a partir de um processo traduzido.
  • Fallback quando o host não está pronto. bundle check confirma que todas as gems do lockfile estão instaladas antes de o script se comprometer com o host. Se falhar, o script imprime uma dica de uma linha e usa o Docker em vez de dar erro.
  • Exit codes repassados. exec substitui o shell, então hooks e CI falham quando há ofensas, com qualquer um dos runners.
  • TTY do container. -T desativa a alocação de TTY para que o caminho do container funcione dentro de hooks e de outros shells não interativos. (--no-TTY é a mesma flag; você só precisa de uma.)

Duas ressalvas:

  • PATH em hooks e clientes GUI. Hooks do Git e clientes Git com interface gráfica costumam rodar sem os shims do seu gerenciador de versões no PATH. Nesse caso, o script cai para o Docker. Se quiser o caminho nativo ali também, adicione eval "$(mise activate sh)" (ou o equivalente do rbenv) perto do topo.
  • Dono dos arquivos no Linux. Em um host Linux, --fix via Docker grava os arquivos como o usuário do container, muitas vezes root. Adicione --user "$(id -u):$(id -g)" à linha do docker compose run se o caminho de gems da imagem puder ser lido por esse usuário.

Integrando ao seu fluxo de trabalho

Editor. Use o Ruby LSP com o add-on do Standard. O language server fica rodando o tempo todo e conversa diretamente com o bundle do host; o script não participa.

// .vscode/settings.json
{
  "rubyLsp.rubyVersionManager": { "identifier": "mise" },
  "rubyLsp.formatter": "standard",
  "rubyLsp.linters": ["standard"],
  "[ruby]": {
    "editor.defaultFormatter": "Shopify.ruby-lsp",
    "editor.formatOnSave": true
  }
}

Desative qualquer extensão separada de Standard ou de RuboCop para evitar diagnósticos duplicados. Em máquinas sem Ruby no host, abra o projeto em um Dev Container para que o Ruby LSP rode dentro dele.

Pre-commit. Encaminhe o hook pelo script para que cada máquina escolha o runner certo:

# lefthook.yml
pre-commit:
  commands:
    standard:
      glob: "*.{rb,rake}"
      run: bin/standardrb --fix --force-exclusion {staged_files}
      stage_fixed: true

O --force-exclusion faz diferença aqui. Quando os arquivos são passados explicitamente, o RuboCop os analisa mesmo que correspondam a um glob de ignore:, então, sem a flag, um db/schema.rb em stage é analisado, e possivelmente reescrito, depois de cada migration.

CI. Pule a detecção. Chame o Standard diretamente no ambiente que o CI já usa, seja a imagem do app ou o ruby/setup-ruby, e use o formatter do GitHub para anotações inline:

bundle exec standardrb --format github

Conclusão

O Standard elimina o debate de estilo por design e, como é RuboCop por baixo, se encaixa em qualquer ferramenta que já fala RuboCop. Fixe o Ruby alvo no .standard.yml, mantenha um único lockfile, e a questão de onde ele roda vira um detalhe de performance, não de corretude: nativo no Apple Silicon pela velocidade, Docker em todo o resto pela consistência, e o mesmo veredito nos dois.

Fontes

  1. Standard Ruby (README) github.com
  2. Wiki do Standard Ruby: VS Code com Ruby LSP github.com
  3. Plugin standard-rails github.com
  4. Configuração do RuboCop: definindo a versão alvo do Ruby docs.rubocop.org
  5. Ruby LSP github.com
  6. Gem pg no RubyGems.org rubygems.org
  7. Referência do docker compose run docs.docker.com
  8. Lefthook github.com

Perguntas frequentes

Posso mudar as regras do Standard?

Não. Você pode desativar um cop em uma linha ou bloco com `# standard:disable`, ignorar arquivos ou cops específicos por glob no `.standard.yml` e adicionar regras via plugins ou `extend_config`. O que não dá é reconfigurar um cop que o Standard já configura, porque a configuração dele é first-in-wins.

Qual a diferença entre o Standard e o rubocop-rails-omakase?

Os dois são configurações do RuboCop. O omakase é o padrão em apps Rails novos desde a 7.2 e fica em um `.rubocop.yml` editável. O Standard mantém o conjunto de regras fixo e traz CLI, tarefas Rake, arquivo de todo e add-on para o Ruby LSP próprios. Escolha um: as regras de layout dos dois divergem, então rodar ambos gera autocorreções conflitantes.

A execução no host e no Docker dão mesmo o mesmo resultado?

Sim, desde que as duas leiam o mesmo `Gemfile.lock` (mesmas versões de Standard e RuboCop), o mesmo `.standard.yml` e o mesmo Ruby alvo. Fixar `ruby_version` no `.standard.yml` elimina a última variável entre os dois runners.

Preciso do PostgreSQL instalado no Mac para rodar o lint no host?

Geralmente não. Desde a 1.6, a `pg` publica gems pré-compiladas para `arm64-darwin` com a libpq incluída. Gems sem builds binários, como a `mysql2`, ainda precisam das bibliotecas cliente para instalar, mesmo que o Standard nunca as carregue.

Como adotar o Standard em uma base de código grande que já existe?

Rode `standardrb --fix` para aplicar todas as autocorreções seguras e depois `standardrb --generate-todo` para registrar as ofensas restantes no `.standard_todo.yml`. Código novo segue o conjunto completo de regras desde o primeiro dia, e você apaga as entradas do todo à medida que corrige. Faça commit dos dois passos separado do trabalho de features.

Por que o script cai para o Docker no meu Mac com Apple Silicon?

Ou o `bundle` não está no `PATH`, ou o `bundle check` falhou. Hooks do Git e clientes GUI costumam pular os shims do gerenciador de versões, então ative o mise ou o rbenv no topo do script, rode `bundle install` no host ou force o caminho nativo com `STANDARDRB_RUNNER=local`.

Comentários

Faça login com GitHub para comentar.

Ainda não há comentários.