diff --git a/doc/VERSIONS b/doc/VERSIONS index 1ea82cb..54722d2 100644 --- a/doc/VERSIONS +++ b/doc/VERSIONS @@ -8,6 +8,7 @@ v26.09 (Release Date: TBD) - Add per-fetch interval handling to FilterFullFeed, FilterImageSource, and FilterDescriptionLink. - Make doc/PLUGINS.md section 6 the single source of truth for the current plugin catalogue. - Fix CLI contract drift so inspect parses the first discovered feed and scaffold restores missing bundled siteinfo and example configuration without overwriting existing user data. +- Distinguish missing optional dependencies from unrelated load failures so CLI diagnostics and plugin-spec skips do not hide broken loads. v26.08 (2026-08-22) ------------------- diff --git a/lib/automatic.rb b/lib/automatic.rb index a6bd3c3..20f90b0 100755 --- a/lib/automatic.rb +++ b/lib/automatic.rb @@ -5,7 +5,7 @@ # License:: The GPL version 3, or LGPL version 3 (Dual License). # Contact:: idnanashi@gmail.com # Created:: Feb 18, 2012 -# Updated:: Aug 14, 2026 +# Updated:: Sep 6, 2026 # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers. # # The framework module: the two directories everything else resolves paths @@ -38,6 +38,14 @@ class NoPluginError < Error; end # A Recipe parsed, but is not a document this framework can run. class InvalidRecipeError < Error; end + # Raised by require_optional when, and only when, the exact feature it was + # asked to require could not be found. A LoadError subtype rather than an + # Error subtype, so that a caller already rescuing LoadError still catches + # it; distinct from plain LoadError so that a caller can tell "the optional + # gem itself is missing" apart from a LoadError raised from inside that + # gem's own load. See doc/POLICY.md section 9.1. + class OptionalDependencyError < LoadError; end + class << self attr_accessor :root_dir @@ -55,7 +63,14 @@ class << self def require_optional(feature, needed_by:, gem_name: feature) require feature rescue LoadError => e - raise LoadError, + # e.path is the argument require failed to find. When it matches + # feature, this require itself is what failed, and the gem naming this + # feature is what is missing. When it does not, the failure happened + # somewhere inside feature's own load -- a different missing file -- and + # is not this gem's absence; it is re-raised unconverted. + raise e unless e.path == feature + + raise OptionalDependencyError, "The `#{gem_name}` gem is not installed. It is needed by #{needed_by}. " \ "Install it with `gem install #{gem_name}`, or in a source checkout add " \ 'its group to the bundle; see the optional plugin dependencies in ' \ diff --git a/lib/automatic/cli.rb b/lib/automatic/cli.rb index f400825..eebe7b3 100644 --- a/lib/automatic/cli.rb +++ b/lib/automatic/cli.rb @@ -5,7 +5,7 @@ # License:: The GPL version 3, or LGPL version 3 (Dual License). # Contact:: idnanashi@gmail.com # Created:: Aug 14, 2026 -# Updated:: Sep 5, 2026 +# Updated:: Sep 6, 2026 # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers. # # Everything that belongs to being a command: option parsing, the subcommands, @@ -103,11 +103,13 @@ def usage(parser) EXIT_FAILURE end - # A LoadError is one of the failures reported as a message here: a Recipe - # naming a plugin whose optional gem is not installed is an operator's - # mistake with an answer, and the answer is in the message rather than in a - # backtrace. What the plugin itself raises is left alone; see - # doc/PLUGINS.md section 2.7. + # Automatic::OptionalDependencyError is one of the failures reported as a + # message here: a Recipe naming a plugin whose optional gem is not + # installed is an operator's mistake with an answer, and the answer is in + # the message rather than in a backtrace. A LoadError raised from inside a + # plugin's own load -- for a different, unrelated missing file -- is not + # this, and is left alone along with everything else the plugin itself + # raises; see doc/PLUGINS.md section 2.7. def run_recipe(path) unless File.exist?(resolve_recipe(path)) @stderr.puts "automatic: no such recipe: #{path}" @@ -116,7 +118,8 @@ def run_recipe(path) Automatic.run(recipe: Automatic::Recipe.new(path), root_dir: @root_dir) EXIT_SUCCESS - rescue Automatic::Error, Psych::Exception, SystemCallError, IOError, LoadError => e + rescue Automatic::Error, Psych::Exception, SystemCallError, IOError, + Automatic::OptionalDependencyError => e @stderr.puts "automatic: #{e.message}" EXIT_FAILURE end @@ -139,7 +142,7 @@ def run_subcommand(argv) handler.call(argv) EXIT_SUCCESS - rescue Automatic::Error, SystemCallError, IOError, LoadError => e + rescue Automatic::Error, SystemCallError, IOError, Automatic::OptionalDependencyError => e @stderr.puts "automatic: #{e.message}" EXIT_FAILURE end diff --git a/spec/lib/automatic/cli_spec.rb b/spec/lib/automatic/cli_spec.rb index 4115f4c..7cb2f55 100644 --- a/spec/lib/automatic/cli_spec.rb +++ b/spec/lib/automatic/cli_spec.rb @@ -5,7 +5,7 @@ # License:: The GPL version 3, or LGPL version 3 (Dual License). # Contact:: idnanashi@gmail.com # Created:: Aug 14, 2026 -# Updated:: Sep 5, 2026 +# Updated:: Sep 6, 2026 # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers. require File.expand_path(File.join(File.dirname(__FILE__), '../../spec_helper')) @@ -103,6 +103,32 @@ def run(*argv, root_dir: APP_ROOT) end end + describe "the autodiscovery subcommand" do + # These stub Automatic.require_optional itself, rather than an actually + # missing or present feedbag, so the two cases below are exercised + # deterministically regardless of whether feedbag happens to be installed + # in the environment running the suite. What require_optional itself + # classifies as a direct missing dependency is covered in automatic_spec.rb. + it "reports a dedicated optional dependency error as a one-line diagnostic and fails" do + allow(Automatic).to receive(:require_optional) + .and_raise(Automatic::OptionalDependencyError, + "The `feedbag` gem is not installed. It is needed by the " \ + 'autodiscovery subcommand. Install it with `gem install feedbag`.') + + expect(run("autodiscovery", "https://example.com/")).to eq Automatic::CLI::EXIT_FAILURE + expect(err.string).to match(/`feedbag` gem is not installed/) + end + + it "propagates a LoadError raised from inside a feature's own load, unconverted" do + allow(Automatic).to receive(:require_optional) + .and_raise(LoadError, "cannot load such file -- automatic_inner_missing_feature") + + expect { + run("autodiscovery", "https://example.com/") + }.to raise_error(LoadError, /automatic_inner_missing_feature/) + end + end + describe "the scaffold subcommand" do around do |example| Dir.mktmpdir("automatic-ruby-scaffold-spec") do |dir| @@ -247,6 +273,22 @@ class FilterNeedsGem end end + # A LoadError raised from inside a plugin's own load -- for a file other + # than the optional gem it asked require_optional for -- is a broken + # plugin or a broken dependency, not an absent optional gem, and must not + # be hidden behind the same one-line diagnostic; see doc/PLUGINS.md + # section 2.7. + it "propagates a LoadError raised from inside a plugin's own load, unconverted" do + allow(Automatic).to receive(:run) + .and_raise(LoadError, "cannot load such file -- automatic_inner_missing_feature") + + Dir.mktmpdir do |dir| + path = File.join(dir, "recipe.yml") + File.write(path, "plugins:\n - module: FilterOne\n") + expect { run("-c", path) }.to raise_error(LoadError, /automatic_inner_missing_feature/) + end + end + it "fails on malformed YAML without raising" do Dir.mktmpdir do |dir| path = File.join(dir, "recipe.yml") diff --git a/spec/lib/automatic_spec.rb b/spec/lib/automatic_spec.rb index 458eeff..08aa646 100644 --- a/spec/lib/automatic_spec.rb +++ b/spec/lib/automatic_spec.rb @@ -5,7 +5,7 @@ # License:: The GPL version 3, or LGPL version 3 (Dual License). # Contact:: idnanashi@gmail.com # Created:: Mar 9, 2012 -# Updated:: Aug 14, 2026 +# Updated:: Sep 6, 2026 # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers. require File.expand_path(File.join(File.dirname(__FILE__), '../spec_helper')) @@ -136,6 +136,62 @@ Automatic.require_optional("automatic_no_such_gem", needed_by: "FilterExample") }.to raise_error(LoadError, /needed by FilterExample/) end + + it "raises the dedicated OptionalDependencyError when the feature itself is missing" do + expect { + Automatic.require_optional("automatic_no_such_gem", needed_by: "FilterExample") + }.to raise_error(Automatic::OptionalDependencyError) + end + + # A feature that itself exists but fails partway through its own require, + # because something *it* requires is missing, is a different failure than + # this gem being absent: doc/PLUGINS.md section 3.8 and doc/POLICY.md + # section 9.1 both describe require_optional as reporting on the feature + # named to it, not on whatever that feature goes on to load. + describe "when the feature loads but requires something missing itself" do + def with_outer_feature_on_load_path + Dir.mktmpdir("automatic-ruby-require-optional-spec") do |dir| + File.write(File.join(dir, "automatic_outer_feature.rb"), + "require 'automatic_inner_missing_feature'\n") + $LOAD_PATH.unshift dir + begin + yield + ensure + $LOAD_PATH.delete(dir) + $LOADED_FEATURES.delete(File.join(dir, "automatic_outer_feature.rb")) + end + end + end + + it "propagates the original LoadError unconverted" do + with_outer_feature_on_load_path do + expect { + Automatic.require_optional("automatic_outer_feature", needed_by: "a spec") + }.to raise_error(LoadError, /automatic_inner_missing_feature/) + end + end + + it "does not raise the dedicated OptionalDependencyError" do + with_outer_feature_on_load_path do + begin + Automatic.require_optional("automatic_outer_feature", needed_by: "a spec") + rescue LoadError => e + expect(e).not_to be_a(Automatic::OptionalDependencyError) + end + end + end + + it "does not mention the outer feature's gem installation guidance" do + with_outer_feature_on_load_path do + begin + Automatic.require_optional("automatic_outer_feature", needed_by: "a spec") + rescue LoadError => e + expect(e.message).not_to match(/gem is not installed/) + expect(e.message).not_to match(/gem install/) + end + end + end + end end end diff --git a/spec/lib/automatic_spec_helper_spec.rb b/spec/lib/automatic_spec_helper_spec.rb new file mode 100644 index 0000000..be06203 --- /dev/null +++ b/spec/lib/automatic_spec_helper_spec.rb @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# Name:: AutomaticSpec +# Author: id774 (More info: http://id774.net) +# Source Code:: https://github.com/id774/automaticruby +# License:: The GPL version 3, or LGPL version 3 (Dual License). +# Contact:: idnanashi@gmail.com +# Created:: Sep 6, 2026 +# Updated:: Sep 6, 2026 +# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers. +# +# AutomaticSpec.plugin_available? itself, defined in spec_helper.rb. Every +# plugin spec that guards itself with it relies on it skipping only a plugin's +# own missing optional gem, and failing on anything else the plugin's load +# raises; see doc/POLICY.md Invariant 7. + +require File.expand_path(File.join(File.dirname(__FILE__), '../spec_helper')) + +describe AutomaticSpec do + describe "#plugin_available?" do + # Puts one throwaway file on the load path under a name unique to this + # example, so a plugin body can be written inline without touching the + # real plugins/ tree or any external gem. + def with_plugin_file(contents) + Dir.mktmpdir("automatic-ruby-plugin-available-spec") do |dir| + feature = "automatic_plugin_available_spec_plugin" + full_path = File.join(dir, "#{feature}.rb") + File.write(full_path, contents) + $LOAD_PATH.unshift dir + begin + yield feature + ensure + $LOAD_PATH.delete(dir) + $LOADED_FEATURES.delete(full_path) + end + end + end + + it "returns true when the plugin loads cleanly" do + with_plugin_file('') do |feature| + expect(AutomaticSpec.plugin_available?(feature)).to be true + end + end + + it "returns false and records the skip when the plugin's own optional gem is missing" do + with_plugin_file(<<~RUBY) do |feature| + Automatic.require_optional('automatic_no_such_gem', needed_by: 'a spec') + RUBY + before = AutomaticSpec.skipped_plugins.length + + expect(AutomaticSpec.plugin_available?(feature)).to be false + expect(AutomaticSpec.skipped_plugins.length).to eq before + 1 + expect(AutomaticSpec.skipped_plugins.last.first).to eq feature + end + end + + # A plugin whose own load fails for an unrelated reason -- a typo, a + # broken installation, a dependency missing something other than the gem + # the plugin asked require_optional for -- is not an absent optional gem, + # and must not be swallowed into a skip: doing so would turn a genuine + # breakage into a silently-passing suite. + it "propagates a LoadError raised from inside the plugin's own load, unconverted" do + with_plugin_file("require 'automatic_inner_missing_feature'\n") do |feature| + before = AutomaticSpec.skipped_plugins.length + + expect { + AutomaticSpec.plugin_available?(feature) + }.to raise_error(LoadError, /automatic_inner_missing_feature/) + expect(AutomaticSpec.skipped_plugins.length).to eq before + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 1ef8671..03c0152 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,7 +5,7 @@ # License:: The GPL version 3, or LGPL version 3 (Dual License). # Contact:: idnanashi@gmail.com # Created:: Mar 9, 2012 -# Updated:: Aug 14, 2026 +# Updated:: Sep 6, 2026 # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers. # # The suite reaches no network and needs no credential. That is a rule, not a @@ -105,10 +105,15 @@ class << self # longer exists, and no currently published gem speaks to it -- is never # stubbed into passing (doc/POLICY.md Invariant 7). Its spec is skipped # instead, and the reason is printed, which is the honest signal. + # + # Only Automatic::OptionalDependencyError, raised by require_optional for + # the plugin's own missing gem, is skipped this way. A plain LoadError + # from elsewhere in the plugin's load is a broken plugin or a broken + # dependency, not an absent optional gem, and is left to fail the spec. def plugin_available?(path) require path true - rescue LoadError => e + rescue Automatic::OptionalDependencyError => e skipped_plugins << [path, e.message] warn "[automatic] skipping #{path} spec: #{e.message}" false