textus 0.22.0 → 0.29.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 (186) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +195 -48
  3. data/CHANGELOG.md +178 -0
  4. data/README.md +55 -13
  5. data/SPEC.md +79 -42
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/boot.rb +31 -29
  8. data/lib/textus/builder/pipeline.rb +13 -12
  9. data/lib/textus/call.rb +28 -0
  10. data/lib/textus/cli/group/mcp.rb +9 -0
  11. data/lib/textus/cli/group/zone.rb +9 -0
  12. data/lib/textus/cli/verb/accept.rb +1 -1
  13. data/lib/textus/cli/verb/audit.rb +2 -2
  14. data/lib/textus/cli/verb/blame.rb +1 -1
  15. data/lib/textus/cli/verb/boot.rb +1 -1
  16. data/lib/textus/cli/verb/build.rb +3 -3
  17. data/lib/textus/cli/verb/delete.rb +1 -1
  18. data/lib/textus/cli/verb/deps.rb +1 -1
  19. data/lib/textus/cli/verb/doctor.rb +1 -1
  20. data/lib/textus/cli/verb/freshness.rb +1 -1
  21. data/lib/textus/cli/verb/get.rb +1 -1
  22. data/lib/textus/cli/verb/hook_run.rb +3 -4
  23. data/lib/textus/cli/verb/hooks.rb +11 -14
  24. data/lib/textus/cli/verb/key_delete.rb +24 -0
  25. data/lib/textus/cli/verb/list.rb +1 -1
  26. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  27. data/lib/textus/cli/verb/migrate.rb +18 -0
  28. data/lib/textus/cli/verb/mv.rb +11 -3
  29. data/lib/textus/cli/verb/published.rb +1 -1
  30. data/lib/textus/cli/verb/pulse.rb +1 -1
  31. data/lib/textus/cli/verb/put.rb +8 -6
  32. data/lib/textus/cli/verb/rdeps.rb +1 -1
  33. data/lib/textus/cli/verb/refresh.rb +1 -1
  34. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  35. data/lib/textus/cli/verb/reject.rb +1 -1
  36. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  37. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  38. data/lib/textus/cli/verb/schema.rb +1 -1
  39. data/lib/textus/cli/verb/uid.rb +1 -1
  40. data/lib/textus/cli/verb/where.rb +1 -1
  41. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  42. data/lib/textus/cli/verb.rb +7 -7
  43. data/lib/textus/cli.rb +0 -7
  44. data/lib/textus/container.rb +23 -0
  45. data/lib/textus/dispatcher.rb +49 -0
  46. data/lib/textus/doctor/check/audit_log.rb +2 -2
  47. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  48. data/lib/textus/doctor/check/hooks.rb +4 -3
  49. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  50. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  51. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  52. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  53. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  54. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  55. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  56. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  57. data/lib/textus/doctor/check/schemas.rb +2 -2
  58. data/lib/textus/doctor/check/sentinels.rb +11 -9
  59. data/lib/textus/doctor/check/templates.rb +2 -2
  60. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  61. data/lib/textus/doctor/check.rb +12 -3
  62. data/lib/textus/doctor.rb +24 -27
  63. data/lib/textus/domain/authorizer.rb +6 -6
  64. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  65. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  66. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  67. data/lib/textus/domain/sentinel.rb +9 -65
  68. data/lib/textus/domain/staleness/generator_check.rb +46 -26
  69. data/lib/textus/domain/staleness/intake_check.rb +20 -12
  70. data/lib/textus/domain/staleness.rb +4 -4
  71. data/lib/textus/envelope/io/reader.rb +44 -0
  72. data/lib/textus/{application/writes/envelope_io.rb → envelope/io/writer.rb} +32 -58
  73. data/lib/textus/hooks/builtin.rb +14 -14
  74. data/lib/textus/hooks/context.rb +30 -13
  75. data/lib/textus/hooks/error_log.rb +32 -0
  76. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  77. data/lib/textus/hooks/loader.rb +29 -3
  78. data/lib/textus/hooks/rpc_registry.rb +77 -0
  79. data/lib/textus/key/path.rb +7 -3
  80. data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
  81. data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
  82. data/lib/textus/maintenance/migrate.rb +51 -0
  83. data/lib/textus/maintenance/rule_lint.rb +56 -0
  84. data/lib/textus/maintenance/zone_mv.rb +51 -0
  85. data/lib/textus/maintenance.rb +15 -0
  86. data/lib/textus/manifest/data.rb +79 -0
  87. data/lib/textus/manifest/entry/base.rb +38 -18
  88. data/lib/textus/manifest/entry/derived.rb +8 -9
  89. data/lib/textus/manifest/entry/nested.rb +7 -9
  90. data/lib/textus/manifest/entry/parser.rb +2 -2
  91. data/lib/textus/manifest/entry/validators/events.rb +2 -2
  92. data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
  93. data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
  94. data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
  95. data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
  96. data/lib/textus/manifest/entry/validators.rb +2 -2
  97. data/lib/textus/manifest/entry.rb +0 -5
  98. data/lib/textus/manifest/policy.rb +48 -0
  99. data/lib/textus/manifest/resolver.rb +14 -14
  100. data/lib/textus/manifest/rules.rb +1 -1
  101. data/lib/textus/manifest.rb +47 -110
  102. data/lib/textus/mcp/errors.rb +32 -0
  103. data/lib/textus/mcp/server.rb +126 -0
  104. data/lib/textus/mcp/session.rb +40 -0
  105. data/lib/textus/mcp/tool_schemas.rb +71 -0
  106. data/lib/textus/mcp/tools.rb +129 -0
  107. data/lib/textus/mcp.rb +6 -0
  108. data/lib/textus/{infra → ports}/audit_log.rb +1 -1
  109. data/lib/textus/{infra → ports}/audit_subscriber.rb +7 -8
  110. data/lib/textus/{infra → ports}/build_lock.rb +1 -1
  111. data/lib/textus/{infra → ports}/clock.rb +1 -1
  112. data/lib/textus/{infra → ports}/publisher.rb +6 -6
  113. data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
  114. data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
  115. data/lib/textus/ports/sentinel_store.rb +67 -0
  116. data/lib/textus/ports/storage/file_stat.rb +19 -0
  117. data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
  118. data/lib/textus/projection.rb +91 -0
  119. data/lib/textus/read/audit.rb +111 -0
  120. data/lib/textus/read/blame.rb +81 -0
  121. data/lib/textus/read/boot.rb +18 -0
  122. data/lib/textus/read/deps.rb +24 -0
  123. data/lib/textus/read/doctor.rb +19 -0
  124. data/lib/textus/read/freshness.rb +101 -0
  125. data/lib/textus/read/get.rb +66 -0
  126. data/lib/textus/read/get_or_refresh.rb +69 -0
  127. data/lib/textus/read/list.rb +15 -0
  128. data/lib/textus/read/policy_explain.rb +37 -0
  129. data/lib/textus/read/published.rb +15 -0
  130. data/lib/textus/read/pulse.rb +89 -0
  131. data/lib/textus/read/rdeps.rb +25 -0
  132. data/lib/textus/read/schema_envelope.rb +16 -0
  133. data/lib/textus/read/stale.rb +17 -0
  134. data/lib/textus/read/uid.rb +20 -0
  135. data/lib/textus/read/validate_all.rb +22 -0
  136. data/lib/textus/read/validator.rb +84 -0
  137. data/lib/textus/read/where.rb +16 -0
  138. data/lib/textus/role_scope.rb +49 -0
  139. data/lib/textus/schema/tools.rb +14 -10
  140. data/lib/textus/store.rb +25 -11
  141. data/lib/textus/version.rb +1 -1
  142. data/lib/textus/write/accept.rb +86 -0
  143. data/lib/textus/write/authority_gate.rb +24 -0
  144. data/lib/textus/write/delete.rb +54 -0
  145. data/lib/textus/write/materializer.rb +48 -0
  146. data/lib/textus/write/mv.rb +123 -0
  147. data/lib/textus/write/publish.rb +66 -0
  148. data/lib/textus/write/put.rb +59 -0
  149. data/lib/textus/write/refresh_all.rb +44 -0
  150. data/lib/textus/write/refresh_orchestrator.rb +102 -0
  151. data/lib/textus/write/refresh_worker.rb +138 -0
  152. data/lib/textus/write/reject.rb +54 -0
  153. data/lib/textus.rb +7 -1
  154. metadata +75 -46
  155. data/lib/textus/application/context.rb +0 -34
  156. data/lib/textus/application/projection.rb +0 -91
  157. data/lib/textus/application/reads/audit.rb +0 -94
  158. data/lib/textus/application/reads/blame.rb +0 -82
  159. data/lib/textus/application/reads/deps.rb +0 -26
  160. data/lib/textus/application/reads/freshness.rb +0 -88
  161. data/lib/textus/application/reads/get.rb +0 -67
  162. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  163. data/lib/textus/application/reads/list.rb +0 -17
  164. data/lib/textus/application/reads/policy_explain.rb +0 -39
  165. data/lib/textus/application/reads/published.rb +0 -17
  166. data/lib/textus/application/reads/pulse.rb +0 -63
  167. data/lib/textus/application/reads/rdeps.rb +0 -27
  168. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  169. data/lib/textus/application/reads/stale.rb +0 -15
  170. data/lib/textus/application/reads/uid.rb +0 -23
  171. data/lib/textus/application/reads/validate_all.rb +0 -24
  172. data/lib/textus/application/reads/validator.rb +0 -86
  173. data/lib/textus/application/reads/where.rb +0 -18
  174. data/lib/textus/application/refresh/all.rb +0 -52
  175. data/lib/textus/application/refresh/orchestrator.rb +0 -78
  176. data/lib/textus/application/refresh/worker.rb +0 -116
  177. data/lib/textus/application/writes/accept.rb +0 -89
  178. data/lib/textus/application/writes/authority_gate.rb +0 -26
  179. data/lib/textus/application/writes/delete.rb +0 -33
  180. data/lib/textus/application/writes/materializer.rb +0 -50
  181. data/lib/textus/application/writes/mv.rb +0 -105
  182. data/lib/textus/application/writes/publish.rb +0 -81
  183. data/lib/textus/application/writes/put.rb +0 -37
  184. data/lib/textus/application/writes/reject.rb +0 -50
  185. data/lib/textus/infra/event_bus.rb +0 -27
  186. data/lib/textus/operations.rb +0 -176
@@ -0,0 +1,46 @@
1
+ module Textus
2
+ module Maintenance
3
+ # Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
4
+ # Calls Write::Mv directly for each entry — emits one audit row per file moved.
5
+ class KeyMvPrefix
6
+ def initialize(container:, call:)
7
+ @container = container
8
+ @call = call
9
+ end
10
+
11
+ def call(from_prefix:, to_prefix:, dry_run: false)
12
+ raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
13
+
14
+ leaves = list_leaves_under(from_prefix)
15
+ warnings = []
16
+ warnings << "no keys under #{from_prefix}" if leaves.empty?
17
+
18
+ steps = leaves.map do |old_key|
19
+ tail = old_key.delete_prefix("#{from_prefix}.")
20
+ new_key = "#{to_prefix}.#{tail}"
21
+ { "op" => "mv", "from" => old_key, "to" => new_key }
22
+ end
23
+
24
+ plan = Plan.new(steps: steps, warnings: warnings)
25
+ return plan if dry_run
26
+
27
+ steps.each do |s|
28
+ mv.call(s["from"], s["to"], dry_run: false)
29
+ end
30
+ plan
31
+ end
32
+
33
+ private
34
+
35
+ def list_leaves_under(prefix)
36
+ Read::List.new(container: @container)
37
+ .call(prefix: prefix)
38
+ .map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
39
+ end
40
+
41
+ def mv
42
+ Write::Mv.new(container: @container, call: @call)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,51 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Maintenance
5
+ # Loads a YAML migration plan and dispatches each op to the
6
+ # appropriate Maintenance use case. Concatenates resulting Plans.
7
+ class Migrate
8
+ def initialize(container:, call:)
9
+ @container = container
10
+ @call = call
11
+ end
12
+
13
+ def call(plan_yaml:, dry_run: false)
14
+ raw = YAML.safe_load(plan_yaml, permitted_classes: [Symbol], aliases: false)
15
+ raise UsageError.new("migration plan must be a YAML mapping") unless raw.is_a?(Hash)
16
+
17
+ ops = Array(raw["operations"])
18
+ all_steps = []
19
+ warnings = []
20
+
21
+ ops.each do |op_hash|
22
+ op_name = op_hash["op"]
23
+ sub_plan = invoke_op(op_name, op_hash, dry_run: dry_run)
24
+ all_steps.concat(sub_plan.steps)
25
+ warnings.concat(sub_plan.warnings)
26
+ end
27
+
28
+ Plan.new(steps: all_steps, warnings: warnings)
29
+ end
30
+
31
+ private
32
+
33
+ def invoke_op(op_name, op_hash, dry_run:)
34
+ kwargs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
35
+ klass = op_class(op_name)
36
+ klass.new(
37
+ container: @container, call: @call,
38
+ ).call(**kwargs)
39
+ end
40
+
41
+ def op_class(op_name)
42
+ case op_name
43
+ when "key_mv_prefix" then KeyMvPrefix
44
+ when "key_delete_prefix" then KeyDeletePrefix
45
+ when "zone_mv" then ZoneMv
46
+ else raise UsageError.new("unknown op: #{op_name}")
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,56 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Maintenance
5
+ # Compare the live manifest's `rules:` block against a candidate
6
+ # YAML string. Returns a Plan describing rule additions/removals/
7
+ # changes. Does NOT write anything.
8
+ class RuleLint
9
+ def initialize(container:, call:)
10
+ @container = container
11
+ @call = call
12
+ @root = container.root
13
+ end
14
+
15
+ def call(candidate_yaml:)
16
+ live_rules = current_rules
17
+ candidate_rules = parse_candidate(candidate_yaml)
18
+
19
+ live_by_match = live_rules.to_h { |r| [r["match"], r] }
20
+ candidate_by_match = candidate_rules.to_h { |r| [r["match"], r] }
21
+
22
+ steps = (candidate_by_match.keys - live_by_match.keys).map do |m|
23
+ { "op" => "add_rule", "match" => m, "rule" => candidate_by_match[m] }
24
+ end
25
+ (live_by_match.keys - candidate_by_match.keys).each do |m|
26
+ steps << { "op" => "remove_rule", "match" => m }
27
+ end
28
+ (live_by_match.keys & candidate_by_match.keys).each do |m|
29
+ next if live_by_match[m] == candidate_by_match[m]
30
+
31
+ steps << { "op" => "change_rule", "match" => m,
32
+ "from" => live_by_match[m], "to" => candidate_by_match[m] }
33
+ end
34
+
35
+ Plan.new(steps: steps, warnings: [])
36
+ end
37
+
38
+ private
39
+
40
+ def current_rules
41
+ raw = YAML.safe_load_file(File.join(@root, "manifest.yaml"),
42
+ permitted_classes: [Symbol], aliases: false)
43
+ Array(raw["rules"])
44
+ end
45
+
46
+ def parse_candidate(yaml_text)
47
+ raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
48
+ raise UsageError.new("candidate is not a YAML mapping") unless raw.is_a?(Hash)
49
+
50
+ Array(raw["rules"])
51
+ rescue Psych::Exception => e
52
+ raise UsageError.new("candidate YAML parse error: #{e.message}")
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,51 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Maintenance
5
+ # Rename a zone — rewrites the manifest's zones[] entry, rewrites
6
+ # the `zone:` field on every entry under the old zone, and moves
7
+ # every file from zones/<old>/ to zones/<new>/.
8
+ class ZoneMv
9
+ def initialize(container:, call:)
10
+ @container = container
11
+ @call = call
12
+ @manifest = container.manifest
13
+ @root = container.root
14
+ end
15
+
16
+ def call(from:, to:, dry_run: false)
17
+ raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
18
+ raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.zones.key?(from)
19
+
20
+ dest_dir = File.join(@root, "zones", to)
21
+ raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
22
+
23
+ affected_keys = @manifest.data.entries.select { |e| e.zone == from }.map(&:key)
24
+
25
+ steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
26
+ steps += affected_keys.map { |k| { "op" => "mv", "from" => k, "to" => "#{to}#{k[from.length..]}" } }
27
+
28
+ plan = Plan.new(steps: steps, warnings: [])
29
+ return plan if dry_run
30
+
31
+ rewrite_manifest!(from, to)
32
+ FileUtils.mv(File.join(@root, "zones", from), dest_dir)
33
+ plan
34
+ end
35
+
36
+ private
37
+
38
+ def rewrite_manifest!(from, to)
39
+ path = File.join(@root, "manifest.yaml")
40
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
41
+ raw["zones"].each { |z| z["name"] = to if z["name"] == from }
42
+ raw["entries"].each do |e|
43
+ e["zone"] = to if e["zone"] == from
44
+ e["key"] = e["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
45
+ e["path"] = e["path"].sub(%r{\A#{Regexp.escape(from)}(/|\z)}, "#{to}\\1")
46
+ end
47
+ File.write(path, YAML.dump(raw))
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ # Bulk and structural changes to a textus store. Each use case returns
3
+ # a Plan when called with dry_run: true, and applies the plan when
4
+ # called with dry_run: false.
5
+ module Maintenance
6
+ # A Plan is a JSON-shaped preview. Steps are op-tagged hashes the
7
+ # use case knows how to apply. Warnings are strings surfaced to
8
+ # the operator (skipped keys, ambiguities).
9
+ Plan = Data.define(:steps, :warnings) do
10
+ def to_h
11
+ { "steps" => steps, "warnings" => warnings }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,79 @@
1
+ require_relative "schema"
2
+ require_relative "role_kinds"
3
+
4
+ module Textus
5
+ class Manifest
6
+ # Immutable, parsed view of a manifest YAML document.
7
+ #
8
+ # Holds raw structural data (zones, entries, audit_config, role_mapping)
9
+ # but no behaviour beyond accessors. Behaviour (zone authority, key
10
+ # resolution, rules) lives on Manifest::Policy / Resolver / Rules.
11
+ class Data
12
+ AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
13
+
14
+ attr_reader :raw, :root, :entries, :zones, :zone_readers, :audit_config, :role_mapping, :policy
15
+
16
+ def self.validate_key!(key)
17
+ raise UsageError.new("empty key") if key.nil? || key.empty?
18
+
19
+ Key::Grammar.validate!(key)
20
+ end
21
+
22
+ # Forwarder used by Resolver and Entry classes that received a Data
23
+ # but were written against the historical Manifest API.
24
+ def validate_key!(key) = self.class.validate_key!(key)
25
+
26
+ def self.parse(raw, root:)
27
+ raise BadFrontmatter.new(File.join(root.to_s, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
28
+
29
+ Schema.validate!(raw)
30
+ new(raw: raw, root: root)
31
+ end
32
+
33
+ def initialize(raw:, root:)
34
+ @raw = raw
35
+ @root = root
36
+ @zones = Array(raw["zones"]).to_h { |z| [z["name"], Array(z["write_policy"])] }
37
+ @zone_readers = Array(raw["zones"]).to_h do |z|
38
+ rp = z["read_policy"]
39
+ [z["name"], rp.nil? ? :all : Array(rp)]
40
+ end
41
+ @audit_config = build_audit_config(raw)
42
+ @role_mapping = RoleKinds.resolve(raw["roles"])
43
+ # Policy is constructed before entries because Entry validators
44
+ # call `entry.in_generator_zone?(policy)` and similar helpers
45
+ # that take Policy as an argument.
46
+ @policy = Policy.new(self)
47
+ @entries = build_entries(raw)
48
+ validate_declared_keys!
49
+ freeze
50
+ end
51
+
52
+ private
53
+
54
+ def build_audit_config(raw)
55
+ a = raw["audit"] || {}
56
+ {
57
+ max_size: a["max_size"] || AUDIT_DEFAULTS[:max_size],
58
+ keep: a["keep"] || AUDIT_DEFAULTS[:keep],
59
+ }.freeze
60
+ end
61
+
62
+ def build_entries(raw)
63
+ Array(raw["entries"]).map do |e|
64
+ entry = Manifest::Entry::Parser.call(e)
65
+ Manifest::Entry::Validators.run_all(entry, policy: @policy)
66
+ entry
67
+ end.freeze
68
+ end
69
+
70
+ def validate_declared_keys!
71
+ @entries.each do |e|
72
+ raise UsageError.new("empty key") if e.key.nil? || e.key.empty?
73
+
74
+ Key::Grammar.validate!(e.key)
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -2,11 +2,10 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
4
  class Base < Entry
5
- attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :manifest, :publish_to
5
+ attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :publish_to
6
6
 
7
7
  # rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
8
- def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:, publish_to: [])
9
- @manifest = manifest
8
+ def initialize(raw:, key:, path:, zone:, schema:, owner:, format:, publish_to: [])
10
9
  @raw = raw
11
10
  @key = key
12
11
  @path = path
@@ -18,14 +17,14 @@ module Textus
18
17
  end
19
18
  # rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
20
19
 
21
- def zone_writers
22
- @manifest.zone_writers(@zone)
20
+ def zone_writers(policy)
21
+ policy.zone_writers(@zone)
23
22
  rescue UsageError => e
24
23
  raise UsageError.new("entry '#{@key}': #{e.message}")
25
24
  end
26
25
 
27
- def in_generator_zone? = @manifest.zone_kinds(@zone).include?(:generator)
28
- def in_proposal_zone? = @manifest.zone_kinds(@zone).include?(:proposer)
26
+ def in_generator_zone?(policy) = policy.zone_kinds(@zone).include?(:generator)
27
+ def in_proposal_zone?(policy) = policy.zone_kinds(@zone).include?(:proposer)
29
28
 
30
29
  def nested? = false
31
30
  def derived? = false
@@ -41,11 +40,32 @@ module Textus
41
40
  def publish_each = nil
42
41
  def index_filename = nil
43
42
 
44
- PublishContext = Struct.new(
45
- :repo_root, :manifest, :file_store, :root, :store, :ctx, :bus, :hook_context,
46
- :reader, :emit, # callables: reader.call(key) envelope; emit.call(event, **payload)
47
- keyword_init: true
48
- )
43
+ # Minimal context object passed into entry `publish_via` hooks.
44
+ # Everything beyond the three primitives is derived. Data.define
45
+ # instances are frozen, so we recompute per-call rather than
46
+ # memoizing — RoleScope/Hooks::Context construction is cheap.
47
+ PublishContext = ::Data.define(:container, :call, :reader) do
48
+ def manifest = container.manifest
49
+ def root = container.root
50
+ def repo_root = File.dirname(container.root)
51
+ def events = container.events
52
+
53
+ def hook_context
54
+ Textus::Hooks::Context.new(scope: scope_for_hooks)
55
+ end
56
+
57
+ def emit(event, **payload)
58
+ events.publish(event, ctx: hook_context, **payload)
59
+ end
60
+
61
+ private
62
+
63
+ def scope_for_hooks
64
+ Textus::RoleScope.new(
65
+ container: container, role: call.role, dry_run: call.dry_run,
66
+ )
67
+ end
68
+ end
49
69
 
50
70
  # Subclasses override to customize publish behavior.
51
71
  # Default: copy the stored file to each publish_to target.
@@ -59,12 +79,12 @@ module Textus
59
79
 
60
80
  publish_to.each do |rel|
61
81
  target_abs = File.join(pctx.repo_root, rel)
62
- Textus::Infra::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
63
- pctx.emit.call(:file_published,
64
- key: @key,
65
- envelope: envelope,
66
- source: source_path,
67
- target: target_abs)
82
+ Textus::Ports::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
83
+ pctx.emit(:file_published,
84
+ key: @key,
85
+ envelope: envelope,
86
+ source: source_path,
87
+ target: target_abs)
68
88
  end
69
89
 
70
90
  { kind: :built, value: { "key" => @key, "path" => source_path, "published_to" => publish_to } }
@@ -2,8 +2,8 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
4
  class Derived < Base
5
- Projection = Data.define(:select, :pluck, :sort_by, :transform)
6
- External = Data.define(:sources, :runner)
5
+ Projection = ::Data.define(:select, :pluck, :sort_by, :transform)
6
+ External = ::Data.define(:sources, :runner)
7
7
 
8
8
  attr_reader :source, :template, :inject_boot, :events
9
9
 
@@ -20,23 +20,22 @@ module Textus
20
20
  def external? = @source.is_a?(External)
21
21
 
22
22
  def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
23
- return nil unless in_generator_zone?
23
+ return nil unless in_generator_zone?(pctx.manifest.policy)
24
24
 
25
- target_path = Textus::Application::Writes::Materializer.new(
26
- ctx: pctx.ctx, manifest: pctx.manifest, file_store: pctx.file_store,
27
- bus: pctx.bus, root: pctx.root, store: pctx.store
25
+ target_path = Textus::Write::Materializer.new(
26
+ container: pctx.container, call: pctx.call,
28
27
  ).run(self)
29
28
 
30
29
  envelope = pctx.reader.call(@key)
31
30
  Array(publish_to).each do |rel|
32
31
  target_abs = File.join(pctx.repo_root, rel)
33
- Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: pctx.root)
34
- pctx.emit.call(:file_published, key: @key, envelope: envelope, source: target_path, target: target_abs)
32
+ Textus::Ports::Publisher.publish(source: target_path, target: target_abs, store_root: pctx.root)
33
+ pctx.emit(:file_published, key: @key, envelope: envelope, source: target_path, target: target_abs)
35
34
  end
36
35
 
37
36
  src = @source
38
37
  selects = src.is_a?(Projection) ? Array(src.select).compact : []
39
- pctx.emit.call(:build_completed, key: @key, envelope: envelope, sources: selects)
38
+ pctx.emit(:build_completed, key: @key, envelope: envelope, sources: selects)
40
39
 
41
40
  { kind: :built, value: { "key" => @key, "path" => target_path, "published_to" => publish_to } }
42
41
  end
@@ -1,5 +1,3 @@
1
- require_relative "validators/publish_each"
2
-
3
1
  module Textus
4
2
  class Manifest
5
3
  class Entry
@@ -37,7 +35,7 @@ module Textus
37
35
  return nil if @publish_each.nil?
38
36
 
39
37
  leaves = []
40
- @manifest.resolver.enumerate(prefix: @key).each do |row|
38
+ pctx.manifest.resolver.enumerate(prefix: @key).each do |row|
41
39
  next unless row[:manifest_entry].equal?(self)
42
40
  next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
43
41
 
@@ -49,12 +47,12 @@ module Textus
49
47
  )
50
48
  end
51
49
 
52
- Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
53
- pctx.emit.call(:file_published,
54
- key: row[:key],
55
- envelope: pctx.reader.call(row[:key]),
56
- source: row[:path],
57
- target: target_abs)
50
+ Textus::Ports::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
51
+ pctx.emit(:file_published,
52
+ key: row[:key],
53
+ envelope: pctx.reader.call(row[:key]),
54
+ source: row[:path],
55
+ target: target_abs)
58
56
  leaves << { "key" => row[:key], "source" => row[:path], "target" => target_abs }
59
57
  end
60
58
 
@@ -4,7 +4,7 @@ module Textus
4
4
  module Parser
5
5
  COMPUTE_KINDS = %w[projection external].freeze
6
6
 
7
- def self.call(manifest, raw)
7
+ def self.call(raw)
8
8
  key = raw["key"] or raise UsageError.new("manifest entry missing key")
9
9
  path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
10
10
  zone = raw["zone"] or raise UsageError.new("manifest entry '#{key}' missing zone")
@@ -14,7 +14,7 @@ module Textus
14
14
  format = resolve_format(raw, path)
15
15
 
16
16
  common = {
17
- manifest: manifest, raw: raw,
17
+ raw: raw,
18
18
  key: key, path: path, zone: zone,
19
19
  schema: raw["schema"], owner: raw["owner"],
20
20
  format: format,
@@ -3,8 +3,8 @@ module Textus
3
3
  class Entry
4
4
  module Validators
5
5
  module Events
6
- def self.call(entry)
7
- pubsub_events = Textus::Hooks::Bus::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
6
+ def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
7
+ pubsub_events = Textus::Hooks::EventBus::EVENTS.keys
8
8
  events = entry.events
9
9
  events.each_key do |evt|
10
10
  next if pubsub_events.include?(evt.to_sym)
@@ -3,7 +3,7 @@ module Textus
3
3
  class Entry
4
4
  module Validators
5
5
  module FormatMatrix
6
- def self.call(entry)
6
+ def self.call(entry, policy:)
7
7
  begin
8
8
  Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested?)
9
9
  rescue UsageError => e
@@ -17,7 +17,7 @@ module Textus
17
17
  has_template = !entry.template.nil?
18
18
  is_external = entry.derived? && entry.external?
19
19
  is_intake = entry.intake?
20
- return unless entry.in_generator_zone? && !has_template && !is_external && !is_intake &&
20
+ return unless entry.in_generator_zone?(policy) && !has_template && !is_external && !is_intake &&
21
21
  %w[markdown text].include?(entry.format) && !entry.nested?
22
22
 
23
23
  raise UsageError.new("entry '#{entry.key}': #{entry.format} entries in a generator zone require a template")
@@ -3,7 +3,7 @@ module Textus
3
3
  class Entry
4
4
  module Validators
5
5
  module IndexFilename
6
- def self.call(entry)
6
+ def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
7
7
  # Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
8
8
  index_filename = entry.nested? ? entry.index_filename : entry.raw["index_filename"]
9
9
  return if index_filename.nil?
@@ -3,10 +3,12 @@ module Textus
3
3
  class Entry
4
4
  module Validators
5
5
  module InjectBoot
6
- def self.call(entry)
6
+ def self.call(entry, policy:)
7
7
  return unless entry.inject_boot
8
8
 
9
- raise UsageError.new("entry '#{entry.key}': inject_boot: is only valid on derived entries") unless entry.in_generator_zone?
9
+ unless entry.in_generator_zone?(policy)
10
+ raise UsageError.new("entry '#{entry.key}': inject_boot: is only valid on derived entries")
11
+ end
10
12
 
11
13
  return unless entry.template.nil?
12
14
 
@@ -7,7 +7,7 @@ module Textus
7
7
  VAR_RE = /\{([a-z]+)\}/
8
8
  REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
9
9
 
10
- def self.call(entry)
10
+ def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
11
11
  # Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
12
12
  publish_each = entry.nested? ? entry.publish_each : entry.raw["publish_each"]
13
13
  return if publish_each.nil?
@@ -10,8 +10,8 @@ module Textus
10
10
  FormatMatrix,
11
11
  ].freeze
12
12
 
13
- def self.run_all(entry)
14
- REGISTERED.each { |v| v.call(entry) }
13
+ def self.run_all(entry, policy:)
14
+ REGISTERED.each { |v| v.call(entry, policy: policy) }
15
15
  nil
16
16
  end
17
17
  end
@@ -1,11 +1,6 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Entry
4
- # Re-exported for backward compatibility with callers that referenced these
5
- # constants on Entry. Canonical source is the PublishEach validator.
6
- PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
7
- PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
8
-
9
4
  # Populated by each Entry::* subclass at load time.
10
5
  REGISTRY = {}
11
6
  end
@@ -0,0 +1,48 @@
1
+ module Textus
2
+ class Manifest
3
+ # Authority over zones and roles derived from a Manifest::Data snapshot.
4
+ # Encapsulates the lookups previously living on Manifest itself
5
+ # (zone_writers, zone_kinds, permission_for, role_kind, roles_with_kind).
6
+ class Policy
7
+ def initialize(data)
8
+ @data = data
9
+ @zone_kinds_cache = {}
10
+ end
11
+
12
+ def zone_writers(zone_name)
13
+ @data.zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
14
+ end
15
+
16
+ def zone_readers
17
+ @data.zone_readers
18
+ end
19
+
20
+ def permission_for(zone_name)
21
+ Textus::Domain::Permission.new(
22
+ zone: zone_name,
23
+ write_policy: zone_writers(zone_name),
24
+ read_policy: @data.zone_readers[zone_name] || :all,
25
+ )
26
+ end
27
+
28
+ def zone_kinds(zone_name)
29
+ @zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
30
+ k = role_kind(w)
31
+ acc << k if k
32
+ end.freeze
33
+ end
34
+
35
+ def role_mapping
36
+ @data.role_mapping
37
+ end
38
+
39
+ def role_kind(name)
40
+ @data.role_mapping[name]
41
+ end
42
+
43
+ def roles_with_kind(kind)
44
+ @data.role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
45
+ end
46
+ end
47
+ end
48
+ end