automatic 14.12.2 → 26.09

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +669 -83
  3. data/VERSION +1 -1
  4. data/assets/siteinfo/items_all.json +60300 -52138
  5. data/automatic.gemspec +110 -248
  6. data/bin/automatic +20 -139
  7. data/config/feed2console.yml +10 -4
  8. data/config/feed2markdown.yml +41 -0
  9. data/doc/AI_TUTORIAL.md +542 -0
  10. data/doc/BASIC_DESIGN.md +547 -0
  11. data/doc/COPYING.LESSER +165 -0
  12. data/doc/DEPLOYMENT.md +830 -0
  13. data/doc/LICENSE.md +14 -0
  14. data/doc/PLUGINS.md +2015 -0
  15. data/doc/PLUGIN_DEVELOPMENT.md +86 -0
  16. data/doc/POLICY.md +962 -0
  17. data/doc/QUICKSTART.md +260 -0
  18. data/doc/RELEASING.md +394 -0
  19. data/doc/REQUIREMENTS.md +556 -0
  20. data/doc/VERSIONS +266 -0
  21. data/lib/automatic/cli.rb +270 -0
  22. data/lib/automatic/environment.rb +31 -5
  23. data/lib/automatic/feed_maker.rb +65 -25
  24. data/lib/automatic/feed_parser.rb +51 -35
  25. data/lib/automatic/http.rb +107 -0
  26. data/lib/automatic/log.rb +49 -18
  27. data/lib/automatic/opml.rb +3 -1
  28. data/lib/automatic/pipeline.rb +73 -32
  29. data/lib/automatic/recipe.rb +100 -17
  30. data/lib/automatic/version.rb +14 -1
  31. data/lib/automatic.rb +93 -20
  32. data/plugins/custom_feed/svn_log.rb +73 -32
  33. data/plugins/custom_feed/web.rb +348 -0
  34. data/plugins/filter/absolute_uri.rb +43 -27
  35. data/plugins/filter/accept.rb +38 -45
  36. data/plugins/filter/batch.rb +97 -0
  37. data/plugins/filter/claude.rb +217 -0
  38. data/plugins/filter/clear.rb +12 -8
  39. data/plugins/filter/description_link.rb +66 -51
  40. data/plugins/filter/full_feed.rb +170 -52
  41. data/plugins/filter/gemini.rb +216 -0
  42. data/plugins/filter/github_feed.rb +38 -26
  43. data/plugins/filter/ignore.rb +33 -44
  44. data/plugins/filter/image.rb +36 -25
  45. data/plugins/filter/image_source.rb +75 -52
  46. data/plugins/filter/join.rb +105 -0
  47. data/plugins/filter/kimi.rb +216 -0
  48. data/plugins/filter/limit.rb +57 -0
  49. data/plugins/filter/one.rb +19 -26
  50. data/plugins/filter/open_ai.rb +198 -0
  51. data/plugins/filter/present.rb +75 -0
  52. data/plugins/filter/rand.rb +16 -17
  53. data/plugins/filter/sakura_ai.rb +205 -0
  54. data/plugins/filter/sanitize.rb +29 -34
  55. data/plugins/filter/sort.rb +20 -27
  56. data/plugins/filter/tumblr_resize.rb +31 -23
  57. data/plugins/notify/ikachan.rb +86 -48
  58. data/plugins/provide/fluentd.rb +43 -24
  59. data/plugins/publish/amazon_s3.rb +79 -40
  60. data/plugins/publish/console.rb +19 -16
  61. data/plugins/publish/console_link.rb +20 -17
  62. data/plugins/publish/eject.rb +48 -26
  63. data/plugins/publish/fluentd.rb +50 -30
  64. data/plugins/publish/hatena_bookmark.rb +88 -71
  65. data/plugins/publish/instapaper.rb +69 -59
  66. data/plugins/publish/markdown.rb +278 -0
  67. data/plugins/publish/memcached.rb +35 -29
  68. data/plugins/store/database.rb +50 -48
  69. data/plugins/store/digest.rb +212 -0
  70. data/plugins/store/file.rb +107 -68
  71. data/plugins/store/full_text.rb +31 -28
  72. data/plugins/store/permalink.rb +18 -22
  73. data/plugins/subscription/feed.rb +34 -21
  74. data/plugins/subscription/link.rb +31 -32
  75. data/plugins/subscription/text.rb +40 -45
  76. data/plugins/subscription/tumblr.rb +55 -44
  77. data/plugins/subscription/xml.rb +40 -36
  78. metadata +112 -321
  79. data/Gemfile +0 -38
  80. data/Rakefile +0 -59
  81. data/doc/ChangeLog +0 -303
  82. data/doc/PLUGINS +0 -750
  83. data/doc/PLUGINS.ja +0 -753
  84. data/doc/README +0 -511
  85. data/doc/README.ja +0 -519
  86. data/plugins/filter/google_news.rb +0 -50
  87. data/plugins/publish/google_calendar.rb +0 -84
  88. data/plugins/publish/hipchat.rb +0 -46
  89. data/plugins/publish/pocket.rb +0 -45
  90. data/plugins/publish/twitter.rb +0 -58
  91. data/plugins/subscription/chan_toru.rb +0 -57
  92. data/plugins/subscription/g_guide.rb +0 -57
  93. data/plugins/subscription/pocket.rb +0 -51
  94. data/plugins/subscription/twitter.rb +0 -69
  95. data/plugins/subscription/twitter_search.rb +0 -50
  96. data/plugins/subscription/weather.rb +0 -33
  97. data/script/build +0 -84
  98. data/spec/fixtures/sampleFeeds.tsv +0 -1
  99. data/spec/fixtures/sampleFeeds2.tsv +0 -2
  100. data/spec/fixtures/sampleRecipe.yml +0 -24
  101. data/spec/lib/automatic/log_spec.rb +0 -32
  102. data/spec/lib/automatic/pipeline_spec.rb +0 -68
  103. data/spec/lib/automatic/recipe_spec.rb +0 -40
  104. data/spec/lib/automatic_spec.rb +0 -99
  105. data/spec/plugins/custom_feed/svn_log_spec.rb +0 -31
  106. data/spec/plugins/filter/absolute_uri_spec.rb +0 -61
  107. data/spec/plugins/filter/accept_spec.rb +0 -331
  108. data/spec/plugins/filter/clear_spec.rb +0 -49
  109. data/spec/plugins/filter/description_link_spec.rb +0 -138
  110. data/spec/plugins/filter/full_feed_spec.rb +0 -129
  111. data/spec/plugins/filter/github_feed_spec.rb +0 -55
  112. data/spec/plugins/filter/google_news_spec.rb +0 -69
  113. data/spec/plugins/filter/ignore_spec.rb +0 -328
  114. data/spec/plugins/filter/image_source_spec.rb +0 -89
  115. data/spec/plugins/filter/image_spec.rb +0 -65
  116. data/spec/plugins/filter/one_spec.rb +0 -71
  117. data/spec/plugins/filter/rand_spec.rb +0 -52
  118. data/spec/plugins/filter/sanitize_spec.rb +0 -153
  119. data/spec/plugins/filter/sort_spec.rb +0 -189
  120. data/spec/plugins/filter/tumblr_resize_spec.rb +0 -109
  121. data/spec/plugins/notify/ikachan_spec.rb +0 -58
  122. data/spec/plugins/provide/fluentd_spec.rb +0 -49
  123. data/spec/plugins/publish/amazon_s3_spec.rb +0 -40
  124. data/spec/plugins/publish/console_spec.rb +0 -30
  125. data/spec/plugins/publish/eject_spec.rb +0 -40
  126. data/spec/plugins/publish/fluentd_spec.rb +0 -40
  127. data/spec/plugins/publish/google_calendar_spec.rb +0 -83
  128. data/spec/plugins/publish/hatena_bookmark_spec.rb +0 -134
  129. data/spec/plugins/publish/hipchat_spec.rb +0 -69
  130. data/spec/plugins/publish/instapaper_spec.rb +0 -82
  131. data/spec/plugins/publish/memcached_spec.rb +0 -63
  132. data/spec/plugins/publish/pocket_spec.rb +0 -51
  133. data/spec/plugins/publish/twitter_spec.rb +0 -73
  134. data/spec/plugins/store/file_spec.rb +0 -58
  135. data/spec/plugins/store/full_text_spec.rb +0 -152
  136. data/spec/plugins/store/permalink_spec.rb +0 -206
  137. data/spec/plugins/subscription/chan_toru_spec.rb +0 -56
  138. data/spec/plugins/subscription/feed_spec.rb +0 -71
  139. data/spec/plugins/subscription/g_guide_spec.rb +0 -82
  140. data/spec/plugins/subscription/link_spec.rb +0 -72
  141. data/spec/plugins/subscription/pocket_spec.rb +0 -57
  142. data/spec/plugins/subscription/text_spec.rb +0 -84
  143. data/spec/plugins/subscription/tumblr_spec.rb +0 -74
  144. data/spec/plugins/subscription/twitter_search_spec.rb +0 -57
  145. data/spec/plugins/subscription/twitter_spec.rb +0 -73
  146. data/spec/plugins/subscription/weather_spec.rb +0 -44
  147. data/spec/plugins/subscription/xml_spec.rb +0 -84
  148. data/spec/spec_helper.rb +0 -106
  149. data/spec/user_dir/plugins/store/mock.rb +0 -16
  150. data/test/fixtures/sampleOPML.xml +0 -11
  151. data/test/integration/test_absoluteurl.yml +0 -25
  152. data/test/integration/test_activerecord.yml +0 -24
  153. data/test/integration/test_add_pocket.yml +0 -26
  154. data/test/integration/test_chan_toru.yml +0 -21
  155. data/test/integration/test_descriptionlink.yml +0 -21
  156. data/test/integration/test_fluentd.yml +0 -22
  157. data/test/integration/test_fulltext.yml +0 -30
  158. data/test/integration/test_google_news.yml +0 -21
  159. data/test/integration/test_googlealert.yml +0 -21
  160. data/test/integration/test_hatenabookmark.yml +0 -30
  161. data/test/integration/test_ignore.yml +0 -25
  162. data/test/integration/test_ignore2.yml +0 -22
  163. data/test/integration/test_image2local.yml +0 -33
  164. data/test/integration/test_instapaper.yml +0 -26
  165. data/test/integration/test_link2local.yml +0 -34
  166. data/test/integration/test_one.yml +0 -23
  167. data/test/integration/test_pocket.yml +0 -22
  168. data/test/integration/test_rand.yml +0 -21
  169. data/test/integration/test_sanitize.yml +0 -23
  170. data/test/integration/test_sort.yml +0 -36
  171. data/test/integration/test_svnlog.yml +0 -15
  172. data/test/integration/test_text2feed.yml +0 -36
  173. data/test/integration/test_tumblr2local.yml +0 -43
  174. data/test/integration/test_twitter_search.yml +0 -22
  175. data/test/integration/test_weather.yml +0 -19
  176. data/test/integration/test_xml2fluentd.yml +0 -21
  177. data/vendor/.gitkeep +0 -0
@@ -0,0 +1,547 @@
1
+ # Basic design: a plugin pipeline for unattended processing
2
+
3
+ ## 1. Purpose
4
+
5
+ This document says how Automatic Ruby is put together: what the pieces are, what
6
+ each is responsible for, which way they depend on each other, and how a value
7
+ travels through a run.
8
+
9
+ What the system is for belongs to [`REQUIREMENTS.md`](REQUIREMENTS.md). The
10
+ rules a change is held to belong to [`POLICY.md`](POLICY.md). The two public
11
+ contracts — the Recipe format and the plugin interface — are specified in
12
+ [`PLUGINS.md`](PLUGINS.md); this document explains the machinery that implements
13
+ them and does not restate them.
14
+
15
+ It stands on its own. Nothing in it is completed by a document kept in another
16
+ repository.
17
+
18
+ ## 2. Design policy
19
+
20
+ Automatic Ruby is a general-purpose composition framework, not host
21
+ infrastructure and not a purpose-built application. The design therefore
22
+ protects the small set of contracts that make composition possible without
23
+ treating every framework implementation detail or every shipped plugin as
24
+ equally permanent.
25
+
26
+ - **The framework is the small part.** It loads a Recipe, finds classes by name,
27
+ and calls them in order. It has no domain knowledge, and gaining some would be
28
+ a design error rather than a feature.
29
+ - **Plugins are the large part, and they are replaceable.** They are found on a
30
+ search path, not registered in a list, so that adding one touches no framework
31
+ file and removing one leaves nothing dangling.
32
+ - **One value connects everything.** Because every plugin takes and returns the
33
+ same shape, composition needs no adapter, no type negotiation and no schema.
34
+ - **The core is loadable without its plugins' dependencies.** `require
35
+ 'automatic'` pulls in the framework and nothing a plugin needs. Each plugin
36
+ requires its own libraries, at the top of its own file, so that an absent gem
37
+ is an error only for a Recipe that asked for that plugin.
38
+ - **The library never exits and never prints.** Exit status is decided by the
39
+ entry point; user-facing text is written by the entry point or logged.
40
+
41
+ Maintenance strength follows the architectural layer:
42
+
43
+ - **Core contracts are strongly protected.** The Recipe format, plugin
44
+ contract, single pipeline shape, lookup and override semantics, execution
45
+ order and established CLI behaviour are depended on outside this repository.
46
+ - **Framework internals may improve inside those contracts.** The loader, CLI,
47
+ helpers and internal structure are not frozen merely because they are old,
48
+ provided the invariants and public contracts remain intact.
49
+ - **Plugins are intentionally replaceable.** They may be added, repaired,
50
+ replaced or removed as their external systems change. A plugin whose service
51
+ or interface no longer exists is not preserved by simulation merely to retain
52
+ catalogue size.
53
+
54
+ Composability is an architectural invariant, not an optimization preference.
55
+ It is the architectural property that defines the framework: a small core,
56
+ independent plugins, one pipeline representation and Recipe-level composition.
57
+ A change that replaces those properties changes the identity of the system and
58
+ is judged as an architecture change.
59
+
60
+ ## 3. Composition
61
+
62
+ A typical run is a short, linear flow. Markdown is one publisher at the edge,
63
+ not a second pipeline representation:
64
+
65
+ ```text
66
+ source plugins -> shared pipeline -> optional filters / stores -> publishers
67
+ ```
68
+
69
+ ```text
70
+ bin/automatic process entry point; exit status only
71
+ |
72
+ v
73
+ lib/automatic/cli.rb option parsing, subcommands, error reporting
74
+ |
75
+ +---------------------------+
76
+ | |
77
+ v v
78
+ lib/automatic.rb lib/automatic/opml.rb diagnostic helpers
79
+ (run, directories) lib/automatic/feed_parser.rb
80
+ |
81
+ +-------------------+
82
+ | |
83
+ v v
84
+ lib/automatic/recipe.rb lib/automatic/pipeline.rb
85
+ | |
86
+ | v
87
+ | Automatic::Plugin::* plugins/<category>/<name>.rb
88
+ | | ~/.automatic/plugins/<category>/<name>.rb
89
+ v v
90
+ lib/automatic/log.rb lib/automatic/feed_maker.rb
91
+ | lib/automatic/http.rb
92
+ v
93
+ standard output
94
+ ```
95
+
96
+ Dependency points downward, and there is no edge back up:
97
+
98
+ - `bin/automatic` knows only `Automatic::CLI`.
99
+ - `Automatic::CLI` knows the framework. Nothing in the framework knows the CLI.
100
+ - `Automatic::Pipeline` knows how to find and call a plugin. It knows no plugin.
101
+ - A plugin knows `Automatic::Log`, `Automatic::FeedMaker`,
102
+ `Automatic::FeedParser`, `Automatic::Http` and its own libraries. It knows no
103
+ other plugin.
104
+ - `Automatic::Log` and `Automatic::FeedMaker` are leaves. They depend on nothing
105
+ in this repository.
106
+
107
+ `Automatic::Recipe` and `Automatic::Pipeline` do not know each other. Both are
108
+ driven by `Automatic.run`.
109
+
110
+ ## 4. What each part is for
111
+
112
+ ### 4.1 `bin/automatic`
113
+
114
+ The process entry point, and deliberately almost empty. It puts the
115
+ installation's `lib` on the load path, requires `automatic/cli`, and exits with
116
+ the status `Automatic::CLI.run(ARGV)` returns.
117
+
118
+ It contains no option definition, no subcommand and no policy. Everything a test
119
+ would want to exercise is therefore in a library file, reachable without
120
+ spawning a process.
121
+
122
+ ### 4.2 `lib/automatic/cli.rb` — `Automatic::CLI`
123
+
124
+ Everything that belongs to being a command:
125
+
126
+ - Builds the `OptionParser`: `-c/--config`, `-h/--help`, `-v/--version`.
127
+ - Holds the subcommand table (`scaffold`, `unscaffold`, `autodiscovery`,
128
+ `feedparser`, `inspect`, `opmlparser`, `log`) as a hash of name to callable.
129
+ - Implements `scaffold` and `unscaffold`, which are the only filesystem
130
+ operations the framework performs on its own behalf.
131
+ - Catches the framework's own exceptions, prints one line to standard error, and
132
+ returns the exit status.
133
+
134
+ `CLI.run` **returns** an `Integer` and never calls `exit`. That is what lets a
135
+ spec assert on a status and on captured output instead of a subprocess.
136
+
137
+ Exit status is decided in exactly one place:
138
+
139
+ | Status | Meaning |
140
+ | --- | --- |
141
+ | `0` | The Recipe ran, or the subcommand did its work, or help or version was printed |
142
+ | `1` | The run or the subcommand failed, or no work was requested |
143
+ | `2` | The command line was rejected by the option parser |
144
+
145
+ The libraries a subcommand needs are required inside that subcommand, not at the
146
+ top of the file, so running a Recipe loads neither `feedbag` nor the OPML
147
+ parser. The framework's own requires still apply: `require 'automatic'` pulls in
148
+ the Recipe loader, the pipeline, the log and the two feed adapters, and through
149
+ them Ruby's `rss`.
150
+
151
+ ### 4.3 `lib/automatic.rb` — `Automatic`
152
+
153
+ The module itself, holding the two directories the rest of the system resolves
154
+ paths against, and the one method that runs a job:
155
+
156
+ - `root_dir` — the installation root, set by the entry point.
157
+ - `user_dir` — `~/.automatic`, or an override when `AUTOMATIC_RUBY_ENV=test`,
158
+ which is how the specs point the loader at a fixture directory without writing
159
+ to a real home directory.
160
+ - `plugins_dir`, `config_dir`, `user_plugins_dir` — derived from those two.
161
+ - `run(recipe:, root_dir:, user_dir:)` — sets the directories and hands the
162
+ Recipe to `Pipeline.run`.
163
+
164
+ It `require`s the framework's own files and nothing else. It does not require a
165
+ plugin, and a plugin's dependency never appears here.
166
+
167
+ ### 4.4 `lib/automatic/environment.rb`
168
+
169
+ Bundler setup for a source checkout: if the repository's `Gemfile` is present,
170
+ set it up so `bin/automatic` resolves the locked gems.
171
+
172
+ It is a convenience for running from a checkout, not a requirement. When the gem
173
+ is installed normally there is no `Gemfile` to find and this file does nothing,
174
+ which is the correct behaviour: an installed library must not impose a bundle on
175
+ the program that requires it.
176
+
177
+ ### 4.5 `lib/automatic/recipe.rb` — `Automatic::Recipe`
178
+
179
+ Turns a Recipe file into an object the pipeline can iterate.
180
+
181
+ - Resolves the path: a bare name is looked for in `~/.automatic/config` first,
182
+ and anything not found there is treated as a path as given.
183
+ - Parses the YAML **safely** — the document may contain only the plain types a
184
+ Recipe needs, so a Recipe cannot name a Ruby class to instantiate. Aliases are
185
+ permitted, because they are a legitimate way to share a block of settings
186
+ between plugins. This is a second line of defence, not the trust boundary; see
187
+ [`REQUIREMENTS.md`](REQUIREMENTS.md) section 17.
188
+ - Wraps the result in `Hashie::Mash`, which is why a plugin entry answers to
189
+ `plugin.module` and `plugin.config`, and why an absent setting reads as `nil`
190
+ rather than raising.
191
+ - Applies `global.log.level` to `Automatic::Log`. This is the only `global` key
192
+ the framework reads.
193
+ - Exposes `each_plugin`, which yields the entries of `plugins` in order.
194
+ - Raises `Automatic::InvalidRecipeError` for a document that is not a mapping,
195
+ or that carries no usable `plugins` sequence.
196
+
197
+ It performs no validation of a plugin's own `config`. Whether a setting is
198
+ required, and what it must look like, is the plugin's business, because the
199
+ framework cannot know.
200
+
201
+ ### 4.6 `lib/automatic/pipeline.rb` — `Automatic::Pipeline`
202
+
203
+ The core, and the shortest file that matters.
204
+
205
+ `load_plugin(module_name)` resolves a class name to a file:
206
+
207
+ 1. `module_name.underscore` turns `SubscriptionFeed` into `subscription_feed`.
208
+ 2. The category directories are listed, `~/.automatic/plugins/*` **before**
209
+ `<root>/plugins/*`, so the user directory wins.
210
+ 3. For each, if the directory's own name is a prefix of the underscored module
211
+ name, the remainder is the file name: directory `subscription` and module
212
+ `subscription_feed` give `subscription/feed.rb`.
213
+ 4. The first existing file wins, and is registered with
214
+ `Automatic::Plugin.autoload`, so the file is read when the constant is first
215
+ used.
216
+ 5. Nothing matched raises `Automatic::NoPluginError` naming the module.
217
+
218
+ The category directory is therefore not a label: it is half of the lookup key.
219
+ This is what lets a plugin be added by dropping in a file, and a shipped plugin
220
+ be overridden by putting a file of the same name in the user directory.
221
+
222
+ `run(recipe)` is the pipeline:
223
+
224
+ ```ruby
225
+ pipeline = []
226
+ recipe.each_plugin do |plugin|
227
+ mod = plugin.module
228
+ load_plugin(mod)
229
+ klass = Automatic::Plugin.const_get(mod)
230
+ pipeline = klass.new(plugin.config, pipeline).run
231
+ end
232
+ ```
233
+
234
+ Each plugin's return value is the next plugin's input. There is no branching, no
235
+ inspection of the value between steps, and no exception handling: a plugin that
236
+ raises ends the run, for the reason given in `REQUIREMENTS.md` section 12.
237
+
238
+ ### 4.7 `lib/automatic/log.rb` — `Automatic::Log`
239
+
240
+ A `Logger` on standard output behind a level filter.
241
+
242
+ - `Log.level(name)` sets the threshold, from `global.log.level`.
243
+ - `Log.puts(level, message)` emits when `level` is at least the threshold.
244
+ - Levels are `info`, `warn`, `error`, `none`, compared by their position in that
245
+ list; `none` is the threshold that admits nothing.
246
+ - Both a `String` and a `Symbol` are accepted for a level, because plugins have
247
+ always passed both, and an unknown level is treated as `info` rather than
248
+ raising in the middle of an unattended run.
249
+
250
+ It is a module with state rather than an injected object. That is a consequence
251
+ of plugins calling `Automatic::Log` directly, which keeps a plugin's signature
252
+ to `(config, pipeline)`.
253
+
254
+ ### 4.8 `lib/automatic/feed_maker.rb`, `feed_parser.rb` and `http.rb`
255
+
256
+ The adapters between "some data" and the pipeline shape, and the one way in for
257
+ what is fetched.
258
+
259
+ - `FeedParser.get_url(url)` fetches a URL and parses it as a feed.
260
+ - `FeedParser.parse_html(html)` builds a feed whose items are the page's links,
261
+ which is how the link and Tumblr subscription plugins work.
262
+ - `FeedMaker.generate_feed(hash)` builds one item-like object from plain values.
263
+ - `FeedMaker.create_pipeline(items)` builds one feed object from a list of them.
264
+ Any plugin producing items from a non-feed source ends with this call.
265
+ - `FeedMaker.content_provide(url, data)` builds a one-item feed carrying an
266
+ arbitrary payload in `content_encoded`, which is the route by which the XML
267
+ subscription plugin feeds the Fluentd provide plugin.
268
+
269
+ The first two use Ruby's bundled `rss` library. That is the reason the pipeline
270
+ value has the shape it has.
271
+
272
+ - `Http.read(url)` fetches a URL and returns the body and `Http.open(url)`
273
+ yields the stream, for a caller — an HTML parser, which detects a page's
274
+ encoding for itself — that would rather not be handed a decoded string;
275
+ `Http.uri(url)` returns a validated URI and `Http.fetchable?(url)` answers
276
+ whether there is one.
277
+
278
+ `Automatic::Http` exists because the decisions a fetch implies — which schemes
279
+ are allowed, how long to wait, how many redirects to follow, what to send as a
280
+ User-Agent — were being made separately by every plugin that fetched, mostly by
281
+ omission. It is a helper of about twenty lines and not a client: a plugin that
282
+ wants something else calls Ruby directly. The scheme allowlist is the part that
283
+ earns it a file of its own, because a link in a pipeline item comes from a feed
284
+ and `URI.open` on such a string will read a local file as readily as an
285
+ article.
286
+
287
+ ### 4.9 `plugins/` — `Automatic::Plugin::*`
288
+
289
+ Every plugin is a class in `Automatic::Plugin`, constructed with `(config,
290
+ pipeline)` and answering `run`. The contract is specified in
291
+ [`PLUGINS.md`](PLUGINS.md) section 3; what matters to this document is how the
292
+ categories divide responsibility:
293
+
294
+ | Category | Directory | Receives | Returns | Role |
295
+ | --- | --- | --- | --- | --- |
296
+ | `Subscription` | `subscription/` | usually an empty pipeline | a pipeline | Acquire from outside |
297
+ | `CustomFeed` | `custom_feed/` | usually an empty pipeline | a pipeline | Build a feed from a non-feed source |
298
+ | `Filter` | `filter/` | a pipeline | a pipeline | Select, reorder or rewrite |
299
+ | `Store` | `store/` | a pipeline | a pipeline, usually reduced | Persist, and drop what was seen before |
300
+ | `Provide` | `provide/` | a pipeline | the same pipeline | Emit the payload elsewhere |
301
+ | `Notify` | `notify/` | a pipeline | the same pipeline | Send a notification |
302
+ | `Publish` | `publish/` | a pipeline | the same pipeline | Send the result out, or print it |
303
+
304
+ The categories are a convention with one mechanical consequence — the directory
305
+ name is part of the lookup key (section 4.6) — and no other. Nothing enforces
306
+ that a `Filter` does not reach the network.
307
+
308
+ Replaceability is part of this boundary. A plugin is not given the same
309
+ permanence as the Recipe format or the plugin contract itself. The framework
310
+ preserves the rules that let plugins compose; it does not preserve a plugin
311
+ whose external purpose has disappeared, and it does not move a plugin's domain
312
+ behaviour into the core merely to make that behaviour permanent.
313
+
314
+ **`Publish` is the boundary at which the pipeline meets a representation that is
315
+ not the pipeline's.** A publishing plugin reads the value described in section
316
+ 4.8, writes it out in whatever form its destination wants — a line on a
317
+ terminal, a request body, a record for a log collector, a document on disk — and
318
+ returns the value it was given, unchanged. The conversion is the plugin's whole
319
+ job and it happens in one direction, at one point:
320
+
321
+ ```text
322
+ RSS-shaped pipeline
323
+ |
324
+ v
325
+ Publish<Something> the plugin: serialize, then emit
326
+ |
327
+ v
328
+ the destination's own form a terminal line, a JSON record, a Markdown document
329
+ ```
330
+
331
+ `PublishMarkdown` is one of these and is architecturally unremarkable: it
332
+ renders each item as a Markdown section and writes the result to standard output
333
+ or to a file. What matters to this document is where it is *not*:
334
+
335
+ - **Not in the framework.** `Automatic::Pipeline` gains no knowledge of
336
+ Markdown, `Automatic::FeedMaker` gains no Markdown constructor, and no
337
+ framework file mentions the format. A reference to it in `lib/` would be the
338
+ same design error as a reference to any other plugin (section 3).
339
+ - **Not a second pipeline value.** Markdown is produced *from* the pipeline at
340
+ the moment the pipeline ends; it is never passed along one. The plugin returns
341
+ its input, so a plugin placed after it receives exactly what it would have
342
+ received without it, and Invariant 2 of [`POLICY.md`](POLICY.md) is untouched.
343
+ - **Not implicit.** Nothing appends it to a Recipe. `Pipeline.run` runs the
344
+ entries the Recipe lists, in order, and that is still the whole of its
345
+ behaviour.
346
+
347
+ Its specification — what it writes for each field, how HTML in a body is
348
+ reduced, where the output goes — is in [`PLUGINS.md`](PLUGINS.md) section 6.7,
349
+ because it is a plugin's specification and not a property of the design.
350
+
351
+ One shared piece sits inside `plugins/` rather than in `lib/`, because it is
352
+ plugin implementation and the framework does not use it:
353
+
354
+ - `plugins/store/database.rb` — the `Automatic::Plugin::Database` mixin: opens
355
+ the SQLite database named in the Recipe, creates the table from the including
356
+ class's `column_definition` when it is absent, and provides
357
+ `for_each_new_feed`, which yields only items whose key is not already stored.
358
+ `StorePermalink` and `StoreFullText` are this mixin plus a model and a column
359
+ list. `StoreDigest` takes the database part of it and decides for itself what
360
+ has been seen, because it is identified by a digest of an item's content
361
+ rather than by the link `for_each_new_feed` reads.
362
+ ### 4.10 `db/`, `config/`, `assets/`
363
+
364
+ Fallbacks inside the installation, used when the corresponding part of the user
365
+ directory is absent: `db/` for SQLite files, `config/` for the example Recipes
366
+ that `scaffold` copies out, `assets/` for data files a plugin needs.
367
+
368
+ ## 5. The flow of one run
369
+
370
+ ```text
371
+ automatic -c feed2console.yml
372
+ |
373
+ | CLI parses the command line, resolves the recipe path
374
+ v
375
+ Recipe.new(path)
376
+ | reads YAML safely, wraps in Hashie::Mash
377
+ | sets the log level from global.log.level
378
+ v
379
+ Automatic.run(recipe:, root_dir:)
380
+ | sets root_dir and user_dir
381
+ v
382
+ Pipeline.run(recipe)
383
+ |
384
+ | pipeline = []
385
+ |
386
+ | entry 1: module SubscriptionFeed
387
+ | load_plugin -> plugins/subscription/feed.rb
388
+ | SubscriptionFeed.new(config, []).run
389
+ | FeedParser.get_url(each configured feed)
390
+ | -> [feed]
391
+ |
392
+ | entry 2: module FilterIgnore
393
+ | load_plugin -> plugins/filter/ignore.rb
394
+ | FilterIgnore.new(config, [feed]).run
395
+ | drops items matching a keyword
396
+ | FeedMaker.create_pipeline(kept items)
397
+ | -> [feed']
398
+ |
399
+ | entry 3: module StorePermalink
400
+ | load_plugin -> plugins/store/permalink.rb
401
+ | StorePermalink.new(config, [feed']).run
402
+ | opens ~/.automatic/db/<db>, creates the table if absent
403
+ | for_each_new_feed: skips links already stored, inserts the rest
404
+ | -> [feed''] containing only what had not been seen
405
+ |
406
+ | entry 4: module PublishConsole
407
+ | PublishConsole.new(config, [feed'']).run
408
+ | prints each item
409
+ | -> [feed'']
410
+ v
411
+ the final value is discarded; CLI returns 0
412
+ ```
413
+
414
+ Three properties of that flow are the design:
415
+
416
+ - The pipeline **narrows**. Subscription plugins produce, filters and stores
417
+ reduce, publishers consume. A Recipe is normally read in that order.
418
+ - A store plugin is what makes a Recipe safe to run every five minutes. It is
419
+ the only thing standing between the operator and a duplicate.
420
+ - Nothing between the steps inspects the value. The framework never looks inside
421
+ a feed.
422
+
423
+ The last entry decides what the run leaves behind, and changing it changes
424
+ nothing else. The same four steps ending in `PublishMarkdown` produce a document
425
+ instead of terminal output:
426
+
427
+ ```text
428
+ acquire SubscriptionFeed, SubscriptionLink, CustomFeed*, ...
429
+ | many sources, one shape
430
+ v
431
+ filter FilterIgnore, FilterSanitize, FilterSort, ...
432
+ |
433
+ v
434
+ store StorePermalink: what has not been seen before
435
+ |
436
+ v
437
+ publish PublishMarkdown: serialize at the boundary
438
+ |
439
+ v
440
+ Markdown a file, or standard output
441
+ |
442
+ +--> read by a person
443
+ +--> searched with grep, processed with the usual tools
444
+ +--> committed, diffed, kept
445
+ +--> handed to a program, a language model or an agent
446
+ ```
447
+
448
+ Everything above the last box is unchanged from the previous diagram, which is
449
+ the point: the acquisition, the filtering and the de-duplication are the same
450
+ plugins over the same value, and the exit is the part the Recipe chooses.
451
+
452
+ ## 6. Settings
453
+
454
+ Two kinds, and they do not mix.
455
+
456
+ **Framework settings** come from the Recipe's `global` mapping, and there is one
457
+ that is read: `global.log.level`. `global.timezone` and `global.cache` appear in
458
+ the shipped examples and in Recipes in the wild, and nothing reads them. They
459
+ are kept — removing them would break nothing but would edit operators' files for
460
+ no gain — and they are documented as inert in
461
+ [`PLUGINS.md`](PLUGINS.md) section 2.4 so that no one adds a behaviour to them
462
+ by accident.
463
+
464
+ **Plugin settings** are the `config` mapping of a plugin entry, passed to the
465
+ constructor and read by nobody else. The framework does not validate, default,
466
+ merge or type-check them. A plugin that needs a value it was not given decides
467
+ what to do, and the common choice — treating an absent `retry` or `interval` as
468
+ zero — is why `nil.to_i` appears throughout the plugins.
469
+
470
+ There is no environment-variable configuration, with one exception:
471
+ `AUTOMATIC_RUBY_ENV=test` permits the user directory to be overridden, and
472
+ exists for the tests.
473
+
474
+ ## 7. Error handling
475
+
476
+ The layers handle errors differently, and on purpose.
477
+
478
+ **Plugins** own transient failure. A plugin that reaches the network wraps the
479
+ attempt, logs the failure, and retries `retry` times with `interval` seconds
480
+ between attempts. What a plugin must not do is swallow an error and return a
481
+ value that looks like success.
482
+
483
+ **The framework** owns nothing it cannot fix, so it catches nothing. Its own
484
+ failures are named:
485
+
486
+ | Exception | Raised when |
487
+ | --- | --- |
488
+ | `Automatic::NoRecipeError` | `Pipeline.run` was given no Recipe |
489
+ | `Automatic::NoPluginError` | No file was found for a module named in a Recipe |
490
+ | `Automatic::InvalidRecipeError` | The Recipe is not a mapping, or has no plugins |
491
+
492
+ All three derive from `Automatic::Error`, so a caller can rescue the framework's
493
+ failures without rescuing everything.
494
+
495
+ **The CLI** is the only place that turns an exception into a message and a
496
+ status. It reports the framework's errors as one line on standard error and
497
+ returns `1`. Anything else it lets propagate, because an unexpected exception is
498
+ a defect and its backtrace is wanted.
499
+
500
+ ## 8. Logging and output
501
+
502
+ - A library file never calls `puts`. It logs.
503
+ - `Automatic::CLI` writes to standard error for diagnostics and standard output
504
+ for requested output — help, version, and the results of the diagnostic
505
+ subcommands.
506
+ - Publishing plugins whose entire purpose is to print (`PublishConsole`,
507
+ `PublishConsoleLink`, `PublishMarkdown` with no `file` setting) write to an
508
+ output object held in an instance variable, defaulting to `$stdout`. That is
509
+ what lets their specs assert on what was printed by substituting a double.
510
+
511
+ The log and a publishing plugin's output therefore share standard output, which
512
+ has never mattered — a `pretty_inspect` dump and a log line interleave without
513
+ either becoming unreadable — and matters for a plugin whose output is a document
514
+ meant to be redirected into a file. The design does not resolve this by giving
515
+ the log a second destination: `Automatic::Log` writing to standard output is
516
+ long-standing behaviour that Recipes and `cron` entries are written around
517
+ (`REQUIREMENTS.md` section 14), and changing it for one plugin's benefit would
518
+ be the framework acquiring a plugin's problem. It is resolved where the choice
519
+ already exists — in the Recipe:
520
+
521
+ - `global.log.level: none` silences the log for that run, leaving standard
522
+ output carrying the document alone;
523
+ - or the plugin writes to a file it is given, and the log keeps standard output.
524
+
525
+ Both are stated in [`PLUGINS.md`](PLUGINS.md) section 6.7 and in
526
+ [`DEPLOYMENT.md`](DEPLOYMENT.md), which is where an operator looks.
527
+
528
+ ## 9. Testability
529
+
530
+ The design choices that exist for the tests:
531
+
532
+ - `CLI.run` returns a status instead of exiting, so command-line behaviour is a
533
+ unit test.
534
+ - `Automatic.user_dir=` accepts an override under `AUTOMATIC_RUBY_ENV=test`, so
535
+ the plugin loader can be pointed at `spec/user_dir` and the user-directory
536
+ precedence rule can be asserted. `spec/user_dir/plugins/store/mock.rb` exists
537
+ for exactly that.
538
+ - The plugin contract is a constructor and one method with no ambient input, so
539
+ a plugin spec is: build a pipeline, construct, `run`, assert on the result.
540
+ `AutomaticSpec.generate_pipeline` builds the pipeline.
541
+ - Each plugin requires its own libraries in its own file, so a plugin whose gem
542
+ is not installed is one spec that does not load, rather than a suite that does
543
+ not start.
544
+
545
+ The default suite covers the framework and the plugins that need neither the
546
+ network nor a credential. What that leaves out, and why, is in
547
+ [`PLUGINS.md`](PLUGINS.md) section 6 and in the README's testing section.