textus 0.15.0 → 0.20.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +50 -55
  3. data/CHANGELOG.md +486 -0
  4. data/README.md +13 -9
  5. data/SPEC.md +13 -10
  6. data/docs/conventions.md +2 -2
  7. data/lib/textus/application/context.rb +20 -34
  8. data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
  9. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  10. data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
  11. data/lib/textus/application/projection.rb +91 -0
  12. data/lib/textus/application/reads/audit.rb +4 -4
  13. data/lib/textus/application/reads/blame.rb +11 -8
  14. data/lib/textus/application/reads/deps.rb +14 -3
  15. data/lib/textus/application/reads/freshness.rb +17 -6
  16. data/lib/textus/application/reads/get.rb +37 -11
  17. data/lib/textus/application/reads/get_or_refresh.rb +8 -8
  18. data/lib/textus/application/reads/list.rb +5 -3
  19. data/lib/textus/application/reads/policy_explain.rb +3 -3
  20. data/lib/textus/application/reads/published.rb +5 -3
  21. data/lib/textus/application/reads/rdeps.rb +15 -3
  22. data/lib/textus/application/reads/schema_envelope.rb +6 -3
  23. data/lib/textus/application/reads/stale.rb +3 -3
  24. data/lib/textus/application/reads/uid.rb +11 -3
  25. data/lib/textus/application/reads/validate_all.rb +12 -3
  26. data/lib/textus/application/reads/validator.rb +84 -0
  27. data/lib/textus/application/reads/where.rb +6 -3
  28. data/lib/textus/application/refresh/all.rb +16 -5
  29. data/lib/textus/application/refresh/orchestrator.rb +9 -9
  30. data/lib/textus/application/refresh/worker.rb +59 -32
  31. data/lib/textus/application/tools/migrate_keys.rb +191 -0
  32. data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
  33. data/lib/textus/application/writes/accept.rb +36 -13
  34. data/lib/textus/application/writes/delete.rb +13 -15
  35. data/lib/textus/application/writes/envelope_io.rb +166 -0
  36. data/lib/textus/application/writes/materializer.rb +50 -0
  37. data/lib/textus/application/writes/mv.rb +56 -95
  38. data/lib/textus/application/writes/publish.rb +132 -27
  39. data/lib/textus/application/writes/put.rb +17 -20
  40. data/lib/textus/application/writes/reject.rb +18 -9
  41. data/lib/textus/builder/pipeline.rb +21 -15
  42. data/lib/textus/builder/renderer/json.rb +4 -1
  43. data/lib/textus/builder/renderer/markdown.rb +7 -1
  44. data/lib/textus/builder/renderer/yaml.rb +4 -1
  45. data/lib/textus/cli/group/hook.rb +1 -3
  46. data/lib/textus/cli/group/key.rb +1 -4
  47. data/lib/textus/cli/group/refresh.rb +1 -2
  48. data/lib/textus/cli/group/rule.rb +1 -3
  49. data/lib/textus/cli/group/schema.rb +1 -5
  50. data/lib/textus/cli/group.rb +12 -16
  51. data/lib/textus/cli/verb/accept.rb +3 -1
  52. data/lib/textus/cli/verb/audit.rb +3 -1
  53. data/lib/textus/cli/verb/blame.rb +3 -1
  54. data/lib/textus/cli/verb/build.rb +4 -5
  55. data/lib/textus/cli/verb/delete.rb +3 -1
  56. data/lib/textus/cli/verb/deps.rb +3 -1
  57. data/lib/textus/cli/verb/doctor.rb +2 -0
  58. data/lib/textus/cli/verb/freshness.rb +3 -1
  59. data/lib/textus/cli/verb/get.rb +4 -2
  60. data/lib/textus/cli/verb/hook_run.rb +6 -4
  61. data/lib/textus/cli/verb/hooks.rb +8 -5
  62. data/lib/textus/cli/verb/init.rb +2 -0
  63. data/lib/textus/cli/verb/intro.rb +2 -0
  64. data/lib/textus/cli/verb/key_normalize.rb +35 -3
  65. data/lib/textus/cli/verb/list.rb +3 -1
  66. data/lib/textus/cli/verb/mv.rb +4 -1
  67. data/lib/textus/cli/verb/published.rb +3 -1
  68. data/lib/textus/cli/verb/put.rb +5 -4
  69. data/lib/textus/cli/verb/rdeps.rb +3 -1
  70. data/lib/textus/cli/verb/refresh.rb +1 -1
  71. data/lib/textus/cli/verb/refresh_stale.rb +4 -2
  72. data/lib/textus/cli/verb/reject.rb +3 -1
  73. data/lib/textus/cli/verb/rule_explain.rb +4 -1
  74. data/lib/textus/cli/verb/rule_list.rb +3 -0
  75. data/lib/textus/cli/verb/schema.rb +4 -1
  76. data/lib/textus/cli/verb/schema_diff.rb +3 -0
  77. data/lib/textus/cli/verb/schema_init.rb +3 -0
  78. data/lib/textus/cli/verb/schema_migrate.rb +3 -0
  79. data/lib/textus/cli/verb/uid.rb +4 -1
  80. data/lib/textus/cli/verb/where.rb +3 -1
  81. data/lib/textus/cli/verb.rb +30 -0
  82. data/lib/textus/cli.rb +18 -27
  83. data/lib/textus/doctor/check/audit_log.rb +1 -1
  84. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  85. data/lib/textus/doctor/check/hooks.rb +4 -2
  86. data/lib/textus/doctor/check/illegal_keys.rb +6 -5
  87. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  88. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  89. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  90. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  91. data/lib/textus/doctor/check/sentinels.rb +2 -2
  92. data/lib/textus/doctor/check/templates.rb +4 -3
  93. data/lib/textus/doctor.rb +3 -4
  94. data/lib/textus/domain/authorizer.rb +37 -0
  95. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  96. data/lib/textus/domain/freshness/policy.rb +1 -1
  97. data/lib/textus/domain/freshness/verdict.rb +1 -1
  98. data/lib/textus/domain/freshness.rb +40 -0
  99. data/lib/textus/{store → domain}/sentinel.rb +1 -1
  100. data/lib/textus/{store → domain}/staleness/generator_check.rb +9 -8
  101. data/lib/textus/{store → domain}/staleness/intake_check.rb +3 -3
  102. data/lib/textus/{store → domain}/staleness.rb +1 -1
  103. data/lib/textus/entry/json.rb +1 -1
  104. data/lib/textus/entry/markdown.rb +1 -1
  105. data/lib/textus/entry/yaml.rb +1 -1
  106. data/lib/textus/envelope.rb +7 -3
  107. data/lib/textus/errors.rb +19 -0
  108. data/lib/textus/hooks/builtin.rb +6 -6
  109. data/lib/textus/hooks/bus.rb +155 -0
  110. data/lib/textus/hooks/context.rb +38 -0
  111. data/lib/textus/hooks/fire_report.rb +23 -0
  112. data/lib/textus/hooks/loader.rb +20 -17
  113. data/lib/textus/{store → infra}/audit_log.rb +1 -1
  114. data/lib/textus/infra/audit_subscriber.rb +43 -0
  115. data/lib/textus/infra/event_bus.rb +3 -3
  116. data/lib/textus/infra/publisher.rb +3 -3
  117. data/lib/textus/infra/refresh/detached.rb +1 -1
  118. data/lib/textus/infra/storage/file_store.rb +26 -0
  119. data/lib/textus/init.rb +14 -11
  120. data/lib/textus/intro.rb +7 -7
  121. data/lib/textus/manifest/entry/base.rb +38 -0
  122. data/lib/textus/manifest/entry/derived.rb +25 -0
  123. data/lib/textus/manifest/entry/intake.rb +19 -0
  124. data/lib/textus/manifest/entry/leaf.rb +16 -0
  125. data/lib/textus/manifest/entry/nested.rb +39 -0
  126. data/lib/textus/manifest/entry/parser.rb +64 -31
  127. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  128. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  129. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  130. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  131. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  132. data/lib/textus/manifest/entry.rb +0 -72
  133. data/lib/textus/manifest/resolution.rb +5 -0
  134. data/lib/textus/manifest/resolver.rb +109 -0
  135. data/lib/textus/manifest/schema.rb +1 -1
  136. data/lib/textus/manifest.rb +4 -100
  137. data/lib/textus/operations.rb +147 -23
  138. data/lib/textus/schema/tools.rb +7 -7
  139. data/lib/textus/schemas.rb +46 -0
  140. data/lib/textus/store.rb +12 -49
  141. data/lib/textus/uid.rb +18 -0
  142. data/lib/textus/version.rb +1 -1
  143. data/lib/textus.rb +17 -1
  144. metadata +31 -23
  145. data/lib/textus/application/writes/build.rb +0 -79
  146. data/lib/textus/dependencies.rb +0 -23
  147. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  148. data/lib/textus/hooks/dispatcher.rb +0 -63
  149. data/lib/textus/hooks/dsl.rb +0 -11
  150. data/lib/textus/hooks/registry.rb +0 -81
  151. data/lib/textus/migrate_keys.rb +0 -187
  152. data/lib/textus/operations/reads.rb +0 -56
  153. data/lib/textus/operations/refresh.rb +0 -27
  154. data/lib/textus/operations/writes.rb +0 -21
  155. data/lib/textus/projection.rb +0 -89
  156. data/lib/textus/refresh.rb +0 -39
  157. data/lib/textus/store/reader.rb +0 -69
  158. data/lib/textus/store/validator.rb +0 -82
  159. data/lib/textus/store/writer.rb +0 -102
@@ -1,38 +1,102 @@
1
1
  module Textus
2
2
  module Application
3
3
  module Writes
4
- # Copies nested-leaf entries to their `publish_each:` targets. Fires
5
- # `:file_published` for each copy. Mirror of `Build` for the publish
6
- # half split out from the old Build per ADR 0007.
4
+ # Single-pass publish use case: materializes Derived entries (template +
5
+ # projection + external runner) AND copies Leaf/Nested entries to their
6
+ # publish targets. Replaces the former two-step Build + Publish split.
7
+ #
8
+ # Return shape: { "protocol", "built", "published_leaves" }
9
+ # — wire-compatible with what the `textus build` CLI verb previously
10
+ # assembled by merging Build + old Publish results.
7
11
  class Publish
8
- def initialize(ctx:, bus:)
9
- @ctx = ctx
10
- @bus = bus
12
+ def initialize(ctx:, manifest:, file_store:, bus:, root:, store:, hook_context:) # rubocop:disable Metrics/ParameterLists
13
+ @ctx = ctx
14
+ @manifest = manifest
15
+ @file_store = file_store
16
+ @bus = bus
17
+ @root = root
18
+ @store = store
19
+ @hook_context = hook_context
11
20
  end
12
21
 
13
22
  def call(prefix: nil)
14
- repo_root = File.dirname(store.root)
15
- out = []
16
- manifest.entries.each do |mentry|
17
- next unless mentry.nested && mentry.publish_each
18
- next if prefix && !mentry.key.start_with?(prefix) && !prefix.start_with?("#{mentry.key}.")
23
+ built = []
24
+ leaves = []
25
+ repo_root = File.dirname(@root)
19
26
 
20
- manifest.enumerate(prefix: mentry.key).each do |row|
21
- next unless row[:manifest_entry].equal?(mentry)
22
- next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
27
+ @manifest.entries.each do |mentry|
28
+ next if prefix && !entry_matches_prefix?(mentry, prefix)
23
29
 
24
- out << publish_leaf(mentry, row, repo_root)
30
+ case mentry
31
+ when Textus::Manifest::Entry::Derived
32
+ next unless mentry.in_generator_zone?
33
+
34
+ result = materialize_derived(mentry, repo_root)
35
+ built << result if result
36
+ when Textus::Manifest::Entry::Nested
37
+ next unless mentry.publish_each
38
+
39
+ publish_nested(mentry, repo_root, prefix, leaves)
40
+ when Textus::Manifest::Entry::Leaf
41
+ next if Array(mentry.publish_to).empty?
42
+
43
+ result = publish_leaf_entry(mentry, repo_root)
44
+ built << result if result
25
45
  end
26
46
  end
27
- { "protocol" => Textus::PROTOCOL, "published_leaves" => out }
47
+
48
+ { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => leaves }
28
49
  end
29
50
 
30
51
  private
31
52
 
32
- def store = @ctx.store
33
- def manifest = store.manifest
53
+ # Materialize a Derived entry and copy to publish_to targets.
54
+ def materialize_derived(mentry, repo_root)
55
+ target_path = Materializer.new(
56
+ ctx: @ctx, manifest: @manifest, file_store: @file_store,
57
+ bus: @bus, root: @root, store: @store
58
+ ).run(mentry)
34
59
 
35
- def publish_leaf(mentry, row, repo_root)
60
+ publish_derived_copies(mentry, target_path, repo_root)
61
+ fire_build_completed(mentry, target_path)
62
+
63
+ { "key" => mentry.key, "path" => target_path, "published_to" => mentry.publish_to }
64
+ end
65
+
66
+ def publish_derived_copies(mentry, target_path, repo_root)
67
+ envelope = reader.call(mentry.key)
68
+ mentry.publish_to.each do |rel|
69
+ target_abs = File.join(repo_root, rel)
70
+ Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: @root)
71
+ publish_event(:file_published,
72
+ key: mentry.key,
73
+ envelope: envelope,
74
+ source: target_path,
75
+ target: target_abs)
76
+ end
77
+ end
78
+
79
+ def fire_build_completed(mentry, target_path) # rubocop:disable Lint/UnusedMethodArgument
80
+ envelope = reader.call(mentry.key)
81
+ src = mentry.source
82
+ selects = src.is_a?(Textus::Manifest::Entry::Derived::Projection) ? Array(src.select).compact : []
83
+ publish_event(:build_completed,
84
+ key: mentry.key,
85
+ envelope: envelope,
86
+ sources: selects)
87
+ end
88
+
89
+ # Publish each leaf under a Nested entry's publish_each pattern.
90
+ def publish_nested(mentry, repo_root, prefix, accumulator)
91
+ @manifest.resolver.enumerate(prefix: mentry.key).each do |row|
92
+ next unless row[:manifest_entry].equal?(mentry)
93
+ next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
94
+
95
+ accumulator << publish_nested_leaf(mentry, row, repo_root)
96
+ end
97
+ end
98
+
99
+ def publish_nested_leaf(mentry, row, repo_root)
36
100
  target_rel = mentry.publish_target_for(row[:key])
37
101
  target_abs = File.expand_path(File.join(repo_root, target_rel))
38
102
  unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
@@ -41,16 +105,57 @@ module Textus
41
105
  )
42
106
  end
43
107
 
44
- Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: store.root)
45
- @bus.publish(:file_published,
46
- store: @ctx.with_role(@ctx.role),
47
- key: row[:key],
48
- envelope: store.reader.get(row[:key]),
49
- source: row[:path],
50
- target: target_abs,
51
- correlation_id: @ctx.correlation_id)
108
+ Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: @root)
109
+ publish_event(:file_published,
110
+ key: row[:key],
111
+ envelope: reader.call(row[:key]),
112
+ source: row[:path],
113
+ target: target_abs)
52
114
  { "key" => row[:key], "source" => row[:path], "target" => target_abs }
53
115
  end
116
+
117
+ # Publish a standalone Leaf entry that has publish_to targets.
118
+ def publish_leaf_entry(mentry, repo_root)
119
+ source_path = @manifest.resolver.resolve(mentry.key).path
120
+ envelope = reader.call(mentry.key)
121
+
122
+ mentry.publish_to.each do |rel|
123
+ target_abs = File.join(repo_root, rel)
124
+ Textus::Infra::Publisher.publish(source: source_path, target: target_abs, store_root: @root)
125
+ publish_event(:file_published,
126
+ key: mentry.key,
127
+ envelope: envelope,
128
+ source: source_path,
129
+ target: target_abs)
130
+ end
131
+
132
+ { "key" => mentry.key, "path" => source_path, "published_to" => mentry.publish_to }
133
+ end
134
+
135
+ # Whether the entry should be processed for the given prefix filter.
136
+ def entry_matches_prefix?(mentry, prefix)
137
+ return true unless prefix
138
+
139
+ case mentry
140
+ when Textus::Manifest::Entry::Nested
141
+ # Nested: process if the entry key is a prefix of `prefix` or
142
+ # `prefix` is a prefix of the entry key (a leaf under it).
143
+ mentry.key.start_with?(prefix) ||
144
+ prefix.start_with?("#{mentry.key}.")
145
+ else
146
+ mentry.key.start_with?(prefix)
147
+ end
148
+ end
149
+
150
+ def reader
151
+ @reader ||= Textus::Application::Reads::Get.new(
152
+ ctx: @ctx, manifest: @manifest, file_store: @file_store,
153
+ )
154
+ end
155
+
156
+ def publish_event(event, **payload)
157
+ @bus.publish(event, ctx: @hook_context, **payload)
158
+ end
54
159
  end
55
160
  end
56
161
  end
@@ -2,35 +2,32 @@ module Textus
2
2
  module Application
3
3
  module Writes
4
4
  class Put
5
- def initialize(ctx:, bus:)
6
- @ctx = ctx
7
- @bus = bus
5
+ def initialize(ctx:, manifest:, envelope_io:, bus:, authorizer:, hook_context:)
6
+ @ctx = ctx
7
+ @manifest = manifest
8
+ @envelope_io = envelope_io
9
+ @bus = bus
10
+ @authorizer = authorizer
11
+ @hook_context = hook_context
8
12
  end
9
13
 
10
- def call(key, meta: nil, body: nil, content: nil, if_etag: nil, suppress_events: false)
11
- @ctx.store.manifest.validate_key!(key)
12
- mentry, = @ctx.store.manifest.resolve(key)
14
+ def call(key, meta: nil, body: nil, content: nil, if_etag: nil)
15
+ @manifest.validate_key!(key)
16
+ mentry = @manifest.resolver.resolve(key).entry
13
17
 
14
- unless @ctx.can_write?(mentry.zone)
15
- raise WriteForbidden.new(key, mentry.zone,
16
- writers: @ctx.store.manifest.zone_writers(mentry.zone))
17
- end
18
+ @authorizer.authorize_write!(mentry, role: @ctx.role)
18
19
 
19
- envelope = @ctx.store.writer.write_envelope_to_disk(
20
+ envelope = @envelope_io.write(
20
21
  key,
21
22
  mentry: mentry,
22
- payload: Textus::Store::Writer::Payload.new(meta: meta, body: body, content: content),
23
- ctx: @ctx,
23
+ payload: Textus::Application::Writes::EnvelopeIO::Payload.new(meta: meta, body: body, content: content),
24
24
  if_etag: if_etag,
25
25
  )
26
26
 
27
- unless suppress_events
28
- @bus.publish(:entry_put,
29
- store: @ctx.with_role(@ctx.role),
30
- key: key,
31
- envelope: envelope,
32
- correlation_id: @ctx.correlation_id)
33
- end
27
+ @bus.publish(:entry_put,
28
+ ctx: @hook_context,
29
+ key: key,
30
+ envelope: envelope)
34
31
 
35
32
  envelope
36
33
  end
@@ -2,32 +2,41 @@ module Textus
2
2
  module Application
3
3
  module Writes
4
4
  class Reject
5
- def initialize(ctx:, bus:)
6
- @ctx = ctx
7
- @bus = bus
5
+ def initialize(ctx:, manifest:, file_store:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
6
+ @ctx = ctx
7
+ @manifest = manifest
8
+ @file_store = file_store
9
+ @envelope_io = envelope_io
10
+ @bus = bus
11
+ @authorizer = authorizer
12
+ @hook_context = hook_context
8
13
  end
9
14
 
10
15
  def call(pending_key)
11
16
  raise ProposalError.new("only human role can reject proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
12
17
 
13
- mentry, = @ctx.store.manifest.resolve(pending_key)
18
+ mentry = @manifest.resolver.resolve(pending_key).entry
14
19
  unless mentry.in_proposal_zone?
15
20
  raise ProposalError.new("reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.zone})")
16
21
  end
17
22
 
18
- env = @ctx.store.reader.get(pending_key)
23
+ env = Textus::Application::Reads::Get.new(
24
+ ctx: @ctx, manifest: @manifest, file_store: @file_store,
25
+ ).call(pending_key)
19
26
  proposal = env.meta&.dig("proposal") or
20
27
  raise ProposalError.new("entry has no proposal block: #{pending_key}")
21
28
  target_key = proposal["target_key"] or
22
29
  raise ProposalError.new("proposal missing target_key")
23
30
 
24
- Textus::Application::Writes::Delete.new(ctx: @ctx, bus: @bus).call(pending_key, suppress_events: true)
31
+ Textus::Application::Writes::Delete.new(
32
+ ctx: @ctx, manifest: @manifest, envelope_io: @envelope_io,
33
+ bus: @bus, authorizer: @authorizer, hook_context: @hook_context
34
+ ).call(pending_key, suppress_events: true)
25
35
 
26
36
  @bus.publish(:proposal_rejected,
27
- store: @ctx.with_role(@ctx.role),
37
+ ctx: @hook_context,
28
38
  key: pending_key,
29
- target_key: target_key,
30
- correlation_id: @ctx.correlation_id)
39
+ target_key: target_key)
31
40
 
32
41
  { "protocol" => PROTOCOL, "rejected" => pending_key, "target_key" => target_key }
33
42
  end
@@ -7,11 +7,15 @@ module Textus
7
7
  # Returns a new hash with _meta as the first key, per SPEC §6 ordering.
8
8
  def self.call(content_hash, mentry)
9
9
  meta = { "generated_at" => Time.now.utc.iso8601 }
10
- from = Array(mentry.projection&.fetch("select", nil)).compact
11
- meta["from"] = from unless from.empty?
10
+ if mentry.is_a?(Textus::Manifest::Entry::Derived)
11
+ src = mentry.source
12
+ if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
13
+ from = Array(src.select).compact
14
+ meta["from"] = from unless from.empty?
15
+ meta["reduce"] = src.transform if src.transform
16
+ end
17
+ end
12
18
  meta["template"] = mentry.template if mentry.template
13
- reduce = mentry.projection&.dig("transform")
14
- meta["reduce"] = reduce if reduce
15
19
 
16
20
  out = { "_meta" => meta }
17
21
  content_hash.each { |k, v| out[k] = v unless k == "_meta" }
@@ -58,22 +62,23 @@ module Textus
58
62
  }
59
63
  end
60
64
 
61
- def self.run(store:, mentry:, template_loader:)
65
+ # rubocop:disable Metrics/ParameterLists
66
+ def self.run(mentry:, manifest:, reader:, lister:, transform_resolver:, template_loader:,
67
+ transform_context: nil, inject_intro: nil)
62
68
  # 1. Load sources + project + reduce
63
69
  data =
64
- if mentry.projection
65
- ops = Operations.for(store)
66
- Projection.new(
67
- reader: ops.reads.get.method(:call),
68
- spec: mentry.projection,
69
- lister: ops.reads.list.method(:call),
70
- transform_resolver: ->(name) { store.registry.rpc_callable(:transform_rows, name) },
71
- transform_context: Application::Context.system(store),
70
+ if mentry.is_a?(Textus::Manifest::Entry::Derived) && mentry.projection?
71
+ Application::Projection.new(
72
+ reader: reader,
73
+ spec: mentry.source.to_h.transform_keys(&:to_s),
74
+ lister: lister,
75
+ transform_resolver: transform_resolver,
76
+ transform_context: transform_context,
72
77
  ).run
73
78
  else
74
79
  { "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
75
80
  end
76
- data = data.merge("intro" => Intro.run(store)) if mentry.inject_intro
81
+ data = data.merge("intro" => inject_intro.call) if mentry.inject_intro && inject_intro
77
82
 
78
83
  # 2. Render
79
84
  klass = renderers[mentry.format] or
@@ -81,12 +86,13 @@ module Textus
81
86
  bytes = klass.new(template_loader: template_loader).call(mentry: mentry, data: data)
82
87
 
83
88
  # 3. Write (idempotent: skip if only generated_at would differ)
84
- target_path = Key::Path.resolve(store.manifest, mentry)
89
+ target_path = Key::Path.resolve(manifest, mentry)
85
90
  FileUtils.mkdir_p(File.dirname(target_path))
86
91
  write_if_changed(target_path, bytes, mentry.format)
87
92
 
88
93
  target_path
89
94
  end
95
+ # rubocop:enable Metrics/ParameterLists
90
96
 
91
97
  def self.write_if_changed(target_path, bytes, format)
92
98
  if File.exist?(target_path)
@@ -28,7 +28,10 @@ module Textus
28
28
  end
29
29
 
30
30
  def default_shape(mentry, data)
31
- if mentry.projection && mentry.projection["transform"] && data.is_a?(Hash) && !data.key?("entries")
31
+ has_transform = mentry.is_a?(Textus::Manifest::Entry::Derived) &&
32
+ mentry.source.is_a?(Textus::Manifest::Entry::Derived::Projection) &&
33
+ mentry.source.transform
34
+ if has_transform && data.is_a?(Hash) && !data.key?("entries")
32
35
  data
33
36
  elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
34
37
  { "entries" => data["entries"] }
@@ -8,10 +8,16 @@ module Textus
8
8
  raise TemplateError.new("entry '#{mentry.key}': markdown build requires a template") unless mentry.template
9
9
 
10
10
  body = Mustache.render(@template_loader.call(mentry.template), data)
11
+ from = if mentry.is_a?(Textus::Manifest::Entry::Derived) &&
12
+ mentry.source.is_a?(Textus::Manifest::Entry::Derived::Projection)
13
+ Array(mentry.source.select).compact
14
+ else
15
+ []
16
+ end
11
17
  frontmatter = {
12
18
  "generated" => {
13
19
  "at" => Time.now.utc.iso8601,
14
- "from" => Array(mentry.projection&.fetch("select", nil)).compact,
20
+ "from" => from,
15
21
  },
16
22
  }
17
23
  Entry.for_format("markdown").serialize(meta: frontmatter, body: body)
@@ -28,7 +28,10 @@ module Textus
28
28
  end
29
29
 
30
30
  def default_shape(mentry, data)
31
- if mentry.projection && mentry.projection["transform"] && data.is_a?(Hash) && !data.key?("entries")
31
+ has_transform = mentry.is_a?(Textus::Manifest::Entry::Derived) &&
32
+ mentry.source.is_a?(Textus::Manifest::Entry::Derived::Projection) &&
33
+ mentry.source.transform
34
+ if has_transform && data.is_a?(Hash) && !data.key?("entries")
32
35
  data
33
36
  elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
34
37
  { "entries" => data["entries"] }
@@ -2,9 +2,7 @@ module Textus
2
2
  class CLI
3
3
  class Group
4
4
  class Hook < Group
5
- self.cli_name = "hook"
6
- subcommands["list"] = Verb::Hooks
7
- subcommands["run"] = Verb::HookRun
5
+ command_name "hook"
8
6
  end
9
7
  end
10
8
  end
@@ -2,10 +2,7 @@ module Textus
2
2
  class CLI
3
3
  class Group
4
4
  class Key < Group
5
- self.cli_name = "key"
6
- subcommands["mv"] = Verb::Mv
7
- subcommands["uid"] = Verb::Uid
8
- subcommands["normalize"] = Verb::KeyNormalize
5
+ command_name "key"
9
6
  end
10
7
  end
11
8
  end
@@ -2,8 +2,7 @@ module Textus
2
2
  class CLI
3
3
  class Group
4
4
  class Refresh < Group
5
- self.cli_name = "refresh"
6
- subcommands["stale"] = Verb::RefreshStale
5
+ command_name "refresh"
7
6
 
8
7
  def parse(argv)
9
8
  if argv.first == "stale"
@@ -2,9 +2,7 @@ module Textus
2
2
  class CLI
3
3
  class Group
4
4
  class Rule < Group
5
- self.cli_name = "rule"
6
- subcommands["list"] = Verb::RuleList
7
- subcommands["explain"] = Verb::RuleExplain
5
+ command_name "rule"
8
6
  end
9
7
  end
10
8
  end
@@ -2,11 +2,7 @@ module Textus
2
2
  class CLI
3
3
  class Group
4
4
  class Schema < Group
5
- self.cli_name = "schema"
6
- subcommands["show"] = Verb::Schema
7
- subcommands["init"] = Verb::SchemaInit
8
- subcommands["diff"] = Verb::SchemaDiff
9
- subcommands["migrate"] = Verb::SchemaMigrate
5
+ command_name "schema"
10
6
  end
11
7
  end
12
8
  end
@@ -2,19 +2,14 @@ module Textus
2
2
  class CLI
3
3
  class Group < Verb
4
4
  class << self
5
+ # Subcommands are auto-derived: any Verb descendant whose
6
+ # `parent_group` is this group counts as a subcommand. Sorted
7
+ # alphabetically by command_name for stable help output.
5
8
  def subcommands
6
- @subcommands ||= {}
7
- end
8
-
9
- def cli_name
10
- @cli_name || raise("subclass must define cli_name")
11
- end
12
-
13
- attr_writer :cli_name
14
-
15
- def inherited(subclass)
16
- super
17
- subclass.instance_variable_set(:@subcommands, {})
9
+ Verb.descendants
10
+ .select { |k| k.parent_group == self && k.command_name }
11
+ .sort_by(&:command_name)
12
+ .to_h { |k| [k.command_name, k] }
18
13
  end
19
14
 
20
15
  def needs_store?
@@ -24,18 +19,19 @@ module Textus
24
19
  end
25
20
 
26
21
  def parse(argv)
22
+ subs = self.class.subcommands
27
23
  subname = argv.shift
28
24
  if subname.nil?
29
25
  raise UsageError.new(
30
- "#{self.class.cli_name} requires a subcommand: #{self.class.subcommands.keys.join(", ")}",
26
+ "#{self.class.command_name} requires a subcommand: #{subs.keys.join(", ")}",
31
27
  )
32
28
  end
33
29
 
34
- @sub_klass = self.class.subcommands[subname]
30
+ @sub_klass = subs[subname]
35
31
  unless @sub_klass
36
32
  raise UsageError.new(
37
- "unknown #{self.class.cli_name} subcommand '#{subname}'. " \
38
- "Valid: #{self.class.subcommands.keys.join(", ")}",
33
+ "unknown #{self.class.command_name} subcommand '#{subname}'. " \
34
+ "Valid: #{subs.keys.join(", ")}",
39
35
  )
40
36
  end
41
37
 
@@ -2,11 +2,13 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class Accept < Verb
5
+ command_name "accept"
6
+
5
7
  option :as_flag, "--as=ROLE"
6
8
 
7
9
  def call(store)
8
10
  key = positional.shift or raise UsageError.new("accept requires a key")
9
- emit(operations_for(store).writes.accept.call(key))
11
+ emit(operations_for(store).accept(key))
10
12
  end
11
13
  end
12
14
  end
@@ -2,6 +2,8 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class Audit < Verb
5
+ command_name "audit"
6
+
5
7
  option :key_filter, "--key=KEY"
6
8
  option :zone, "--zone=Z"
7
9
  option :role_filter, "--role=ROLE"
@@ -13,7 +15,7 @@ module Textus
13
15
  def call(store)
14
16
  ops = operations_for(store)
15
17
  since_time = since && Textus::Application::Reads::Audit.parse_since(since, now: ops.ctx.now)
16
- rows = ops.reads.audit.call(
18
+ rows = ops.audit(
17
19
  key: key_filter,
18
20
  zone: zone,
19
21
  role: role_filter,
@@ -2,11 +2,13 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class Blame < Verb
5
+ command_name "blame"
6
+
5
7
  option :limit, "--limit=N"
6
8
 
7
9
  def call(store)
8
10
  key = positional.shift or raise UsageError.new("blame requires a key")
9
- rows = operations_for(store).reads.blame.call(key: key, limit: limit&.to_i)
11
+ rows = operations_for(store).blame(key: key, limit: limit&.to_i)
10
12
  emit({ "verb" => "blame", "key" => key, "rows" => rows })
11
13
  end
12
14
  end
@@ -2,16 +2,15 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class Build < Verb
5
+ command_name "build"
6
+
5
7
  option :prefix, "--prefix=K"
6
8
 
7
9
  def call(store)
8
10
  Textus::Infra::BuildLock.with(root: store.root) do
9
11
  ops = Textus::Operations.for(store, role: "builder")
10
- build_res = ops.writes.build.call(prefix: prefix)
11
- publish_res = ops.writes.publish.call(prefix: prefix)
12
- emit({ "protocol" => Textus::PROTOCOL,
13
- "built" => build_res["built"],
14
- "published_leaves" => publish_res["published_leaves"] })
12
+ result = ops.publish(prefix: prefix)
13
+ emit(result)
15
14
  end
16
15
  end
17
16
  end
@@ -2,12 +2,14 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class Delete < Verb
5
+ command_name "delete"
6
+
5
7
  option :as_flag, "--as=ROLE"
6
8
  option :if_etag, "--if-etag=E"
7
9
 
8
10
  def call(store)
9
11
  key = positional.shift or raise UsageError.new("delete requires a key")
10
- emit(operations_for(store).writes.delete.call(key, if_etag: if_etag))
12
+ emit(operations_for(store).delete(key, if_etag: if_etag))
11
13
  end
12
14
  end
13
15
  end
@@ -2,9 +2,11 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class Deps < Verb
5
+ command_name "deps"
6
+
5
7
  def call(store)
6
8
  key = positional.shift or raise UsageError.new("deps requires a key")
7
- emit({ "key" => key, "deps" => operations_for(store).reads.deps.call(key) })
9
+ emit({ "key" => key, "deps" => operations_for(store).deps(key) })
8
10
  end
9
11
  end
10
12
  end
@@ -2,6 +2,8 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class Doctor < Verb
5
+ command_name "doctor"
6
+
5
7
  option :checks, "--check=NAME"
6
8
 
7
9
  def call(store)
@@ -2,11 +2,13 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class Freshness < Verb
5
+ command_name "freshness"
6
+
5
7
  option :prefix, "--prefix=KEY"
6
8
  option :zone, "--zone=Z"
7
9
 
8
10
  def call(store)
9
- rows = operations_for(store).reads.freshness.call(prefix: prefix, zone: zone)
11
+ rows = operations_for(store).freshness(prefix: prefix, zone: zone)
10
12
  emit({ "verb" => "freshness", "rows" => rows })
11
13
  end
12
14
  end