textus 0.10.4 → 0.12.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -3
  3. data/README.md +45 -86
  4. data/SPEC.md +266 -138
  5. data/docs/conventions.md +47 -15
  6. data/lib/textus/application/reads/freshness.rb +2 -2
  7. data/lib/textus/application/reads/get.rb +1 -1
  8. data/lib/textus/application/reads/policy_explain.rb +2 -2
  9. data/lib/textus/application/refresh/orchestrator.rb +1 -1
  10. data/lib/textus/application/refresh/worker.rb +5 -5
  11. data/lib/textus/application/writes/accept.rb +19 -1
  12. data/lib/textus/application/writes/build.rb +5 -5
  13. data/lib/textus/application/writes/delete.rb +2 -3
  14. data/lib/textus/application/writes/publish.rb +1 -1
  15. data/lib/textus/application/writes/put.rb +3 -6
  16. data/lib/textus/builder/pipeline.rb +1 -1
  17. data/lib/textus/builder/renderer/json.rb +1 -1
  18. data/lib/textus/builder/renderer/yaml.rb +1 -1
  19. data/lib/textus/cli/group/key.rb +1 -1
  20. data/lib/textus/cli/group/refresh.rb +21 -0
  21. data/lib/textus/cli/group/rule.rb +11 -0
  22. data/lib/textus/cli/verb/build.rb +1 -1
  23. data/lib/textus/cli/verb/hook_run.rb +3 -2
  24. data/lib/textus/cli/verb/hooks.rb +1 -1
  25. data/lib/textus/cli/verb/{migrate_keys.rb → key_normalize.rb} +1 -1
  26. data/lib/textus/cli/verb/put.rb +1 -1
  27. data/lib/textus/cli/verb/{policy_explain.rb → rule_explain.rb} +1 -1
  28. data/lib/textus/cli/verb/{policy_list.rb → rule_list.rb} +3 -3
  29. data/lib/textus/cli/verb.rb +3 -2
  30. data/lib/textus/cli.rb +6 -6
  31. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  32. data/lib/textus/doctor/check/illegal_keys.rb +39 -16
  33. data/lib/textus/doctor/check/intake_registration.rb +4 -4
  34. data/lib/textus/doctor/check/protocol_version.rb +47 -0
  35. data/lib/textus/doctor/check/{policy_ambiguity.rb → rule_ambiguity.rb} +6 -6
  36. data/lib/textus/doctor.rb +5 -4
  37. data/lib/textus/domain/permission.rb +4 -4
  38. data/lib/textus/domain/policy/predicates/human_accept.rb +31 -0
  39. data/lib/textus/domain/policy/predicates/schema_valid.rb +50 -0
  40. data/lib/textus/domain/policy/promotion.rb +45 -0
  41. data/lib/textus/errors.rb +24 -5
  42. data/lib/textus/hooks/builtin.rb +5 -5
  43. data/lib/textus/hooks/dispatcher.rb +1 -1
  44. data/lib/textus/hooks/dsl.rb +3 -10
  45. data/lib/textus/hooks/loader.rb +1 -2
  46. data/lib/textus/hooks/registry.rb +22 -21
  47. data/lib/textus/infra/refresh/detached.rb +1 -1
  48. data/lib/textus/init.rb +25 -34
  49. data/lib/textus/intro.rb +9 -9
  50. data/lib/textus/manifest/entry.rb +66 -6
  51. data/lib/textus/manifest/{policies.rb → rules.rb} +12 -10
  52. data/lib/textus/manifest/schema.rb +49 -0
  53. data/lib/textus/manifest.rb +79 -39
  54. data/lib/textus/migrate_keys.rb +1 -1
  55. data/lib/textus/projection.rb +4 -4
  56. data/lib/textus/refresh.rb +1 -1
  57. data/lib/textus/store/mover.rb +91 -50
  58. data/lib/textus/store/staleness/generator_check.rb +88 -0
  59. data/lib/textus/store/staleness/intake_check.rb +46 -0
  60. data/lib/textus/store/staleness.rb +9 -104
  61. data/lib/textus/store/writer.rb +14 -12
  62. data/lib/textus/store.rb +1 -1
  63. data/lib/textus/version.rb +2 -2
  64. data/lib/textus.rb +1 -0
  65. metadata +15 -7
  66. data/lib/textus/cli/group/policy.rb +0 -11
@@ -0,0 +1,88 @@
1
+ require "time"
2
+
3
+ module Textus
4
+ class Store
5
+ class Staleness
6
+ # Reports staleness for generator-zone entries — derived files whose
7
+ # generator's listed sources have been modified more recently than the
8
+ # entry's `_meta.generated.at` timestamp. Returns an Array of row hashes
9
+ # (possibly empty) per entry.
10
+ class GeneratorCheck
11
+ def initialize(manifest:)
12
+ @manifest = manifest
13
+ end
14
+
15
+ def rows_for(mentry)
16
+ return [] unless mentry.in_generator_zone?
17
+
18
+ gen = mentry.generator
19
+ return [] unless gen
20
+
21
+ path = Textus::Key::Path.resolve(@manifest, mentry)
22
+ return [stale_row(mentry, path, "derived entry has never been generated")] unless File.exist?(path)
23
+
24
+ parsed = Entry.for_format(mentry.format).parse(File.binread(path), path: path)
25
+ generated_at = parsed["_meta"].dig("generated", "at")
26
+ return [stale_row(mentry, path, "missing generated.at frontmatter")] unless generated_at
27
+
28
+ gen_time = parse_time(generated_at)
29
+ return [stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")] unless gen_time
30
+
31
+ offender = newest_source_after(gen, gen_time)
32
+ return [stale_row(mentry, path, "source '#{offender}' modified after generated.at")] if offender
33
+
34
+ []
35
+ end
36
+
37
+ private
38
+
39
+ def parse_time(str)
40
+ Time.parse(str.to_s)
41
+ rescue StandardError
42
+ nil
43
+ end
44
+
45
+ def newest_source_after(gen, gen_time)
46
+ Array(gen["sources"]).each do |src|
47
+ offender = check_source(src, gen_time)
48
+ return offender if offender
49
+ end
50
+ nil
51
+ end
52
+
53
+ def check_source(src, gen_time)
54
+ if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
55
+ @manifest.enumerate(prefix: src).each do |row|
56
+ return src if File.mtime(row[:path]) > gen_time
57
+ end
58
+ nil
59
+ else
60
+ check_filesystem_source(src, gen_time)
61
+ end
62
+ end
63
+
64
+ def check_filesystem_source(src, gen_time)
65
+ abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.root), src)
66
+ if File.directory?(abs)
67
+ Dir.glob(File.join(abs, "**", "*")).each do |fp|
68
+ next unless File.file?(fp)
69
+ return src if File.mtime(fp) > gen_time
70
+ end
71
+ nil
72
+ elsif File.exist?(abs) && File.mtime(abs) > gen_time
73
+ src
74
+ end
75
+ end
76
+
77
+ def stale_row(mentry, path, reason)
78
+ {
79
+ "key" => mentry.key,
80
+ "path" => path,
81
+ "generator" => mentry.generator,
82
+ "reason" => reason,
83
+ }
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,46 @@
1
+ require "time"
2
+
3
+ module Textus
4
+ class Store
5
+ class Staleness
6
+ # Reports TTL-exceeded staleness for intake-handler entries. Returns an
7
+ # Array of row hashes (possibly empty) per entry.
8
+ class IntakeCheck
9
+ def initialize(manifest:)
10
+ @manifest = manifest
11
+ end
12
+
13
+ def rows_for(mentry)
14
+ return [] unless mentry.intake_handler
15
+
16
+ ttl = @manifest.rules_for(mentry.key).refresh&.ttl_seconds
17
+ return [] unless ttl
18
+
19
+ path = Textus::Key::Path.resolve(@manifest, mentry)
20
+ return [row(mentry, path, "never refreshed")] unless File.exist?(path)
21
+
22
+ meta = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["_meta"]
23
+ last_str = meta["last_refreshed_at"]
24
+ return [row(mentry, path, "never refreshed (no last_refreshed_at)")] if last_str.nil?
25
+
26
+ last = parse_time(last_str)
27
+ return [row(mentry, path, "ttl exceeded (#{ttl}s)")] if last.nil? || (Time.now - last) > ttl
28
+
29
+ []
30
+ end
31
+
32
+ private
33
+
34
+ def parse_time(str)
35
+ Time.parse(str.to_s)
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ def row(mentry, path, reason)
41
+ { "key" => mentry.key, "path" => path, "handler" => mentry.intake_handler, "reason" => reason }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,121 +1,26 @@
1
- require "time"
2
-
3
1
  module Textus
4
2
  class Store
5
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
6
3
  class Staleness
7
4
  def initialize(manifest:)
8
5
  @manifest = manifest
6
+ @generator_check = GeneratorCheck.new(manifest: manifest)
7
+ @intake_check = IntakeCheck.new(manifest: manifest)
9
8
  end
10
9
 
11
10
  def call(prefix: nil, zone: nil)
12
- out = []
13
- @manifest.entries.each do |mentry|
14
- next unless mentry.in_generator_zone?
15
- next if zone && mentry.zone != zone
16
-
17
- gen = mentry.generator
18
- next unless gen
19
- next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
20
-
21
- path = Textus::Key::Path.resolve(@manifest, mentry)
22
-
23
- unless File.exist?(path)
24
- out << stale_row(mentry, path, "derived entry has never been generated")
25
- next
26
- end
27
-
28
- raw = File.binread(path)
29
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
30
- generated_at = parsed["_meta"].dig("generated", "at")
31
- unless generated_at
32
- out << stale_row(mentry, path, "missing generated.at frontmatter")
33
- next
34
- end
35
- gen_time = begin
36
- Time.parse(generated_at.to_s)
37
- rescue StandardError
38
- nil
39
- end
40
- unless gen_time
41
- out << stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")
42
- next
43
- end
44
-
45
- offender = newest_source_after(gen, gen_time)
46
- out << stale_row(mentry, path, "source '#{offender}' modified after generated.at") if offender
47
- end
48
-
49
- @manifest.entries.each do |mentry|
50
- next unless mentry.intake_handler
51
- next if zone && mentry.zone != zone
52
- next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
53
-
54
- policy_set = @manifest.policies_for(mentry.key)
55
- ttl = policy_set.refresh&.ttl_seconds
56
- next unless ttl
57
-
58
- path = Textus::Key::Path.resolve(@manifest, mentry)
59
-
60
- unless File.exist?(path)
61
- out << intake_stale_row(mentry, path, "never refreshed")
62
- next
63
- end
64
-
65
- meta = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["_meta"]
66
- last_str = meta["last_refreshed_at"]
67
- if last_str.nil?
68
- out << intake_stale_row(mentry, path, "never refreshed (no last_refreshed_at)")
69
- next
70
- end
71
-
72
- last = begin
73
- Time.parse(last_str.to_s)
74
- rescue StandardError
75
- nil
76
- end
77
- out << intake_stale_row(mentry, path, "ttl exceeded (#{ttl}s)") if last.nil? || (Time.now - last) > ttl
78
- end
79
-
80
- out
11
+ @manifest.entries
12
+ .select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
13
+ .flat_map { |m| @generator_check.rows_for(m) + @intake_check.rows_for(m) }
81
14
  end
82
15
 
83
16
  private
84
17
 
85
- def newest_source_after(gen, gen_time)
86
- Array(gen["sources"]).each do |src|
87
- if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
88
- @manifest.enumerate(prefix: src).each do |row|
89
- return src if File.mtime(row[:path]) > gen_time
90
- end
91
- else
92
- abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.root), src)
93
- if File.directory?(abs)
94
- Dir.glob(File.join(abs, "**", "*")).each do |fp|
95
- next unless File.file?(fp)
96
- return src if File.mtime(fp) > gen_time
97
- end
98
- elsif File.exist?(abs)
99
- return src if File.mtime(abs) > gen_time
100
- end
101
- end
102
- end
103
- nil
104
- end
105
-
106
- def intake_stale_row(mentry, path, reason)
107
- { "key" => mentry.key, "path" => path, "handler" => mentry.intake_handler, "reason" => reason }
108
- end
18
+ def entry_matches?(mentry, prefix:, zone:)
19
+ return false if zone && mentry.zone != zone
20
+ return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
109
21
 
110
- def stale_row(mentry, path, reason)
111
- {
112
- "key" => mentry.key,
113
- "path" => path,
114
- "generator" => mentry.generator,
115
- "reason" => reason,
116
- }
22
+ true
117
23
  end
118
24
  end
119
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
120
25
  end
121
26
  end
@@ -2,8 +2,9 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  class Store
5
- # rubocop:disable Metrics/ParameterLists
6
5
  class Writer
6
+ Payload = Data.define(:meta, :body, :content)
7
+
7
8
  def initialize(store)
8
9
  @store = store
9
10
  @manifest = store.manifest
@@ -11,28 +12,30 @@ module Textus
11
12
  end
12
13
 
13
14
  # Backward-compat shim — orchestration now lives in Application::Writes::Put.
15
+ # rubocop:disable Metrics/ParameterLists
14
16
  def put(key, meta: nil, body: nil, content: nil, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
15
17
  ctx = Textus::Application::Context.new(store: @store, role: as)
16
18
  Textus::Application::Writes::Put.new(ctx: ctx, bus: @store.bus).call(
17
19
  key, meta: meta, body: body, content: content, if_etag: if_etag, suppress_events: suppress_events
18
20
  )
19
21
  end
22
+ # rubocop:enable Metrics/ParameterLists
20
23
 
21
24
  # Pure I/O: validate, serialize, etag-check, write to disk, audit. No
22
25
  # permission check and no event firing — those are handled by the caller
23
26
  # (Application::Writes::Put).
24
- def write_envelope_to_disk(key, mentry:, meta: nil, body: nil, content: nil, if_etag: nil, as: Role::DEFAULT, correlation_id: nil)
27
+ def write_envelope_to_disk(key, mentry:, payload:, ctx:, if_etag: nil)
25
28
  _, path, = @manifest.resolve(key)
26
29
 
27
- meta ||= {}
30
+ meta = payload.meta || {}
28
31
  strategy = Entry.for_format(mentry.format)
29
32
 
30
33
  existing_uid = existing_uid_for(mentry, path)
31
- meta, content = ensure_uid(mentry.format, meta, content, existing_uid)
34
+ meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
32
35
 
33
36
  bytes, eff_meta, eff_body, eff_content = serialize_for_put(
34
37
  mentry: mentry, path: path, strategy: strategy,
35
- meta: meta, body: body, content: content
38
+ meta: meta, body: payload.body, content: content
36
39
  )
37
40
 
38
41
  enforce_name_match!(path, eff_meta, mentry.format)
@@ -52,9 +55,9 @@ module Textus
52
55
  File.binwrite(path, bytes)
53
56
  etag_after = Etag.for_bytes(bytes)
54
57
  @store.audit_log.append(
55
- role: as, verb: "put", key: key,
58
+ role: ctx.role, verb: "put", key: key,
56
59
  etag_before: etag_before, etag_after: etag_after,
57
- extras: correlation_id ? { "correlation_id" => correlation_id } : nil
60
+ extras: ctx.correlation_id ? { "correlation_id" => ctx.correlation_id } : nil
58
61
  )
59
62
  Envelope.build(
60
63
  key: key, mentry: mentry, path: path,
@@ -129,7 +132,7 @@ module Textus
129
132
  # Pure I/O: resolve path, validate etag, delete from disk, audit. No
130
133
  # permission check and no event firing — those are handled by the caller
131
134
  # (Application::Writes::Delete).
132
- def delete_envelope_from_disk(key, if_etag: nil, as: Role::DEFAULT, correlation_id: nil)
135
+ def delete_envelope_from_disk(key, ctx:, if_etag: nil)
133
136
  _, path, = @manifest.resolve(key)
134
137
  raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless File.exist?(path)
135
138
 
@@ -138,9 +141,9 @@ module Textus
138
141
 
139
142
  File.delete(path)
140
143
  @store.audit_log.append(
141
- role: as, verb: "delete", key: key,
144
+ role: ctx.role, verb: "delete", key: key,
142
145
  etag_before: etag_before, etag_after: nil,
143
- extras: correlation_id ? { "correlation_id" => correlation_id } : nil
146
+ extras: ctx.correlation_id ? { "correlation_id" => ctx.correlation_id } : nil
144
147
  )
145
148
  end
146
149
 
@@ -160,10 +163,9 @@ module Textus
160
163
  target_key = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
161
164
 
162
165
  delete(pending_key, as: as)
163
- @store.fire_event(:reject, key: pending_key, target_key: target_key)
166
+ @store.fire_event(:proposal_rejected, key: pending_key, target_key: target_key)
164
167
  { "protocol" => PROTOCOL, "rejected" => pending_key, "target_key" => target_key }
165
168
  end
166
169
  end
167
- # rubocop:enable Metrics/ParameterLists
168
170
  end
169
171
  end
data/lib/textus/store.rb CHANGED
@@ -45,7 +45,7 @@ module Textus
45
45
  load_hooks
46
46
  @reader = Reader.new(self)
47
47
  @writer = Writer.new(self)
48
- fire_event(:loaded)
48
+ fire_event(:store_loaded)
49
49
  end
50
50
 
51
51
  def load_hooks
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.10.4"
3
- PROTOCOL = "textus/2"
2
+ VERSION = "0.12.1"
3
+ PROTOCOL = "textus/3"
4
4
  end
data/lib/textus.rb CHANGED
@@ -7,6 +7,7 @@ loader.inflector.inflect(
7
7
  "cli" => "CLI",
8
8
  "json" => "Json",
9
9
  "yaml" => "Yaml",
10
+ "hook_dsl_scanner" => "HookDSLScanner",
10
11
  )
11
12
  loader.ignore(File.expand_path("textus/errors.rb", __dir__))
12
13
  loader.setup
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.10.4
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -133,7 +133,8 @@ files:
133
133
  - lib/textus/cli/group.rb
134
134
  - lib/textus/cli/group/hook.rb
135
135
  - lib/textus/cli/group/key.rb
136
- - lib/textus/cli/group/policy.rb
136
+ - lib/textus/cli/group/refresh.rb
137
+ - lib/textus/cli/group/rule.rb
137
138
  - lib/textus/cli/group/schema.rb
138
139
  - lib/textus/cli/verb.rb
139
140
  - lib/textus/cli/verb/accept.rb
@@ -149,17 +150,17 @@ files:
149
150
  - lib/textus/cli/verb/hooks.rb
150
151
  - lib/textus/cli/verb/init.rb
151
152
  - lib/textus/cli/verb/intro.rb
153
+ - lib/textus/cli/verb/key_normalize.rb
152
154
  - lib/textus/cli/verb/list.rb
153
- - lib/textus/cli/verb/migrate_keys.rb
154
155
  - lib/textus/cli/verb/mv.rb
155
- - lib/textus/cli/verb/policy_explain.rb
156
- - lib/textus/cli/verb/policy_list.rb
157
156
  - lib/textus/cli/verb/published.rb
158
157
  - lib/textus/cli/verb/put.rb
159
158
  - lib/textus/cli/verb/rdeps.rb
160
159
  - lib/textus/cli/verb/refresh.rb
161
160
  - lib/textus/cli/verb/refresh_stale.rb
162
161
  - lib/textus/cli/verb/reject.rb
162
+ - lib/textus/cli/verb/rule_explain.rb
163
+ - lib/textus/cli/verb/rule_list.rb
163
164
  - lib/textus/cli/verb/schema.rb
164
165
  - lib/textus/cli/verb/schema_diff.rb
165
166
  - lib/textus/cli/verb/schema_init.rb
@@ -176,7 +177,8 @@ files:
176
177
  - lib/textus/doctor/check/illegal_keys.rb
177
178
  - lib/textus/doctor/check/intake_registration.rb
178
179
  - lib/textus/doctor/check/manifest_files.rb
179
- - lib/textus/doctor/check/policy_ambiguity.rb
180
+ - lib/textus/doctor/check/protocol_version.rb
181
+ - lib/textus/doctor/check/rule_ambiguity.rb
180
182
  - lib/textus/doctor/check/schema_parse_error.rb
181
183
  - lib/textus/doctor/check/schema_violations.rb
182
184
  - lib/textus/doctor/check/schemas.rb
@@ -192,7 +194,10 @@ files:
192
194
  - lib/textus/domain/policy.rb
193
195
  - lib/textus/domain/policy/handler_allowlist.rb
194
196
  - lib/textus/domain/policy/matcher.rb
197
+ - lib/textus/domain/policy/predicates/human_accept.rb
198
+ - lib/textus/domain/policy/predicates/schema_valid.rb
195
199
  - lib/textus/domain/policy/promote.rb
200
+ - lib/textus/domain/policy/promotion.rb
196
201
  - lib/textus/domain/policy/refresh.rb
197
202
  - lib/textus/entry.rb
198
203
  - lib/textus/entry/base.rb
@@ -220,7 +225,8 @@ files:
220
225
  - lib/textus/key/path.rb
221
226
  - lib/textus/manifest.rb
222
227
  - lib/textus/manifest/entry.rb
223
- - lib/textus/manifest/policies.rb
228
+ - lib/textus/manifest/rules.rb
229
+ - lib/textus/manifest/schema.rb
224
230
  - lib/textus/migrate_keys.rb
225
231
  - lib/textus/mustache.rb
226
232
  - lib/textus/projection.rb
@@ -235,6 +241,8 @@ files:
235
241
  - lib/textus/store/reader.rb
236
242
  - lib/textus/store/sentinel.rb
237
243
  - lib/textus/store/staleness.rb
244
+ - lib/textus/store/staleness/generator_check.rb
245
+ - lib/textus/store/staleness/intake_check.rb
238
246
  - lib/textus/store/validator.rb
239
247
  - lib/textus/store/writer.rb
240
248
  - lib/textus/version.rb
@@ -1,11 +0,0 @@
1
- module Textus
2
- class CLI
3
- class Group
4
- class Policy < Group
5
- self.cli_name = "policy"
6
- subcommands["list"] = Verb::PolicyList
7
- subcommands["explain"] = Verb::PolicyExplain
8
- end
9
- end
10
- end
11
- end