textus 0.20.0 → 0.22.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -0
  3. data/README.md +7 -4
  4. data/SPEC.md +77 -5
  5. data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
  6. data/lib/textus/application/policy/promotion.rb +6 -11
  7. data/lib/textus/application/reads/audit.rb +40 -15
  8. data/lib/textus/application/reads/pulse.rb +63 -0
  9. data/lib/textus/application/reads/validator.rb +3 -1
  10. data/lib/textus/application/writes/accept.rb +5 -1
  11. data/lib/textus/application/writes/authority_gate.rb +26 -0
  12. data/lib/textus/application/writes/materializer.rb +1 -1
  13. data/lib/textus/application/writes/publish.rb +25 -106
  14. data/lib/textus/application/writes/reject.rb +5 -1
  15. data/lib/textus/{intro.rb → boot.rb} +71 -25
  16. data/lib/textus/builder/pipeline.rb +2 -2
  17. data/lib/textus/cli/verb/audit.rb +2 -0
  18. data/lib/textus/cli/verb/{intro.rb → boot.rb} +3 -3
  19. data/lib/textus/cli/verb/build.rb +2 -1
  20. data/lib/textus/cli/verb/pulse.rb +17 -0
  21. data/lib/textus/cli.rb +1 -1
  22. data/lib/textus/doctor/check/illegal_keys.rb +2 -3
  23. data/lib/textus/domain/policy/promote.rb +4 -2
  24. data/lib/textus/domain/policy/refresh.rb +2 -0
  25. data/lib/textus/errors.rb +16 -0
  26. data/lib/textus/infra/audit_log.rb +126 -16
  27. data/lib/textus/manifest/entry/base.rb +43 -6
  28. data/lib/textus/manifest/entry/derived.rb +40 -4
  29. data/lib/textus/manifest/entry/intake.rb +15 -3
  30. data/lib/textus/manifest/entry/leaf.rb +6 -5
  31. data/lib/textus/manifest/entry/nested.rb +42 -3
  32. data/lib/textus/manifest/entry/parser.rb +9 -51
  33. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  34. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
  35. data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
  36. data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
  37. data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
  38. data/lib/textus/manifest/entry/validators.rb +1 -1
  39. data/lib/textus/manifest/entry.rb +3 -0
  40. data/lib/textus/manifest/resolver.rb +8 -5
  41. data/lib/textus/manifest/role_kinds.rb +21 -0
  42. data/lib/textus/manifest/schema.rb +63 -5
  43. data/lib/textus/manifest.rb +31 -1
  44. data/lib/textus/operations.rb +8 -1
  45. data/lib/textus/schema/tools.rb +8 -1
  46. data/lib/textus/store.rb +5 -1
  47. data/lib/textus/version.rb +1 -1
  48. metadata +9 -10
  49. data/lib/textus/application/policy/predicates/human_accept.rb +0 -30
  50. data/lib/textus/application/tools/migrate_keys.rb +0 -191
  51. data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +0 -31
  52. data/lib/textus/cli/verb/key_normalize.rb +0 -48
  53. data/lib/textus/domain/policy.rb +0 -7
  54. data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
  55. data/lib/textus/manifest/resolution.rb +0 -5
@@ -1,11 +1,18 @@
1
+ require "fileutils"
1
2
  require "json"
2
3
  require "time"
3
4
 
4
5
  module Textus
5
6
  module Infra
6
7
  class AuditLog
7
- def initialize(root)
8
- @path = File.join(root, "audit.log")
8
+ DEFAULT_MAX_SIZE = 10_485_760
9
+ DEFAULT_KEEP = 5
10
+
11
+ def initialize(root, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
12
+ @root = root
13
+ @path = File.join(root, "audit.log")
14
+ @max_size = max_size
15
+ @keep = keep
9
16
  end
10
17
 
11
18
  def last_writer_for(key)
@@ -23,27 +30,38 @@ module Textus
23
30
  last_role
24
31
  end
25
32
 
26
- def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
27
- row = {
28
- "ts" => Time.now.utc.iso8601,
29
- "role" => role,
30
- "verb" => verb,
31
- "key" => key,
32
- "etag_before" => etag_before,
33
- "etag_after" => etag_after,
34
- }
33
+ def latest_seq
34
+ return scan_max_seq(@path) if File.exist?(@path) && File.size(@path).positive?
35
35
 
36
- if extras.is_a?(Hash) && !extras.empty?
37
- extras = extras.dup
38
- %w[from_key to_key uid].each do |k|
39
- row[k] = extras.delete(k) if extras.key?(k)
36
+ # Active log is empty/missing — consult the most recent rotated file's sidecar.
37
+ meta = read_meta(1)
38
+ return meta["max_seq"] if meta
39
+
40
+ 0
41
+ end
42
+
43
+ def min_available_seq
44
+ rotated_metas = (1..@keep).map { |n| read_meta(n) }.compact
45
+ if rotated_metas.any?
46
+ rotated_metas.map { |m| m["min_seq"] }.min
47
+ elsif File.exist?(@path)
48
+ File.foreach(@path) do |line|
49
+ parsed = parse_row(line.chomp)
50
+ return parsed["seq"] if parsed && parsed["seq"]
40
51
  end
41
- row["extras"] = extras unless extras.empty?
52
+ nil
42
53
  end
54
+ end
43
55
 
56
+ def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
44
57
  File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
45
58
  f.flock(File::LOCK_EX)
59
+ next_seq = current_max_seq_unlocked + 1
60
+ row = assemble_row(next_seq, { role: role, verb: verb, key: key,
61
+ etag_before: etag_before, etag_after: etag_after }, extras)
46
62
  f.write(JSON.generate(row) + "\n")
63
+ f.flush
64
+ rotate!(f) if f.size > @max_size
47
65
  end
48
66
  end
49
67
 
@@ -63,6 +81,98 @@ module Textus
63
81
 
64
82
  private
65
83
 
84
+ # Caller holds the flock. Returns the highest seq across the active log,
85
+ # OR the most-recent rotated file's max_seq if the active log is empty.
86
+ def current_max_seq_unlocked
87
+ return scan_max_seq(@path) if File.exist?(@path) && File.size(@path).positive?
88
+
89
+ meta = read_meta(1)
90
+ meta ? meta["max_seq"] : 0
91
+ end
92
+
93
+ def scan_max_seq(file)
94
+ last = 0
95
+ File.foreach(file) do |line|
96
+ parsed = parse_row(line.chomp)
97
+ last = parsed["seq"] if parsed && parsed["seq"].is_a?(Integer)
98
+ end
99
+ last
100
+ end
101
+
102
+ def scan_seq_range(file)
103
+ min = nil
104
+ max = 0
105
+ File.foreach(file) do |line|
106
+ parsed = parse_row(line.chomp)
107
+ next unless parsed && parsed["seq"]
108
+
109
+ min ||= parsed["seq"]
110
+ max = parsed["seq"]
111
+ end
112
+ [min, max]
113
+ end
114
+
115
+ def read_meta(n)
116
+ path = File.join(@root, "audit.log.#{n}.meta.json")
117
+ return nil unless File.exist?(path)
118
+
119
+ JSON.parse(File.read(path))
120
+ rescue JSON::ParserError
121
+ nil
122
+ end
123
+
124
+ def assemble_row(seq, fields, extras = nil)
125
+ row = {
126
+ "seq" => seq,
127
+ "ts" => Time.now.utc.iso8601,
128
+ "role" => fields[:role],
129
+ "verb" => fields[:verb],
130
+ "key" => fields[:key],
131
+ "etag_before" => fields[:etag_before],
132
+ "etag_after" => fields[:etag_after],
133
+ }
134
+
135
+ if extras.is_a?(Hash) && !extras.empty?
136
+ extras = extras.dup
137
+ %w[from_key to_key uid].each do |k|
138
+ row[k] = extras.delete(k) if extras.key?(k)
139
+ end
140
+ row["extras"] = extras unless extras.empty?
141
+ end
142
+
143
+ row
144
+ end
145
+
146
+ # Called inside the flock, after a successful write that pushed size over max.
147
+ # Renames audit.log → audit.log.1 (shifting older files), writes sidecar meta.
148
+ def rotate!(open_file)
149
+ open_file.flush
150
+ min_seq, max_seq = scan_seq_range(@path)
151
+ meta = { "min_seq" => min_seq, "max_seq" => max_seq, "rotated_at" => Time.now.utc.iso8601 }
152
+
153
+ # Drop the file that would be shifted past @keep.
154
+ oldest = File.join(@root, "audit.log.#{@keep}")
155
+ oldest_meta = File.join(@root, "audit.log.#{@keep}.meta.json")
156
+ FileUtils.rm_f(oldest)
157
+ FileUtils.rm_f(oldest_meta)
158
+
159
+ # Shift .N → .(N+1) for N = keep-1 down to 1.
160
+ (@keep - 1).downto(1) do |n|
161
+ src = File.join(@root, "audit.log.#{n}")
162
+ dst = File.join(@root, "audit.log.#{n + 1}")
163
+ File.rename(src, dst) if File.exist?(src)
164
+
165
+ src_meta = File.join(@root, "audit.log.#{n}.meta.json")
166
+ dst_meta = File.join(@root, "audit.log.#{n + 1}.meta.json")
167
+ File.rename(src_meta, dst_meta) if File.exist?(src_meta)
168
+ end
169
+
170
+ # Active log → .1
171
+ File.rename(@path, File.join(@root, "audit.log.1"))
172
+ File.write(File.join(@root, "audit.log.1.meta.json"), JSON.generate(meta) + "\n")
173
+ # Next append will create a fresh audit.log via File::CREAT.
174
+ end
175
+
66
176
  def parse_row(line)
67
177
  return nil if line.empty?
68
178
 
@@ -2,10 +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
5
+ attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :manifest, :publish_to
6
6
 
7
7
  # rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
8
- def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:)
8
+ def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:, publish_to: [])
9
9
  @manifest = manifest
10
10
  @raw = raw
11
11
  @key = key
@@ -14,24 +14,61 @@ module Textus
14
14
  @schema = schema
15
15
  @owner = owner
16
16
  @format = format
17
+ @publish_to = Array(publish_to)
17
18
  end
18
19
  # rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
19
20
 
20
- def kind = self.class.name.split("::").last.downcase.to_sym
21
-
22
21
  def zone_writers
23
22
  @manifest.zone_writers(@zone)
24
23
  rescue UsageError => e
25
24
  raise UsageError.new("entry '#{@key}': #{e.message}")
26
25
  end
27
26
 
28
- def in_generator_zone? = zone_writers.include?("builder")
29
- def in_proposal_zone? = zone_writers.include?("agent")
27
+ def in_generator_zone? = @manifest.zone_kinds(@zone).include?(:generator)
28
+ def in_proposal_zone? = @manifest.zone_kinds(@zone).include?(:proposer)
30
29
 
31
30
  def nested? = false
32
31
  def derived? = false
33
32
  def intake? = false
34
33
  def leaf? = false
34
+
35
+ # Nil stubs for cross-cutting optional attrs. Subclasses override the
36
+ # ones they own. Validators and serializers can call these directly
37
+ # without `respond_to?` guards.
38
+ def template = nil
39
+ def inject_boot = false # rubocop:disable Naming/PredicateMethod
40
+ def events = {}
41
+ def publish_each = nil
42
+ def index_filename = nil
43
+
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
+ )
49
+
50
+ # Subclasses override to customize publish behavior.
51
+ # Default: copy the stored file to each publish_to target.
52
+ # Returns: { kind: :built|:leaves, value: ... } to be accumulated by
53
+ # Publish#call, or nil to skip.
54
+ def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
55
+ return nil if Array(publish_to).empty?
56
+
57
+ source_path = pctx.manifest.resolver.resolve(@key).path
58
+ envelope = pctx.reader.call(@key)
59
+
60
+ publish_to.each do |rel|
61
+ 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)
68
+ end
69
+
70
+ { kind: :built, value: { "key" => @key, "path" => source_path, "published_to" => publish_to } }
71
+ end
35
72
  end
36
73
  end
37
74
  end
@@ -5,20 +5,56 @@ module Textus
5
5
  Projection = Data.define(:select, :pluck, :sort_by, :transform)
6
6
  External = Data.define(:sources, :runner)
7
7
 
8
- attr_reader :source, :template, :inject_intro, :publish_to, :events
8
+ attr_reader :source, :template, :inject_boot, :events
9
9
 
10
- def initialize(source:, template: nil, inject_intro: false, publish_to: [], events: {}, **rest)
10
+ def initialize(source:, template: nil, inject_boot: false, events: {}, **rest)
11
11
  super(**rest)
12
12
  @source = source
13
13
  @template = template
14
- @inject_intro = inject_intro
15
- @publish_to = Array(publish_to)
14
+ @inject_boot = inject_boot
16
15
  @events = events || {}
17
16
  end
18
17
 
19
18
  def derived? = true
20
19
  def projection? = @source.is_a?(Projection)
21
20
  def external? = @source.is_a?(External)
21
+
22
+ def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
23
+ return nil unless in_generator_zone?
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
28
+ ).run(self)
29
+
30
+ envelope = pctx.reader.call(@key)
31
+ Array(publish_to).each do |rel|
32
+ 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)
35
+ end
36
+
37
+ src = @source
38
+ selects = src.is_a?(Projection) ? Array(src.select).compact : []
39
+ pctx.emit.call(:build_completed, key: @key, envelope: envelope, sources: selects)
40
+
41
+ { kind: :built, value: { "key" => @key, "path" => target_path, "published_to" => publish_to } }
42
+ end
43
+
44
+ KIND = :derived
45
+
46
+ def self.from_raw(common, raw)
47
+ source = Parser.parse_source(raw, common[:key])
48
+ new(
49
+ source: source,
50
+ template: raw["template"],
51
+ inject_boot: raw["inject_boot"] == true,
52
+ events: raw["events"] || {},
53
+ **common,
54
+ )
55
+ end
56
+
57
+ Entry::REGISTRY[KIND] = self
22
58
  end
23
59
  end
24
60
  end
@@ -2,17 +2,29 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
4
  class Intake < Base
5
- attr_reader :handler, :config, :events, :publish_to
5
+ attr_reader :handler, :config, :events
6
6
 
7
7
  def initialize(handler:, config: {}, events: {}, **rest)
8
8
  super(**rest)
9
9
  @handler = handler
10
10
  @config = config || {}
11
11
  @events = events || {}
12
- @publish_to = []
13
12
  end
14
13
 
15
- def intake? = true
14
+ def intake? = true
15
+ def nested? = !!@raw["nested"]
16
+
17
+ KIND = :intake
18
+
19
+ def self.from_raw(common, raw)
20
+ intake = raw["intake"] || {}
21
+ handler = intake["handler"] || raw["intake_handler"] or
22
+ raise UsageError.new("intake entry '#{common[:key]}' missing handler")
23
+ config = intake["config"] || raw["intake_config"] || {}
24
+ new(handler: handler, config: config, events: raw["events"] || {}, **common)
25
+ end
26
+
27
+ Entry::REGISTRY[KIND] = self
16
28
  end
17
29
  end
18
30
  end
@@ -2,14 +2,15 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
4
  class Leaf < Base
5
- attr_reader :publish_to
5
+ KIND = :leaf
6
6
 
7
- def initialize(publish_to: [], **rest)
8
- super(**rest)
9
- @publish_to = Array(publish_to)
7
+ def leaf? = true
8
+
9
+ def self.from_raw(common, _raw)
10
+ new(**common)
10
11
  end
11
12
 
12
- def leaf? = true
13
+ Entry::REGISTRY[KIND] = self
13
14
  end
14
15
  end
15
16
  end
@@ -7,13 +7,12 @@ module Textus
7
7
  PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
8
8
  PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
9
9
 
10
- attr_reader :index_filename, :publish_each, :publish_to
10
+ attr_reader :index_filename, :publish_each
11
11
 
12
- def initialize(index_filename: nil, publish_each: nil, publish_to: [], **rest)
12
+ def initialize(index_filename: nil, publish_each: nil, **rest)
13
13
  super(**rest)
14
14
  @index_filename = index_filename
15
15
  @publish_each = publish_each
16
- @publish_to = Array(publish_to)
17
16
  end
18
17
 
19
18
  def nested? = true
@@ -33,6 +32,46 @@ module Textus
33
32
  vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
34
33
  @publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
35
34
  end
35
+
36
+ def publish_via(pctx, prefix: nil)
37
+ return nil if @publish_each.nil?
38
+
39
+ leaves = []
40
+ @manifest.resolver.enumerate(prefix: @key).each do |row|
41
+ next unless row[:manifest_entry].equal?(self)
42
+ next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
43
+
44
+ target_rel = publish_target_for(row[:key])
45
+ target_abs = File.expand_path(File.join(pctx.repo_root, target_rel))
46
+ unless target_abs.start_with?(File.expand_path(pctx.repo_root) + File::SEPARATOR)
47
+ raise Textus::PublishError.new(
48
+ "entry '#{@key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
49
+ )
50
+ end
51
+
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)
58
+ leaves << { "key" => row[:key], "source" => row[:path], "target" => target_abs }
59
+ end
60
+
61
+ { kind: :leaves, value: leaves }
62
+ end
63
+
64
+ KIND = :nested
65
+
66
+ def self.from_raw(common, raw)
67
+ new(
68
+ index_filename: raw["index_filename"],
69
+ publish_each: raw["publish_each"],
70
+ **common,
71
+ )
72
+ end
73
+
74
+ Entry::REGISTRY[KIND] = self
36
75
  end
37
76
  end
38
77
  end
@@ -9,7 +9,7 @@ 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
- raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (leaf|nested|derived|intake)")
12
+ raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (#{Entry::REGISTRY.keys.join("|")})")
13
13
  kind = raw_kind.to_sym
14
14
  format = resolve_format(raw, path)
15
15
 
@@ -17,60 +17,18 @@ module Textus
17
17
  manifest: manifest, raw: raw,
18
18
  key: key, path: path, zone: zone,
19
19
  schema: raw["schema"], owner: raw["owner"],
20
- format: format
20
+ format: format,
21
+ publish_to: raw["publish_to"]
21
22
  }
22
23
 
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"],
39
- publish_each: raw["publish_each"],
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"],
50
- inject_intro: raw["inject_intro"] == true,
51
- publish_to: raw["publish_to"],
52
- events: raw["events"] || {},
53
- **common,
54
- )
55
- end
56
-
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)
24
+ klass = Entry::REGISTRY[kind] or
25
+ raise BadManifest.new("entry '#{key}': unknown kind: #{kind.inspect} (known: #{Entry::REGISTRY.keys.join(", ")})")
26
+ klass.from_raw(common, raw)
63
27
  end
64
28
 
65
29
  def self.parse_source(raw, key)
66
30
  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"]
71
-
72
- raise BadManifest.new("derived entry '#{key}' requires compute: { kind: projection|external } or template:")
73
- end
31
+ raise BadManifest.new("derived entry '#{key}' requires compute: { kind: projection|external } or template:") if compute.nil?
74
32
 
75
33
  unless COMPUTE_KINDS.include?(compute["kind"])
76
34
  raise BadManifest.new(
@@ -79,14 +37,14 @@ module Textus
79
37
  end
80
38
 
81
39
  if compute["kind"] == "projection"
82
- Derived::Projection.new(
40
+ Entry::Derived::Projection.new(
83
41
  select: compute["select"],
84
42
  pluck: compute["pluck"],
85
43
  sort_by: compute["sort_by"],
86
44
  transform: compute["transform"],
87
45
  )
88
46
  else
89
- Derived::External.new(sources: compute["sources"], runner: compute["runner"])
47
+ Entry::Derived::External.new(sources: compute["sources"], runner: compute["runner"])
90
48
  end
91
49
  end
92
50
 
@@ -5,7 +5,7 @@ module Textus
5
5
  module Events
6
6
  def self.call(entry)
7
7
  pubsub_events = Textus::Hooks::Bus::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
8
- events = entry.respond_to?(:events) ? entry.events : {}
8
+ events = entry.events
9
9
  events.each_key do |evt|
10
10
  next if pubsub_events.include?(evt.to_sym)
11
11
 
@@ -14,12 +14,13 @@ module Textus
14
14
  raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
15
15
  end
16
16
 
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 &&
17
+ has_template = !entry.template.nil?
18
+ is_external = entry.derived? && entry.external?
19
+ is_intake = entry.intake?
20
+ return unless entry.in_generator_zone? && !has_template && !is_external && !is_intake &&
20
21
  %w[markdown text].include?(entry.format) && !entry.nested?
21
22
 
22
- raise UsageError.new("entry '#{entry.key}': derived #{entry.format} entries require a template")
23
+ raise UsageError.new("entry '#{entry.key}': #{entry.format} entries in a generator zone require a template")
23
24
  end
24
25
  end
25
26
  end
@@ -4,7 +4,8 @@ module Textus
4
4
  module Validators
5
5
  module IndexFilename
6
6
  def self.call(entry)
7
- index_filename = entry.respond_to?(:index_filename) ? entry.index_filename : entry.raw["index_filename"]
7
+ # Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
8
+ index_filename = entry.nested? ? entry.index_filename : entry.raw["index_filename"]
8
9
  return if index_filename.nil?
9
10
 
10
11
  check_shape!(entry, index_filename)
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ module InjectBoot
6
+ def self.call(entry)
7
+ return unless entry.inject_boot
8
+
9
+ raise UsageError.new("entry '#{entry.key}': inject_boot: is only valid on derived entries") unless entry.in_generator_zone?
10
+
11
+ return unless entry.template.nil?
12
+
13
+ raise UsageError.new("entry '#{entry.key}': inject_boot: requires a template:")
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -7,13 +7,14 @@ 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) # rubocop:disable Metrics/AbcSize
11
- publish_each = entry.respond_to?(:publish_each) ? entry.publish_each : entry.raw["publish_each"]
10
+ def self.call(entry)
11
+ # Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
12
+ publish_each = entry.nested? ? entry.publish_each : entry.raw["publish_each"]
12
13
  return if publish_each.nil?
13
14
 
14
15
  raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested?
15
16
 
16
- publish_to = entry.respond_to?(:publish_to) ? entry.publish_to : Array(entry.raw["publish_to"])
17
+ publish_to = entry.publish_to
17
18
  raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless publish_to.empty?
18
19
  raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless publish_each.is_a?(String)
19
20
 
@@ -5,7 +5,7 @@ module Textus
5
5
  REGISTERED = [
6
6
  Events,
7
7
  PublishEach,
8
- InjectIntro,
8
+ InjectBoot,
9
9
  IndexFilename,
10
10
  FormatMatrix,
11
11
  ].freeze
@@ -5,6 +5,9 @@ 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
+ # Populated by each Entry::* subclass at load time.
10
+ REGISTRY = {}
8
11
  end
9
12
  end
10
13
  end