textus 0.18.0 → 0.20.2

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +43 -48
  3. data/CHANGELOG.md +238 -0
  4. data/SPEC.md +35 -2
  5. data/lib/textus/application/context.rb +20 -58
  6. data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
  7. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  8. data/lib/textus/{domain → application}/policy/promotion.rb +18 -6
  9. data/lib/textus/application/projection.rb +91 -0
  10. data/lib/textus/application/reads/audit.rb +4 -4
  11. data/lib/textus/application/reads/blame.rb +9 -8
  12. data/lib/textus/application/reads/deps.rb +14 -3
  13. data/lib/textus/application/reads/freshness.rb +10 -8
  14. data/lib/textus/application/reads/get.rb +10 -8
  15. data/lib/textus/application/reads/get_or_refresh.rb +3 -3
  16. data/lib/textus/application/reads/list.rb +3 -3
  17. data/lib/textus/application/reads/policy_explain.rb +3 -3
  18. data/lib/textus/application/reads/published.rb +5 -3
  19. data/lib/textus/application/reads/rdeps.rb +15 -3
  20. data/lib/textus/application/reads/schema_envelope.rb +5 -4
  21. data/lib/textus/application/reads/stale.rb +3 -3
  22. data/lib/textus/application/reads/uid.rb +11 -3
  23. data/lib/textus/application/reads/validate_all.rb +10 -6
  24. data/lib/textus/application/reads/validator.rb +5 -3
  25. data/lib/textus/application/reads/where.rb +3 -3
  26. data/lib/textus/application/refresh/all.rb +15 -11
  27. data/lib/textus/application/refresh/orchestrator.rb +9 -8
  28. data/lib/textus/application/refresh/worker.rb +56 -32
  29. data/lib/textus/application/writes/accept.rb +43 -16
  30. data/lib/textus/application/writes/authority_gate.rb +26 -0
  31. data/lib/textus/application/writes/delete.rb +13 -10
  32. data/lib/textus/application/writes/envelope_io.rb +64 -4
  33. data/lib/textus/application/writes/materializer.rb +50 -0
  34. data/lib/textus/application/writes/mv.rb +57 -94
  35. data/lib/textus/application/writes/publish.rb +132 -26
  36. data/lib/textus/application/writes/put.rb +15 -14
  37. data/lib/textus/application/writes/reject.rb +25 -12
  38. data/lib/textus/builder/pipeline.rb +21 -15
  39. data/lib/textus/builder/renderer/json.rb +4 -1
  40. data/lib/textus/builder/renderer/markdown.rb +7 -1
  41. data/lib/textus/builder/renderer/yaml.rb +4 -1
  42. data/lib/textus/cli/verb/build.rb +4 -6
  43. data/lib/textus/cli/verb/get.rb +1 -1
  44. data/lib/textus/cli/verb/hook_run.rb +3 -4
  45. data/lib/textus/cli/verb/hooks.rb +5 -5
  46. data/lib/textus/cli/verb/put.rb +2 -3
  47. data/lib/textus/cli/verb/refresh_stale.rb +1 -2
  48. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  49. data/lib/textus/doctor/check/hooks.rb +2 -2
  50. data/lib/textus/doctor/check/illegal_keys.rb +7 -7
  51. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  52. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  53. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  54. data/lib/textus/doctor/check/templates.rb +4 -3
  55. data/lib/textus/doctor.rb +3 -4
  56. data/lib/textus/domain/authorizer.rb +37 -0
  57. data/lib/textus/domain/policy/promote.rb +4 -2
  58. data/lib/textus/domain/policy/refresh.rb +2 -0
  59. data/lib/textus/domain/staleness/generator_check.rb +8 -7
  60. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  61. data/lib/textus/hooks/builtin.rb +6 -6
  62. data/lib/textus/hooks/bus.rb +155 -0
  63. data/lib/textus/hooks/context.rb +38 -0
  64. data/lib/textus/hooks/fire_report.rb +23 -0
  65. data/lib/textus/hooks/loader.rb +3 -3
  66. data/lib/textus/infra/audit_subscriber.rb +4 -4
  67. data/lib/textus/infra/event_bus.rb +3 -3
  68. data/lib/textus/infra/refresh/detached.rb +1 -1
  69. data/lib/textus/init.rb +3 -2
  70. data/lib/textus/intro.rb +51 -27
  71. data/lib/textus/manifest/entry/base.rb +38 -0
  72. data/lib/textus/manifest/entry/derived.rb +25 -0
  73. data/lib/textus/manifest/entry/intake.rb +19 -0
  74. data/lib/textus/manifest/entry/leaf.rb +16 -0
  75. data/lib/textus/manifest/entry/nested.rb +39 -0
  76. data/lib/textus/manifest/entry/parser.rb +58 -31
  77. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  78. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  79. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  80. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  81. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  82. data/lib/textus/manifest/entry.rb +0 -72
  83. data/lib/textus/manifest/resolver.rb +112 -0
  84. data/lib/textus/manifest/role_kinds.rb +21 -0
  85. data/lib/textus/manifest/schema.rb +46 -2
  86. data/lib/textus/manifest.rb +24 -101
  87. data/lib/textus/operations.rb +131 -74
  88. data/lib/textus/schema/tools.rb +10 -3
  89. data/lib/textus/store.rb +6 -6
  90. data/lib/textus/version.rb +1 -1
  91. metadata +18 -14
  92. data/lib/textus/application/writes/build.rb +0 -78
  93. data/lib/textus/cli/verb/key_normalize.rb +0 -19
  94. data/lib/textus/dependencies.rb +0 -23
  95. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  96. data/lib/textus/domain/policy.rb +0 -7
  97. data/lib/textus/hooks/dispatcher.rb +0 -71
  98. data/lib/textus/hooks/registry.rb +0 -85
  99. data/lib/textus/manifest/resolution.rb +0 -5
  100. data/lib/textus/migrate_keys.rb +0 -187
  101. data/lib/textus/projection.rb +0 -89
  102. data/lib/textus/refresh.rb +0 -39
@@ -0,0 +1,38 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ class Base < Entry
5
+ attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :manifest
6
+
7
+ # rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
8
+ def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:)
9
+ @manifest = manifest
10
+ @raw = raw
11
+ @key = key
12
+ @path = path
13
+ @zone = zone
14
+ @schema = schema
15
+ @owner = owner
16
+ @format = format
17
+ end
18
+ # rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
19
+
20
+ def kind = self.class.name.split("::").last.downcase.to_sym
21
+
22
+ def zone_writers
23
+ @manifest.zone_writers(@zone)
24
+ rescue UsageError => e
25
+ raise UsageError.new("entry '#{@key}': #{e.message}")
26
+ end
27
+
28
+ def in_generator_zone? = @manifest.zone_kinds(@zone).include?(:generator)
29
+ def in_proposal_zone? = @manifest.zone_kinds(@zone).include?(:proposer)
30
+
31
+ def nested? = false
32
+ def derived? = false
33
+ def intake? = false
34
+ def leaf? = false
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ class Derived < Base
5
+ Projection = Data.define(:select, :pluck, :sort_by, :transform)
6
+ External = Data.define(:sources, :runner)
7
+
8
+ attr_reader :source, :template, :inject_intro, :publish_to, :events
9
+
10
+ def initialize(source:, template: nil, inject_intro: false, publish_to: [], events: {}, **rest)
11
+ super(**rest)
12
+ @source = source
13
+ @template = template
14
+ @inject_intro = inject_intro
15
+ @publish_to = Array(publish_to)
16
+ @events = events || {}
17
+ end
18
+
19
+ def derived? = true
20
+ def projection? = @source.is_a?(Projection)
21
+ def external? = @source.is_a?(External)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ class Intake < Base
5
+ attr_reader :handler, :config, :events, :publish_to
6
+
7
+ def initialize(handler:, config: {}, events: {}, **rest)
8
+ super(**rest)
9
+ @handler = handler
10
+ @config = config || {}
11
+ @events = events || {}
12
+ @publish_to = []
13
+ end
14
+
15
+ def intake? = true
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ class Leaf < Base
5
+ attr_reader :publish_to
6
+
7
+ def initialize(publish_to: [], **rest)
8
+ super(**rest)
9
+ @publish_to = Array(publish_to)
10
+ end
11
+
12
+ def leaf? = true
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,39 @@
1
+ require_relative "validators/publish_each"
2
+
3
+ module Textus
4
+ class Manifest
5
+ class Entry
6
+ class Nested < Base
7
+ PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
8
+ PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
9
+
10
+ attr_reader :index_filename, :publish_each, :publish_to
11
+
12
+ def initialize(index_filename: nil, publish_each: nil, publish_to: [], **rest)
13
+ super(**rest)
14
+ @index_filename = index_filename
15
+ @publish_each = publish_each
16
+ @publish_to = Array(publish_to)
17
+ end
18
+
19
+ def nested? = true
20
+
21
+ def publish_target_for(full_key)
22
+ return nil if @publish_each.nil?
23
+
24
+ entry_segs = @key.split(".")
25
+ key_segs = full_key.split(".")
26
+ raise UsageError.new("key '#{full_key}' is not under entry '#{@key}'") unless key_segs[0, entry_segs.length] == entry_segs
27
+
28
+ remaining = key_segs[entry_segs.length..] || []
29
+ leaf = remaining.join("/")
30
+ basename = remaining.last || ""
31
+ ext = Textus::Entry.for_format(@format).extensions.first.to_s.sub(/^\./, "")
32
+
33
+ vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
34
+ @publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -9,61 +9,88 @@ module Textus
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")
11
11
 
12
- nested = raw["nested"] == true
13
- compute, projection, generator = parse_compute(raw, key)
14
- intake_handler, intake_config = parse_intake(raw["intake"])
15
- format = resolve_format(raw, path, nested)
12
+ raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (leaf|nested|derived|intake)")
13
+ kind = raw_kind.to_sym
14
+ format = resolve_format(raw, path)
16
15
 
17
- Textus::Manifest::Entry.new(
16
+ common = {
18
17
  manifest: manifest, raw: raw,
19
18
  key: key, path: path, zone: zone,
20
19
  schema: raw["schema"], owner: raw["owner"],
21
- nested: nested,
22
- template: raw["template"],
23
- publish_to: Array(raw["publish_to"]),
20
+ format: format
21
+ }
22
+
23
+ case kind
24
+ when :leaf then build_leaf(common, raw)
25
+ when :nested then build_nested(common, raw)
26
+ when :derived then build_derived(common, raw, key)
27
+ when :intake then build_intake(common, raw, key)
28
+ else raise BadManifest.new("entry '#{key}': unknown kind: #{kind.inspect}")
29
+ end
30
+ end
31
+
32
+ def self.build_leaf(common, raw)
33
+ Leaf.new(publish_to: raw["publish_to"], **common)
34
+ end
35
+
36
+ def self.build_nested(common, raw)
37
+ Nested.new(
38
+ index_filename: raw["index_filename"],
24
39
  publish_each: raw["publish_each"],
25
- events: raw["events"] || {},
40
+ publish_to: raw["publish_to"],
41
+ **common,
42
+ )
43
+ end
44
+
45
+ def self.build_derived(common, raw, key)
46
+ source = parse_source(raw, key)
47
+ Derived.new(
48
+ source: source,
49
+ template: raw["template"],
26
50
  inject_intro: raw["inject_intro"] == true,
27
- index_filename: raw["index_filename"],
28
- format: format,
29
- compute: compute, projection: projection, generator: generator,
30
- intake_handler: intake_handler, intake_config: intake_config
51
+ publish_to: raw["publish_to"],
52
+ events: raw["events"] || {},
53
+ **common,
31
54
  )
32
55
  end
33
56
 
34
- def self.parse_compute(raw, key)
35
- src = raw["compute"]
36
- return [nil, nil, nil] if src.nil?
57
+ def self.build_intake(common, raw, key)
58
+ intake = raw["intake"] || {}
59
+ handler = intake["handler"] || raw["intake_handler"] or
60
+ raise UsageError.new("intake entry '#{key}' missing handler")
61
+ config = intake["config"] || raw["intake_config"] || {}
62
+ Intake.new(handler: handler, config: config, events: raw["events"] || {}, **common)
63
+ end
64
+
65
+ def self.parse_source(raw, key)
66
+ compute = raw["compute"]
67
+ raise BadManifest.new("derived entry '#{key}' requires compute: { kind: projection|external } or template:") if compute.nil?
37
68
 
38
- kind = src["kind"]
39
- unless COMPUTE_KINDS.include?(kind)
69
+ unless COMPUTE_KINDS.include?(compute["kind"])
40
70
  raise BadManifest.new(
41
- "entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{kind.inspect})",
71
+ "entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{compute["kind"].inspect})",
42
72
  )
43
73
  end
44
74
 
45
- frozen = src.freeze
46
- if kind == "projection"
47
- [frozen, frozen, nil]
75
+ if compute["kind"] == "projection"
76
+ Derived::Projection.new(
77
+ select: compute["select"],
78
+ pluck: compute["pluck"],
79
+ sort_by: compute["sort_by"],
80
+ transform: compute["transform"],
81
+ )
48
82
  else
49
- [frozen, nil, frozen]
83
+ Derived::External.new(sources: compute["sources"], runner: compute["runner"])
50
84
  end
51
85
  end
52
86
 
53
- def self.parse_intake(src)
54
- src ||= {}
55
- [src["handler"], src["config"] || {}]
56
- end
57
-
58
- def self.resolve_format(raw, path, nested)
87
+ def self.resolve_format(raw, path)
59
88
  declared = raw["format"]
60
89
  ext = File.extname(path)
61
90
  inferred = Textus::Entry.infer_from_extension(ext)
62
91
 
63
92
  if declared.nil?
64
93
  return inferred if inferred
65
- return "markdown" if ext == "" && nested
66
- return "markdown" if ext == ""
67
94
 
68
95
  return "markdown"
69
96
  end
@@ -4,8 +4,9 @@ module Textus
4
4
  module Validators
5
5
  module Events
6
6
  def self.call(entry)
7
- pubsub_events = Textus::Hooks::Registry::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
8
- entry.events.each_key do |evt|
7
+ pubsub_events = Textus::Hooks::Bus::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
8
+ events = entry.respond_to?(:events) ? entry.events : {}
9
+ events.each_key do |evt|
9
10
  next if pubsub_events.include?(evt.to_sym)
10
11
 
11
12
  raise UsageError.new(
@@ -5,7 +5,7 @@ module Textus
5
5
  module FormatMatrix
6
6
  def self.call(entry)
7
7
  begin
8
- Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested)
8
+ Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested?)
9
9
  rescue UsageError => e
10
10
  raise UsageError.new("entry '#{entry.key}': #{e.message}")
11
11
  end
@@ -14,8 +14,10 @@ module Textus
14
14
  raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
15
15
  end
16
16
 
17
- return unless entry.in_generator_zone? && entry.template.nil? && entry.generator.nil? &&
18
- %w[markdown text].include?(entry.format) && !entry.nested
17
+ has_template = entry.respond_to?(:template) && !entry.template.nil?
18
+ is_external = entry.derived? && entry.external?
19
+ return unless entry.in_generator_zone? && !has_template && !is_external &&
20
+ %w[markdown text].include?(entry.format) && !entry.nested?
19
21
 
20
22
  raise UsageError.new("entry '#{entry.key}': derived #{entry.format} entries require a template")
21
23
  end
@@ -4,31 +4,32 @@ module Textus
4
4
  module Validators
5
5
  module IndexFilename
6
6
  def self.call(entry)
7
- return if entry.index_filename.nil?
7
+ index_filename = entry.respond_to?(:index_filename) ? entry.index_filename : entry.raw["index_filename"]
8
+ return if index_filename.nil?
8
9
 
9
- check_shape!(entry)
10
- check_extension!(entry)
10
+ check_shape!(entry, index_filename)
11
+ check_extension!(entry, index_filename)
11
12
  end
12
13
 
13
- def self.check_shape!(entry)
14
- raise UsageError.new("entry '#{entry.key}': index_filename requires nested: true") unless entry.nested
14
+ def self.check_shape!(entry, index_filename)
15
+ raise UsageError.new("entry '#{entry.key}': index_filename requires nested: true") unless entry.nested?
15
16
 
16
- unless entry.index_filename.is_a?(String) && !entry.index_filename.empty?
17
+ unless index_filename.is_a?(String) && !index_filename.empty?
17
18
  raise UsageError.new("entry '#{entry.key}': index_filename must be a non-empty string")
18
19
  end
19
20
 
20
- return unless entry.index_filename.include?("/") || File.basename(entry.index_filename) != entry.index_filename
21
+ return unless index_filename.include?("/") || File.basename(index_filename) != index_filename
21
22
 
22
23
  raise UsageError.new("entry '#{entry.key}': index_filename must be a bare basename (no slashes)")
23
24
  end
24
25
 
25
- def self.check_extension!(entry)
26
- ext = File.extname(entry.index_filename)
26
+ def self.check_extension!(entry, index_filename)
27
+ ext = File.extname(index_filename)
27
28
  inferred = Textus::Entry.infer_from_extension(ext)
28
29
 
29
30
  if inferred.nil?
30
31
  raise UsageError.new(
31
- "entry '#{entry.key}': index_filename #{entry.index_filename.inspect} has unknown extension #{ext.inspect}",
32
+ "entry '#{entry.key}': index_filename #{index_filename.inspect} has unknown extension #{ext.inspect}",
32
33
  )
33
34
  end
34
35
  return if inferred == entry.format
@@ -4,10 +4,13 @@ module Textus
4
4
  module Validators
5
5
  module InjectIntro
6
6
  def self.call(entry)
7
- return unless entry.inject_intro
7
+ inject_intro = entry.respond_to?(:inject_intro) ? entry.inject_intro : false
8
+ return unless inject_intro
8
9
 
9
10
  raise UsageError.new("entry '#{entry.key}': inject_intro: is only valid on derived entries") unless entry.in_generator_zone?
10
- return unless entry.template.nil?
11
+
12
+ has_template = entry.respond_to?(:template) && !entry.template.nil?
13
+ return if has_template
11
14
 
12
15
  raise UsageError.new("entry '#{entry.key}': inject_intro: requires a template:")
13
16
  end
@@ -7,14 +7,17 @@ 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)
11
- return if entry.publish_each.nil?
10
+ def self.call(entry) # rubocop:disable Metrics/AbcSize
11
+ publish_each = entry.respond_to?(:publish_each) ? entry.publish_each : entry.raw["publish_each"]
12
+ return if publish_each.nil?
12
13
 
13
- raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested
14
- raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless entry.publish_to.empty?
15
- raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless entry.publish_each.is_a?(String)
14
+ raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested?
16
15
 
17
- used_vars = entry.publish_each.scan(VAR_RE).flatten
16
+ publish_to = entry.respond_to?(:publish_to) ? entry.publish_to : Array(entry.raw["publish_to"])
17
+ raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless publish_to.empty?
18
+ raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless publish_each.is_a?(String)
19
+
20
+ used_vars = publish_each.scan(VAR_RE).flatten
18
21
  unknown = used_vars - KNOWN_VARS
19
22
  unless unknown.empty?
20
23
  raise UsageError.new(
@@ -5,78 +5,6 @@ module Textus
5
5
  # constants on Entry. Canonical source is the PublishEach validator.
6
6
  PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
7
7
  PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
8
-
9
- attr_reader :raw, :key, :path, :zone, :schema, :owner, :nested,
10
- :template, :publish_to, :publish_each,
11
- :events, :inject_intro, :index_filename, :format,
12
- :compute, :projection, :generator,
13
- :intake_handler, :intake_config
14
-
15
- # rubocop:disable Metrics/ParameterLists
16
- def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, nested:,
17
- template:, publish_to:, publish_each:, events:, inject_intro:,
18
- index_filename:, format:, compute:, projection:, generator:,
19
- intake_handler:, intake_config:)
20
- @manifest = manifest
21
- @raw = raw
22
- @key = key
23
- @path = path
24
- @zone = zone
25
- @schema = schema
26
- @owner = owner
27
- @nested = nested
28
- @template = template
29
- @publish_to = publish_to
30
- @publish_each = publish_each
31
- @events = events
32
- @inject_intro = inject_intro
33
- @index_filename = index_filename
34
- @format = format
35
- @compute = compute
36
- @projection = projection
37
- @generator = generator
38
- @intake_handler = intake_handler
39
- @intake_config = intake_config
40
- end
41
- # rubocop:enable Metrics/ParameterLists
42
-
43
- # Resolves the per-leaf target path (relative to repo root) for a full
44
- # dotted key under this entry's prefix. Returns nil if this entry has no
45
- # publish_each template.
46
- def publish_target_for(full_key)
47
- return nil if @publish_each.nil?
48
-
49
- entry_segs = @key.split(".")
50
- key_segs = full_key.split(".")
51
- raise UsageError.new("key '#{full_key}' is not under entry '#{@key}'") unless key_segs[0, entry_segs.length] == entry_segs
52
-
53
- remaining = key_segs[entry_segs.length..] || []
54
- leaf = remaining.join("/")
55
- basename = remaining.last || ""
56
- ext = Textus::Entry.for_format(@format).extensions.first.to_s.sub(/^\./, "")
57
-
58
- vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
59
- @publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
60
- end
61
-
62
- # Signal-based zone-kind predicates: derive the "kind" of a zone from its
63
- # write_policy signals rather than its literal name, so detection keeps
64
- # working when users rename the default zones.
65
- def in_generator_zone?
66
- zone_writers.include?("builder")
67
- end
68
-
69
- def in_proposal_zone?
70
- zone_writers.include?("agent")
71
- end
72
-
73
- private
74
-
75
- def zone_writers
76
- @manifest.zone_writers(@zone)
77
- rescue UsageError => e
78
- raise UsageError.new("entry '#{@key}': #{e.message}")
79
- end
80
8
  end
81
9
  end
82
10
  end
@@ -0,0 +1,112 @@
1
+ module Textus
2
+ class Manifest
3
+ class Resolver
4
+ Resolution = Data.define(:entry, :path, :remaining)
5
+
6
+ def initialize(manifest)
7
+ @manifest = manifest
8
+ end
9
+
10
+ def resolve(key)
11
+ @manifest.validate_key!(key)
12
+ segments = key.split(".")
13
+ candidates = @manifest.entries
14
+ .map { |e| [e, e.key.split(".")] }
15
+ .select { |(_, esegs)| esegs == segments[0, esegs.length] }
16
+ .sort_by { |(_, esegs)| -esegs.length }
17
+ raise UnknownKey.new(key, suggestions: suggestions_for(key)) if candidates.empty?
18
+
19
+ entry, esegs = candidates.first
20
+ remaining = segments[esegs.length..]
21
+ build_resolution(entry, remaining, key)
22
+ end
23
+
24
+ def suggestions_for(key)
25
+ candidates = enumerate.map { |r| r[:key] }
26
+ candidates.concat(@manifest.entries.reject { |e| nested_entry?(e) }.map(&:key))
27
+ candidates.uniq!
28
+ Key::Distance.suggest(key, candidates, limit: 5)
29
+ rescue StandardError
30
+ []
31
+ end
32
+
33
+ def enumerate(prefix: nil)
34
+ out = @manifest.entries.flat_map { |entry| nested_entry?(entry) ? enumerate_nested(entry) : enumerate_leaf(entry) }
35
+ out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
36
+ out.sort_by { |row| row[:key] }
37
+ end
38
+
39
+ private
40
+
41
+ # Returns true for entries that behave as nested (Nested subclass, or any
42
+ # entry with nested: true in the raw YAML — e.g. Intake entries covering
43
+ # a directory of leaf files).
44
+ def nested_entry?(entry)
45
+ entry.is_a?(Textus::Manifest::Entry::Nested) || entry.raw["nested"] == true
46
+ end
47
+
48
+ def build_resolution(entry, remaining, key)
49
+ if remaining.empty?
50
+ Resolution.new(entry: entry, path: resolve_leaf_path(entry), remaining: [])
51
+ else
52
+ raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless nested_entry?(entry)
53
+
54
+ index_fn = entry.respond_to?(:index_filename) ? entry.index_filename : nil
55
+ path = if index_fn
56
+ File.join(@manifest.root, "zones", entry.path, *remaining, index_fn)
57
+ else
58
+ primary_ext = Textus::Entry.for_format(entry.format).extensions.first
59
+ File.join(@manifest.root, "zones", entry.path, *remaining) + primary_ext
60
+ end
61
+ Resolution.new(entry: entry, path: path, remaining: remaining)
62
+ end
63
+ end
64
+
65
+ def enumerate_leaf(entry)
66
+ fp = resolve_leaf_path(entry)
67
+ File.exist?(fp) ? [{ key: entry.key, path: fp, manifest_entry: entry }] : []
68
+ end
69
+
70
+ def enumerate_nested(entry)
71
+ base = File.join(@manifest.root, "zones", entry.path)
72
+ return [] unless File.directory?(base)
73
+
74
+ entry_index_filename = entry.respond_to?(:index_filename) ? entry.index_filename : nil
75
+ glob_pattern = entry_index_filename ? "**/#{entry_index_filename}" : nested_glob(entry.format)
76
+ Dir.glob(File.join(base, glob_pattern)).filter_map { |path| nested_row_for(entry, base, path) }
77
+ end
78
+
79
+ def nested_row_for(entry, base, path)
80
+ rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
81
+ entry_if = entry.respond_to?(:index_filename) ? entry.index_filename : nil
82
+ stripped = entry_if ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
83
+ segs = stripped.split("/").reject { |s| s.empty? || s == "." }
84
+ return nil if segs.empty?
85
+
86
+ illegal = segs.find { |s| !valid_segment?(s) }
87
+ if illegal
88
+ warn("textus: skipping illegal key segment '#{illegal}' at #{path} — " \
89
+ "rename to match [a-z0-9][a-z0-9-]* (run 'textus doctor' for the full list)")
90
+ return nil
91
+ end
92
+
93
+ { key: (entry.key.split(".") + segs).join("."), path: path, manifest_entry: entry }
94
+ end
95
+
96
+ def valid_segment?(seg)
97
+ return false if seg.nil? || seg.empty?
98
+ return false if seg.length > Key::Grammar::MAX_SEGMENT_LEN
99
+
100
+ seg.match?(Key::Grammar::SEGMENT)
101
+ end
102
+
103
+ def resolve_leaf_path(entry)
104
+ Textus::Key::Path.resolve(@manifest, entry)
105
+ end
106
+
107
+ def nested_glob(format)
108
+ Textus::Entry.for_format(format).nested_glob
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ class Manifest
3
+ module RoleKinds
4
+ DEFAULT_MAPPING = {
5
+ "human" => :accept_authority,
6
+ "agent" => :proposer,
7
+ "builder" => :generator,
8
+ "runner" => :runner,
9
+ }.freeze
10
+
11
+ # Returns { role_name => kind_symbol }. When `roles:` is declared we use
12
+ # exactly that; defaults are *not* layered in (declaring roles is an opt-in
13
+ # to a fully user-defined vocabulary).
14
+ def self.resolve(raw_roles)
15
+ return DEFAULT_MAPPING if raw_roles.nil?
16
+
17
+ raw_roles.to_h { |r| [r["name"], r["kind"].to_sym] }.freeze
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,10 +1,12 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  module Schema
4
- ROOT_KEYS = %w[version zones entries rules].freeze
4
+ ROOT_KEYS = %w[version roles zones entries rules].freeze
5
+ ROLE_KEYS = %w[name kind].freeze
6
+ ROLE_KINDS = %w[accept_authority generator proposer runner].freeze
5
7
  ZONE_KEYS = %w[name write_policy read_policy].freeze
6
8
  ENTRY_KEYS = %w[
7
- key path zone schema owner nested format
9
+ key path zone kind schema owner nested format
8
10
  compute template publish_to publish_each
9
11
  intake events inject_intro index_filename
10
12
  ].freeze
@@ -19,6 +21,7 @@ module Textus
19
21
  raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
20
22
 
21
23
  walk(raw, ROOT_KEYS, "$")
24
+ validate_roles!(raw["roles"])
22
25
  Array(raw["zones"]).each_with_index do |z, i|
23
26
  walk(z, ZONE_KEYS, "$.zones[#{i}]")
24
27
  end
@@ -37,6 +40,47 @@ module Textus
37
40
  end
38
41
  walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
39
42
  end
43
+ validate_zone_writers_declared!(raw)
44
+ end
45
+
46
+ def self.validate_zone_writers_declared!(raw)
47
+ return if raw["roles"].nil? # default mapping is permissive
48
+
49
+ declared = Array(raw["roles"]).map { |r| r["name"] }.compact.to_set
50
+ Array(raw["zones"]).each do |z|
51
+ Array(z["write_policy"]).each_with_index do |w, j|
52
+ next if declared.include?(w)
53
+
54
+ raise BadManifest.new(
55
+ "zone '#{z["name"]}' write_policy[#{j}] references undeclared role '#{w}' " \
56
+ "(declared roles: #{declared.to_a.join(", ")})",
57
+ )
58
+ end
59
+ end
60
+ end
61
+
62
+ def self.validate_roles!(roles)
63
+ return if roles.nil?
64
+ raise BadManifest.new("roles: must be a list") unless roles.is_a?(Array)
65
+
66
+ accept_authority_count = 0
67
+ roles.each_with_index do |r, i|
68
+ path = "$.roles[#{i}]"
69
+ walk(r, ROLE_KEYS, path)
70
+ name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
71
+ kind = r["kind"] or raise BadManifest.new("role '#{name}' at '#{path}' missing kind")
72
+ unless ROLE_KINDS.include?(kind)
73
+ raise BadManifest.new("unknown role kind '#{kind}' at '#{path}' (known: #{ROLE_KINDS.join(", ")})")
74
+ end
75
+
76
+ accept_authority_count += 1 if kind == "accept_authority"
77
+ end
78
+ return unless accept_authority_count > 1
79
+
80
+ raise BadManifest.new(
81
+ "manifest declares #{accept_authority_count} accept_authority roles; " \
82
+ "at most one accept_authority role is allowed",
83
+ )
40
84
  end
41
85
 
42
86
  def self.validate_fetch_timeout!(value, path)