textus 0.54.2 → 0.55.1

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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +8 -1
  4. data/SPEC.md +27 -0
  5. data/docs/architecture/README.md +20 -8
  6. data/docs/reference/conventions.md +1 -1
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +23 -21
  9. data/lib/textus/action/audit.rb +24 -61
  10. data/lib/textus/action/base.rb +9 -9
  11. data/lib/textus/action/blame.rb +18 -36
  12. data/lib/textus/action/boot.rb +2 -4
  13. data/lib/textus/action/data_mv.rb +20 -31
  14. data/lib/textus/action/deps.rb +3 -18
  15. data/lib/textus/action/doctor.rb +2 -9
  16. data/lib/textus/action/drain.rb +11 -19
  17. data/lib/textus/action/enqueue.rb +14 -30
  18. data/lib/textus/action/get.rb +12 -56
  19. data/lib/textus/action/ingest.rb +74 -78
  20. data/lib/textus/action/jobs.rb +6 -15
  21. data/lib/textus/action/key_delete.rb +6 -16
  22. data/lib/textus/action/key_delete_prefix.rb +8 -17
  23. data/lib/textus/action/key_mv.rb +54 -61
  24. data/lib/textus/action/key_mv_prefix.rb +13 -22
  25. data/lib/textus/action/list.rb +7 -21
  26. data/lib/textus/action/propose.rb +16 -26
  27. data/lib/textus/action/published.rb +3 -5
  28. data/lib/textus/action/pulse.rb +19 -26
  29. data/lib/textus/action/put.rb +15 -29
  30. data/lib/textus/action/rdeps.rb +3 -18
  31. data/lib/textus/action/reject.rb +12 -21
  32. data/lib/textus/action/rule_explain.rb +12 -22
  33. data/lib/textus/action/rule_lint.rb +10 -16
  34. data/lib/textus/action/rule_list.rb +5 -9
  35. data/lib/textus/action/schema_envelope.rb +3 -10
  36. data/lib/textus/action/uid.rb +3 -17
  37. data/lib/textus/action/where.rb +3 -18
  38. data/lib/textus/boot.rb +7 -15
  39. data/lib/textus/contract/arg.rb +10 -0
  40. data/lib/textus/contract/dsl.rb +88 -0
  41. data/lib/textus/contract/spec.rb +25 -0
  42. data/lib/textus/contract.rb +0 -162
  43. data/lib/textus/doctor/check/audit_log.rb +2 -2
  44. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  45. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  46. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  47. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  48. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  49. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  50. data/lib/textus/doctor/check/schemas.rb +1 -1
  51. data/lib/textus/doctor/check/sentinels.rb +4 -4
  52. data/lib/textus/doctor/check/templates.rb +1 -1
  53. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  54. data/lib/textus/doctor/check.rb +4 -7
  55. data/lib/textus/doctor.rb +1 -1
  56. data/lib/textus/errors.rb +6 -0
  57. data/lib/textus/format/base.rb +0 -4
  58. data/lib/textus/format/json.rb +5 -6
  59. data/lib/textus/format/markdown.rb +5 -6
  60. data/lib/textus/format/shared.rb +17 -0
  61. data/lib/textus/format/text.rb +5 -4
  62. data/lib/textus/format/yaml.rb +30 -6
  63. data/lib/textus/format.rb +6 -0
  64. data/lib/textus/gate/auth.rb +2 -17
  65. data/lib/textus/gate/binder.rb +50 -0
  66. data/lib/textus/gate.rb +64 -88
  67. data/lib/textus/init.rb +2 -4
  68. data/lib/textus/jobs.rb +3 -9
  69. data/lib/textus/manifest/capabilities.rb +3 -3
  70. data/lib/textus/manifest/entry/base.rb +1 -1
  71. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  72. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  73. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  74. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  75. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  76. data/lib/textus/manifest/schema/semantics.rb +11 -216
  77. data/lib/textus/meta.rb +54 -0
  78. data/lib/textus/{ports → port}/audit_log.rb +44 -4
  79. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  80. data/lib/textus/{ports → port}/clock.rb +1 -1
  81. data/lib/textus/{ports → port}/publisher.rb +5 -5
  82. data/lib/textus/{ports → port}/sentinel_store.rb +3 -3
  83. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  84. data/lib/textus/{ports → port}/storage/file_store.rb +2 -2
  85. data/lib/textus/port/store.rb +93 -0
  86. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  87. data/lib/textus/produce/engine.rb +1 -1
  88. data/lib/textus/schema/tools.rb +11 -7
  89. data/lib/textus/store/compositor.rb +34 -0
  90. data/lib/textus/store/container.rb +43 -0
  91. data/lib/textus/store/cursor.rb +26 -0
  92. data/lib/textus/store/envelope/reader.rb +43 -0
  93. data/lib/textus/store/envelope/writer.rb +195 -0
  94. data/lib/textus/store/geometry.rb +81 -0
  95. data/lib/textus/store/index/builder.rb +74 -0
  96. data/lib/textus/store/index/lookup.rb +60 -0
  97. data/lib/textus/store/jobs/base.rb +13 -0
  98. data/lib/textus/store/jobs/index.rb +15 -0
  99. data/lib/textus/store/jobs/materialize.rb +15 -0
  100. data/lib/textus/store/jobs/plan.rb +11 -0
  101. data/lib/textus/store/jobs/planner.rb +104 -0
  102. data/lib/textus/store/jobs/queue.rb +154 -0
  103. data/lib/textus/store/jobs/registry.rb +19 -0
  104. data/lib/textus/store/jobs/retention.rb +50 -0
  105. data/lib/textus/store/jobs/sweep.rb +21 -0
  106. data/lib/textus/store/jobs/worker.rb +64 -0
  107. data/lib/textus/store/session.rb +37 -0
  108. data/lib/textus/store.rb +21 -13
  109. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  110. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  111. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  112. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  113. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  114. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  115. data/lib/textus/{surfaces → surface}/cli/runner.rb +10 -63
  116. data/lib/textus/surface/cli/sources.rb +41 -0
  117. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +4 -6
  118. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +4 -4
  119. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  120. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  121. data/lib/textus/{surfaces → surface}/cli/verb/put.rb +6 -11
  122. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  123. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  124. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  125. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  126. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  127. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  128. data/lib/textus/{surfaces → surface}/mcp/catalog.rb +9 -26
  129. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -1
  130. data/lib/textus/{surfaces → surface}/mcp/server.rb +5 -5
  131. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  132. data/lib/textus/surface/projector.rb +27 -0
  133. data/lib/textus/{surfaces → surface}/role_scope.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  135. data/lib/textus/value/call.rb +30 -0
  136. data/lib/textus/value/command.rb +16 -0
  137. data/lib/textus/value/envelope.rb +89 -0
  138. data/lib/textus/value/etag.rb +39 -0
  139. data/lib/textus/value/result.rb +26 -0
  140. data/lib/textus/value/role.rb +38 -0
  141. data/lib/textus/value/types.rb +13 -0
  142. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  143. data/lib/textus/version.rb +1 -1
  144. data/lib/textus/workflow/loader.rb +4 -4
  145. data/lib/textus/workflow/runner.rb +4 -18
  146. data/lib/textus.rb +9 -10
  147. metadata +100 -63
  148. data/lib/textus/action/write_verb.rb +0 -44
  149. data/lib/textus/call.rb +0 -28
  150. data/lib/textus/command.rb +0 -41
  151. data/lib/textus/container.rb +0 -26
  152. data/lib/textus/contract/around.rb +0 -29
  153. data/lib/textus/contract/binder.rb +0 -88
  154. data/lib/textus/contract/resources/build_lock.rb +0 -17
  155. data/lib/textus/contract/resources/cursor.rb +0 -26
  156. data/lib/textus/contract/sources.rb +0 -39
  157. data/lib/textus/contract/view.rb +0 -15
  158. data/lib/textus/cursor_store.rb +0 -24
  159. data/lib/textus/envelope/reader.rb +0 -46
  160. data/lib/textus/envelope/writer.rb +0 -209
  161. data/lib/textus/envelope.rb +0 -79
  162. data/lib/textus/etag.rb +0 -36
  163. data/lib/textus/jobs/base.rb +0 -23
  164. data/lib/textus/jobs/materialize.rb +0 -20
  165. data/lib/textus/jobs/plan.rb +0 -9
  166. data/lib/textus/jobs/planner.rb +0 -101
  167. data/lib/textus/jobs/retention.rb +0 -48
  168. data/lib/textus/jobs/sweep.rb +0 -27
  169. data/lib/textus/jobs/worker.rb +0 -67
  170. data/lib/textus/layout.rb +0 -91
  171. data/lib/textus/ports/job_store/job.rb +0 -65
  172. data/lib/textus/ports/job_store.rb +0 -123
  173. data/lib/textus/ports/raw_index.rb +0 -61
  174. data/lib/textus/role.rb +0 -36
  175. data/lib/textus/session.rb +0 -35
  176. data/lib/textus/types.rb +0 -15
@@ -11,9 +11,9 @@ module Textus
11
11
  # declares a `kind: workspace` zone is therefore rejected at load (no
12
12
  # `keep`-holder); declare `roles:` to opt into a workspace lane (ADR 0033).
13
13
  DEFAULT_MAPPING = {
14
- Textus::Role::HUMAN => %w[author propose].freeze,
15
- Textus::Role::AGENT => %w[propose].freeze,
16
- Textus::Role::AUTOMATION => %w[converge].freeze,
14
+ Textus::Value::Role::HUMAN => %w[author propose].freeze,
15
+ Textus::Value::Role::AGENT => %w[propose].freeze,
16
+ Textus::Value::Role::AUTOMATION => %w[converge].freeze,
17
17
  }.freeze
18
18
 
19
19
  # Returns { role_name => [verbs] }. When `roles:` is declared we use
@@ -75,7 +75,7 @@ module Textus
75
75
  # Read a named template from the store's templates/ directory.
76
76
  # Raises TemplateError when the file doesn't exist.
77
77
  def read_template(name)
78
- path = File.join(container.root.to_s, "templates", name)
78
+ path = container.geometry.template_path(name)
79
79
  unless File.exist?(path)
80
80
  raise Textus::TemplateError.new(
81
81
  "template '#{name}' not found",
@@ -9,7 +9,7 @@ module Textus
9
9
  # shared shape — Tree always walks at `base` and honors `ignore` in the
10
10
  # prune (ADR 0047 D4, so a derived index in the mirrored dir survives).
11
11
  class SubtreeMirror
12
- def initialize(entry, pctx, publisher: Textus::Ports::Publisher.new)
12
+ def initialize(entry, pctx, publisher: Textus::Port::Publisher.new)
13
13
  @entry = entry
14
14
  @pctx = pctx
15
15
  @publisher = publisher
@@ -52,7 +52,7 @@ module Textus
52
52
  # targets_under can't reach another leaf's sentinels.
53
53
  def prune(target_dir, written, honor_ignore)
54
54
  kept = written.map { |w| File.expand_path(w["target"]) }
55
- store = Textus::Ports::SentinelStore.new
55
+ store = Textus::Port::SentinelStore.new
56
56
  store.targets_under(target_dir, @pctx.root).filter_map do |managed|
57
57
  abs = File.expand_path(managed)
58
58
  next nil if kept.include?(abs)
@@ -9,7 +9,7 @@ module Textus
9
9
  # ADR 0094: iterates publish_targets (to-targets), rendering through a
10
10
  # template when the target declares one, or copying verbatim otherwise.
11
11
  class ToPaths < Mode
12
- def initialize(entry, publisher: Textus::Ports::Publisher.new)
12
+ def initialize(entry, publisher: Textus::Port::Publisher.new)
13
13
  super(entry)
14
14
  @publisher = publisher
15
15
  end
@@ -0,0 +1,53 @@
1
+ module Textus
2
+ class Manifest
3
+ module Schema
4
+ module Semantics
5
+ module CrossField
6
+ def check_cross_field!(raw)
7
+ check_owners!(raw["lanes"], raw["entries"])
8
+ check_lane_kind_consistency!(raw)
9
+ end
10
+
11
+ def check_owners!(lanes, entries)
12
+ Array(lanes).each_with_index { |z, i| check_owner!(z["owner"], "$.lanes[#{i}]") }
13
+ Array(entries).each_with_index { |e, i| check_owner!(e["owner"], "$.entries[#{i}]") }
14
+ end
15
+
16
+ def check_owner!(owner, path)
17
+ return if owner.nil?
18
+ return if valid_owner?(owner)
19
+
20
+ raise BadManifest.new(
21
+ "invalid owner '#{owner}' at '#{path}' " \
22
+ "(expected <archetype> or <archetype>:<subject>, archetype one of: #{Textus::Value::Role::NAMES.join(", ")})",
23
+ )
24
+ end
25
+
26
+ def valid_owner?(token)
27
+ return false unless token.is_a?(String) && !token.empty?
28
+
29
+ archetype, subject = token.split(":", 2)
30
+ return false unless Textus::Value::Role::NAMES.include?(archetype)
31
+ return true if subject.nil?
32
+
33
+ OWNER_SUBJECT_PATTERN.match?(subject)
34
+ end
35
+
36
+ def check_lane_kind_consistency!(raw)
37
+ held = Capabilities.resolve(raw["roles"]).values.flatten.uniq
38
+
39
+ Array(raw["lanes"]).each_with_index do |z, i|
40
+ verb = KIND_REQUIRES_VERB[z["kind"]]
41
+ next if verb.nil? || held.include?(verb)
42
+
43
+ raise BadManifest.new(
44
+ "lane '#{z["name"]}' (#{z["kind"]}) at '$.lanes[#{i}]' " \
45
+ "needs a role with capability '#{verb}'; none declared",
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,125 @@
1
+ module Textus
2
+ class Manifest
3
+ module Schema
4
+ module Semantics
5
+ module Invariants
6
+ def check_invariants!(raw)
7
+ check_roles!(raw["roles"])
8
+ check_lanes!(raw["lanes"])
9
+ check_entries!(raw["entries"])
10
+ check_rules!(raw["rules"])
11
+ check_single_queue!(raw)
12
+ check_single_machine!(raw)
13
+ walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
14
+ end
15
+
16
+ def check_roles!(roles)
17
+ return if roles.nil?
18
+
19
+ roles.each_with_index do |r, i|
20
+ path = "$.roles[#{i}]"
21
+ name = r["name"]
22
+ unless Textus::Value::Role::NAMES.include?(name)
23
+ raise BadManifest.new(
24
+ "unknown role name '#{name}' at '#{path}' (allowed: #{Textus::Value::Role::NAMES.join(", ")})",
25
+ )
26
+ end
27
+ Array(r["can"]).each do |verb|
28
+ next if CAPABILITIES.include?(verb)
29
+
30
+ hint = %w[ingest fetch].include?(verb) ? " — the quarantine capability folded into 'converge' (ADR 0090)" : ""
31
+ raise BadManifest.new(
32
+ "unknown capability '#{verb}' for role '#{name}' at '#{path}' " \
33
+ "(known: #{CAPABILITIES.join(", ")})#{hint}",
34
+ )
35
+ end
36
+ end
37
+
38
+ author_holders = roles.count { |r| Array(r["can"]).include?("author") }
39
+ return if author_holders <= 1
40
+
41
+ raise BadManifest.new(
42
+ "manifest declares #{author_holders} roles with the author capability; at most one is allowed",
43
+ )
44
+ end
45
+
46
+ def check_lanes!(lanes)
47
+ Array(lanes).each_with_index do |z, i|
48
+ walk(z, LANE_KEYS, "$.lanes[#{i}]")
49
+ next unless %w[quarantine derived].include?(z["kind"])
50
+
51
+ raise BadManifest.new(
52
+ "lane kind '#{z["kind"]}' at '$.lanes[#{i}]' was folded into 'machine' (ADR 0091) — " \
53
+ "use `kind: machine`",
54
+ )
55
+ end
56
+ end
57
+
58
+ def check_entries!(entries)
59
+ Array(entries).each_with_index do |e, i|
60
+ path = "$.entries[#{i}]"
61
+ walk(e, ENTRY_KEYS, path)
62
+ check_publish_block!(e, path)
63
+ walk(e["source"], SOURCE_KEYS, "#{path}.source") if e.is_a?(Hash) && e["source"].is_a?(Hash)
64
+ end
65
+ end
66
+
67
+ def check_rules!(rules)
68
+ Array(rules).each_with_index do |r, i|
69
+ path = "$.rules[#{i}]"
70
+ walk(r, RULE_KEYS, path)
71
+ FIELD_REGISTRY.each_value do |meta|
72
+ next unless meta[:sub_keys]
73
+
74
+ value = r.is_a?(Hash) ? r[meta[:yaml_key]] : nil
75
+ walk(value, meta[:sub_keys], "#{path}.#{meta[:yaml_key]}") if value.is_a?(Hash)
76
+ end
77
+ end
78
+ end
79
+
80
+ def check_publish_block!(entry, path)
81
+ return unless entry.is_a?(Hash) && entry.key?("publish")
82
+
83
+ block = entry["publish"]
84
+ if block.is_a?(Hash)
85
+ raise BadManifest.new(
86
+ "publish: at '#{path}.publish' must be a list of targets (ADR 0094); the map form was retired.",
87
+ )
88
+ end
89
+ raise BadManifest.new("publish: must be a list of targets at '#{path}.publish'") unless block.is_a?(Array)
90
+
91
+ block.each_with_index do |t, i|
92
+ raise BadManifest.new("publish target ##{i} must be a mapping at '#{path}.publish'") unless t.is_a?(Hash)
93
+
94
+ walk(t, %w[to tree template inject_boot], "#{path}.publish[#{i}]")
95
+ end
96
+ end
97
+
98
+ def check_single_queue!(raw)
99
+ queues = Array(raw["lanes"]).select { |z| z["kind"] == "queue" }.map { |z| z["name"] }
100
+ return if queues.size <= 1
101
+
102
+ raise BadManifest.new("at most one lane may declare kind: queue (found: #{queues.join(", ")})")
103
+ end
104
+
105
+ def check_single_machine!(raw)
106
+ machines = Array(raw["lanes"]).select { |z| z["kind"] == "machine" }.map { |z| z["name"] }
107
+ return if machines.size <= 1
108
+
109
+ raise BadManifest.new("at most one lane may declare kind: machine (found: #{machines.join(", ")})")
110
+ end
111
+
112
+ def walk(hash, allowed, path)
113
+ return unless hash.is_a?(Hash)
114
+
115
+ hash.each_key do |k|
116
+ next if allowed.include?(k)
117
+
118
+ raise BadManifest.new("unknown key '#{k}' at '#{path}'")
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,83 @@
1
+ module Textus
2
+ class Manifest
3
+ module Schema
4
+ module Semantics
5
+ module Migration
6
+ def check_migration!(raw)
7
+ Array(raw["entries"]).each_with_index do |e, i|
8
+ path = "$.entries[#{i}]"
9
+ check_retired_publish_keys!(e, path)
10
+ check_retired_render_keys!(e, path)
11
+ end
12
+ check_rules_retired_keys!(raw["rules"])
13
+ end
14
+
15
+ def check_retired_publish_keys!(entry, path)
16
+ return unless entry.is_a?(Hash)
17
+
18
+ if entry.key?("publish_each")
19
+ raise BadManifest.new(
20
+ "publish_each was removed in 0.42.0 (ADR 0051) at '#{path}' — " \
21
+ "mirror the subtree with `publish: { tree: \"...\" }`.",
22
+ )
23
+ end
24
+ if entry.key?("publish_to")
25
+ raise BadManifest.new(
26
+ "publish_to was replaced by the publish: block in 0.43.0 (ADR 0052) at '#{path}' — " \
27
+ "use `publish: { to: [...] }`.",
28
+ )
29
+ end
30
+ if entry.key?("publish_tree")
31
+ raise BadManifest.new(
32
+ "publish_tree was replaced by the publish: block in 0.43.0 (ADR 0052) at '#{path}' — " \
33
+ "use `publish: { tree: \"...\" }`.",
34
+ )
35
+ end
36
+ return unless entry.key?("index_filename")
37
+
38
+ raise BadManifest.new(
39
+ "index_filename was removed in 0.43.0 (ADR 0053) at '#{path}'.",
40
+ )
41
+ end
42
+
43
+ def check_retired_render_keys!(entry, path)
44
+ return unless entry.is_a?(Hash)
45
+
46
+ if entry.key?("template")
47
+ raise BadManifest.new(
48
+ "entry-level `template:` was removed at '#{path}' (ADR 0094): rendering is a " \
49
+ "publish concern — `publish: [{ to:, template: }]`.",
50
+ )
51
+ end
52
+ if entry.key?("inject_boot")
53
+ raise BadManifest.new(
54
+ "entry-level `inject_boot:` was removed at '#{path}' (ADR 0094).",
55
+ )
56
+ end
57
+ return unless entry.key?("provenance")
58
+
59
+ raise BadManifest.new("entry-level `provenance:` was removed at '#{path}' (ADR 0094).")
60
+ end
61
+
62
+ def check_rules_retired_keys!(rules)
63
+ Array(rules).each_with_index do |r, i|
64
+ path = "$.rules[#{i}]"
65
+ { "lifecycle" => "age GC moved to `retention:` rule", "materialize" => "removed (ADR 0093)" }
66
+ .each do |old, hint|
67
+ next unless r.is_a?(Hash) && r.key?(old)
68
+
69
+ raise BadManifest.new("`#{old}:` was removed at '#{path}' (ADR 0093) — #{hint}.")
70
+ end
71
+ next unless r.is_a?(Hash) && r.key?("upkeep")
72
+
73
+ raise BadManifest.new(
74
+ "rule key `upkeep:` was removed (ADR 0093): move age-GC to `retention:` " \
75
+ "and production to the entry's `source:`",
76
+ )
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,230 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "semantics/invariants"
4
+ require_relative "semantics/migration"
5
+ require_relative "semantics/cross_field"
6
+
3
7
  module Textus
4
8
  class Manifest
5
9
  module Schema
6
10
  # Cross-field rules and ADR migration hints. Called by Validator.validate!
7
11
  # AFTER the structural dry-schema Contract passes. Operates on the raw hash.
8
12
  module Semantics
13
+ extend Invariants
14
+ extend Migration
15
+ extend CrossField
16
+
9
17
  module_function
10
18
 
11
19
  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
20
+ check_migration!(raw)
21
+ check_invariants!(raw)
22
+ check_cross_field!(raw)
228
23
  end
229
24
  end
230
25
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Textus
6
+ module Meta
7
+ NO_META_FORMATS = %w[text].freeze
8
+
9
+ FIELDS = {
10
+ "uid" => {
11
+ inject: lambda { |meta, content, existing_meta|
12
+ m = meta.is_a?(Hash) ? meta.dup : {}
13
+ existing = existing_meta.is_a?(Hash) ? existing_meta["uid"] : nil
14
+ m["uid"] = existing || Textus::Value::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
15
+ [m, content]
16
+ },
17
+ },
18
+ "sources" => {
19
+ inject: lambda { |meta, content, existing_meta|
20
+ m = meta.is_a?(Hash) ? meta.dup : {}
21
+ existing = existing_meta.is_a?(Hash) ? existing_meta["sources"] : nil
22
+
23
+ if m.key?("sources")
24
+ raise Textus::BadContent.new(nil, "_meta.sources must be an array") unless m["sources"].is_a?(Array)
25
+
26
+ m["sources"] = m["sources"].map { |s| validate_source_shape!(s) }
27
+ elsif existing.is_a?(Array) && !existing.empty?
28
+ m["sources"] = existing
29
+ end
30
+
31
+ [m, content]
32
+ },
33
+ },
34
+ }.freeze
35
+
36
+ def self.inject_all(meta, content, existing_meta = {}, format: nil)
37
+ return [meta, content] if NO_META_FORMATS.include?(format)
38
+
39
+ FIELDS.each_value do |field|
40
+ meta, content = field[:inject].call(meta, content, existing_meta)
41
+ end
42
+
43
+ [meta, content]
44
+ end
45
+
46
+ def self.validate_source_shape!(src)
47
+ raise Textus::BadContent.new(nil, "each source must be a string") unless src.is_a?(String)
48
+
49
+ raise Textus::BadContent.new(nil, "each source must start with 'raw.', got #{src.inspect}") unless src.match?(/\Araw\./)
50
+
51
+ src
52
+ end
53
+ end
54
+ end