Module: Textus::Manifest::Schema::Semantics

Defined in:
lib/textus/manifest/schema/semantics.rb

Overview

Cross-field rules and ADR migration hints. Called by Validator.validate! AFTER the structural dry-schema Contract passes. Operates on the raw hash.

Class Method Summary collapse

Class Method Details

.check!(raw) ⇒ Object



11
12
13
14
15
16
17
18
19
20
21
# File 'lib/textus/manifest/schema/semantics.rb', line 11

def check!(raw)
  check_roles!(raw["roles"])
  check_lanes!(raw["lanes"])
  check_entries!(raw["entries"])
  check_owners!(raw["lanes"], raw["entries"])
  check_rules!(raw["rules"])
  check_single_queue!(raw)
  check_single_machine!(raw)
  check_lane_kind_consistency!(raw)
  walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
end

.check_entries!(entries) ⇒ Object



65
66
67
68
69
70
71
72
73
74
# File 'lib/textus/manifest/schema/semantics.rb', line 65

def check_entries!(entries)
  Array(entries).each_with_index do |e, i|
    path = "$.entries[#{i}]"
    check_retired_publish_keys!(e, path)
    check_retired_render_keys!(e, path)
    walk(e, ENTRY_KEYS, path)
    check_publish_block!(e, path)
    walk(e["source"], SOURCE_KEYS, "#{path}.source") if e.is_a?(Hash) && e["source"].is_a?(Hash)
  end
end

.check_lane_kind_consistency!(raw) ⇒ Object



206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/textus/manifest/schema/semantics.rb', line 206

def check_lane_kind_consistency!(raw)
  held = Capabilities.resolve(raw["roles"]).values.flatten.uniq

  Array(raw["lanes"]).each_with_index do |z, i|
    verb = KIND_REQUIRES_VERB[z["kind"]]
    next if verb.nil? || held.include?(verb)

    raise BadManifest.new(
      "lane '#{z["name"]}' (#{z["kind"]}) at '$.lanes[#{i}]' " \
      "needs a role with capability '#{verb}'; none declared",
    )
  end
end

.check_lanes!(lanes) ⇒ Object



53
54
55
56
57
58
59
60
61
62
63
# File 'lib/textus/manifest/schema/semantics.rb', line 53

def check_lanes!(lanes)
  Array(lanes).each_with_index do |z, i|
    walk(z, LANE_KEYS, "$.lanes[#{i}]")
    next unless %w[quarantine derived].include?(z["kind"])

    raise BadManifest.new(
      "lane kind '#{z["kind"]}' at '$.lanes[#{i}]' was folded into 'machine' (ADR 0091) — " \
      "use `kind: machine`",
    )
  end
end

.check_owner!(owner, path) ⇒ Object

Raises:



146
147
148
149
150
151
152
153
154
# File 'lib/textus/manifest/schema/semantics.rb', line 146

def check_owner!(owner, path)
  return if owner.nil?
  return if valid_owner?(owner)

  raise BadManifest.new(
    "invalid owner '#{owner}' at '#{path}' " \
    "(expected <archetype> or <archetype>:<subject>, archetype one of: #{Textus::Role::NAMES.join(", ")})",
  )
end

.check_owners!(lanes, entries) ⇒ Object



141
142
143
144
# File 'lib/textus/manifest/schema/semantics.rb', line 141

def check_owners!(lanes, entries)
  Array(lanes).each_with_index { |z, i| check_owner!(z["owner"], "$.lanes[#{i}]") }
  Array(entries).each_with_index { |e, i| check_owner!(e["owner"], "$.entries[#{i}]") }
end

.check_publish_block!(entry, path) ⇒ Object

Raises:



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/textus/manifest/schema/semantics.rb', line 123

def check_publish_block!(entry, path)
  return unless entry.is_a?(Hash) && entry.key?("publish")

  block = entry["publish"]
  if block.is_a?(Hash)
    raise BadManifest.new(
      "publish: at '#{path}.publish' must be a list of targets (ADR 0094); the map form was retired.",
    )
  end
  raise BadManifest.new("publish: must be a list of targets at '#{path}.publish'") unless block.is_a?(Array)

  block.each_with_index do |t, i|
    raise BadManifest.new("publish target ##{i} must be a mapping at '#{path}.publish'") unless t.is_a?(Hash)

    walk(t, %w[to tree template inject_boot], "#{path}.publish[#{i}]")
  end
end

.check_retired_publish_keys!(entry, path) ⇒ Object

Raises:



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/textus/manifest/schema/semantics.rb', line 76

def check_retired_publish_keys!(entry, path)
  return unless entry.is_a?(Hash)

  if entry.key?("publish_each")
    raise BadManifest.new(
      "publish_each was removed in 0.42.0 (ADR 0051) at '#{path}' — " \
      "mirror the subtree with `publish: { tree: \"...\" }`.",
    )
  end
  if entry.key?("publish_to")
    raise BadManifest.new(
      "publish_to was replaced by the publish: block in 0.43.0 (ADR 0052) at '#{path}' — " \
      "use `publish: { to: [...] }`.",
    )
  end
  if entry.key?("publish_tree")
    raise BadManifest.new(
      "publish_tree was replaced by the publish: block in 0.43.0 (ADR 0052) at '#{path}' — " \
      "use `publish: { tree: \"...\" }`.",
    )
  end
  return unless entry.key?("index_filename")

  raise BadManifest.new(
    "index_filename was removed in 0.43.0 (ADR 0053) at '#{path}'.",
  )
end

.check_retired_render_keys!(entry, path) ⇒ Object

Raises:



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/textus/manifest/schema/semantics.rb', line 104

def check_retired_render_keys!(entry, path)
  return unless entry.is_a?(Hash)

  if entry.key?("template")
    raise BadManifest.new(
      "entry-level `template:` was removed at '#{path}' (ADR 0094): rendering is a " \
      "publish concern — `publish: [{ to:, template: }]`.",
    )
  end
  if entry.key?("inject_boot")
    raise BadManifest.new(
      "entry-level `inject_boot:` was removed at '#{path}' (ADR 0094).",
    )
  end
  return unless entry.key?("provenance")

  raise BadManifest.new("entry-level `provenance:` was removed at '#{path}' (ADR 0094).")
end

.check_roles!(roles) ⇒ Object

Raises:



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/textus/manifest/schema/semantics.rb', line 23

def check_roles!(roles)
  return if roles.nil?

  roles.each_with_index do |r, i|
    path = "$.roles[#{i}]"
    name = r["name"]
    unless Textus::Role::NAMES.include?(name)
      raise BadManifest.new(
        "unknown role name '#{name}' at '#{path}' (allowed: #{Textus::Role::NAMES.join(", ")})",
      )
    end
    Array(r["can"]).each do |verb|
      next if CAPABILITIES.include?(verb)

      hint = %w[ingest fetch].include?(verb) ? " — the quarantine capability folded into 'converge' (ADR 0090)" : ""
      raise BadManifest.new(
        "unknown capability '#{verb}' for role '#{name}' at '#{path}' " \
        "(known: #{CAPABILITIES.join(", ")})#{hint}",
      )
    end
  end

  author_holders = roles.count { |r| Array(r["can"]).include?("author") }
  return if author_holders <= 1

  raise BadManifest.new(
    "manifest declares #{author_holders} roles with the author capability; at most one is allowed",
  )
end

.check_rules!(rules) ⇒ Object



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/textus/manifest/schema/semantics.rb', line 166

def check_rules!(rules)
  Array(rules).each_with_index do |r, i|
    path = "$.rules[#{i}]"
    # Check retired keys BEFORE the generic walk so specific hints fire first.
    { "lifecycle" => "age GC moved to `retention:` rule", "materialize" => "removed (ADR 0093)" }
      .each do |old, hint|
        next unless r.is_a?(Hash) && r.key?(old)

        raise BadManifest.new("`#{old}:` was removed at '#{path}' (ADR 0093) — #{hint}.")
      end
    if r.is_a?(Hash) && r.key?("upkeep")
      raise BadManifest.new(
        "rule key `upkeep:` was removed (ADR 0093): move age-GC to `retention:` " \
        "and production to the entry's `source:`",
      )
    end
    walk(r, RULE_KEYS, path)
    FIELD_REGISTRY.each_value do |meta|
      next unless meta[:sub_keys]

      value = r.is_a?(Hash) ? r[meta[:yaml_key]] : nil
      walk(value, meta[:sub_keys], "#{path}.#{meta[:yaml_key]}") if value.is_a?(Hash)
    end
  end
end

.check_single_machine!(raw) ⇒ Object

Raises:



199
200
201
202
203
204
# File 'lib/textus/manifest/schema/semantics.rb', line 199

def check_single_machine!(raw)
  machines = Array(raw["lanes"]).select { |z| z["kind"] == "machine" }.map { |z| z["name"] }
  return if machines.size <= 1

  raise BadManifest.new("at most one lane may declare kind: machine (found: #{machines.join(", ")})")
end

.check_single_queue!(raw) ⇒ Object

Raises:



192
193
194
195
196
197
# File 'lib/textus/manifest/schema/semantics.rb', line 192

def check_single_queue!(raw)
  queues = Array(raw["lanes"]).select { |z| z["kind"] == "queue" }.map { |z| z["name"] }
  return if queues.size <= 1

  raise BadManifest.new("at most one lane may declare kind: queue (found: #{queues.join(", ")})")
end

.valid_owner?(token) ⇒ Boolean

Returns:

  • (Boolean)


156
157
158
159
160
161
162
163
164
# File 'lib/textus/manifest/schema/semantics.rb', line 156

def valid_owner?(token)
  return false unless token.is_a?(String) && !token.empty?

  archetype, subject = token.split(":", 2)
  return false unless Textus::Role::NAMES.include?(archetype)
  return true if subject.nil?

  OWNER_SUBJECT_PATTERN.match?(subject)
end

.walk(hash, allowed, path) ⇒ Object



220
221
222
223
224
225
226
227
228
# File 'lib/textus/manifest/schema/semantics.rb', line 220

def walk(hash, allowed, path)
  return unless hash.is_a?(Hash)

  hash.each_key do |k|
    next if allowed.include?(k)

    raise BadManifest.new("unknown key '#{k}' at '#{path}'")
  end
end