Class: Rigor::Configuration

Inherits:
Object
  • Object
show all
Defined in:
lib/rigor/configuration.rb,
lib/rigor/configuration/dependencies.rb,
lib/rigor/configuration/severity_profile.rb,
sig/rigor.rbs

Overview

rubocop:disable Metrics/ClassLength

Defined Under Namespace

Modules: SeverityProfile Classes: Dependencies

Constant Summary collapse

DISCOVERY_ORDER =

File-discovery order for Configuration.load(nil).

The first file present is loaded; the others are NOT implicitly merged. To extend a base config explicitly the winning file MUST list the base via includes:.

.rigor.yml is a developer-local override (typically gitignored); .rigor.dist.yml is the project default (committed to the repo). When both are present the developer's local override wins outright — there is no implicit auto-merge.

%w[.rigor.yml .rigor.dist.yml].freeze
DEFAULT_PATH =

Back-compat alias. Keep here so external callers that read Configuration::DEFAULT_PATH for help text / fixture paths still work; the discovery list is the canonical source.

Returns:

  • (String)
DISCOVERY_ORDER.first
BUILTIN_EXCLUDES =

Built-in exclusion patterns appended to exclude: so vendored dependencies, Bundler artefacts, and JavaScript node_modules are never analysed by accident when a directory glob expands. Users cannot disable these defaults; the trade-off is that analysing any of these paths is essentially never what the user wants (they're build outputs / external dependencies, not source).

We deliberately keep this list narrow. tmp/ and similar directories vary across project layouts (Rails has tmp/, libraries usually don't); user-supplied exclude: entries in .rigor.yml cover the project-specific cases.

%w[
  **/vendor/bundle/**
  **/.bundle/**
  **/node_modules/**
].freeze
DEFAULTS =

Returns:

  • (Hash[String, untyped])
{
  "target_ruby" => "4.0",
  "paths" => ["lib"],
  "exclude" => [],
  "plugins" => [],
  "disable" => [],
  "libraries" => [],
  "signature_paths" => nil,
  # ADR-17 — project-side monkey-patch pre-evaluation. Empty by default; users opt in by listing explicit
  # files that the analyzer walks before per-file inference so patched-method declarations are visible
  # across the project (e.g. `lib/core_ext/string_extensions.rb`). Slice 1 plumbing only — listed files
  # are validated at config-load time (`pre-eval.file-not-found` on a missing path), but the dispatcher
  # tier consuming the registry lands in slice 2.
  "pre_eval" => [],
  # ADR-22 — baseline file path. nil (default) means no baseline is loaded; the `false` literal is
  # treated as the explicit-disable form for `.rigor.yml`-side override of an upstream `.rigor.dist.yml`
  # `baseline:` declaration. The presence of `.rigor-baseline.yml` on disk alone does NOT activate
  # filtering — the path must be named here (WD2 (b) of ADR-22).
  "baseline" => nil,
  "fold_platform_specific_paths" => false,
  "cache" => {
    "path" => ".rigor/cache",
    # LRU eviction cap in bytes (ADR-54 WD3). The least-recently-used entries are removed at the end of a
    # run when the total exceeds this limit. The 256 MB default exists to reap orphans — entries whose
    # content key nothing references any more (an rbs gem bump, a signature change) and that no run
    # would otherwise ever delete; a full active per-project set is ~2 MB, so the cap never touches live
    # entries. Set explicitly to `null` to disable eviction (pre-WD3 behaviour: the cache grows until
    # `--clear-cache`).
    "max_bytes" => 268_435_456
  },
  "plugins_io" => {
    "network" => "disabled",
    "allowed_paths" => [],
    "allowed_url_hosts" => []
  },
  "severity_profile" => "balanced",
  "severity_overrides" => {},
  # ADR-50 § WD2 — bleeding-edge overlay opt-in. Selects which of the *next major's* queued changes
  # ({Rigor::BleedingEdge}) this project adopts early. Orthogonal to `severity_profile:`. Accepts `false`
  # (default — adopt none), `true` (adopt the whole overlay), a list of feature ids (adopt only those),
  # or `{ all: true, except: [ids] }` (adopt all but the named). The overlay is empty today, so every
  # form is currently a no-op; it becomes live when the first discipline is queued for a major.
  "bleeding_edge" => false,
  "dependencies" => {
    "source_inference" => [],
    "budget_per_gem" => Configuration::Dependencies::DEFAULT_BUDGET_PER_GEM
  },
  "parallel" => {
    # ADR-15 Phase 4c — when greater than zero, `rigor check` dispatches per-file analysis across N
    # Ractor workers built around {Rigor::Analysis::WorkerSession}. `0` (default) keeps the sequential
    # coordinator path bit-for-bit unchanged. The CLI's `--workers=N` flag and the `RIGOR_RACTOR_WORKERS`
    # env var both override this setting; precedence is CLI > env > config > 0.
    "workers" => 0
  },
  "bundler" => {
    # Open item O4 — target-project Bundler awareness. When `bundle_path:` is set (or auto-detected),
    # Rigor walks `<bundle_path>/ruby/*/gems/*/sig/` and adds each gem-shipped sig directory to
    # `signature_paths:`. With O7's failure-memo in place, conflicts (a vendored sig already declares the
    # same constant) degrade gracefully to "no RBS env" with a single-line warning naming the offending
    # file, rather than hanging.
    #
    # `bundle_path:` (String, optional): explicit path to the bundler install root (e.g.,
    # "vendor/bundle" or an absolute path). Resolved relative to the project root (`paths:`'s base) when
    # relative.
    #
    # `auto_detect:` (Boolean, default true): when no explicit `bundle_path:` is set, try
    # `.bundle/config`'s `BUNDLE_PATH:` first; fall back to `vendor/bundle/` under the project root if it
    # exists. When neither is found, no extra sigs are added — the analyzer sees only rigor's vendored
    # RBS and the user's `signature_paths:`.
    #
    # O4 Layer 3 keys:
    #
    # `lockfile:` (String, optional): explicit path to a `Gemfile.lock`. Resolved relative to the
    # project root when relative. When set (or auto-detected via the `auto_detect:` flag below) Rigor
    # parses the lockfile and uses it to FILTER the bundle-discovered `sig/` directories: only gems whose
    # `(name, version, platform)` matches a lockfile entry are admitted to `signature_paths:`. Stale or
    # out-of-band gems sitting in the bundle install tree are silently dropped.
    #
    # `auto_detect:` (Boolean, also gates the lockfile search): when true and `lockfile:` is nil, look
    # for `<project_root>/Gemfile.lock`.
    "bundle_path" => nil,
    "auto_detect" => true,
    "lockfile" => nil
  },
  "rbs_collection" => {
    # Open item O4 Layer 3 slice 2 — `rbs collection install` awareness. When the target project has been
    # set up with `rbs collection install`, the resulting `rbs_collection.lock.yaml` carries the resolved
    # (gem, version, source) triples and `.gem_rbs_collection/` holds the downloaded `.rbs` files. Rigor
    # parses the lockfile and auto-feeds each gem's `<collection_root>/<name>/<version>/` directory into
    # `RbsLoader`'s `signature_paths:`. Sources of type `stdlib` are skipped because rigor's bundled
    # `DEFAULT_LIBRARIES` already covers that surface.
    #
    # `lockfile:` (String, optional): explicit path to `rbs_collection.lock.yaml`. Resolved relative to
    # the project root when relative.
    #
    # `auto_detect:` (Boolean, default true): when no explicit `lockfile:` is set, look for
    # `<project_root>/rbs_collection.lock.yaml`.
    "lockfile" => nil,
    "auto_detect" => true
  }
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(data = DEFAULTS) ⇒ Configuration

rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity

Parameters:

  • data (Hash[String, untyped]) (defaults to: DEFAULTS)


282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
# File 'lib/rigor/configuration.rb', line 282

def initialize(data = DEFAULTS)
  cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
  plugins_io = DEFAULTS.fetch("plugins_io").merge(data.fetch("plugins_io", {}))

  @target_ruby = coerce_target_ruby(data.fetch("target_ruby", DEFAULTS.fetch("target_ruby")))
  @paths = Array(data.fetch("paths", DEFAULTS.fetch("paths"))).map(&:to_s).freeze
  user_excludes = Array(data.fetch("exclude", DEFAULTS.fetch("exclude"))).map(&:to_s)
  @exclude_patterns = (BUILTIN_EXCLUDES + user_excludes).uniq.freeze
  @plugins = Array(data.fetch("plugins", DEFAULTS.fetch("plugins"))).map do |entry|
    coerce_plugin_entry(entry)
  end.freeze
  @disabled_rules = Array(data.fetch("disable", DEFAULTS.fetch("disable"))).map(&:to_s).freeze
  @libraries = Array(data.fetch("libraries", DEFAULTS.fetch("libraries"))).map(&:to_s).freeze
  sig_paths = data.fetch("signature_paths", DEFAULTS.fetch("signature_paths"))
  @signature_paths = sig_paths.nil? ? nil : Array(sig_paths).map(&:to_s).freeze
  @pre_eval = expand_pre_eval_entries(
    Array(data.fetch("pre_eval", DEFAULTS.fetch("pre_eval"))).map(&:to_s)
  )
  @baseline_path = coerce_baseline_path(data.fetch("baseline", DEFAULTS.fetch("baseline")))
  @fold_platform_specific_paths = data.fetch(
    "fold_platform_specific_paths", DEFAULTS.fetch("fold_platform_specific_paths")
  ) == true
  @cache_path = cache.fetch("path").to_s
  raw_max = cache.fetch("max_bytes")
  @cache_max_bytes = raw_max.nil? ? nil : Integer(raw_max)
  @plugins_io_network = coerce_network_policy(plugins_io.fetch("network"))
  @plugins_io_allowed_paths = Array(plugins_io.fetch("allowed_paths")).map(&:to_s).freeze
  @plugins_io_allowed_url_hosts = Array(plugins_io.fetch("allowed_url_hosts")).map(&:to_s).freeze
  @severity_profile = coerce_severity_profile(
    data.fetch("severity_profile", DEFAULTS.fetch("severity_profile"))
  )
  @severity_overrides = coerce_severity_overrides(
    data.fetch("severity_overrides", DEFAULTS.fetch("severity_overrides"))
  )
  @bleeding_edge = coerce_bleeding_edge(
    data.fetch("bleeding_edge", DEFAULTS.fetch("bleeding_edge"))
  )
  @bleeding_edge_severity_overrides = BleedingEdge.severity_overrides_for(@bleeding_edge)
  @dependencies = Dependencies.from_h(
    data.fetch("dependencies", DEFAULTS.fetch("dependencies"))
  )
  parallel = DEFAULTS.fetch("parallel").merge(data.fetch("parallel", {}))
  @parallel_workers = coerce_parallel_workers(parallel.fetch("workers"))
  bundler = DEFAULTS.fetch("bundler").merge(data.fetch("bundler", {}))
  bp = bundler.fetch("bundle_path")
  @bundler_bundle_path = bp.nil? ? nil : bp.to_s.dup.freeze
  @bundler_auto_detect = bundler.fetch("auto_detect") == true
  lf = bundler.fetch("lockfile")
  @bundler_lockfile = lf.nil? ? nil : lf.to_s.dup.freeze
  rbs_collection = DEFAULTS.fetch("rbs_collection").merge(data.fetch("rbs_collection", {}))
  rclf = rbs_collection.fetch("lockfile")
  @rbs_collection_lockfile = rclf.nil? ? nil : rclf.to_s.dup.freeze
  @rbs_collection_auto_detect = rbs_collection.fetch("auto_detect") == true
  # Ractor migration Phase 2a: deep-freeze the Configuration so it is `Ractor.shareable?`. Every ivar
  # above is now either a frozen value (Symbol / nil / Boolean) or an explicitly frozen collection /
  # value object; freezing `self` makes the whole carrier safe to send across Ractor boundaries (and
  # catches accidental post-init mutation in any caller). See `docs/design/20260514-ractor-migration.md`.
  freeze
end

Instance Attribute Details

#baseline_pathString? (readonly)

Returns the value of attribute baseline_path.

Returns:

  • (String, nil)


146
147
148
# File 'lib/rigor/configuration.rb', line 146

def baseline_path
  @baseline_path
end

#bleeding_edgeObject (readonly)

Returns the value of attribute bleeding_edge.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def bleeding_edge
  @bleeding_edge
end

#bleeding_edge_severity_overridesObject (readonly)

Returns the value of attribute bleeding_edge_severity_overrides.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def bleeding_edge_severity_overrides
  @bleeding_edge_severity_overrides
end

#bundler_auto_detectObject (readonly)

Returns the value of attribute bundler_auto_detect.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def bundler_auto_detect
  @bundler_auto_detect
end

#bundler_bundle_pathObject (readonly)

Returns the value of attribute bundler_bundle_path.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def bundler_bundle_path
  @bundler_bundle_path
end

#bundler_lockfileObject (readonly)

Returns the value of attribute bundler_lockfile.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def bundler_lockfile
  @bundler_lockfile
end

#cache_max_bytesObject (readonly)

Returns the value of attribute cache_max_bytes.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def cache_max_bytes
  @cache_max_bytes
end

#cache_pathString (readonly)

Returns the value of attribute cache_path.

Returns:

  • (String)


146
147
148
# File 'lib/rigor/configuration.rb', line 146

def cache_path
  @cache_path
end

#dependenciesObject (readonly)

Returns the value of attribute dependencies.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def dependencies
  @dependencies
end

#disabled_rulesObject (readonly)

Returns the value of attribute disabled_rules.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def disabled_rules
  @disabled_rules
end

#exclude_patternsObject (readonly)

Returns the value of attribute exclude_patterns.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def exclude_patterns
  @exclude_patterns
end

#fold_platform_specific_pathsObject (readonly)

Returns the value of attribute fold_platform_specific_paths.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def fold_platform_specific_paths
  @fold_platform_specific_paths
end

#librariesObject (readonly)

Returns the value of attribute libraries.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def libraries
  @libraries
end

#parallel_workersObject (readonly)

Returns the value of attribute parallel_workers.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def parallel_workers
  @parallel_workers
end

#pathsArray[String] (readonly)

Returns the value of attribute paths.

Returns:

  • (Array[String])


146
147
148
# File 'lib/rigor/configuration.rb', line 146

def paths
  @paths
end

#pluginsArray[String] (readonly)

Returns the value of attribute plugins.

Returns:

  • (Array[String])


146
147
148
# File 'lib/rigor/configuration.rb', line 146

def plugins
  @plugins
end

#plugins_io_allowed_pathsObject (readonly)

Returns the value of attribute plugins_io_allowed_paths.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def plugins_io_allowed_paths
  @plugins_io_allowed_paths
end

#plugins_io_allowed_url_hostsObject (readonly)

Returns the value of attribute plugins_io_allowed_url_hosts.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def plugins_io_allowed_url_hosts
  @plugins_io_allowed_url_hosts
end

#plugins_io_networkObject (readonly)

Returns the value of attribute plugins_io_network.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def plugins_io_network
  @plugins_io_network
end

#pre_evalObject (readonly)

Returns the value of attribute pre_eval.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def pre_eval
  @pre_eval
end

#rbs_collection_auto_detectObject (readonly)

Returns the value of attribute rbs_collection_auto_detect.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def rbs_collection_auto_detect
  @rbs_collection_auto_detect
end

#rbs_collection_lockfileObject (readonly)

Returns the value of attribute rbs_collection_lockfile.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def rbs_collection_lockfile
  @rbs_collection_lockfile
end

#severity_overridesObject (readonly)

Returns the value of attribute severity_overrides.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def severity_overrides
  @severity_overrides
end

#severity_profileObject (readonly)

Returns the value of attribute severity_profile.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def severity_profile
  @severity_profile
end

#signature_pathsObject (readonly)

Returns the value of attribute signature_paths.



146
147
148
# File 'lib/rigor/configuration.rb', line 146

def signature_paths
  @signature_paths
end

#target_rubyString (readonly)

Returns the value of attribute target_ruby.

Returns:

  • (String)


146
147
148
# File 'lib/rigor/configuration.rb', line 146

def target_ruby
  @target_ruby
end

Class Method Details

.discoverString?

Returns the path to the config file Rigor would load under auto-discovery, or nil when neither candidate exists. Public so the CLI / spec drift checks can introspect the resolved file.

Returns:

  • (String, nil)


179
180
181
# File 'lib/rigor/configuration.rb', line 179

def self.discover
  DISCOVERY_ORDER.find { |candidate| File.exist?(candidate) }
end

.load(path = nil) ⇒ Configuration

Loads a configuration file.

path == nil triggers auto-discovery against DISCOVERY_ORDER. The first present file in that list is loaded; if none exist the built-in DEFAULTS are used.

When a path is supplied (whether by auto-discovery or by the caller) the YAML body is processed for includes: recursively, and every relative path inside path-bearing keys (paths:, signature_paths:, plugins_io.allowed_paths:, includes:) is resolved against THAT file's directory. The resolution is per-file: an included file's relative paths resolve against the included file's directory, not the top-level file. Path resolution mirrors PHPStan.

Parameters:

  • path (String, nil) (defaults to: nil)

Returns:



169
170
171
172
173
174
175
# File 'lib/rigor/configuration.rb', line 169

def self.load(path = nil)
  resolved = path || discover
  return new(DEFAULTS) if resolved.nil? || !File.exist?(resolved)

  data = load_with_includes(resolved)
  new(DEFAULTS.merge(data))
end

.load_with_includes(path, visited: Set.new) ⇒ Hash[String, untyped]

Reads path (which MUST exist) plus every file listed in its includes: chain, merging them under the order: included files first (in declaration order), then the current file's own keys override. Relative paths inside each file are resolved against that file's directory. Public so the CLI can run the include-aware load before applying --treat-all-as-inline-rbs's plugin injection (see Rigor::CLI::CheckCommand#load_check_configuration).

Parameters:

  • path (String)
  • visited: (Set[String]) (defaults to: Set.new)

Returns:

  • (Hash[String, untyped])

Raises:

  • (ArgumentError)


188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/rigor/configuration.rb', line 188

def self.load_with_includes(path, visited: Set.new)
  absolute = File.expand_path(path)
  raise ArgumentError, "circular include: #{absolute}" if visited.include?(absolute)

  raw = YAML.safe_load_file(absolute, aliases: false) || {}
  raise ArgumentError, "config file must be a YAML mapping: #{absolute}" unless raw.is_a?(Hash)

  base_dir = File.dirname(absolute)
  includes = Array(raw.delete("includes") || [])
  data = resolve_paths_in(raw, base_dir)
  next_visited = visited + [absolute]
  merge_includes(data, includes, base_dir, next_visited)
end

.resolve_path_key!(out, key, base_dir) ⇒ Object



230
231
232
233
234
# File 'lib/rigor/configuration.rb', line 230

def self.resolve_path_key!(out, key, base_dir)
  return unless out.key?(key) && !out[key].nil?

  out[key] = Array(out[key]).map { |p| File.expand_path(p.to_s, base_dir) }
end

.resolve_plugins_io_paths!(out, base_dir) ⇒ Object



236
237
238
239
240
241
242
243
# File 'lib/rigor/configuration.rb', line 236

def self.resolve_plugins_io_paths!(out, base_dir)
  plugins_io = out["plugins_io"]
  return unless plugins_io.is_a?(Hash) && plugins_io["allowed_paths"]

  duped = plugins_io.dup
  duped["allowed_paths"] = Array(plugins_io["allowed_paths"]).map { |p| File.expand_path(p.to_s, base_dir) }
  out["plugins_io"] = duped
end

Instance Method Details

#to_hHash[String, untyped]

rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity

Returns:

  • (Hash[String, untyped])


343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
# File 'lib/rigor/configuration.rb', line 343

def to_h # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
  {
    "target_ruby" => target_ruby,
    "paths" => paths,
    "exclude" => exclude_patterns - BUILTIN_EXCLUDES,
    "plugins" => plugins,
    "disable" => disabled_rules,
    "libraries" => libraries,
    "signature_paths" => signature_paths,
    "pre_eval" => pre_eval,
    "fold_platform_specific_paths" => fold_platform_specific_paths,
    "cache" => {
      "path" => cache_path,
      "max_bytes" => cache_max_bytes
    },
    "plugins_io" => {
      "network" => plugins_io_network.to_s,
      "allowed_paths" => plugins_io_allowed_paths,
      "allowed_url_hosts" => plugins_io_allowed_url_hosts
    },
    "severity_profile" => severity_profile.to_s,
    "severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] },
    "bleeding_edge" => bleeding_edge_to_h,
    "dependencies" => dependencies.to_h,
    "parallel" => {
      "workers" => parallel_workers
    },
    "bundler" => {
      "bundle_path" => bundler_bundle_path,
      "auto_detect" => bundler_auto_detect,
      "lockfile" => bundler_lockfile
    },
    "rbs_collection" => {
      "lockfile" => rbs_collection_lockfile,
      "auto_detect" => rbs_collection_auto_detect
    }
  }
end

#with_bleeding_edge(value) ⇒ Object

ADR-50 § WD2 — returns a sibling Configuration whose bleeding-edge selection (and the derived bleeding_edge_severity_overrides the two Rigor::Configuration::SeverityProfile.resolve sites consult) is replaced by value, leaving every other field shared with the receiver. value takes the same forms as the bleeding_edge: config key — false / true / a feature-id Array / { "all" => true, "except" => [...] } — and is normalised through the same #coerce_bleeding_edge path, so an unknown id stays inert.

The CLI's --bleeding-edge[=ids] / --no-bleeding-edge flag uses this to override the configured selection for a single run (the same CLI-over-config precedence --workers and --no-cache follow). It is a frozen dup with the two bleeding-edge ivars re-set: dup returns an unfrozen shallow copy (every other ivar is the receiver's deeply-frozen value, safe to share read-only), the two replacements are themselves deeply frozen, and the re-freeze keeps the result Ractor.shareable? for the worker path.



395
396
397
398
399
400
401
402
# File 'lib/rigor/configuration.rb', line 395

def with_bleeding_edge(value)
  selector = coerce_bleeding_edge(value)
  copy = dup
  copy.instance_variable_set(:@bleeding_edge, selector)
  copy.instance_variable_set(:@bleeding_edge_severity_overrides,
                             BleedingEdge.severity_overrides_for(selector))
  copy.freeze
end