textus 0.3.0 → 0.5.0

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -1
  3. data/README.md +22 -18
  4. data/SPEC.md +49 -35
  5. data/docs/architecture.md +63 -28
  6. data/lib/textus/audit_log.rb +46 -11
  7. data/lib/textus/builder.rb +3 -3
  8. data/lib/textus/{builtin_fetchers.rb → builtin_actions.rb} +16 -11
  9. data/lib/textus/cli/accept.rb +13 -0
  10. data/lib/textus/cli/action.rb +51 -0
  11. data/lib/textus/cli/build.rb +11 -0
  12. data/lib/textus/cli/delete.rb +14 -0
  13. data/lib/textus/cli/deprecated_alias.rb +31 -0
  14. data/lib/textus/cli/deps.rb +10 -0
  15. data/lib/textus/cli/doctor.rb +13 -0
  16. data/lib/textus/cli/extension_group.rb +9 -0
  17. data/lib/textus/cli/extensions.rb +49 -0
  18. data/lib/textus/cli/get.rb +10 -0
  19. data/lib/textus/cli/group.rb +51 -0
  20. data/lib/textus/cli/init.rb +12 -0
  21. data/lib/textus/cli/intro.rb +9 -0
  22. data/lib/textus/cli/key_group.rb +10 -0
  23. data/lib/textus/cli/list.rb +12 -0
  24. data/lib/textus/cli/migrate.rb +41 -0
  25. data/lib/textus/cli/migrate_keys.rb +19 -0
  26. data/lib/textus/cli/mv.rb +20 -0
  27. data/lib/textus/cli/published.rb +9 -0
  28. data/lib/textus/cli/put.rb +48 -0
  29. data/lib/textus/cli/rdeps.rb +10 -0
  30. data/lib/textus/cli/refresh.rb +13 -0
  31. data/lib/textus/cli/schema.rb +10 -0
  32. data/lib/textus/cli/schema_diff.rb +15 -0
  33. data/lib/textus/cli/schema_group.rb +33 -0
  34. data/lib/textus/cli/schema_init.rb +19 -0
  35. data/lib/textus/cli/schema_migrate.rb +19 -0
  36. data/lib/textus/cli/stale.rb +12 -0
  37. data/lib/textus/cli/uid.rb +15 -0
  38. data/lib/textus/cli/verb.rb +62 -0
  39. data/lib/textus/cli/where.rb +10 -0
  40. data/lib/textus/cli.rb +65 -347
  41. data/lib/textus/doctor.rb +103 -32
  42. data/lib/textus/entry/json.rb +6 -4
  43. data/lib/textus/entry/markdown.rb +4 -4
  44. data/lib/textus/entry/text.rb +3 -3
  45. data/lib/textus/entry/yaml.rb +6 -4
  46. data/lib/textus/entry.rb +2 -2
  47. data/lib/textus/errors.rb +2 -2
  48. data/lib/textus/extension_registry.rb +22 -9
  49. data/lib/textus/extensions.rb +6 -2
  50. data/lib/textus/init.rb +6 -5
  51. data/lib/textus/intro.rb +11 -9
  52. data/lib/textus/manifest.rb +11 -215
  53. data/lib/textus/manifest_entry.rb +185 -0
  54. data/lib/textus/migrate_v2.rb +27 -0
  55. data/lib/textus/projection.rb +1 -1
  56. data/lib/textus/proposal.rb +3 -3
  57. data/lib/textus/refresh.rb +21 -20
  58. data/lib/textus/schema_tools.rb +8 -8
  59. data/lib/textus/store/events.rb +31 -0
  60. data/lib/textus/store/mover.rb +118 -0
  61. data/lib/textus/store/staleness.rb +142 -0
  62. data/lib/textus/store/validator.rb +53 -0
  63. data/lib/textus/store.rb +50 -355
  64. data/lib/textus/store_view.rb +11 -2
  65. data/lib/textus/version.rb +2 -2
  66. data/lib/textus.rb +39 -1
  67. metadata +39 -2
data/lib/textus/doctor.rb CHANGED
@@ -1,25 +1,32 @@
1
1
  require "digest"
2
2
  require "json"
3
+ require "timeout"
3
4
 
4
5
  module Textus
5
6
  # Health check for a Textus store. Returns a JSON-friendly Hash envelope
6
7
  # with an `issues` array and a summary. Each issue is a Hash with
7
8
  # `code`, `level`, `subject`, `message`, and optionally `fix`.
8
- module Doctor
9
+ module Doctor # rubocop:disable Metrics/ModuleLength -- 9 built-in checks + extension dispatch
9
10
  LEVELS = %w[error warning info].freeze
11
+ DOCTOR_CHECK_TIMEOUT_SECONDS = 2
12
+ ALL_CHECKS = %w[
13
+ manifest_files schemas templates extensions illegal_keys
14
+ sentinels audit_log unowned_schema_fields schema_violations
15
+ ].freeze
10
16
 
11
17
  module_function
12
18
 
13
- def run(store)
14
- issues = []
15
- issues.concat(check_manifest_files(store))
16
- issues.concat(check_schemas(store))
17
- issues.concat(check_templates(store))
18
- issues.concat(check_extensions(store))
19
- issues.concat(check_illegal_keys(store))
20
- issues.concat(check_sentinels(store))
21
- issues.concat(check_audit_log(store))
22
- issues.concat(check_unowned_schema_fields(store))
19
+ def run(store, checks: nil)
20
+ selected = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
21
+ unknown = selected - ALL_CHECKS
22
+ unless unknown.empty?
23
+ raise UsageError.new(
24
+ "unknown doctor check: #{unknown.first}. Valid checks: #{ALL_CHECKS.join(", ")}",
25
+ )
26
+ end
27
+
28
+ issues = run_builtin_checks(store, selected)
29
+ issues.concat(run_registered_checks(store)) # extensions always run
23
30
 
24
31
  summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
25
32
  {
@@ -30,6 +37,20 @@ module Textus
30
37
  }
31
38
  end
32
39
 
40
+ def run_builtin_checks(store, selected)
41
+ issues = []
42
+ issues.concat(check_manifest_files(store)) if selected.include?("manifest_files")
43
+ issues.concat(check_schemas(store)) if selected.include?("schemas")
44
+ issues.concat(check_templates(store)) if selected.include?("templates")
45
+ issues.concat(check_extensions(store)) if selected.include?("extensions")
46
+ issues.concat(check_illegal_keys(store)) if selected.include?("illegal_keys")
47
+ issues.concat(check_sentinels(store)) if selected.include?("sentinels")
48
+ issues.concat(check_audit_log(store)) if selected.include?("audit_log")
49
+ issues.concat(check_unowned_schema_fields(store)) if selected.include?("unowned_schema_fields")
50
+ issues.concat(check_schema_violations(store)) if selected.include?("schema_violations")
51
+ issues
52
+ end
53
+
33
54
  # --- Checks -----------------------------------------------------------
34
55
 
35
56
  def check_manifest_files(store)
@@ -196,32 +217,30 @@ module Textus
196
217
  stripped = line.chomp
197
218
  next if stripped.empty?
198
219
 
199
- # Audit log is TSV, not NDJSON. Treat as malformed if it doesn't have
200
- # at least 6 tab-separated fields (timestamp, role, verb, key, etag_before, etag_after).
201
- fields = stripped.split("\t")
202
- if fields.length < 6
203
- out << {
204
- "code" => "audit.parse_error",
205
- "level" => "warning",
206
- "subject" => "#{path}:#{lineno}",
207
- "message" => "audit log line #{lineno} has #{fields.length} fields (expected >=6)",
208
- "fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
209
- }
210
- next
211
- end
212
-
213
- extras = fields[6]
214
- next if extras.nil? || extras.empty?
220
+ if stripped.start_with?("{")
221
+ begin
222
+ JSON.parse(stripped)
223
+ rescue JSON::ParserError => e
224
+ out << {
225
+ "code" => "audit.parse_error",
226
+ "level" => "warning",
227
+ "subject" => "#{path}:#{lineno}",
228
+ "message" => "audit log line #{lineno} is invalid JSON: #{e.message}",
229
+ "fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
230
+ }
231
+ end
232
+ else
233
+ # Legacy TSV: minimum 6 fields. Removed in 0.6.
234
+ fields = stripped.split("\t")
235
+ next if fields.length >= 6
215
236
 
216
- begin
217
- JSON.parse(extras)
218
- rescue JSON::ParserError => e
219
237
  out << {
220
238
  "code" => "audit.parse_error",
221
239
  "level" => "warning",
222
240
  "subject" => "#{path}:#{lineno}",
223
- "message" => "audit log line #{lineno} extras JSON malformed: #{e.message}",
224
- "fix" => "inspect #{path} at line #{lineno} and fix the JSON in the last column",
241
+ "message" => "audit log line #{lineno} has #{fields.length} fields " \
242
+ "(expected >=6 for legacy TSV; consider migrating to NDJSON)",
243
+ "fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
225
244
  }
226
245
  end
227
246
  end
@@ -255,6 +274,58 @@ module Textus
255
274
  out
256
275
  end
257
276
 
277
+ def check_schema_violations(store)
278
+ res = store.validate_all
279
+ res["violations"].map do |v|
280
+ fix = v["expected"] &&
281
+ "field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"
282
+ {
283
+ "code" => v["code"],
284
+ "level" => "error",
285
+ "subject" => v["key"],
286
+ "message" => v["message"] || "#{v["code"]} on #{v["key"]}",
287
+ "fix" => fix,
288
+ }.compact
289
+ end
290
+ end
291
+
292
+ def run_registered_checks(store)
293
+ out = []
294
+ view = StoreView.new(store)
295
+ store.registry.doctor_check_names.each do |name|
296
+ callable = store.registry.doctor_check(name)
297
+ begin
298
+ result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: view) }
299
+ if result.is_a?(Array)
300
+ out.concat(result.map { |h| h.transform_keys(&:to_s) })
301
+ else
302
+ out << fail_issue(name, code: "doctor_check.bad_return",
303
+ message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
304
+ fix: "return an array of issue hashes from the doctor_check block")
305
+ end
306
+ rescue Timeout::Error
307
+ out << fail_issue(name, code: "doctor_check.timeout",
308
+ message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
309
+ fix: "shorten the check or split it into smaller checks")
310
+ rescue StandardError => e
311
+ out << fail_issue(name, code: "doctor_check.failed",
312
+ message: "#{e.class}: #{e.message}",
313
+ fix: "fix the doctor_check block in .textus/extensions/")
314
+ end
315
+ end
316
+ out
317
+ end
318
+
319
+ def fail_issue(name, code:, message:, fix:)
320
+ {
321
+ "code" => code,
322
+ "level" => "error",
323
+ "subject" => name.to_s,
324
+ "message" => message,
325
+ "fix" => fix,
326
+ }
327
+ end
328
+
258
329
  # --- Helpers ----------------------------------------------------------
259
330
 
260
331
  def leaf_path_for(store, entry)
@@ -17,13 +17,15 @@ module Textus
17
17
 
18
18
  meta = parsed["_meta"]
19
19
  fm = meta.is_a?(Hash) ? meta : {}
20
- { "frontmatter" => fm, "body" => raw, "content" => parsed }
20
+ content_without_meta = parsed.except("_meta")
21
+ { "_meta" => fm, "body" => raw, "content" => content_without_meta }
21
22
  end
22
23
 
23
- def self.serialize(frontmatter:, body:, content: nil)
24
- _ = frontmatter
24
+ def self.serialize(meta:, body:, content: nil)
25
25
  if content.is_a?(Hash)
26
- out = ::JSON.pretty_generate(content)
26
+ # Re-inject _meta as the first key so on-disk shape is stable.
27
+ on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
28
+ out = ::JSON.pretty_generate(on_disk)
27
29
  out += "\n" unless out.end_with?("\n")
28
30
  out
29
31
  elsif body && !body.to_s.empty?
@@ -7,7 +7,7 @@ module Textus
7
7
  def self.parse(raw, path: nil)
8
8
  raw = raw.dup.force_encoding(Encoding::UTF_8)
9
9
  raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
10
- return { "frontmatter" => {}, "body" => raw, "content" => nil } unless raw.start_with?("---\n") || raw.start_with?("---\r\n")
10
+ return { "_meta" => {}, "body" => raw, "content" => nil } unless raw.start_with?("---\n") || raw.start_with?("---\r\n")
11
11
 
12
12
  lines = raw.split(/\r?\n/, -1)
13
13
  close_idx = lines[1..].index("---")
@@ -22,12 +22,12 @@ module Textus
22
22
  raise BadFrontmatter.new(path, "YAML parse failed: #{e.message}")
23
23
  end
24
24
  fm = {} unless fm.is_a?(Hash)
25
- { "frontmatter" => fm, "body" => body, "content" => nil }
25
+ { "_meta" => fm, "body" => body, "content" => nil }
26
26
  end
27
27
 
28
- def self.serialize(frontmatter:, body:, content: nil)
28
+ def self.serialize(meta:, body:, content: nil)
29
29
  _ = content # markdown ignores content
30
- fm_yaml = frontmatter.empty? ? "" : ::YAML.dump(frontmatter).sub(/\A---\n/, "")
30
+ fm_yaml = meta.empty? ? "" : ::YAML.dump(meta).sub(/\A---\n/, "")
31
31
  body = body.to_s
32
32
  body += "\n" unless body.empty? || body.end_with?("\n")
33
33
  "---\n#{fm_yaml}---\n#{body}"
@@ -6,11 +6,11 @@ module Textus
6
6
  raw = raw.dup.force_encoding(Encoding::UTF_8)
7
7
  raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
8
8
 
9
- { "frontmatter" => {}, "body" => raw, "content" => nil }
9
+ { "_meta" => {}, "body" => raw, "content" => nil }
10
10
  end
11
11
 
12
- def self.serialize(frontmatter:, body:, content: nil)
13
- _ = frontmatter
12
+ def self.serialize(meta:, body:, content: nil)
13
+ _ = meta
14
14
  _ = content
15
15
  b = body.to_s
16
16
  b += "\n" unless b.empty? || b.end_with?("\n")
@@ -17,13 +17,15 @@ module Textus
17
17
 
18
18
  meta = parsed["_meta"]
19
19
  fm = meta.is_a?(Hash) ? meta : {}
20
- { "frontmatter" => fm, "body" => raw, "content" => parsed }
20
+ content_without_meta = parsed.except("_meta")
21
+ { "_meta" => fm, "body" => raw, "content" => content_without_meta }
21
22
  end
22
23
 
23
- def self.serialize(frontmatter:, body:, content: nil)
24
- _ = frontmatter
24
+ def self.serialize(meta:, body:, content: nil)
25
25
  if content.is_a?(Hash)
26
- ::YAML.dump(content).sub(/\A---\n/, "")
26
+ # Re-inject _meta as the first key so on-disk shape is stable.
27
+ on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
28
+ ::YAML.dump(on_disk).sub(/\A---\n/, "")
27
29
  elsif body && !body.to_s.empty?
28
30
  b = body.to_s
29
31
  b += "\n" unless b.end_with?("\n")
data/lib/textus/entry.rb CHANGED
@@ -23,8 +23,8 @@ module Textus
23
23
  for_format(format).parse(raw, path: path)
24
24
  end
25
25
 
26
- def self.serialize(frontmatter: {}, body: "", content: nil, format: "markdown")
27
- for_format(format).serialize(frontmatter: frontmatter, body: body, content: content)
26
+ def self.serialize(meta: {}, body: "", content: nil, format: "markdown")
27
+ for_format(format).serialize(meta: meta, body: body, content: content)
28
28
  end
29
29
  end
30
30
  end
data/lib/textus/errors.rb CHANGED
@@ -51,10 +51,10 @@ module Textus
51
51
  private
52
52
 
53
53
  def default_hint_for(path, m)
54
- if m.is_a?(String) && (match = m.match(/frontmatter name '([^']+)' does not match basename '([^']+)'/))
54
+ if m.is_a?(String) && (match = m.match(/name '([^']+)' does not match basename '([^']+)'/))
55
55
  name, basename = match.captures
56
56
  ext = File.extname(path)
57
- "rename the file to '#{name}#{ext}' or change frontmatter name: to '#{basename}'"
57
+ "rename the file to '#{name}#{ext}' or change _meta.name to '#{basename}'"
58
58
  else
59
59
  "open #{path} and check the YAML frontmatter for syntax errors"
60
60
  end
@@ -3,16 +3,17 @@ module Textus
3
3
  EVENTS = %i[put delete refresh build accept].freeze
4
4
 
5
5
  def initialize
6
- @fetchers = {}
6
+ @actions = {}
7
7
  @reducers = {}
8
8
  @hooks = {}
9
+ @doctor_checks = {}
9
10
  end
10
11
 
11
- def register_fetcher(name, &blk)
12
+ def register_action(name, &blk)
12
13
  name = name.to_sym
13
- raise UsageError.new("fetcher '#{name}' already registered") if @fetchers.key?(name)
14
+ raise UsageError.new("action '#{name}' already registered") if @actions.key?(name)
14
15
 
15
- @fetchers[name] = blk
16
+ @actions[name] = blk
16
17
  end
17
18
 
18
19
  def register_reducer(name, &blk)
@@ -29,8 +30,15 @@ module Textus
29
30
  (@hooks[event] ||= []) << { name: name.to_sym, callable: blk }
30
31
  end
31
32
 
32
- def fetcher(name)
33
- @fetchers[name.to_sym] or raise UsageError.new("unknown fetcher: #{name}")
33
+ def register_doctor_check(name, &blk)
34
+ name = name.to_sym
35
+ raise UsageError.new("doctor_check '#{name}' already registered") if @doctor_checks.key?(name)
36
+
37
+ @doctor_checks[name] = blk
38
+ end
39
+
40
+ def action(name)
41
+ @actions[name.to_sym] or raise UsageError.new("unknown action: #{name}")
34
42
  end
35
43
 
36
44
  def reducer(name)
@@ -41,8 +49,13 @@ module Textus
41
49
  @hooks[event.to_sym] || []
42
50
  end
43
51
 
44
- def fetcher_names = @fetchers.keys
45
- def reducer_names = @reducers.keys
46
- def hook_events = @hooks.keys
52
+ def doctor_check(name)
53
+ @doctor_checks[name.to_sym] or raise UsageError.new("unknown doctor_check: #{name}")
54
+ end
55
+
56
+ def action_names = @actions.keys
57
+ def reducer_names = @reducers.keys
58
+ def hook_events = @hooks.keys
59
+ def doctor_check_names = @doctor_checks.keys
47
60
  end
48
61
  end
@@ -15,8 +15,8 @@ module Textus
15
15
  raise UsageError.new("no active registry; extension code must be loaded by a Store")
16
16
  end
17
17
 
18
- def self.fetcher(name, &)
19
- current_registry.register_fetcher(name, &)
18
+ def self.action(name, &)
19
+ current_registry.register_action(name, &)
20
20
  end
21
21
 
22
22
  def self.reducer(name, &)
@@ -26,4 +26,8 @@ module Textus
26
26
  def self.hook(event, name, &)
27
27
  current_registry.register_hook(event, name, &)
28
28
  end
29
+
30
+ def self.doctor_check(name, &)
31
+ current_registry.register_doctor_check(name, &)
32
+ end
29
33
  end
data/lib/textus/init.rb CHANGED
@@ -5,7 +5,7 @@ module Textus
5
5
  ZONES = %w[canon working intake pending derived].freeze
6
6
 
7
7
  DEFAULT_MANIFEST = <<~YAML
8
- version: textus/1
8
+ version: textus/2
9
9
  zones:
10
10
  - { name: canon, writable_by: [human] }
11
11
  - { name: working, writable_by: [human, ai, script] }
@@ -31,12 +31,13 @@ module Textus
31
31
  File.write(File.join(target_root, "extensions", "README.md"), <<~MD)
32
32
  # Extensions
33
33
 
34
- Drop one Ruby file per extension. Three verbs are available:
34
+ Drop one Ruby file per extension. Four verbs are available:
35
35
 
36
36
  ```ruby
37
- Textus.fetcher(:name) { |config:, store:| ... }
38
- Textus.reducer(:name) { |rows:, config:| ... }
39
- Textus.hook(:event, :name) { |key:, envelope:, store:, **kw| ... }
37
+ Textus.action(:name) { |config:, store:, args:| ... }
38
+ Textus.reducer(:name) { |rows:, config:| ... }
39
+ Textus.hook(:event, :name) { |key:, envelope:, **kw| ... }
40
+ Textus.doctor_check(:name) { |store:| ... }
40
41
  ```
41
42
 
42
43
  Events: :put, :delete, :refresh, :build, :accept.
data/lib/textus/intro.rb CHANGED
@@ -6,14 +6,14 @@ module Textus
6
6
  #
7
7
  # Intro is side-effect-free.
8
8
  module Intro
9
- PROTOCOL_ID = "textus/1".freeze
9
+ PROTOCOL_ID = PROTOCOL
10
10
 
11
11
  # Conventional zone purposes. Unknown zones (declared in the manifest
12
12
  # but not listed here) get no `purpose` field.
13
13
  ZONE_PURPOSES = {
14
14
  "canon" => "slow-changing identity; human-only writes",
15
15
  "working" => "active project state; humans, AI, and scripts share this surface",
16
- "intake" => "declared external inputs; script-refreshed via fetchers",
16
+ "intake" => "declared external inputs; script-refreshed via actions",
17
17
  "pending" => "AI proposals awaiting human accept",
18
18
  "derived" => "build-computed outputs; never hand-edited",
19
19
  }.freeze
@@ -22,7 +22,7 @@ module Textus
22
22
  "human" => "edit files in canon/working zones, then 'textus put KEY --as=human'",
23
23
  "ai" => "propose changes by writing 'pending.*' entries with --as=ai and a 'proposal:' frontmatter block; " \
24
24
  "a human runs 'textus accept' to apply",
25
- "script" => "refresh intake entries with 'textus refresh KEY --as=script' (uses the entry's declared fetcher)",
25
+ "script" => "refresh intake entries with 'textus refresh KEY --as=script' (uses the entry's declared action)",
26
26
  "build" => "'textus build' computes derived entries from projections; derived files are never hand-edited",
27
27
  }.freeze
28
28
 
@@ -32,7 +32,7 @@ module Textus
32
32
  CLI_VERBS = [
33
33
  { "name" => "intro", "summary" => "this output — orientation for agents and tools" },
34
34
  { "name" => "list", "summary" => "enumerate keys (optional --prefix)" },
35
- { "name" => "get", "summary" => "read an entry; envelope with frontmatter, body, uid, etag" },
35
+ { "name" => "get", "summary" => "read an entry; envelope with _meta, body, uid, etag" },
36
36
  { "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
37
37
  { "name" => "schema", "summary" => "field shape for a key family" },
38
38
  { "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
@@ -40,11 +40,11 @@ module Textus
40
40
  { "name" => "mv", "summary" => "rename a key in place; uid preserved, audit row written" },
41
41
  { "name" => "delete", "summary" => "delete an entry; --as=<role>" },
42
42
  { "name" => "build", "summary" => "materialize derived entries; publish_to and publish_each fan out copies" },
43
- { "name" => "refresh", "summary" => "run a fetcher for an intake entry" },
43
+ { "name" => "refresh", "summary" => "run an action for an intake entry" },
44
44
  { "name" => "stale", "summary" => "list derived/intake entries past their freshness check" },
45
45
  { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
46
46
  { "name" => "migrate-keys", "summary" => "rename files whose basenames violate the strict key grammar" },
47
- { "name" => "extensions", "summary" => "list registered reducers/fetchers/hooks" },
47
+ { "name" => "extensions", "summary" => "list registered actions, reducers, doctor_checks, declared hooks" },
48
48
  ].freeze
49
49
 
50
50
  def self.run(store)
@@ -80,7 +80,7 @@ module Textus
80
80
  "owner" => e.owner,
81
81
  "format" => e.format,
82
82
  "derived" => derived,
83
- "intake" => !e.fetcher.nil?,
83
+ "intake" => !e.action.nil?,
84
84
  "publish_to" => Array(e.publish_to),
85
85
  "publish_each" => e.publish_each,
86
86
  }
@@ -90,13 +90,15 @@ module Textus
90
90
  def self.extensions_for(store)
91
91
  reg = store.registry
92
92
  reducers = reg.reducer_names.map(&:to_s).sort
93
- fetchers = reg.fetcher_names.map(&:to_s).sort
93
+ actions = reg.action_names.map(&:to_s).sort
94
+ doctor_checks = reg.doctor_check_names.map(&:to_s).sort
94
95
  hooks = reg.hook_events.flat_map do |evt|
95
96
  reg.hooks(evt).map { |h| { "event" => evt.to_s, "name" => h[:name].to_s } }
96
97
  end.sort_by { |h| [h["event"], h["name"]] }
97
98
  {
98
99
  "reducers" => reducers,
99
- "fetchers" => fetchers,
100
+ "actions" => actions,
101
+ "doctor_checks" => doctor_checks,
100
102
  "hooks" => hooks,
101
103
  }
102
104
  end