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,6 +1,8 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Resolver
4
+ Resolution = Data.define(:entry, :path, :remaining)
5
+
4
6
  def initialize(manifest)
5
7
  @manifest = manifest
6
8
  end
@@ -40,7 +42,7 @@ module Textus
40
42
  # entry with nested: true in the raw YAML — e.g. Intake entries covering
41
43
  # a directory of leaf files).
42
44
  def nested_entry?(entry)
43
- entry.is_a?(Textus::Manifest::Entry::Nested) || entry.raw["nested"] == true
45
+ entry.nested?
44
46
  end
45
47
 
46
48
  def build_resolution(entry, remaining, key)
@@ -49,7 +51,7 @@ module Textus
49
51
  else
50
52
  raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless nested_entry?(entry)
51
53
 
52
- index_fn = entry.respond_to?(:index_filename) ? entry.index_filename : nil
54
+ index_fn = entry.index_filename
53
55
  path = if index_fn
54
56
  File.join(@manifest.root, "zones", entry.path, *remaining, index_fn)
55
57
  else
@@ -69,21 +71,22 @@ module Textus
69
71
  base = File.join(@manifest.root, "zones", entry.path)
70
72
  return [] unless File.directory?(base)
71
73
 
72
- entry_index_filename = entry.respond_to?(:index_filename) ? entry.index_filename : nil
74
+ entry_index_filename = entry.index_filename
73
75
  glob_pattern = entry_index_filename ? "**/#{entry_index_filename}" : nested_glob(entry.format)
74
76
  Dir.glob(File.join(base, glob_pattern)).filter_map { |path| nested_row_for(entry, base, path) }
75
77
  end
76
78
 
77
79
  def nested_row_for(entry, base, path)
78
80
  rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
79
- entry_if = entry.respond_to?(:index_filename) ? entry.index_filename : nil
81
+ entry_if = entry.index_filename
80
82
  stripped = entry_if ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
81
83
  segs = stripped.split("/").reject { |s| s.empty? || s == "." }
82
84
  return nil if segs.empty?
83
85
 
84
86
  illegal = segs.find { |s| !valid_segment?(s) }
85
87
  if illegal
86
- warn("textus: skipping illegal key segment '#{illegal}' at #{path} — run 'textus key normalize --dry-run'")
88
+ warn("textus: skipping illegal key segment '#{illegal}' at #{path} — " \
89
+ "rename to match [a-z0-9][a-z0-9-]* (run 'textus doctor' for the full list)")
87
90
  return nil
88
91
  end
89
92
 
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ class Manifest
3
+ module RoleKinds
4
+ DEFAULT_MAPPING = {
5
+ "human" => :accept_authority,
6
+ "agent" => :proposer,
7
+ "builder" => :generator,
8
+ "runner" => :runner,
9
+ }.freeze
10
+
11
+ # Returns { role_name => kind_symbol }. When `roles:` is declared we use
12
+ # exactly that; defaults are *not* layered in (declaring roles is an opt-in
13
+ # to a fully user-defined vocabulary).
14
+ def self.resolve(raw_roles)
15
+ return DEFAULT_MAPPING if raw_roles.nil?
16
+
17
+ raw_roles.to_h { |r| [r["name"], r["kind"].to_sym] }.freeze
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,12 +1,14 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  module Schema
4
- ROOT_KEYS = %w[version zones entries rules].freeze
4
+ ROOT_KEYS = %w[version roles zones entries rules audit].freeze
5
+ ROLE_KEYS = %w[name kind].freeze
6
+ ROLE_KINDS = %w[accept_authority generator proposer runner].freeze
5
7
  ZONE_KEYS = %w[name write_policy read_policy].freeze
6
8
  ENTRY_KEYS = %w[
7
9
  key path zone kind schema owner nested format
8
10
  compute template publish_to publish_each
9
- intake events inject_intro index_filename
11
+ intake events inject_boot index_filename
10
12
  ].freeze
11
13
  COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
12
14
  INTAKE_KEYS = %w[handler config].freeze
@@ -14,21 +16,37 @@ module Textus
14
16
  REFRESH_KEYS = %w[ttl on_stale sync_budget_ms fetch_timeout_seconds].freeze
15
17
  FETCH_TIMEOUT_SECONDS_CEILING = 3600
16
18
  PROMOTION_KEYS = %w[requires].freeze
19
+ AUDIT_KEYS = %w[max_size keep].freeze
17
20
 
18
21
  def self.validate!(raw)
19
22
  raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
20
23
 
21
24
  walk(raw, ROOT_KEYS, "$")
22
- Array(raw["zones"]).each_with_index do |z, i|
25
+ validate_roles!(raw["roles"])
26
+ validate_zones!(raw["zones"])
27
+ validate_entries!(raw["entries"])
28
+ validate_rules!(raw["rules"])
29
+ walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
30
+ validate_zone_writers_declared!(raw)
31
+ end
32
+
33
+ def self.validate_zones!(zones)
34
+ Array(zones).each_with_index do |z, i|
23
35
  walk(z, ZONE_KEYS, "$.zones[#{i}]")
24
36
  end
25
- Array(raw["entries"]).each_with_index do |e, i|
37
+ end
38
+
39
+ def self.validate_entries!(entries)
40
+ Array(entries).each_with_index do |e, i|
26
41
  path = "$.entries[#{i}]"
27
42
  walk(e, ENTRY_KEYS, path)
28
43
  walk(e["compute"], COMPUTE_KEYS, "#{path}.compute") if e["compute"].is_a?(Hash)
29
44
  walk(e["intake"], INTAKE_KEYS, "#{path}.intake") if e["intake"].is_a?(Hash)
30
45
  end
31
- Array(raw["rules"]).each_with_index do |r, i|
46
+ end
47
+
48
+ def self.validate_rules!(rules)
49
+ Array(rules).each_with_index do |r, i|
32
50
  path = "$.rules[#{i}]"
33
51
  walk(r, RULE_KEYS, path)
34
52
  if r["refresh"].is_a?(Hash)
@@ -39,6 +57,46 @@ module Textus
39
57
  end
40
58
  end
41
59
 
60
+ def self.validate_zone_writers_declared!(raw)
61
+ return if raw["roles"].nil? # default mapping is permissive
62
+
63
+ declared = Array(raw["roles"]).map { |r| r["name"] }.compact.to_set
64
+ Array(raw["zones"]).each do |z|
65
+ Array(z["write_policy"]).each_with_index do |w, j|
66
+ next if declared.include?(w)
67
+
68
+ raise BadManifest.new(
69
+ "zone '#{z["name"]}' write_policy[#{j}] references undeclared role '#{w}' " \
70
+ "(declared roles: #{declared.to_a.join(", ")})",
71
+ )
72
+ end
73
+ end
74
+ end
75
+
76
+ def self.validate_roles!(roles)
77
+ return if roles.nil?
78
+ raise BadManifest.new("roles: must be a list") unless roles.is_a?(Array)
79
+
80
+ accept_authority_count = 0
81
+ roles.each_with_index do |r, i|
82
+ path = "$.roles[#{i}]"
83
+ walk(r, ROLE_KEYS, path)
84
+ name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
85
+ kind = r["kind"] or raise BadManifest.new("role '#{name}' at '#{path}' missing kind")
86
+ unless ROLE_KINDS.include?(kind)
87
+ raise BadManifest.new("unknown role kind '#{kind}' at '#{path}' (known: #{ROLE_KINDS.join(", ")})")
88
+ end
89
+
90
+ accept_authority_count += 1 if kind == "accept_authority"
91
+ end
92
+ return unless accept_authority_count > 1
93
+
94
+ raise BadManifest.new(
95
+ "manifest declares #{accept_authority_count} accept_authority roles; " \
96
+ "at most one accept_authority role is allowed",
97
+ )
98
+ end
99
+
42
100
  def self.validate_fetch_timeout!(value, path)
43
101
  return if value.nil?
44
102
  return if value.is_a?(Integer) && value.positive? && value <= FETCH_TIMEOUT_SECONDS_CEILING
@@ -1,7 +1,7 @@
1
1
  require "yaml"
2
2
  require_relative "manifest/schema"
3
- require_relative "manifest/resolution"
4
3
  require_relative "manifest/resolver"
4
+ require_relative "manifest/role_kinds"
5
5
 
6
6
  module Textus
7
7
  class Manifest
@@ -30,6 +30,36 @@ module Textus
30
30
  )
31
31
  end
32
32
 
33
+ AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
34
+
35
+ def audit_config
36
+ raw = @raw["audit"] || {}
37
+ {
38
+ max_size: raw["max_size"] || AUDIT_DEFAULTS[:max_size],
39
+ keep: raw["keep"] || AUDIT_DEFAULTS[:keep],
40
+ }
41
+ end
42
+
43
+ def role_mapping
44
+ @role_mapping ||= RoleKinds.resolve(@raw["roles"])
45
+ end
46
+
47
+ def role_kind(name)
48
+ role_mapping[name]
49
+ end
50
+
51
+ def roles_with_kind(kind)
52
+ role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
53
+ end
54
+
55
+ def zone_kinds(zone_name)
56
+ @zone_kinds_cache ||= {}
57
+ @zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
58
+ k = role_kind(w)
59
+ acc << k if k
60
+ end.freeze
61
+ end
62
+
33
63
  def self.parse(yaml_text, root: ".")
34
64
  raw = YAML.safe_load(yaml_text, aliases: false)
35
65
  check_version!(raw, "<string>")
@@ -115,11 +115,18 @@ module Textus
115
115
  def rdeps(...) = Application::Reads::Rdeps.new(manifest: @manifest).call(...)
116
116
  def published(...) = Application::Reads::Published.new(manifest: @manifest).call(...)
117
117
  def stale(...) = Application::Reads::Stale.new(manifest: @manifest).call(...)
118
- def audit(...) = Application::Reads::Audit.new(manifest: @manifest, root: @root).call(...)
118
+ def audit(...) = Application::Reads::Audit.new(manifest: @manifest, root: @root, audit_log: @audit_log).call(...)
119
119
  def blame(...) = Application::Reads::Blame.new(manifest: @manifest, root: @root).call(...)
120
120
  def policy_explain(...) = Application::Reads::PolicyExplain.new(manifest: @manifest).call(...)
121
121
  def freshness(...) = Application::Reads::Freshness.new(ctx: @ctx, manifest: @manifest, file_store: @file_store).call(...)
122
122
 
123
+ def pulse(...)
124
+ Application::Reads::Pulse.new(
125
+ ctx: @ctx, manifest: @manifest, file_store: @file_store,
126
+ audit_log: @audit_log, root: @root, store: @store
127
+ ).call(...)
128
+ end
129
+
123
130
  def validate_all(...)
124
131
  Application::Reads::ValidateAll.new(
125
132
  ctx: @ctx, manifest: @manifest, file_store: @file_store, schemas: @schemas, audit_log: @audit_log,
@@ -49,7 +49,14 @@ module Textus
49
49
  end
50
50
  raise UsageError.new("schema migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
51
51
 
52
- ops = Textus::Operations.for(store, role: "human")
52
+ authority = store.manifest.roles_with_kind(:accept_authority).first
53
+ if authority.nil?
54
+ raise UsageError.new(
55
+ "schema migrate requires a role with kind :accept_authority in the manifest; " \
56
+ "none declared (add e.g. `- { name: owner, kind: accept_authority }` to roles:)",
57
+ )
58
+ end
59
+ ops = Textus::Operations.for(store, role: authority)
53
60
  touched = []
54
61
  store.manifest.resolver.enumerate.each do |row|
55
62
  env = ops.get(row[:key])
data/lib/textus/store.rb CHANGED
@@ -33,7 +33,11 @@ module Textus
33
33
  @manifest = Manifest.load(@root)
34
34
  @schemas = Schemas.new(File.join(@root, "schemas"))
35
35
  @file_store = Infra::Storage::FileStore.new
36
- @audit_log = Infra::AuditLog.new(@root)
36
+ @audit_log = Infra::AuditLog.new(
37
+ @root,
38
+ max_size: @manifest.audit_config[:max_size],
39
+ keep: @manifest.audit_config[:keep],
40
+ )
37
41
  @bus = Hooks::Bus.new
38
42
  Infra::AuditSubscriber.new(@audit_log).attach(@bus)
39
43
  Hooks::Builtin.register_all(@bus)
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.20.0"
2
+ VERSION = "0.22.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.0
4
+ version: 0.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -110,7 +110,7 @@ files:
110
110
  - exe/textus
111
111
  - lib/textus.rb
112
112
  - lib/textus/application/context.rb
113
- - lib/textus/application/policy/predicates/human_accept.rb
113
+ - lib/textus/application/policy/predicates/accept_authority_signed.rb
114
114
  - lib/textus/application/policy/predicates/schema_valid.rb
115
115
  - lib/textus/application/policy/promotion.rb
116
116
  - lib/textus/application/projection.rb
@@ -123,6 +123,7 @@ files:
123
123
  - lib/textus/application/reads/list.rb
124
124
  - lib/textus/application/reads/policy_explain.rb
125
125
  - lib/textus/application/reads/published.rb
126
+ - lib/textus/application/reads/pulse.rb
126
127
  - lib/textus/application/reads/rdeps.rb
127
128
  - lib/textus/application/reads/schema_envelope.rb
128
129
  - lib/textus/application/reads/stale.rb
@@ -133,9 +134,8 @@ files:
133
134
  - lib/textus/application/refresh/all.rb
134
135
  - lib/textus/application/refresh/orchestrator.rb
135
136
  - lib/textus/application/refresh/worker.rb
136
- - lib/textus/application/tools/migrate_keys.rb
137
- - lib/textus/application/tools/migrate_manifest_to_kinds.rb
138
137
  - lib/textus/application/writes/accept.rb
138
+ - lib/textus/application/writes/authority_gate.rb
139
139
  - lib/textus/application/writes/delete.rb
140
140
  - lib/textus/application/writes/envelope_io.rb
141
141
  - lib/textus/application/writes/materializer.rb
@@ -143,6 +143,7 @@ files:
143
143
  - lib/textus/application/writes/publish.rb
144
144
  - lib/textus/application/writes/put.rb
145
145
  - lib/textus/application/writes/reject.rb
146
+ - lib/textus/boot.rb
146
147
  - lib/textus/builder/pipeline.rb
147
148
  - lib/textus/builder/renderer.rb
148
149
  - lib/textus/builder/renderer/json.rb
@@ -160,6 +161,7 @@ files:
160
161
  - lib/textus/cli/verb/accept.rb
161
162
  - lib/textus/cli/verb/audit.rb
162
163
  - lib/textus/cli/verb/blame.rb
164
+ - lib/textus/cli/verb/boot.rb
163
165
  - lib/textus/cli/verb/build.rb
164
166
  - lib/textus/cli/verb/delete.rb
165
167
  - lib/textus/cli/verb/deps.rb
@@ -169,11 +171,10 @@ files:
169
171
  - lib/textus/cli/verb/hook_run.rb
170
172
  - lib/textus/cli/verb/hooks.rb
171
173
  - lib/textus/cli/verb/init.rb
172
- - lib/textus/cli/verb/intro.rb
173
- - lib/textus/cli/verb/key_normalize.rb
174
174
  - lib/textus/cli/verb/list.rb
175
175
  - lib/textus/cli/verb/mv.rb
176
176
  - lib/textus/cli/verb/published.rb
177
+ - lib/textus/cli/verb/pulse.rb
177
178
  - lib/textus/cli/verb/put.rb
178
179
  - lib/textus/cli/verb/rdeps.rb
179
180
  - lib/textus/cli/verb/refresh.rb
@@ -212,7 +213,6 @@ files:
212
213
  - lib/textus/domain/freshness/verdict.rb
213
214
  - lib/textus/domain/outcome.rb
214
215
  - lib/textus/domain/permission.rb
215
- - lib/textus/domain/policy.rb
216
216
  - lib/textus/domain/policy/handler_allowlist.rb
217
217
  - lib/textus/domain/policy/matcher.rb
218
218
  - lib/textus/domain/policy/promote.rb
@@ -245,7 +245,6 @@ files:
245
245
  - lib/textus/infra/refresh/lock.rb
246
246
  - lib/textus/infra/storage/file_store.rb
247
247
  - lib/textus/init.rb
248
- - lib/textus/intro.rb
249
248
  - lib/textus/key/distance.rb
250
249
  - lib/textus/key/grammar.rb
251
250
  - lib/textus/key/path.rb
@@ -261,10 +260,10 @@ files:
261
260
  - lib/textus/manifest/entry/validators/events.rb
262
261
  - lib/textus/manifest/entry/validators/format_matrix.rb
263
262
  - lib/textus/manifest/entry/validators/index_filename.rb
264
- - lib/textus/manifest/entry/validators/inject_intro.rb
263
+ - lib/textus/manifest/entry/validators/inject_boot.rb
265
264
  - lib/textus/manifest/entry/validators/publish_each.rb
266
- - lib/textus/manifest/resolution.rb
267
265
  - lib/textus/manifest/resolver.rb
266
+ - lib/textus/manifest/role_kinds.rb
268
267
  - lib/textus/manifest/rules.rb
269
268
  - lib/textus/manifest/schema.rb
270
269
  - lib/textus/mustache.rb
@@ -1,30 +0,0 @@
1
- module Textus
2
- module Application
3
- module Policy
4
- module Predicates
5
- class HumanAccept
6
- attr_reader :reason
7
-
8
- def name
9
- "human_accept"
10
- end
11
-
12
- # The role is passed explicitly. In practice, Accept already enforces
13
- # role == "human" before reaching the promotion gate, so this predicate
14
- # trivially passes. It documents intent and future-proofs multi-actor
15
- # accept flows.
16
- def call(role:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
17
- role_str = role&.to_s
18
- # If we cannot determine the role, trust that Accept has already
19
- # checked — allow through.
20
- return true if role_str.nil? || role_str.empty?
21
-
22
- ok = (role_str == "human")
23
- @reason = "current role is '#{role_str}', expected 'human'" unless ok
24
- ok
25
- end
26
- end
27
- end
28
- end
29
- end
30
- end
@@ -1,191 +0,0 @@
1
- module Textus
2
- module Application
3
- module Tools
4
- # Run-once helper that renames files/directories whose basenames don't
5
- # conform to the strict key grammar (§3 of plan-1.2). Only walks
6
- # nested: true manifest entries — leaf entries with illegal declared
7
- # keys are caught by Manifest load and must be fixed by hand.
8
- module MigrateKeys
9
- SEGMENT = /\A[a-z0-9][a-z0-9-]*\z/
10
-
11
- module_function
12
-
13
- # Returns the envelope hash described in plan-1.2 §3.
14
- def run(store, write: false)
15
- plan = build_plan(store)
16
- collisions = plan[:collisions]
17
- renames = plan[:renames]
18
-
19
- ok = collisions.empty?
20
- apply!(store, renames) if write && ok
21
-
22
- {
23
- "protocol" => Textus::PROTOCOL,
24
- "mode" => write ? "write" : "dry-run",
25
- "renames" => renames.map { |r| envelope_rename(r) },
26
- "collisions" => collisions.map { |c| envelope_collision(c) },
27
- "ok" => ok,
28
- }
29
- end
30
-
31
- # ------------------------------------------------------------------
32
- # Plan construction
33
- # ------------------------------------------------------------------
34
-
35
- # Returns { renames: [...], collisions: [...] }
36
- # Each rename: { from:, to:, old_key:, new_key:, kind: :file|:dir }
37
- # Each collision: { target:, sources: [...] }
38
- def build_plan(store) # rubocop:disable Metrics/AbcSize
39
- renames = []
40
- target_buckets = Hash.new { |h, k| h[k] = [] } # target_path => [source_path, ...]
41
-
42
- store.manifest.entries.each do |entry|
43
- next unless entry.nested?
44
-
45
- base = File.join(store.root, "zones", entry.path)
46
- next unless File.directory?(base)
47
-
48
- # Walk depth-first. Order matters when computing the "new key"
49
- # for files inside a renamed directory: we record renames bottom-up,
50
- # so children are renamed before their parents on apply.
51
- walk(base) do |abs_path, is_dir|
52
- next if abs_path == base
53
-
54
- basename = File.basename(abs_path)
55
- stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
56
- next if stem.match?(SEGMENT)
57
-
58
- new_stem = normalize(stem)
59
- # Skip if normalization yields the same stem (e.g. already-legal
60
- # under a different lens). In practice match?(SEGMENT) catches that
61
- # above; this is a safety net.
62
- next if new_stem == stem
63
-
64
- new_basename = is_dir ? new_stem : new_stem + File.extname(basename)
65
- target = File.join(File.dirname(abs_path), new_basename)
66
- target_buckets[target] << abs_path
67
-
68
- renames << {
69
- from: abs_path,
70
- to: target,
71
- kind: is_dir ? :dir : :file,
72
- entry: entry,
73
- base: base,
74
- }
75
- end
76
- end
77
-
78
- collisions = target_buckets.select { |_, srcs| srcs.length > 1 }
79
- .map { |t, srcs| { target: t, sources: srcs.sort } }
80
-
81
- # Drop colliding entries from renames (we won't apply any of them)
82
- colliding_targets = collisions.to_set { |c| c[:target] }
83
- renames.reject! { |r| colliding_targets.include?(r[:to]) }
84
-
85
- # Sort renames bottom-up (deepest path first) so children move before parents.
86
- renames.sort_by! { |r| -r[:from].count("/") }
87
-
88
- { renames: renames, collisions: collisions }
89
- end
90
-
91
- # Yields [absolute_path, is_dir] for every entry under root. Depth-first.
92
- def walk(root, &block)
93
- Dir.each_child(root) do |name|
94
- abs = File.join(root, name)
95
- if File.directory?(abs)
96
- walk(abs, &block)
97
- yield abs, true
98
- else
99
- yield abs, false
100
- end
101
- end
102
- end
103
-
104
- # Deterministic transform per plan §3.
105
- def normalize(s)
106
- s = s.downcase
107
- s = s.gsub(/[^a-z0-9-]/, "-") # ., _, and anything else become -
108
- s = s.gsub(/-+/, "-")
109
- s.sub(/\A-+/, "").sub(/-+\z/, "")
110
- end
111
-
112
- # ------------------------------------------------------------------
113
- # Apply
114
- # ------------------------------------------------------------------
115
-
116
- def apply!(store, renames)
117
- audit = Textus::Infra::AuditLog.new(store.root)
118
- renames.each do |r|
119
- # Bottom-up order means a child's ancestors haven't moved yet, so
120
- # `from`/`to` are valid as-recorded. The audit `key` reflects the
121
- # eventual full key once every rename in this batch has applied.
122
- from = r[:from]
123
- to = r[:to]
124
- File.rename(from, to)
125
- new_key = compute_new_key(r, renames)
126
- audit.append(
127
- role: "runner",
128
- verb: "migrate-keys",
129
- key: new_key,
130
- etag_before: nil,
131
- etag_after: nil,
132
- extras: { "from" => from, "to" => to },
133
- )
134
- end
135
- end
136
-
137
- # If an ancestor of `path` was renamed earlier in this batch, rewrite the path.
138
- def resolve_current_path(path, renames)
139
- out = path
140
- renames.each do |r|
141
- prefix = r[:from] + "/"
142
- out = r[:to] + out[r[:from].length..] if out.start_with?(prefix)
143
- end
144
- out
145
- end
146
-
147
- # New full key after applying all renames up through this one.
148
- def compute_new_key(rename, renames)
149
- base = rename[:base]
150
- entry = rename[:entry]
151
- new_to = resolve_current_path(rename[:to], renames)
152
-
153
- rel = new_to.sub(%r{\A#{Regexp.escape(base)}/?}, "")
154
- stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "") unless rename[:kind] == :dir
155
- stripped ||= rel
156
- segs = stripped.split("/").reject(&:empty?)
157
- (entry.key.split(".") + segs).join(".")
158
- end
159
-
160
- # ------------------------------------------------------------------
161
- # Envelope helpers
162
- # ------------------------------------------------------------------
163
-
164
- def envelope_rename(r)
165
- {
166
- "from" => r[:from],
167
- "to" => r[:to],
168
- "old_key" => path_to_key(r[:from], r[:base], r[:entry], r[:kind]),
169
- "new_key" => path_to_key(r[:to], r[:base], r[:entry], r[:kind]),
170
- }
171
- end
172
-
173
- def envelope_collision(col)
174
- { "target" => col[:target], "sources" => col[:sources] }
175
- end
176
-
177
- def path_to_key(path, base, entry, kind)
178
- rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
179
- stripped =
180
- if kind == :dir
181
- rel
182
- else
183
- rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
184
- end
185
- segs = stripped.split("/").reject(&:empty?)
186
- (entry.key.split(".") + segs).join(".")
187
- end
188
- end
189
- end
190
- end
191
- end
@@ -1,31 +0,0 @@
1
- require "yaml"
2
-
3
- module Textus
4
- module Application
5
- module Tools
6
- module MigrateManifestToKinds
7
- module_function
8
-
9
- def upgrade_yaml(yaml_text)
10
- raw = YAML.safe_load(yaml_text, aliases: false)
11
- raw["entries"] = Array(raw["entries"]).map { |row| upgrade_row(row) }
12
- YAML.dump(raw)
13
- end
14
-
15
- def upgrade_row(row)
16
- return row if row["kind"]
17
-
18
- row.merge("kind" => infer_kind(row))
19
- end
20
-
21
- def infer_kind(row)
22
- return "intake" if row["intake"].is_a?(Hash) || row["intake_handler"]
23
- return "derived" if row["template"] || row["compute"] || row["generator"] || row["projection"]
24
- return "nested" if row["nested"] == true
25
-
26
- "leaf"
27
- end
28
- end
29
- end
30
- end
31
- end
@@ -1,48 +0,0 @@
1
- module Textus
2
- class CLI
3
- class Verb
4
- class KeyNormalize < Verb
5
- command_name "normalize"
6
- parent_group Group::Key
7
-
8
- option :write, "--write"
9
- option :dry_run, "--dry-run"
10
- option :upgrade_manifest, "--upgrade-manifest"
11
-
12
- def call(store)
13
- if upgrade_manifest
14
- run_upgrade_manifest(store)
15
- else
16
- effective_write = write && !dry_run
17
- res = Textus::Application::Tools::MigrateKeys.run(store, write: effective_write || false)
18
- emit(res, exit_code: res["ok"] ? 0 : 1)
19
- end
20
- end
21
-
22
- private
23
-
24
- def run_upgrade_manifest(store)
25
- manifest_path = File.join(store.root, "manifest.yaml")
26
- orig = File.read(manifest_path)
27
- new_yaml = Textus::Application::Tools::MigrateManifestToKinds.upgrade_yaml(orig)
28
-
29
- if dry_run
30
- diff_lines = unified_diff(orig, new_yaml, manifest_path)
31
- emit({ "protocol" => PROTOCOL, "dry_run" => true, "diff" => diff_lines, "ok" => true }, exit_code: 0)
32
- else
33
- File.write(manifest_path, new_yaml)
34
- puts "upgraded manifest at #{manifest_path}"
35
- emit({ "protocol" => PROTOCOL, "upgraded" => manifest_path, "ok" => true }, exit_code: 0)
36
- end
37
- end
38
-
39
- def unified_diff(before, after, _path)
40
- before.lines.zip(after.lines).each_with_object([]) do |(a, b), acc|
41
- acc << "- #{a.chomp}" if a && a != b
42
- acc << "+ #{b.chomp}" if b && a != b
43
- end
44
- end
45
- end
46
- end
47
- end
48
- end