Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/VERSIONS
Original file line number Diff line number Diff line change
Expand Up @@ -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)
-------------------
Expand Down
19 changes: 17 additions & 2 deletions lib/automatic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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 ' \
Expand Down
19 changes: 11 additions & 8 deletions lib/automatic/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}"
Expand All @@ -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
Expand All @@ -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
Expand Down
44 changes: 43 additions & 1 deletion spec/lib/automatic/cli_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -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|
Expand Down Expand Up @@ -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")
Expand Down
58 changes: 57 additions & 1 deletion spec/lib/automatic_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -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
72 changes: 72 additions & 0 deletions spec/lib/automatic_spec_helper_spec.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading