From 1918018dc81c9d00f21b402f6d1dbdde42c97cf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 17:06:47 +0000 Subject: [PATCH 1/2] Modernize the codebase and CI for Ruby 3.3 through 4.0 Validate 3.3, 3.4 and 4.0 in CI instead of 3.3, 3.4 and 3.5, and state the supported range and the continuously validated set as two separate facts: required_ruby_version keeps its lower bound and gains no upper one, so a Ruby outside the matrix is permitted rather than refused. Replace the APIs Ruby 3.4 deprecated with the ones the whole supported range shares, without a RUBY_VERSION branch: URI::Parser#escape, URI.extract and URI::PATTERN answer through the RFC 3986 parser and report themselves obsolete, so URI::RFC2396_Parser is named directly. Stop mutating a string literal in the OPML parser, which Ruby 4.0 warns about. Correct the direction of the HTML parser dependency. Requiring automatic loaded nokogiri through FeedMaker, which never used it, and through FeedParser, where only parse_html does; the dead require is gone and the live one moved into that method, so the framework loads no HTML parser. nokogiri stays a runtime dependency because Supported plugins an installed gem must be able to run need it. nkf and sanitize are each needed by one plugin, so they move to the Gemfile's optional :plugins group, and a dead require of kconv is deleted. Keep the optional group out of the default test path. Its gems are a declared list rather than a discovered load failure, the two specs that need one guard their file with it and say what is not verified, and installing the group runs them as part of the ordinary suite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTSJZ48Jhsj9rquWgEvAXA --- .github/workflows/ci.yml | 24 +- Gemfile | 15 +- README.md | 31 ++- automatic.gemspec | 29 ++- doc/DEPLOYMENT.md | 22 +- doc/PLUGINS.md | 36 ++- doc/POLICY.md | 76 +++++- doc/QUICKSTART.md | 4 +- doc/REQUIREMENTS.md | 35 ++- doc/VERSIONS | 4 +- lib/automatic/feed_maker.rb | 1 - lib/automatic/feed_parser.rb | 7 +- lib/automatic/opml.rb | 4 +- plugins/filter/absolute_uri.rb | 5 +- plugins/filter/description_link.rb | 10 +- plugins/filter/image_source.rb | 1 - plugins/subscription/g_guide.rb | 6 +- plugins/subscription/link.rb | 2 +- plugins/subscription/xml.rb | 2 +- spec/plugins/filter/description_link_spec.rb | 225 +++++++++-------- spec/plugins/filter/sanitize_spec.rb | 249 ++++++++++--------- spec/plugins/subscription/g_guide_spec.rb | 4 +- spec/spec_helper.rb | 48 +++- 23 files changed, 534 insertions(+), 306 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e12dcc8..c0b0cd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,18 @@ name: CI -# Installs the bundle and runs the test suite on every supported Ruby version. +# Installs the bundle, builds the gem and runs the default test suite on the +# Ruby versions this project validates continuously. +# +# The supported range and the validated set are two different statements. The +# code is written for Ruby 3.3 through 4.0, which is what required_ruby_version +# says; the matrix below runs the ends of that range and the release in the +# middle, rather than every intermediate release. See doc/POLICY.md section 11 +# and doc/REQUIREMENTS.md section 20. # # No credential is configured here and nothing reaches an external service: the -# suite must need neither (doc/POLICY.md Invariant 6). Specs tagged :network -# are excluded by default and are not run here. +# suite must need neither (doc/POLICY.md Invariant 6). Examples tagged :network +# are excluded by default, and the Gemfile's optional :plugins group is not +# installed, so no plugin's own gem is a condition of this workflow passing. on: push: @@ -22,9 +30,10 @@ jobs: strategy: fail-fast: false matrix: - # Keep this in step with required_ruby_version in automatic.gemspec - # and with the supported environment section of README.md. - ruby: ['3.3', '3.4', '3.5'] + # The continuously validated versions. Keep this in step with the + # supported environment section of README.md; the floor here and + # required_ruby_version in automatic.gemspec are the same version. + ruby: ['3.3', '3.4', '4.0'] steps: - uses: actions/checkout@v4 @@ -43,6 +52,9 @@ jobs: - name: Check that the gemspec is valid and buildable run: gem build automatic.gemspec + - name: Check that the library loads + run: bundle exec ruby -Ilib -e "require 'automatic'" + - name: Check that the CLI runs run: | bundle exec bin/automatic --version diff --git a/Gemfile b/Gemfile index 3eddbaf..cc077a8 100644 --- a/Gemfile +++ b/Gemfile @@ -11,10 +11,19 @@ gemspec # runtime dependencies of the gem: installing automatic does not install them, # and a Recipe that does not use the plugin does not need them. # -# Uncomment what you use in a checkout. The table of which plugin needs which -# gem, and which of those plugins still work, is in doc/DEPLOYMENT.md and -# doc/PLUGINS.md section 6. +# The group is optional, so `bundle install` does not install it and neither +# the default test suite nor CI depends on it. Install it deliberately, and the +# specs of the plugins that need it then run as part of the ordinary suite: +# +# BUNDLE_WITH=plugins bundle install +# bundle exec rake +# +# The table of which plugin needs which gem, and which of those plugins still +# work, is in doc/DEPLOYMENT.md and doc/PLUGINS.md section 6. group :plugins, optional: true do + gem 'nkf' # FilterDescriptionLink + gem 'sanitize' # FilterSanitize + # PublishAmazonS3 and the s3n:// path of StoreFile call AWS::S3, which only # AWS SDK for Ruby v1 provided. No currently published gem satisfies them, so # there is nothing to uncomment; they need rework. See doc/PLUGINS.md. diff --git a/README.md b/README.md index 5f6ce7d..2bb33bd 100644 --- a/README.md +++ b/README.md @@ -160,14 +160,23 @@ The full account is [`doc/BASIC_DESIGN.md`](doc/BASIC_DESIGN.md). ## 4. Supported environment -- **Ruby 3.3 or later.** Tested on 3.3, 3.4 and 3.5. +- **Ruby 3.3 through 4.0.** CI validates 3.3, 3.4 and 4.0. - A Unix-like system. GNU/Linux and macOS are what it is used on. Windows is not supported. - A compiler, if `nokogiri` or `sqlite3` build from source on your platform. Ruby 3.3 is the floor: it is the oldest maintained release the dependencies are -resolved and tested against. Nothing older is tested or supported. A newer Ruby than -the matrix covers is permitted by the gemspec, which sets a lower bound only. +resolved and tested against. Nothing older is tested or supported. + +Two statements, and they are not the same one: + +- **Supported range.** The code is written for Ruby 3.3 through 4.0, using APIs + the whole range shares. `required_ruby_version` is `>= 3.3.0` and has no upper + bound, so a Ruby newer than the matrix is permitted rather than refused. +- **Continuously validated versions.** CI runs the ends of the range and the + release in the middle — 3.3, 3.4 and 4.0 — rather than every intermediate + release. A version's absence from the matrix means it is not verified on every + commit; it does not mean it is expected to fail. ## 5. Installation @@ -463,6 +472,15 @@ COVERAGE=on bundle exec rake spec AUTOMATIC_NETWORK_SPECS=1 bundle exec rake spec ``` +- A plugin whose gem the Gemfile declares in its optional `:plugins` group is + **not verified by the default suite**, because that group is not installed. + Install it to run those specs as part of the ordinary suite: + + ```sh + BUNDLE_WITH=plugins bundle install + bundle exec rake + ``` + - A spec whose plugin needs a gem that is not installed is skipped, and says which gem is missing. That absence is the signal; a plugin whose service no longer exists is never stubbed into passing. @@ -471,8 +489,11 @@ COVERAGE=on bundle exec rake spec in CI. Most need a credential, a dead service, or both — read one before running it. -CI runs `bundle install` and the suite on every supported Ruby version, from -[`.github/workflows/ci.yml`](.github/workflows/ci.yml). +CI installs the bundle, builds the gem, loads the library, runs the CLI and runs +the default suite on each validated Ruby version, from +[`.github/workflows/ci.yml`](.github/workflows/ci.yml). It configures no secret +and installs no optional plugin gem, so no plugin's own dependency is a +condition of a green build. ## 13. Development diff --git a/automatic.gemspec b/automatic.gemspec index d527d77..17f9345 100644 --- a/automatic.gemspec +++ b/automatic.gemspec @@ -39,9 +39,12 @@ Gem::Specification.new do |spec| } # Ruby 3.3 is the floor: the oldest maintained release the dependencies are - # resolved and tested against. This is a lower bound only, so a newer Ruby is - # permitted before it reaches the CI matrix. See doc/REQUIREMENTS.md - # section 20. + # resolved and tested against. The code is written for Ruby 3.3 through 4.0. + # + # A lower bound only, and deliberately so. An upper bound would refuse a Ruby + # this code has every reason to work on, on the day it is released, and the + # only way to lift it would be a new release of this gem. What CI validates + # is a separate and narrower statement; see doc/REQUIREMENTS.md section 20. spec.required_ruby_version = '>= 3.3.0' # Shipped files. Derived from what Git sees, so that the list cannot drift @@ -83,24 +86,28 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] spec.extra_rdoc_files = ['README.md', 'doc/LICENSE.md'] - # Runtime dependencies: what the framework itself needs, plus the store - # plugins, which nearly every Recipe uses to avoid repeating its work. + # Runtime dependencies: what the framework itself needs, plus what the + # documented primary workflow needs — the store plugins, which nearly every + # Recipe uses to avoid repeating its work, and the Markdown publisher. # # A gem needed by a single plugin is NOT declared here. It is required inside # that plugin's own file and installed by the operator who uses the plugin. # See doc/POLICY.md section 9.1 and doc/DEPLOYMENT.md. + # + # rexml, rss and nkf left the standard library and became gems over the 3.x + # series. Each one listed here is listed because something committed here + # requires it, not because a Ruby release moved it. spec.add_dependency 'activerecord', '>= 7.1', '< 9.0' # store plugins spec.add_dependency 'activesupport', '>= 7.1', '< 9.0' # plugin loader, XML subscription spec.add_dependency 'feedbag', '>= 1.0', '< 2.0' # autodiscovery subcommand spec.add_dependency 'hashie', '>= 4.0', '< 6.0' # Recipe - # nkf stopped being a default gem in Ruby 3.4 and is needed by - # FilterDescriptionLink, which is a Supported plugin; it is a standard - # library extraction with no transitive dependencies. - spec.add_dependency 'nkf', '>= 0.1', '< 1.0' # FilterDescriptionLink - spec.add_dependency 'nokogiri', '>= 1.15', '< 2.0' # HTML parsing + # Used by no framework file on the way in: requiring `automatic` loads no + # HTML parser. It is here because Supported plugins that an installed gem + # must be able to run need it -- PublishMarkdown, and FeedParser.parse_html + # for SubscriptionLink and SubscriptionTumblr. + spec.add_dependency 'nokogiri', '>= 1.15', '< 2.0' # HTML parsing, in plugins spec.add_dependency 'rexml', '>= 3.2', '< 4.0' # OPML parser spec.add_dependency 'rss', '>= 0.3', '< 1.0' # the pipeline value - spec.add_dependency 'sanitize', '>= 6.0', '< 8.0' # FilterSanitize spec.add_dependency 'sqlite3', '>= 1.7', '< 3.0' # store plugins spec.add_development_dependency 'rake', '~> 13.0' diff --git a/doc/DEPLOYMENT.md b/doc/DEPLOYMENT.md index e905c00..c6b2690 100644 --- a/doc/DEPLOYMENT.md +++ b/doc/DEPLOYMENT.md @@ -28,8 +28,10 @@ nothing to stop. - A Unix-like system. GNU/Linux and macOS are what this is used on; Windows is not supported. -- **Ruby 3.3 or later.** Check with `ruby -v`. The supported versions are 3.3, - 3.4 and 3.5. +- **Ruby 3.3 through 4.0.** Check with `ruby -v`. CI validates 3.3, 3.4 and 4.0; + a version between them is supported and is simply not checked on every commit, + and a Ruby newer than 4.0 is permitted rather than refused. See + [`REQUIREMENTS.md`](REQUIREMENTS.md) section 20. - A build environment for native extensions, because `nokogiri` and `sqlite3` may build from source: @@ -395,6 +397,8 @@ several of these plugins talk to services that no longer exist. | Plugin | Needs | Status | | --- | --- | --- | +| `FilterSanitize` | `sanitize` | Supported | +| `FilterDescriptionLink` | `nkf` | Supported | | `CustomFeedSVNLog` | `xml-simple`, and the `svn` command | Supported (external) | | `ProvideFluentd`, `PublishFluentd` | `fluent-logger`, and a Fluentd instance | Supported (external) | | `PublishMemcached` | `dalli`, and a memcached server | Supported (external) | @@ -409,6 +413,8 @@ several of these plugins talk to services that no longer exist. | `SubscriptionWeather` | — | Unsupported | ```sh +gem install sanitize # for FilterSanitize +gem install nkf # for FilterDescriptionLink gem install fluent-logger # for the Fluentd plugins gem install dalli # for PublishMemcached gem install xml-simple # for CustomFeedSVNLog @@ -420,8 +426,16 @@ They call `AWS::S3`, which AWS SDK for Ruby version 1 provided and the current need rework. `StoreFile` makes that requirement lazily, so its ordinary HTTP download path works with no AWS gem installed at all. -In a checkout, uncomment the `plugins` group in the `Gemfile` instead and run -`bundle install`. +In a checkout, install the `Gemfile`'s optional `plugins` group instead — +uncommenting the entry first, where the gem is one of the commented ones: + +```sh +BUNDLE_WITH=plugins bundle install +``` + +That group is not installed by default and is not installed in CI, so these +plugins are outside what the default test suite verifies. Installing it also +brings their specs into the ordinary `bundle exec rake` run. ## Your own plugins diff --git a/doc/PLUGINS.md b/doc/PLUGINS.md index d7c822e..2c3bd32 100644 --- a/doc/PLUGINS.md +++ b/doc/PLUGINS.md @@ -388,7 +388,9 @@ module Automatic::Plugin That is what keeps a gem needed by one plugin out of everyone else's installation. A gem used by a single plugin is not added to the framework's -runtime dependencies; see [`POLICY.md`](POLICY.md) section 9. +runtime dependencies; it goes in the `Gemfile`'s optional `:plugins` group and +the operator who uses the plugin installs it. See [`POLICY.md`](POLICY.md) +section 9. Where a plugin has an optional capability that needs a heavier library — S3 support in `StoreFile`, for instance — the `require` goes inside the branch that @@ -520,6 +522,14 @@ Two rules govern this table, and they are the reason it exists at all: framework was used for, and several remain useful as templates for a replacement. Removal is a separate, deliberate decision. +**Supported is not the same as covered by CI.** A Supported plugin whose gem is +an optional plugin dependency — `FilterSanitize` and `FilterDescriptionLink` — +works, and is simply not part of what a green build guarantees, because the +default bundle does not install that gem. Its entry says so, and installing the +gem runs its spec as part of the ordinary suite. Nothing here is classified by +what CI happens to run; a plugin is not demoted for needing a gem, and is not +promoted by a test that CI never executes. + **This classification is a snapshot taken in August 2026,** based on the published status of each service and on what each plugin's code actually calls. The statuses in the "external service" rows depend on the outside world and can @@ -759,6 +769,11 @@ page the link points at. Fetching pages means network access. No settings. | --- | --- | --- | | `mode` | string | `basic`, `relaxed`, or `restricted`. Default `restricted`. | +Needs the `sanitize` gem, which is an optional plugin dependency and is not +installed with the framework. Its spec is therefore outside the default suite +and outside CI; installing the gem brings the spec back into the ordinary run. +See [`DEPLOYMENT.md`](DEPLOYMENT.md). + #### FilterTumblrResize — **Supported** `filter/tumblr_resize.rb`. Rewrites a Tumblr image link to the 1280-pixel @@ -779,6 +794,12 @@ the body. `get_title` makes one request per item; use `FilterOne` or a store plugin before it on a large feed. +Needs the `nkf` gem, which the plugin uses to normalize a fetched page's +encoding. `nkf` left the standard library after Ruby 3.3 and is an optional +plugin dependency rather than a framework one, so it is not installed with the +framework, and this plugin's spec is outside the default suite and outside CI. +See [`DEPLOYMENT.md`](DEPLOYMENT.md). + #### FilterFullFeed — **Supported (external)** `filter/full_feed.rb`. Replaces a summary with the article body, by matching the @@ -997,11 +1018,14 @@ arbitrary markup back into equivalent Markdown — tables, nested lists, inline links, images — is a large job with a large library behind it, and a library that size does not become a dependency for one plugin ([`POLICY.md`](POLICY.md) section 9.1). Reducing markup to text needs nothing -that is not already installed: `nokogiri` is a runtime dependency, used by the -framework's own feed adapters. The result is defined by its two ends — the -text survives, the markup does not — which is what both a reader and a program -reading the file want from it. A link inside a body becomes its own text; the -item's own link is in the metadata list, where nothing loses it. +beyond `nokogiri`, which `gem install automatic` installs: it is a runtime +dependency of this gem precisely so that the Supported plugins an installed gem +must be able to run — this one, and `FeedParser.parse_html` for +`SubscriptionLink` and `SubscriptionTumblr` — work with nothing else added. +Requiring `automatic` itself loads no HTML parser. The result is defined by its +two ends — the text survives, the markup does not — which is what both a reader +and a program reading the file want from it. A link inside a body becomes its +own text; the item's own link is in the metadata list, where nothing loses it. Where a different treatment is wanted, the pipeline already has the means: `FilterSanitize` before this plugin decides what markup survives into the diff --git a/doc/POLICY.md b/doc/POLICY.md index 5e75ad7..bc2f436 100644 --- a/doc/POLICY.md +++ b/doc/POLICY.md @@ -336,8 +336,17 @@ repository level only; see section 10. ### 2.4 Ruby version compatibility - The supported range is stated in one place, `automatic.gemspec` - (`required_ruby_version`), and the README, the CI matrix and the documents - agree with it. + (`required_ruby_version`), and the README and the documents agree with it. The + set CI validates is a narrower statement and lives in the matrix; section 11 + says how the two relate. +- **Compatibility is written in the range's common API.** Where a Ruby release + deprecates or removes something, the replacement chosen is the one that works + unchanged on every supported version. `URI::Parser#escape` becoming obsolete + is answered by naming `URI::RFC2396_Parser`, which means the same thing on all + of them — not by a `RUBY_VERSION` branch. +- **A `RUBY_VERSION` conditional is a last resort**, for a difference that has + no common expression. Two implementations of one behaviour cost more than the + compatibility they buy: the branch not taken is the branch not tested. - **Code for an unsupported Ruby is removed, not kept for safety.** A `RUBY_VERSION` comparison against 1.8 or 1.9, a branch for an interpreter that cannot install the dependencies, and a shim for a method that has been in core @@ -346,6 +355,12 @@ repository level only; see section 10. - A method removed by Ruby is replaced by its supported equivalent, and that is a compatibility fix rather than a refactor: `Kernel#open` on a URL becomes `URI.open`, `File.exists?` becomes `File.exist?`. +- **A library leaving the standard library is not by itself a reason to declare + it.** Ruby moves libraries to default and then to bundled gems as it goes. + Each one is judged on what actually requires it: the framework's own + requirement becomes a runtime dependency, a plugin's becomes an optional one + (section 9.1), and a requirement left over from code that no longer uses it is + deleted. ### 2.5 Requiring @@ -426,9 +441,27 @@ Plugins outlive the services they talk to. The policy for what happens then: that no longer serve what they expect, which is a further reason not to make them a gate. A new example that reaches a host is tagged; one that does not is never given the tag to make a failure go away. +- **The default suite does not depend on an optional plugin gem.** A gem the + `Gemfile` declares in its optional `:plugins` group is not installed by + `bundle install`, so the plugins that need it are not verified by the default + suite or by CI. That is a decision, taken here, and not something a failure + discovers: the gems it applies to are the declared list + `AutomaticSpec::OPTIONAL_PLUGIN_GEMS`, a spec whose plugin needs one guards + its file with `AutomaticSpec.optional_dependency?`, and naming a gem that is + not on the list raises rather than skipping. Installing the group with + `BUNDLE_WITH=plugins bundle install` runs those specs as part of the ordinary + suite. - A spec whose plugin's gem is not installed is skipped by `AutomaticSpec.plugin_available?`, which names the missing gem. That is the intended behaviour and is not worked around by faking the gem. +- **The default suite is kept small and reliable rather than large.** It reaches + no network, needs no credential, needs no external daemon, writes outside no + temporary directory of its own, redirects `HOME`, and does not depend on + filesystem ordering, on the clock or on a random seed. A test that cannot be + made repeatable does not belong in it. Guaranteeing fewer things reliably is + the better trade, and it is not the same as weakening a test: `|| true`, + `continue-on-error` and a rescue that swallows a failure are forbidden, and + the tests that remain are held strictly. - **A spec does not write outside its own temporary directory.** Where a plugin resolves a path under the home directory, the spec redirects `HOME` to a temporary directory rather than operating on the developer's real @@ -530,12 +563,23 @@ not an accident: **Runtime dependencies** — declared in `automatic.gemspec`, installed by `gem install automatic`. A gem is here only if the framework itself uses it, or if a -plugin that the majority of Recipes use needs it. +plugin that the majority of Recipes use — or that the documented primary +workflow runs — needs it. A gem that reached this list because of a plugin that +no longer uses it, or because a Ruby release moved a library out of the standard +library, is moved back out. **Optional dependencies** — used by one plugin or a few, required inside the -plugin's own file, and **not declared as runtime dependencies**. An operator who -uses that plugin installs the gem. This is Invariant 4, and it is why installing -this gem does not install an AWS SDK. +plugin's own file, declared in the `Gemfile`'s optional `:plugins` group, and +**not declared as runtime dependencies**. An operator who uses that plugin +installs the gem. This is Invariant 4, and it is why installing this gem does +not install an AWS SDK. + +- **An unsupported or optional integration does not decide a framework-wide + dependency.** Where one plugin needs a gem, that gem is the plugin's, however + useful the plugin is. +- Being in this group has a consequence that is intended: the default suite and + CI do not install it, so those plugins are not part of what a green build + guarantees. See section 5. **Development dependencies** — the test and build tooling. @@ -704,11 +748,25 @@ the version history records what it amounts to. ## 11. Continuous integration - CI runs on GitHub Actions, from `.github/workflows/ci.yml`. -- It installs the bundle and runs the default test suite on each supported Ruby - version. A version in the matrix and a version in `required_ruby_version` are - the same set. +- **CI validates representative supported Ruby versions rather than every + intermediate release.** The matrix runs the ends of the supported range and + the release in the middle. The matrix and `required_ruby_version` are + therefore *not* the same set, and neither is wrong: the gemspec states what + the code is written for, the matrix states what is checked on every commit. + [`REQUIREMENTS.md`](REQUIREMENTS.md) section 20 states both. +- Removing a version from the matrix is not a statement that it fails, and no + incompatibility is introduced to make it one. +- What CI does is: install the bundle, build the gem, load the library, run the + command line, run the default suite. It is deliberately short, and an optional + integration is not added to it. - **CI holds no secret and reaches no external service.** No credential is configured, and no integration test against a third-party API is run there. +- **CI installs no optional plugin gem**, so no plugin's own dependency is a + condition of a green build. Where an optional integration is worth testing at + all, it is tested separately from the required workflow; section 5 says how. +- **A failure is fixed, not silenced.** `|| true`, `continue-on-error` and a + step that hides its exit status are not how a build is made green. Narrowing + what is guaranteed is a legitimate answer; pretending to guarantee it is not. - The Jenkins instance the project used until 2015 is gone. References to it have been removed and are not to be reintroduced. - A red build is fixed or reverted. It is not left red. diff --git a/doc/QUICKSTART.md b/doc/QUICKSTART.md index 40fc413..f1a5873 100644 --- a/doc/QUICKSTART.md +++ b/doc/QUICKSTART.md @@ -2,11 +2,11 @@ This guide takes public information through one short Automatic Ruby pipeline and leaves it as Markdown. It needs no account, credential, paid service or -database server. +database server, and no gem beyond the ones `gem install automatic` brings. ## 1. Install -Use Ruby 3.3, 3.4 or 3.5 on a Unix-like system: +Use Ruby 3.3 through 4.0 on a Unix-like system: ```sh ruby -v diff --git a/doc/REQUIREMENTS.md b/doc/REQUIREMENTS.md index 1a576e9..ef9006a 100644 --- a/doc/REQUIREMENTS.md +++ b/doc/REQUIREMENTS.md @@ -439,20 +439,39 @@ plugin knows to upload a local file rather than a remote one. ## 20. Supported Ruby +Two statements are made here, and they are deliberately different. + +**The supported range** is **Ruby 3.3 through Ruby 4.0**. + - The floor is **Ruby 3.3**: the oldest maintained release the dependency set is resolved and tested against. Nothing older is tested or supported. -- The versions upstream currently maintains are the recommended ones. The - supported set is what the CI matrix runs, which at the time of writing is - 3.3, 3.4 and 3.5. -- The gemspec's `required_ruby_version` is a lower bound and not an upper one, so - a newer Ruby is permitted before it has been added to the matrix. Adding one - to the matrix is the act of supporting it. +- The code shall be written against APIs the whole range shares. Where a Ruby + release deprecates or removes one, the replacement that works on the whole + range is used, rather than a `RUBY_VERSION` branch; see + [`POLICY.md`](POLICY.md) section 2.4. +- The gemspec's `required_ruby_version` is a lower bound and not an upper one, + so a Ruby newer than the range is permitted rather than refused. Refusing one + would need a new release of this gem to lift. - The floor shall not be lowered to accommodate an unmaintained Ruby, and shall not be raised to the newest release for its own sake. It moves when a dependency the project needs moves it, or when the version drops out of the distributions the project is used on. -- One statement of the supported range lives in the gemspec, and the README, the - CI matrix and the documents agree with it. + +**The continuously validated versions** are **3.3, 3.4 and 4.0** — the ends of +the range and the release in the middle. + +- CI runs representative versions rather than every intermediate release. The + cost of a matrix entry is paid on every commit, and a third entry between two + that pass says little about a range whose code shares one set of APIs. +- **A version's absence from the matrix is not a statement that it fails.** It + is a statement that it is not verified on every commit. Nothing is written to + be deliberately incompatible with a supported Ruby that the matrix omits. +- Adding a released Ruby to the matrix is how support for it becomes continuous, + and is a small change. + +One statement of the supported range lives in the gemspec, one statement of the +validated set lives in the CI matrix, and the README and the documents agree +with both. ## 21. Portability diff --git a/doc/VERSIONS b/doc/VERSIONS index 95645cd..3a091b7 100644 --- a/doc/VERSIONS +++ b/doc/VERSIONS @@ -3,11 +3,11 @@ automaticruby Repository Version History v26.08 (Release Date: TBD) -------------------------- -- Support Ruby 3.3 through 3.5 and modernize the codebase for current Ruby and maintained library APIs. +- Support Ruby 3.3 through 4.0 and modernize the codebase for current Ruby and maintained library APIs, validating representative versions in CI. - Harden Recipe loading with safe YAML parsing, structural validation and framework-specific errors. - Restructure the CLI with help and version options, predictable error reporting and documented exit statuses. - Verify TLS certificates when publishing to Instapaper instead of accepting an unverified connection. -- Modernize gem packaging and dependency policy, isolating optional plugin dependencies and excluding development and generated files. +- Modernize gem packaging and dependency policy, isolating optional plugin dependencies outside the framework and the default test path, and excluding development and generated files. - Classify every shipped plugin by its current support status rather than simulating obsolete services in tests. - Rebuild the test and CI strategy for current RSpec and Ruby, with deterministic isolation from user data and external services. - Add Markdown as the primary service-independent publication format, with a documented and tested first-run workflow. diff --git a/lib/automatic/feed_maker.rb b/lib/automatic/feed_maker.rb index 0f354cf..5832d55 100644 --- a/lib/automatic/feed_maker.rb +++ b/lib/automatic/feed_maker.rb @@ -12,7 +12,6 @@ module Automatic module FeedMaker require 'rss' require 'uri' - require 'nokogiri' class FeedObject attr_accessor :title, :link, :description, :author, :comments diff --git a/lib/automatic/feed_parser.rb b/lib/automatic/feed_parser.rb index 4f38be1..921a516 100644 --- a/lib/automatic/feed_parser.rb +++ b/lib/automatic/feed_parser.rb @@ -13,7 +13,6 @@ module Automatic module FeedParser - require 'nokogiri' require 'open-uri' require 'rss' require 'uri' @@ -32,7 +31,13 @@ def self.get_url(url) # Build a feed whose items are the links of an HTML document. This is how # a page that publishes no feed enters the pipeline. + # + # nokogiri is required here rather than at the top of the file: it is the + # only thing in the framework that wants an HTML parser, and requiring + # `automatic` should not load one. See doc/POLICY.md section 2.5. def self.parse_html(html) + require 'nokogiri' + RSS::Maker.make('2.0') do |maker| maker.xml_stylesheets.new_xml_stylesheet maker.channel.title = 'Automatic Ruby' diff --git a/lib/automatic/opml.rb b/lib/automatic/opml.rb index ab3f99a..82dacb0 100644 --- a/lib/automatic/opml.rb +++ b/lib/automatic/opml.rb @@ -220,7 +220,9 @@ def each_outline end # each_outline def read_text - text = "" + # +'' rather than "": this string is appended to below, and a literal + # is on its way to being frozen. + text = +'' while event = @p.pull case event.event_type when :end_element diff --git a/plugins/filter/absolute_uri.rb b/plugins/filter/absolute_uri.rb index d8c9cf8..1b3fa9d 100644 --- a/plugins/filter/absolute_uri.rb +++ b/plugins/filter/absolute_uri.rb @@ -5,11 +5,12 @@ # License:: The GPL version 3, or LGPL version 3 (Dual License). # Contact:: idnanashi@gmail.com # Created:: Jun 20, 2012 -# Updated:: Oct 29, 2014 +# Updated:: Aug 14, 2026 # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers. module Automatic::Plugin class FilterAbsoluteURI + require 'uri' def initialize(config, pipeline=[]) @config = config @@ -39,7 +40,7 @@ def rewrite(string) @config['url'] = @config['url'] + '/' end string = @config['url'] + string.sub(/^\./,'').sub(/^\//,'') - string = URI::Parser.new.escape(string) + string = URI::RFC2396_Parser.new.escape(string) return string end end diff --git a/plugins/filter/description_link.rb b/plugins/filter/description_link.rb index c292016..894a4fa 100644 --- a/plugins/filter/description_link.rb +++ b/plugins/filter/description_link.rb @@ -16,6 +16,12 @@ class FilterDescriptionLink require 'open-uri' require 'uri' + # URI.extract and URI::PATTERN answer through the RFC 3986 parser that + # URI::Parser became in Ruby 3.4, which reports both as obsolete. The RFC + # 2396 parser is what they were always reaching, it is spelled the same way + # on every supported Ruby, and it is named here directly. + PARSER = URI::RFC2396_Parser.new + def initialize(config, pipeline=[]) @config = config @pipeline = pipeline @@ -40,7 +46,7 @@ def run def get_title(url) new_title = nil if url.class == String - url.gsub!(Regexp.new("[^#{URI::PATTERN::ALNUM}\/\:\?\=&~,\.\(\)#]")) {|match| ERB::Util.url_encode(match)} + url.gsub!(Regexp.new("[^#{URI::RFC2396_Parser::PATTERN::ALNUM}\/\:\?\=&~,\.\(\)#]")) {|match| ERB::Util.url_encode(match)} begin read_data = NKF.nkf("--utf8", URI.open(url).read) get_text = Nokogiri::HTML.parse(read_data, nil, 'utf8').xpath('//title').text @@ -54,7 +60,7 @@ def get_title(url) end def rewrite_link(feed) - new_link = URI.extract(feed.description, %w{http https}).uniq.last + new_link = PARSER.extract(feed.description, %w{http https}).uniq.last feed.link = new_link unless new_link.nil? if @config.class == Hash diff --git a/plugins/filter/image_source.rb b/plugins/filter/image_source.rb index af495ea..ff017cf 100644 --- a/plugins/filter/image_source.rb +++ b/plugins/filter/image_source.rb @@ -10,7 +10,6 @@ module Automatic::Plugin class FilterImageSource - require 'kconv' require 'net/http' require 'nokogiri' require 'open-uri' diff --git a/plugins/subscription/g_guide.rb b/plugins/subscription/g_guide.rb index 4183591..50551bd 100644 --- a/plugins/subscription/g_guide.rb +++ b/plugins/subscription/g_guide.rb @@ -5,11 +5,13 @@ # License:: The GPL version 3, or LGPL version 3 (Dual License). # Contact:: idnanashi@gmail.com # Created:: Jun 28, 2013 -# Updated:: Oct 29, 2014 +# Updated:: Aug 14, 2026 # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers. module Automatic::Plugin class SubscriptionGGuide + require 'uri' + G_GUIDE_RSS = 'http://tv.so-net.ne.jp/rss/schedulesBySearch.action?' def initialize(config, pipeline=[]) @@ -44,7 +46,7 @@ def feed_url keyword = nil feed += "condition.keyword=#{keyword}&" end feed += station_param - URI::Parser.new.escape(feed) + URI::RFC2396_Parser.new.escape(feed) end def station_param diff --git a/plugins/subscription/link.rb b/plugins/subscription/link.rb index 5c4fcaa..caf8323 100644 --- a/plugins/subscription/link.rb +++ b/plugins/subscription/link.rb @@ -24,7 +24,7 @@ def run retries = 0 retry_max = @config['retry'].to_i || 0 begin - create_rss(URI::Parser.new.escape(url)) + create_rss(URI::RFC2396_Parser.new.escape(url)) rescue retries += 1 Automatic::Log.puts("error", "ErrorCount: #{retries}, Fault in parsing: #{url}") diff --git a/plugins/subscription/xml.rb b/plugins/subscription/xml.rb index 4a005b4..0b03f19 100644 --- a/plugins/subscription/xml.rb +++ b/plugins/subscription/xml.rb @@ -27,7 +27,7 @@ def run retries = 0 retry_max = @config['retry'].to_i || 0 begin - create_rss(URI::Parser.new.escape(url)) + create_rss(URI::RFC2396_Parser.new.escape(url)) rescue retries += 1 Automatic::Log.puts("error", "ErrorCount: #{retries}, Fault in parsing: #{url}") diff --git a/spec/plugins/filter/description_link_spec.rb b/spec/plugins/filter/description_link_spec.rb index 3d982d4..7fc2f2f 100644 --- a/spec/plugins/filter/description_link_spec.rb +++ b/spec/plugins/filter/description_link_spec.rb @@ -10,131 +10,136 @@ require File.expand_path(File.dirname(__FILE__) + '../../../spec_helper') -require 'filter/description_link' - -describe Automatic::Plugin::FilterDescriptionLink do +# FilterDescriptionLink needs the nkf gem, which the Gemfile declares in its +# optional :plugins group. The default suite and CI do not install it, so this +# spec runs only where the operator has. See doc/POLICY.md section 5. +if AutomaticSpec.optional_dependency?('nkf') + require 'filter/description_link' + + describe Automatic::Plugin::FilterDescriptionLink do + + context "It should be rewrite link based on the description" do + + subject { + Automatic::Plugin::FilterDescriptionLink.new( + {}, + AutomaticSpec.generate_pipeline { + feed { + item "http://test1.id774.net", + "dummy title", + "aaa bbb ccc http://test2.id774.net ddd eee", + "Mon, 07 Mar 2011 15:54:11 +0900" + } + } + ) + } - context "It should be rewrite link based on the description" do + describe "#run" do + its(:run) { should have(1).feeds } - subject { - Automatic::Plugin::FilterDescriptionLink.new( - {}, - AutomaticSpec.generate_pipeline { - feed { - item "http://test1.id774.net", - "dummy title", - "aaa bbb ccc http://test2.id774.net ddd eee", - "Mon, 07 Mar 2011 15:54:11 +0900" - } + specify { + subject.run + subject.instance_variable_get(:@pipeline)[0].items[0].link. + should == "http://test2.id774.net" + subject.instance_variable_get(:@pipeline)[0].items[0].description. + should == "aaa bbb ccc http://test2.id774.net ddd eee" } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@pipeline)[0].items[0].link. - should == "http://test2.id774.net" - subject.instance_variable_get(:@pipeline)[0].items[0].description. - should == "aaa bbb ccc http://test2.id774.net ddd eee" - } + end end - end - context "It should be empty description if clear_description specified" do - - subject { - Automatic::Plugin::FilterDescriptionLink.new({ - 'clear_description' => 1, - }, - AutomaticSpec.generate_pipeline { - feed { - item "http://test1.id774.net", - "dummy title", - "aaa bbb ccc http://test2.id774.net ddd eee", - "Mon, 07 Mar 2011 15:54:11 +0900" + context "It should be empty description if clear_description specified" do + + subject { + Automatic::Plugin::FilterDescriptionLink.new({ + 'clear_description' => 1, + }, + AutomaticSpec.generate_pipeline { + feed { + item "http://test1.id774.net", + "dummy title", + "aaa bbb ccc http://test2.id774.net ddd eee", + "Mon, 07 Mar 2011 15:54:11 +0900" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@pipeline)[0].items[0].link. - should == "http://test2.id774.net" - subject.instance_variable_get(:@pipeline)[0].items[0].description. - should == "" + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@pipeline)[0].items[0].link. + should == "http://test2.id774.net" + subject.instance_variable_get(:@pipeline)[0].items[0].description. + should == "" + } + end end - end - context "It should be got title if get_title specified", :network do - - subject { - Automatic::Plugin::FilterDescriptionLink.new({ - 'get_title' => 1, - }, - AutomaticSpec.generate_pipeline { - feed { - item "http://test1.id774.net", - "dummy title", - "aaa bbb ccc http://blog.id774.net/post/2014/10/01/531/ ddd eee", - "Mon, 07 Mar 2011 15:54:11 +0900" + context "It should be got title if get_title specified", :network do + + subject { + Automatic::Plugin::FilterDescriptionLink.new({ + 'get_title' => 1, + }, + AutomaticSpec.generate_pipeline { + feed { + item "http://test1.id774.net", + "dummy title", + "aaa bbb ccc http://blog.id774.net/post/2014/10/01/531/ ddd eee", + "Mon, 07 Mar 2011 15:54:11 +0900" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@pipeline)[0].items[0].link. - should == "http://blog.id774.net/post/2014/10/01/531/" - subject.instance_variable_get(:@pipeline)[0].items[0].title. - should == "二穂様は俺の嫁 | 774::Blog" - subject.instance_variable_get(:@pipeline)[0].items[0].description. - should == "aaa bbb ccc http://blog.id774.net/post/2014/10/01/531/ ddd eee" + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@pipeline)[0].items[0].link. + should == "http://blog.id774.net/post/2014/10/01/531/" + subject.instance_variable_get(:@pipeline)[0].items[0].title. + should == "二穂様は俺の嫁 | 774::Blog" + subject.instance_variable_get(:@pipeline)[0].items[0].description. + should == "aaa bbb ccc http://blog.id774.net/post/2014/10/01/531/ ddd eee" + } + end end - end - context "It should be handling error if 404 Not Found", :network do - - subject { - Automatic::Plugin::FilterDescriptionLink.new({ - 'get_title' => 1, - }, - AutomaticSpec.generate_pipeline { - feed { - item "http://test1.id774.net", - "dummy title", - "aaa bbb ccc http://blog.id774.net/post/2014/10/01/532/ ddd eee", - "Mon, 07 Mar 2011 15:54:11 +0900" + context "It should be handling error if 404 Not Found", :network do + + subject { + Automatic::Plugin::FilterDescriptionLink.new({ + 'get_title' => 1, + }, + AutomaticSpec.generate_pipeline { + feed { + item "http://test1.id774.net", + "dummy title", + "aaa bbb ccc http://blog.id774.net/post/2014/10/01/532/ ddd eee", + "Mon, 07 Mar 2011 15:54:11 +0900" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@pipeline)[0].items[0].link. - should == "http://blog.id774.net/post/2014/10/01/532/" - subject.instance_variable_get(:@pipeline)[0].items[0].title. - should == "dummy title" - subject.instance_variable_get(:@pipeline)[0].items[0].description. - should == "aaa bbb ccc http://blog.id774.net/post/2014/10/01/532/ ddd eee" + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@pipeline)[0].items[0].link. + should == "http://blog.id774.net/post/2014/10/01/532/" + subject.instance_variable_get(:@pipeline)[0].items[0].title. + should == "dummy title" + subject.instance_variable_get(:@pipeline)[0].items[0].description. + should == "aaa bbb ccc http://blog.id774.net/post/2014/10/01/532/ ddd eee" + } + end end - end + end end diff --git a/spec/plugins/filter/sanitize_spec.rb b/spec/plugins/filter/sanitize_spec.rb index 6248bfe..189e15b 100644 --- a/spec/plugins/filter/sanitize_spec.rb +++ b/spec/plugins/filter/sanitize_spec.rb @@ -10,146 +10,151 @@ require File.expand_path(File.dirname(__FILE__) + '../../../spec_helper') -require 'filter/sanitize' - -describe Automatic::Plugin::FilterSanitize do - context "It should be sanitized" do - subject { - Automatic::Plugin::FilterSanitize.new( - {}, - AutomaticSpec.generate_pipeline { - feed { - item "http://testsite.org", "hoge", - "fuga", - "Mon, 07 Mar 2011 15:54:11 +0900" +# FilterSanitize needs the sanitize gem, which the Gemfile declares in its +# optional :plugins group. The default suite and CI do not install it, so this +# spec runs only where the operator has. See doc/POLICY.md section 5. +if AutomaticSpec.optional_dependency?('sanitize') + require 'filter/sanitize' + + describe Automatic::Plugin::FilterSanitize do + context "It should be sanitized" do + subject { + Automatic::Plugin::FilterSanitize.new( + {}, + AutomaticSpec.generate_pipeline { + feed { + item "http://testsite.org", "hoge", + "fuga", + "Mon, 07 Mar 2011 15:54:11 +0900" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@return_feeds)[0].items. - count.should == 1 - subject.instance_variable_get(:@return_feeds)[0].items[0].description. - should == 'fuga' + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@return_feeds)[0].items. + count.should == 1 + subject.instance_variable_get(:@return_feeds)[0].items[0].description. + should == 'fuga' + } + end end - end - context "It should not be sanitized in basic mode" do - subject { - Automatic::Plugin::FilterSanitize.new( - { - 'mode' => "basic" - }, - AutomaticSpec.generate_pipeline { - feed { - item "http://testsite.org", "hoge", - "fuga", - "Mon, 07 Mar 2011 15:54:11 +0900" + context "It should not be sanitized in basic mode" do + subject { + Automatic::Plugin::FilterSanitize.new( + { + 'mode' => "basic" + }, + AutomaticSpec.generate_pipeline { + feed { + item "http://testsite.org", "hoge", + "fuga", + "Mon, 07 Mar 2011 15:54:11 +0900" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@return_feeds)[0].items. - count.should == 1 - subject.instance_variable_get(:@return_feeds)[0].items[0].description. - should == 'fuga' + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@return_feeds)[0].items. + count.should == 1 + subject.instance_variable_get(:@return_feeds)[0].items[0].description. + should == 'fuga' + } + end end - end - context "It should not be sanitized in restricted mode" do - subject { - Automatic::Plugin::FilterSanitize.new( - { - 'mode' => "restricted" - }, - AutomaticSpec.generate_pipeline { - feed { - item "http://testsite.org", "hoge", - "fuga", - "Mon, 07 Mar 2011 15:54:11 +0900" + context "It should not be sanitized in restricted mode" do + subject { + Automatic::Plugin::FilterSanitize.new( + { + 'mode' => "restricted" + }, + AutomaticSpec.generate_pipeline { + feed { + item "http://testsite.org", "hoge", + "fuga", + "Mon, 07 Mar 2011 15:54:11 +0900" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@return_feeds)[0].items. - count.should == 1 - subject.instance_variable_get(:@return_feeds)[0].items[0].description. - should == 'fuga' + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@return_feeds)[0].items. + count.should == 1 + subject.instance_variable_get(:@return_feeds)[0].items[0].description. + should == 'fuga' + } + end end - end - context "It should not be sanitized in relaxed mode" do - subject { - Automatic::Plugin::FilterSanitize.new( - { - 'mode' => "relaxed" - }, - AutomaticSpec.generate_pipeline { - feed { - item "http://testsite.org", "hoge", - "fuga", - "Mon, 07 Mar 2011 15:54:11 +0900" + context "It should not be sanitized in relaxed mode" do + subject { + Automatic::Plugin::FilterSanitize.new( + { + 'mode' => "relaxed" + }, + AutomaticSpec.generate_pipeline { + feed { + item "http://testsite.org", "hoge", + "fuga", + "Mon, 07 Mar 2011 15:54:11 +0900" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@return_feeds)[0].items. - count.should == 1 - subject.instance_variable_get(:@return_feeds)[0].items[0].description. - should == 'fuga' + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@return_feeds)[0].items. + count.should == 1 + subject.instance_variable_get(:@return_feeds)[0].items[0].description. + should == 'fuga' + } + end end - end - context "It should be sanitized" do - subject { - Automatic::Plugin::FilterSanitize.new( - {}, - AutomaticSpec.generate_pipeline { - feed { - item "http://testsite.org", "hoge" + context "It should be sanitized" do + subject { + Automatic::Plugin::FilterSanitize.new( + {}, + AutomaticSpec.generate_pipeline { + feed { + item "http://testsite.org", "hoge" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@return_feeds)[0].items. - count.should == 1 - subject.instance_variable_get(:@return_feeds)[0].items[0].description. - should == '' + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@return_feeds)[0].items. + count.should == 1 + subject.instance_variable_get(:@return_feeds)[0].items[0].description. + should == '' + } + end end - end + end end diff --git a/spec/plugins/subscription/g_guide_spec.rb b/spec/plugins/subscription/g_guide_spec.rb index 5d63ca4..5add3cd 100644 --- a/spec/plugins/subscription/g_guide_spec.rb +++ b/spec/plugins/subscription/g_guide_spec.rb @@ -31,7 +31,7 @@ def g_guide(config = {}, pipeline = []) subject { g_guide(config) } it 'feed_url' do - subject.feed_url(config['keyword']).should == URI::Parser.new.escape( + subject.feed_url(config['keyword']).should == URI::RFC2396_Parser.new.escape( Automatic::Plugin::SubscriptionGGuide::G_GUIDE_RSS + "condition.keyword=#{config['keyword']}&" + 'stationPlatformId=0&') @@ -42,7 +42,7 @@ def g_guide(config = {}, pipeline = []) subject { g_guide(config) } its(:feed_url) { - should == URI::Parser.new.escape( + should == URI::RFC2396_Parser.new.escape( Automatic::Plugin::SubscriptionGGuide::G_GUIDE_RSS + 'stationPlatformId=1&') } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ec648a3..c6eed05 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -83,13 +83,23 @@ module AutomaticSpec FileUtils.remove_entry(TEST_HOME) if File.directory?(TEST_HOME) end + # Gems the Gemfile declares in its optional :plugins group. They are not + # runtime dependencies of the framework, `bundle install` does not install + # them, and the default suite therefore does not verify the plugins that need + # them. Which plugin needs which is in doc/PLUGINS.md section 6. + # + # This is a declared list rather than something inferred from a load failure: + # what the default suite does not cover is decided here, in one place, and a + # spec that names a gem absent from this list is a mistake and says so. + OPTIONAL_PLUGIN_GEMS = %w[nkf sanitize].freeze + class << self # Load a plugin, or report that its dependency is absent. # - # A plugin whose gem is not installed -- because the gem is optional, or - # because the service it talks to no longer exists -- 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. + # A plugin whose gem is not installed -- because the service it talks to no + # 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. def plugin_available?(path) require path true @@ -99,10 +109,40 @@ def plugin_available?(path) false end + # Whether an optional plugin gem is in this bundle. A spec whose plugin + # needs one guards its whole file with this, because the plugin's own + # `require` runs when the file loads. + # + # if AutomaticSpec.optional_dependency?('sanitize') + # require 'filter/sanitize' + # describe ... do ... end + # end + # + # Installing the group is what runs these: + # + # BUNDLE_WITH=plugins bundle install + def optional_dependency?(gem_name) + unless OPTIONAL_PLUGIN_GEMS.include?(gem_name) + raise ArgumentError, + "#{gem_name} is not one of the optional plugin gems the Gemfile declares" + end + + return true if Gem::Specification.find_all_by_name(gem_name).any? + + skipped_optional << gem_name + warn "[automatic] not verified by this run: the optional plugin gem " \ + "#{gem_name} is not installed (BUNDLE_WITH=plugins bundle install)" + false + end + def skipped_plugins @skipped_plugins ||= [] end + def skipped_optional + @skipped_optional ||= [] + end + def generate_pipeline(&block) generator = StubPipelineGenerator.new generator.instance_eval(&block) From cdb711305e6362bd2380c799026a1cd3176e3b43 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 17:07:49 +0000 Subject: [PATCH 2/2] Correct the gemspec's note on libraries that left the standard library nkf is a plugin's dependency and is no longer declared as a runtime one, so listing it among the gems the framework requires read as a contradiction. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTSJZ48Jhsj9rquWgEvAXA --- automatic.gemspec | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/automatic.gemspec b/automatic.gemspec index 17f9345..efb7b4b 100644 --- a/automatic.gemspec +++ b/automatic.gemspec @@ -94,9 +94,11 @@ Gem::Specification.new do |spec| # that plugin's own file and installed by the operator who uses the plugin. # See doc/POLICY.md section 9.1 and doc/DEPLOYMENT.md. # - # rexml, rss and nkf left the standard library and became gems over the 3.x - # series. Each one listed here is listed because something committed here - # requires it, not because a Ruby release moved it. + # rexml and rss left the standard library and became gems over the 3.x + # series, and nkf followed after 3.3. Each gem listed here is listed because + # something committed here requires it, and a library's move out of the + # standard library is not by itself a reason to declare it: nkf is a plugin's + # dependency and is in the Gemfile's optional group instead. spec.add_dependency 'activerecord', '>= 7.1', '< 9.0' # store plugins spec.add_dependency 'activesupport', '>= 7.1', '< 9.0' # plugin loader, XML subscription spec.add_dependency 'feedbag', '>= 1.0', '< 2.0' # autodiscovery subcommand