Configurable Cops
Configurable Cops: a style guide nobody can configure is a style FIGHT on a delay timer. RuboCop's deepest lesson isn't any single cop - it's the .yml: enable/disable per cop, parameters instead of hardcoded taste, and (the policy that saved a thousand upgrades) NEW COPS ARRIVE PENDING - they never fire until the team opts in, so a linter update can't turn a green build red by surprise.
Observability & Ops
Round 16
Bozhidar Batsov
exit 0
bundle exec ruby examples/configurable_cops.rb
a real captured run
CONFIGURABLE COPS (same contract, two teams, two configs)
team A (defaults + opted into pending cop): 4 offense(s)
Documentation/Description capability has no description
Style/EnumOrder input :mode enum is not sorted
Metrics/RequiredInputCount 6 required inputs (Max: 5)
Lint/UntypedField input :ref has no type
team B (raised Max, disabled EnumOrder, pending cop stays off): 1 offense(s)
Documentation/Description capability has no description
the pending status is the load-bearing idea: Lint/UntypedField
shipped in this release, and for team B it fired ZERO times -
not because it's wrong but because a linter update must never
turn a green build red without the team's signature. team A
signed. and look at what team B's config really is: a RECORD OF
DECISIONS - 'Max: 8' with a comment, blame-able to a person and
a date, instead of the same argument re-fought in every review.
hardcoded taste creates rebels; configurable taste creates a
paper trail. the style guide is the conversation; the config
file is its minutes.
source
# frozen_string_literal: true # Configurable Cops: a style guide nobody can configure is a style # FIGHT on a delay timer. RuboCop's deepest lesson isn't any single # cop - it's the .yml: enable/disable per cop, parameters instead of # hardcoded taste, and (the policy that saved a thousand upgrades) # NEW COPS ARRIVE PENDING - they never fire until the team opts in, # so a linter update can't turn a green build red by surprise. # # bundle exec ruby examples/configurable_cops.rb # # Runs offline; one contract, two teams, two verdicts, zero fights. require class="s">"bundler/setup" require class="s">"agentic" require class="s">"yaml" # The defendant, unchanged between teams CONTRACT = { name: class="s">"quote_shipping", description: class="s">"", version: class="s">"1.0.0", inputs: { mode: {type: class="s">"string", required: true, enum: %w[sea air road]}, weight_kg: {type: class="s">"number", required: true, min: 0, max: 5_000}, a: {type: class="s">"string", required: true}, b: {type: class="s">"string", required: true}, c: {type: class="s">"string", required: true}, d: {type: class="s">"string", required: true}, ref: {} } }.freeze # Cops carry status (:stable or :pending) and read their params from config COPS = { class="s">"Documentation/Description" => { status: class="y">:stable, check: ->(s, _p) { s[class="y">:description].to_s.empty? ? [class="s">"capability has no description"] : [] } }, class="s">"Style/EnumOrder" => { status: class="y">:stable, check: ->(s, _p) { s[class="y">:inputs].select { |_, d| d[class="y">:enum] && d[class="y">:enum] != d[class="y">:enum].sort }.map { |k, _| class="s">"input :#{k} enum is not sorted" } } }, class="s">"Metrics/RequiredInputCount" => { status: class="y">:stable, defaults: {class="s">"Max" => 5}, check: ->(s, p) { required = s[class="y">:inputs].count { |_, d| d[class="y">:required] } (required > p[class="s">"Max"]) ? [class="s">"#{required} required inputs (Max: #{p["Maxclass="s">"]})"] : [] } }, class="s">"Lint/UntypedField" => { status: class="y">:pending, # arrived in this release: fires ONLY if the team opts in check: ->(s, _p) { s[class="y">:inputs].select { |_, d| d[class="y">:type].nil? }.map { |k, _| class="s">"input :#{k} has no type" } } } }.freeze def inspect_with(config_yaml, spec) config = YAML.safe_load(config_yaml) || {} COPS.flat_map do |name, cop| cop_config = config.fetch(name, {}) enabled = cop_config.fetch(class="s">"Enabled", cop[class="y">:status] == class="y">:stable) next [] unless enabled params = (cop[class="y">:defaults] || {}).merge(cop_config.except(class="s">"Enabled")) cop[class="y">:check].call(spec, params).map { |offense| [name, offense] } end end TEAM_A = <<~YAML # team A: defaults, plus we opted into the new pending cop Lint/UntypedField: Enabled: true YAML TEAM_B = <<~YAML # team B: we have seven required inputs and we've MET our capability; # raising Max is a decision, recorded here, reviewable in git blame Metrics/RequiredInputCount: Max: 8 Style/EnumOrder: Enabled: false # our enums are ordered by freight class, not alphabet YAML puts class="s">"CONFIGURABLE COPS (same contract, two teams, two configs)" puts [[class="s">"team A (defaults + opted into pending cop)", TEAM_A], [class="s">"team B (raised Max, disabled EnumOrder, pending cop stays off)", TEAM_B]].each do |team, config| offenses = inspect_with(config, CONTRACT) puts class="s">" #{team}: #{offenses.size} offense(s)" offenses.each { |cop, offense| puts format(class="s">" %-30s %s", cop, offense) } puts end puts class="s">" the pending status is the load-bearing idea: Lint/UntypedField" puts class="s">" shipped in this release, and for team B it fired ZERO times -" puts class="s">" not because it's wrong but because a linter update must never" puts class="s">" turn a green build red without the team's signature. team A" puts class="s">" signed. and look at what team B's config really is: a RECORD OF" puts class="s">" DECISIONS - 'Max: 8' with a comment, blame-able to a person and" puts class="s">" a date, instead of the same argument re-fought in every review." puts class="s">" hardcoded taste creates rebels; configurable taste creates a" puts class="s">" paper trail. the style guide is the conversation; the config" puts class="s">" file is its minutes."