textus 0.18.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +43 -48
  3. data/CHANGELOG.md +173 -0
  4. data/lib/textus/application/context.rb +20 -58
  5. data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
  6. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  7. data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
  8. data/lib/textus/application/projection.rb +91 -0
  9. data/lib/textus/application/reads/audit.rb +4 -4
  10. data/lib/textus/application/reads/blame.rb +9 -8
  11. data/lib/textus/application/reads/deps.rb +14 -3
  12. data/lib/textus/application/reads/freshness.rb +10 -8
  13. data/lib/textus/application/reads/get.rb +10 -8
  14. data/lib/textus/application/reads/get_or_refresh.rb +3 -3
  15. data/lib/textus/application/reads/list.rb +3 -3
  16. data/lib/textus/application/reads/policy_explain.rb +3 -3
  17. data/lib/textus/application/reads/published.rb +5 -3
  18. data/lib/textus/application/reads/rdeps.rb +15 -3
  19. data/lib/textus/application/reads/schema_envelope.rb +5 -4
  20. data/lib/textus/application/reads/stale.rb +3 -3
  21. data/lib/textus/application/reads/uid.rb +11 -3
  22. data/lib/textus/application/reads/validate_all.rb +10 -6
  23. data/lib/textus/application/reads/validator.rb +2 -2
  24. data/lib/textus/application/reads/where.rb +3 -3
  25. data/lib/textus/application/refresh/all.rb +15 -11
  26. data/lib/textus/application/refresh/orchestrator.rb +9 -8
  27. data/lib/textus/application/refresh/worker.rb +56 -32
  28. data/lib/textus/application/tools/migrate_keys.rb +191 -0
  29. data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
  30. data/lib/textus/application/writes/accept.rb +38 -15
  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 +20 -11
  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 +2 -5
  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/key_normalize.rb +32 -3
  47. data/lib/textus/cli/verb/put.rb +2 -3
  48. data/lib/textus/cli/verb/refresh_stale.rb +1 -2
  49. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  50. data/lib/textus/doctor/check/hooks.rb +2 -2
  51. data/lib/textus/doctor/check/illegal_keys.rb +6 -5
  52. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  53. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  54. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  55. data/lib/textus/doctor/check/templates.rb +4 -3
  56. data/lib/textus/doctor.rb +3 -4
  57. data/lib/textus/domain/authorizer.rb +37 -0
  58. data/lib/textus/domain/staleness/generator_check.rb +8 -7
  59. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  60. data/lib/textus/hooks/builtin.rb +6 -6
  61. data/lib/textus/hooks/bus.rb +155 -0
  62. data/lib/textus/hooks/context.rb +38 -0
  63. data/lib/textus/hooks/fire_report.rb +23 -0
  64. data/lib/textus/hooks/loader.rb +3 -3
  65. data/lib/textus/infra/audit_subscriber.rb +4 -4
  66. data/lib/textus/infra/event_bus.rb +3 -3
  67. data/lib/textus/infra/refresh/detached.rb +1 -1
  68. data/lib/textus/init.rb +3 -2
  69. data/lib/textus/intro.rb +7 -7
  70. data/lib/textus/manifest/entry/base.rb +38 -0
  71. data/lib/textus/manifest/entry/derived.rb +25 -0
  72. data/lib/textus/manifest/entry/intake.rb +19 -0
  73. data/lib/textus/manifest/entry/leaf.rb +16 -0
  74. data/lib/textus/manifest/entry/nested.rb +39 -0
  75. data/lib/textus/manifest/entry/parser.rb +64 -31
  76. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  77. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  78. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  79. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  80. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  81. data/lib/textus/manifest/entry.rb +0 -72
  82. data/lib/textus/manifest/resolver.rb +109 -0
  83. data/lib/textus/manifest/schema.rb +1 -1
  84. data/lib/textus/manifest.rb +3 -100
  85. data/lib/textus/operations.rb +131 -74
  86. data/lib/textus/schema/tools.rb +2 -2
  87. data/lib/textus/store.rb +6 -6
  88. data/lib/textus/version.rb +1 -1
  89. metadata +18 -11
  90. data/lib/textus/application/writes/build.rb +0 -78
  91. data/lib/textus/dependencies.rb +0 -23
  92. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  93. data/lib/textus/hooks/dispatcher.rb +0 -71
  94. data/lib/textus/hooks/registry.rb +0 -85
  95. data/lib/textus/migrate_keys.rb +0 -187
  96. data/lib/textus/projection.rb +0 -89
  97. data/lib/textus/refresh.rb +0 -39
@@ -1,12 +1,12 @@
1
1
  module Textus
2
2
  module Infra
3
3
  class EventBus
4
- def initialize(registry:)
5
- @registry = registry
4
+ def initialize(bus:)
5
+ @bus = bus
6
6
  end
7
7
 
8
8
  def publish(event, **payload)
9
- @registry.pubsub_handlers(event).each do |entry|
9
+ @bus.pubsub_handlers(event).each do |entry|
10
10
  next unless entry[:keys].nil? || matches?(entry[:keys], payload[:key])
11
11
 
12
12
  entry[:callable].call(**payload)
@@ -21,7 +21,7 @@ module Textus
21
21
 
22
22
  begin
23
23
  store = Textus::Store.new(store_root)
24
- Textus::Refresh.call(store, key, as: "runner")
24
+ Textus::Operations.for(store, role: "runner").refresh(key)
25
25
  rescue StandardError
26
26
  # Already logged via :refresh_failed; exit cleanly.
27
27
  ensure
data/lib/textus/init.rb CHANGED
@@ -13,8 +13,8 @@ module Textus
13
13
  - { name: review, write_policy: [agent, human], read_policy: [all] }
14
14
  - { name: output, write_policy: [builder], read_policy: [all] }
15
15
  entries:
16
- - { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self }
17
- - { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true }
16
+ - { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self, kind: leaf }
17
+ - { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true, kind: nested }
18
18
  YAML
19
19
 
20
20
  HOOKS_README = <<~MD
@@ -51,6 +51,7 @@ module Textus
51
51
  ```yaml
52
52
  entries:
53
53
  - key: intake.foo
54
+ kind: intake
54
55
  path: intake/foo.md
55
56
  zone: intake
56
57
  intake:
data/lib/textus/intro.rb CHANGED
@@ -135,26 +135,26 @@ module Textus
135
135
  "key" => e.key,
136
136
  "zone" => e.zone,
137
137
  "schema" => e.schema,
138
- "nested" => e.nested ? true : false,
138
+ "nested" => e.is_a?(Textus::Manifest::Entry::Nested),
139
139
  "owner" => e.owner,
140
140
  "format" => e.format,
141
141
  "derived" => derived,
142
- "intake" => !e.intake_handler.nil?,
142
+ "intake" => e.is_a?(Textus::Manifest::Entry::Intake),
143
143
  "publish_to" => Array(e.publish_to),
144
- "publish_each" => e.publish_each,
144
+ "publish_each" => e.respond_to?(:publish_each) ? e.publish_each : nil,
145
145
  }
146
146
  end
147
147
  end
148
148
 
149
149
  def self.hooks_for(store)
150
- reg = store.registry
150
+ bus = store.bus
151
151
  sections = {}
152
- Hooks::Registry::EVENTS.each do |event, spec|
152
+ Hooks::Bus::EVENTS.each do |event, spec|
153
153
  case spec[:mode]
154
154
  when :rpc
155
- sections[event.to_s] = reg.rpc_names(event).map(&:to_s).sort
155
+ sections[event.to_s] = bus.rpc_names(event).map(&:to_s).sort
156
156
  when :pubsub
157
- sections[event.to_s] = reg.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
157
+ sections[event.to_s] = bus.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
158
158
  end
159
159
  end
160
160
  sections
@@ -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? = zone_writers.include?("builder")
29
+ def in_proposal_zone? = zone_writers.include?("agent")
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,94 @@ 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
+ if compute.nil?
68
+ # Tolerate legacy derived entries with bare template (no compute block):
69
+ # treat as projection with no select.
70
+ return Derived::Projection.new(select: nil, pluck: nil, sort_by: nil, transform: nil) if raw["template"]
37
71
 
38
- kind = src["kind"]
39
- unless COMPUTE_KINDS.include?(kind)
72
+ raise BadManifest.new("derived entry '#{key}' requires compute: { kind: projection|external } or template:")
73
+ end
74
+
75
+ unless COMPUTE_KINDS.include?(compute["kind"])
40
76
  raise BadManifest.new(
41
- "entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{kind.inspect})",
77
+ "entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{compute["kind"].inspect})",
42
78
  )
43
79
  end
44
80
 
45
- frozen = src.freeze
46
- if kind == "projection"
47
- [frozen, frozen, nil]
81
+ if compute["kind"] == "projection"
82
+ Derived::Projection.new(
83
+ select: compute["select"],
84
+ pluck: compute["pluck"],
85
+ sort_by: compute["sort_by"],
86
+ transform: compute["transform"],
87
+ )
48
88
  else
49
- [frozen, nil, frozen]
89
+ Derived::External.new(sources: compute["sources"], runner: compute["runner"])
50
90
  end
51
91
  end
52
92
 
53
- def self.parse_intake(src)
54
- src ||= {}
55
- [src["handler"], src["config"] || {}]
56
- end
57
-
58
- def self.resolve_format(raw, path, nested)
93
+ def self.resolve_format(raw, path)
59
94
  declared = raw["format"]
60
95
  ext = File.extname(path)
61
96
  inferred = Textus::Entry.infer_from_extension(ext)
62
97
 
63
98
  if declared.nil?
64
99
  return inferred if inferred
65
- return "markdown" if ext == "" && nested
66
- return "markdown" if ext == ""
67
100
 
68
101
  return "markdown"
69
102
  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,109 @@
1
+ module Textus
2
+ class Manifest
3
+ class Resolver
4
+ def initialize(manifest)
5
+ @manifest = manifest
6
+ end
7
+
8
+ def resolve(key)
9
+ @manifest.validate_key!(key)
10
+ segments = key.split(".")
11
+ candidates = @manifest.entries
12
+ .map { |e| [e, e.key.split(".")] }
13
+ .select { |(_, esegs)| esegs == segments[0, esegs.length] }
14
+ .sort_by { |(_, esegs)| -esegs.length }
15
+ raise UnknownKey.new(key, suggestions: suggestions_for(key)) if candidates.empty?
16
+
17
+ entry, esegs = candidates.first
18
+ remaining = segments[esegs.length..]
19
+ build_resolution(entry, remaining, key)
20
+ end
21
+
22
+ def suggestions_for(key)
23
+ candidates = enumerate.map { |r| r[:key] }
24
+ candidates.concat(@manifest.entries.reject { |e| nested_entry?(e) }.map(&:key))
25
+ candidates.uniq!
26
+ Key::Distance.suggest(key, candidates, limit: 5)
27
+ rescue StandardError
28
+ []
29
+ end
30
+
31
+ def enumerate(prefix: nil)
32
+ out = @manifest.entries.flat_map { |entry| nested_entry?(entry) ? enumerate_nested(entry) : enumerate_leaf(entry) }
33
+ out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
34
+ out.sort_by { |row| row[:key] }
35
+ end
36
+
37
+ private
38
+
39
+ # Returns true for entries that behave as nested (Nested subclass, or any
40
+ # entry with nested: true in the raw YAML — e.g. Intake entries covering
41
+ # a directory of leaf files).
42
+ def nested_entry?(entry)
43
+ entry.is_a?(Textus::Manifest::Entry::Nested) || entry.raw["nested"] == true
44
+ end
45
+
46
+ def build_resolution(entry, remaining, key)
47
+ if remaining.empty?
48
+ Resolution.new(entry: entry, path: resolve_leaf_path(entry), remaining: [])
49
+ else
50
+ raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless nested_entry?(entry)
51
+
52
+ index_fn = entry.respond_to?(:index_filename) ? entry.index_filename : nil
53
+ path = if index_fn
54
+ File.join(@manifest.root, "zones", entry.path, *remaining, index_fn)
55
+ else
56
+ primary_ext = Textus::Entry.for_format(entry.format).extensions.first
57
+ File.join(@manifest.root, "zones", entry.path, *remaining) + primary_ext
58
+ end
59
+ Resolution.new(entry: entry, path: path, remaining: remaining)
60
+ end
61
+ end
62
+
63
+ def enumerate_leaf(entry)
64
+ fp = resolve_leaf_path(entry)
65
+ File.exist?(fp) ? [{ key: entry.key, path: fp, manifest_entry: entry }] : []
66
+ end
67
+
68
+ def enumerate_nested(entry)
69
+ base = File.join(@manifest.root, "zones", entry.path)
70
+ return [] unless File.directory?(base)
71
+
72
+ entry_index_filename = entry.respond_to?(:index_filename) ? entry.index_filename : nil
73
+ glob_pattern = entry_index_filename ? "**/#{entry_index_filename}" : nested_glob(entry.format)
74
+ Dir.glob(File.join(base, glob_pattern)).filter_map { |path| nested_row_for(entry, base, path) }
75
+ end
76
+
77
+ def nested_row_for(entry, base, path)
78
+ rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
79
+ entry_if = entry.respond_to?(:index_filename) ? entry.index_filename : nil
80
+ stripped = entry_if ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
81
+ segs = stripped.split("/").reject { |s| s.empty? || s == "." }
82
+ return nil if segs.empty?
83
+
84
+ illegal = segs.find { |s| !valid_segment?(s) }
85
+ if illegal
86
+ warn("textus: skipping illegal key segment '#{illegal}' at #{path} — run 'textus key normalize --dry-run'")
87
+ return nil
88
+ end
89
+
90
+ { key: (entry.key.split(".") + segs).join("."), path: path, manifest_entry: entry }
91
+ end
92
+
93
+ def valid_segment?(seg)
94
+ return false if seg.nil? || seg.empty?
95
+ return false if seg.length > Key::Grammar::MAX_SEGMENT_LEN
96
+
97
+ seg.match?(Key::Grammar::SEGMENT)
98
+ end
99
+
100
+ def resolve_leaf_path(entry)
101
+ Textus::Key::Path.resolve(@manifest, entry)
102
+ end
103
+
104
+ def nested_glob(format)
105
+ Textus::Entry.for_format(format).nested_glob
106
+ end
107
+ end
108
+ end
109
+ end