textus 0.54.0 → 0.54.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.
data/lib/textus/layout.rb CHANGED
@@ -1,9 +1,9 @@
1
1
  module Textus
2
2
  # Single source of truth for every path textus owns under a store root.
3
- # All disposable runtime state nests under <root>/.run/ so the
3
+ # All disposable runtime state nests under <root>/.state/ so the
4
4
  # tracked/disposable boundary is a directory boundary. ADR 0038.
5
5
  module Layout
6
- RUN = ".run"
6
+ RUN = ".state"
7
7
  DATA = "data"
8
8
 
9
9
  def self.data(root)
@@ -18,12 +18,12 @@ module Textus
18
18
  File.join(root, RUN)
19
19
  end
20
20
 
21
- def self.state(root)
22
- File.join(run(root), "state")
21
+ def self.cursors(root)
22
+ File.join(run(root), "cursors")
23
23
  end
24
24
 
25
25
  def self.cursor(root, role)
26
- File.join(state(root), "cursor.#{role}")
26
+ File.join(cursors(root), role.to_s)
27
27
  end
28
28
 
29
29
  def self.locks(root)
@@ -51,19 +51,27 @@ module Textus
51
51
  end
52
52
 
53
53
  # Sentinels are machine-generated (the published target's sha), not authored
54
- # source, so they live on the runtime side under `.run/` — git-ignored,
54
+ # source, so they live on the runtime side under `.state/` — git-ignored,
55
55
  # regenerated by the next build via content-identical adoption (ADR 0070,
56
56
  # superseding ADR 0038's `:config` classification).
57
57
  def self.sentinels(root)
58
58
  File.join(run(root), "sentinels")
59
59
  end
60
60
 
61
+ def self.indexes(root)
62
+ File.join(run(root), "indexes")
63
+ end
64
+
65
+ def self.raw_index(root)
66
+ File.join(indexes(root), "raw.yaml")
67
+ end
68
+
61
69
  def self.audit_log(root)
62
70
  File.join(audit_dir(root), "audit.log")
63
71
  end
64
72
 
65
73
  # The store's `.gitignore` body. Always ignores the runtime subtree
66
- # (`.run/`, ADR 0038); when given untracked entry paths (entries marked
74
+ # (`.state/`, ADR 0038); when given untracked entry paths (entries marked
67
75
  # `tracked: false`), it also lists those so they stay protocol-readable but
68
76
  # uncommitted (ADR 0043, refining 0038). Generated, never hand-kept — no
69
77
  # drift between the manifest and the ignore file.
@@ -45,11 +45,8 @@ module Textus
45
45
 
46
46
  private
47
47
 
48
- # A structured-data entry that textus owns: its `_meta` stays in the
49
- # store, so the published file is the re-serialized meta-free content.
50
- # An external (command) entry is opaque — never parse/re-serialize it.
51
48
  def strip_meta?(entry)
52
- !entry.external? && %w[json yaml].include?(entry.format.to_s)
49
+ %w[json yaml].include?(entry.format.to_s)
53
50
  end
54
51
 
55
52
  def render_bytes(target, content, renderer, pctx)
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/schema"
4
+
5
+ module Textus
6
+ class Manifest
7
+ module Schema
8
+ # rubocop:disable Metrics/BlockLength
9
+ Contract = Dry::Schema.JSON do
10
+ required(:lanes).value(:array).each do
11
+ hash do
12
+ required(:name).value(:string)
13
+ required(:kind).value(included_in?: Vocabulary::LANE_KINDS)
14
+ optional(:owner).value(:string)
15
+ optional(:desc).value(:string)
16
+ end
17
+ end
18
+
19
+ optional(:roles).value(:array).each do
20
+ hash do
21
+ required(:name).value(:string)
22
+ optional(:can).value(:array).each(:string)
23
+ end
24
+ end
25
+
26
+ optional(:entries).value(:array).each do
27
+ hash do
28
+ required(:key).value(:string)
29
+ required(:lane).value(:string)
30
+ optional(:path).value(:string)
31
+ optional(:owner).value(:string)
32
+ optional(:format).value(:string)
33
+ optional(:schema).maybe(:string)
34
+ optional(:kind).value(:string)
35
+ optional(:nested).value(:bool)
36
+ optional(:tracked).value(:bool)
37
+ optional(:ignore)
38
+ optional(:source)
39
+ optional(:publish).value(:array)
40
+ end
41
+ end
42
+
43
+ optional(:rules).value(:array).each do
44
+ hash do
45
+ optional(:match).value(:string)
46
+ optional(:guard)
47
+ optional(:retention)
48
+ optional(:react)
49
+ end
50
+ end
51
+
52
+ optional(:audit).hash do
53
+ optional(:max_size).value(:integer)
54
+ optional(:keep).value(:integer)
55
+ end
56
+ optional(:version).value(:string)
57
+ end
58
+ # rubocop:enable Metrics/BlockLength
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Manifest
5
+ module Schema
6
+ # Cross-field rules and ADR migration hints. Called by Validator.validate!
7
+ # AFTER the structural dry-schema Contract passes. Operates on the raw hash.
8
+ module Semantics
9
+ module_function
10
+
11
+ def check!(raw)
12
+ check_roles!(raw["roles"])
13
+ check_lanes!(raw["lanes"])
14
+ check_entries!(raw["entries"])
15
+ check_owners!(raw["lanes"], raw["entries"])
16
+ check_rules!(raw["rules"])
17
+ check_single_queue!(raw)
18
+ check_single_machine!(raw)
19
+ check_lane_kind_consistency!(raw)
20
+ walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
21
+ end
22
+
23
+ def check_roles!(roles)
24
+ return if roles.nil?
25
+
26
+ roles.each_with_index do |r, i|
27
+ path = "$.roles[#{i}]"
28
+ name = r["name"]
29
+ unless Textus::Role::NAMES.include?(name)
30
+ raise BadManifest.new(
31
+ "unknown role name '#{name}' at '#{path}' (allowed: #{Textus::Role::NAMES.join(", ")})",
32
+ )
33
+ end
34
+ Array(r["can"]).each do |verb|
35
+ next if CAPABILITIES.include?(verb)
36
+
37
+ hint = %w[ingest fetch].include?(verb) ? " — the quarantine capability folded into 'converge' (ADR 0090)" : ""
38
+ raise BadManifest.new(
39
+ "unknown capability '#{verb}' for role '#{name}' at '#{path}' " \
40
+ "(known: #{CAPABILITIES.join(", ")})#{hint}",
41
+ )
42
+ end
43
+ end
44
+
45
+ author_holders = roles.count { |r| Array(r["can"]).include?("author") }
46
+ return if author_holders <= 1
47
+
48
+ raise BadManifest.new(
49
+ "manifest declares #{author_holders} roles with the author capability; at most one is allowed",
50
+ )
51
+ end
52
+
53
+ def check_lanes!(lanes)
54
+ Array(lanes).each_with_index do |z, i|
55
+ walk(z, LANE_KEYS, "$.lanes[#{i}]")
56
+ next unless %w[quarantine derived].include?(z["kind"])
57
+
58
+ raise BadManifest.new(
59
+ "lane kind '#{z["kind"]}' at '$.lanes[#{i}]' was folded into 'machine' (ADR 0091) — " \
60
+ "use `kind: machine`",
61
+ )
62
+ end
63
+ end
64
+
65
+ def check_entries!(entries)
66
+ Array(entries).each_with_index do |e, i|
67
+ path = "$.entries[#{i}]"
68
+ check_retired_publish_keys!(e, path)
69
+ check_retired_render_keys!(e, path)
70
+ walk(e, ENTRY_KEYS, path)
71
+ check_publish_block!(e, path)
72
+ walk(e["source"], SOURCE_KEYS, "#{path}.source") if e.is_a?(Hash) && e["source"].is_a?(Hash)
73
+ end
74
+ end
75
+
76
+ def check_retired_publish_keys!(entry, path)
77
+ return unless entry.is_a?(Hash)
78
+
79
+ if entry.key?("publish_each")
80
+ raise BadManifest.new(
81
+ "publish_each was removed in 0.42.0 (ADR 0051) at '#{path}' — " \
82
+ "mirror the subtree with `publish: { tree: \"...\" }`.",
83
+ )
84
+ end
85
+ if entry.key?("publish_to")
86
+ raise BadManifest.new(
87
+ "publish_to was replaced by the publish: block in 0.43.0 (ADR 0052) at '#{path}' — " \
88
+ "use `publish: { to: [...] }`.",
89
+ )
90
+ end
91
+ if entry.key?("publish_tree")
92
+ raise BadManifest.new(
93
+ "publish_tree was replaced by the publish: block in 0.43.0 (ADR 0052) at '#{path}' — " \
94
+ "use `publish: { tree: \"...\" }`.",
95
+ )
96
+ end
97
+ return unless entry.key?("index_filename")
98
+
99
+ raise BadManifest.new(
100
+ "index_filename was removed in 0.43.0 (ADR 0053) at '#{path}'.",
101
+ )
102
+ end
103
+
104
+ def check_retired_render_keys!(entry, path)
105
+ return unless entry.is_a?(Hash)
106
+
107
+ if entry.key?("template")
108
+ raise BadManifest.new(
109
+ "entry-level `template:` was removed at '#{path}' (ADR 0094): rendering is a " \
110
+ "publish concern — `publish: [{ to:, template: }]`.",
111
+ )
112
+ end
113
+ if entry.key?("inject_boot")
114
+ raise BadManifest.new(
115
+ "entry-level `inject_boot:` was removed at '#{path}' (ADR 0094).",
116
+ )
117
+ end
118
+ return unless entry.key?("provenance")
119
+
120
+ raise BadManifest.new("entry-level `provenance:` was removed at '#{path}' (ADR 0094).")
121
+ end
122
+
123
+ def check_publish_block!(entry, path)
124
+ return unless entry.is_a?(Hash) && entry.key?("publish")
125
+
126
+ block = entry["publish"]
127
+ if block.is_a?(Hash)
128
+ raise BadManifest.new(
129
+ "publish: at '#{path}.publish' must be a list of targets (ADR 0094); the map form was retired.",
130
+ )
131
+ end
132
+ raise BadManifest.new("publish: must be a list of targets at '#{path}.publish'") unless block.is_a?(Array)
133
+
134
+ block.each_with_index do |t, i|
135
+ raise BadManifest.new("publish target ##{i} must be a mapping at '#{path}.publish'") unless t.is_a?(Hash)
136
+
137
+ walk(t, %w[to tree template inject_boot], "#{path}.publish[#{i}]")
138
+ end
139
+ end
140
+
141
+ def check_owners!(lanes, entries)
142
+ Array(lanes).each_with_index { |z, i| check_owner!(z["owner"], "$.lanes[#{i}]") }
143
+ Array(entries).each_with_index { |e, i| check_owner!(e["owner"], "$.entries[#{i}]") }
144
+ end
145
+
146
+ def check_owner!(owner, path)
147
+ return if owner.nil?
148
+ return if valid_owner?(owner)
149
+
150
+ raise BadManifest.new(
151
+ "invalid owner '#{owner}' at '#{path}' " \
152
+ "(expected <archetype> or <archetype>:<subject>, archetype one of: #{Textus::Role::NAMES.join(", ")})",
153
+ )
154
+ end
155
+
156
+ def valid_owner?(token)
157
+ return false unless token.is_a?(String) && !token.empty?
158
+
159
+ archetype, subject = token.split(":", 2)
160
+ return false unless Textus::Role::NAMES.include?(archetype)
161
+ return true if subject.nil?
162
+
163
+ OWNER_SUBJECT_PATTERN.match?(subject)
164
+ end
165
+
166
+ def check_rules!(rules)
167
+ Array(rules).each_with_index do |r, i|
168
+ path = "$.rules[#{i}]"
169
+ # Check retired keys BEFORE the generic walk so specific hints fire first.
170
+ { "lifecycle" => "age GC moved to `retention:` rule", "materialize" => "removed (ADR 0093)" }
171
+ .each do |old, hint|
172
+ next unless r.is_a?(Hash) && r.key?(old)
173
+
174
+ raise BadManifest.new("`#{old}:` was removed at '#{path}' (ADR 0093) — #{hint}.")
175
+ end
176
+ if r.is_a?(Hash) && r.key?("upkeep")
177
+ raise BadManifest.new(
178
+ "rule key `upkeep:` was removed (ADR 0093): move age-GC to `retention:` " \
179
+ "and production to the entry's `source:`",
180
+ )
181
+ end
182
+ walk(r, RULE_KEYS, path)
183
+ FIELD_REGISTRY.each_value do |meta|
184
+ next unless meta[:sub_keys]
185
+
186
+ value = r.is_a?(Hash) ? r[meta[:yaml_key]] : nil
187
+ walk(value, meta[:sub_keys], "#{path}.#{meta[:yaml_key]}") if value.is_a?(Hash)
188
+ end
189
+ end
190
+ end
191
+
192
+ def check_single_queue!(raw)
193
+ queues = Array(raw["lanes"]).select { |z| z["kind"] == "queue" }.map { |z| z["name"] }
194
+ return if queues.size <= 1
195
+
196
+ raise BadManifest.new("at most one lane may declare kind: queue (found: #{queues.join(", ")})")
197
+ end
198
+
199
+ def check_single_machine!(raw)
200
+ machines = Array(raw["lanes"]).select { |z| z["kind"] == "machine" }.map { |z| z["name"] }
201
+ return if machines.size <= 1
202
+
203
+ raise BadManifest.new("at most one lane may declare kind: machine (found: #{machines.join(", ")})")
204
+ end
205
+
206
+ def check_lane_kind_consistency!(raw)
207
+ held = Capabilities.resolve(raw["roles"]).values.flatten.uniq
208
+
209
+ Array(raw["lanes"]).each_with_index do |z, i|
210
+ verb = KIND_REQUIRES_VERB[z["kind"]]
211
+ next if verb.nil? || held.include?(verb)
212
+
213
+ raise BadManifest.new(
214
+ "lane '#{z["name"]}' (#{z["kind"]}) at '$.lanes[#{i}]' " \
215
+ "needs a role with capability '#{verb}'; none declared",
216
+ )
217
+ end
218
+ end
219
+
220
+ def walk(hash, allowed, path)
221
+ return unless hash.is_a?(Hash)
222
+
223
+ hash.each_key do |k|
224
+ next if allowed.include?(k)
225
+
226
+ raise BadManifest.new("unknown key '#{k}' at '#{path}'")
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end