textus 0.9.2 → 0.10.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -0
  3. data/lib/textus/application/context.rb +0 -24
  4. data/lib/textus/application/refresh/orchestrator.rb +3 -2
  5. data/lib/textus/application/refresh/worker.rb +1 -1
  6. data/lib/textus/application/writes/accept.rb +3 -2
  7. data/lib/textus/application/writes/build.rb +101 -9
  8. data/lib/textus/application/writes/delete.rb +1 -2
  9. data/lib/textus/application/writes/put.rb +1 -2
  10. data/lib/textus/builder/pipeline.rb +1 -1
  11. data/lib/textus/builder/renderer/json.rb +1 -1
  12. data/lib/textus/builder/renderer/markdown.rb +1 -1
  13. data/lib/textus/builder/renderer/text.rb +1 -1
  14. data/lib/textus/builder/renderer/yaml.rb +1 -1
  15. data/lib/textus/builder/renderer.rb +1 -1
  16. data/lib/textus/cli/verb/accept.rb +1 -2
  17. data/lib/textus/cli/verb/audit.rb +1 -2
  18. data/lib/textus/cli/verb/blame.rb +1 -2
  19. data/lib/textus/cli/verb/delete.rb +1 -2
  20. data/lib/textus/cli/verb/freshness.rb +1 -2
  21. data/lib/textus/cli/verb/get.rb +1 -2
  22. data/lib/textus/cli/verb/hook_run.rb +1 -1
  23. data/lib/textus/cli/verb/mv.rb +1 -2
  24. data/lib/textus/cli/verb/policy_explain.rb +1 -2
  25. data/lib/textus/cli/verb/put.rb +5 -4
  26. data/lib/textus/cli/verb/refresh.rb +1 -2
  27. data/lib/textus/cli/verb/refresh_stale.rb +1 -2
  28. data/lib/textus/cli/verb/reject.rb +1 -2
  29. data/lib/textus/cli/verb.rb +14 -0
  30. data/lib/textus/composition.rb +1 -0
  31. data/lib/textus/doctor.rb +3 -1
  32. data/lib/textus/intro.rb +0 -5
  33. data/lib/textus/manifest/entry.rb +21 -4
  34. data/lib/textus/manifest.rb +0 -11
  35. data/lib/textus/projection.rb +1 -1
  36. data/lib/textus/refresh.rb +1 -2
  37. data/lib/textus/store/staleness.rb +1 -1
  38. data/lib/textus/store/writer.rb +1 -1
  39. data/lib/textus/store.rb +1 -1
  40. data/lib/textus/version.rb +1 -1
  41. metadata +1 -4
  42. data/lib/textus/builder.rb +0 -99
  43. data/lib/textus/publisher.rb +0 -6
  44. data/lib/textus/store/view.rb +0 -18
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca479a6c2f4282b97184aee5c65bc94c3607d18ae0a608756737c70ef53407b5
4
- data.tar.gz: 2294eaa31b51276d48f1a7bc850464c3351a2c93b5d273100192fec4d89561d9
3
+ metadata.gz: 198dc9a4561b79bf4da22a2f890caa5da2764b8d82b1665b506d6c4c8c0e3fe3
4
+ data.tar.gz: dc5333c3605b7b05174f4b290fcb63260aad7ea089da900f0b3325cf3823d83c
5
5
  SHA512:
6
- metadata.gz: 6a6c5a434cf90e8e417faed9034e6ea653f1f94b3e373ea00b47045297e73b9e0f58d4619a84f497b45cb3078930da9b4327d7e179540369af7bf7297db2bd05
7
- data.tar.gz: 4cbcd09254c93d94d1fd06c766ceefb5125990851422db6f6b3af4ad716b7754d71e432ebc88161d938aa5177ec26d6e1f43b7577daa80210e7a493e0266f6ea
6
+ metadata.gz: f9e08a7a3fc46732dcd9458114765a54e3f8d2aca08b3ead6b70ecfc2782c0e9fec5eec38fc933cef9582c5943db52973c8bb06e544c02afec468dc50bbc8797
7
+ data.tar.gz: 9cb891ae4ce1d7583af7546e16e1f1a23d8d88af105430d31c2eb3fd4e25af6df2a60c5677c810a3d11f01582d7500a18d681b8996e9f2f5da1948e32ec2a39f
data/CHANGELOG.md CHANGED
@@ -8,6 +8,111 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
8
8
  (currently `textus/2`, embedded in every envelope as `protocol`). The protocol
9
9
  is additive within a major; a new major would change the wire string.
10
10
 
11
+ ## 0.10.0 — Shim removal, signal-based zone detection, Builder extraction (2026-05-22)
12
+
13
+ ### Breaking — Ruby API
14
+
15
+ - `Textus::Publisher` constant removed. Use `Textus::Infra::Publisher`.
16
+ - `Textus::Store::View` class removed. Use `Textus::Application::Context`
17
+ (constructed via `Composition.context(store, role:)`).
18
+ - `Textus::Builder` class removed as a public entry point. Build logic lives
19
+ in `Textus::Application::Writes::Build`. External callers should use
20
+ `Textus::Composition.writes_build(ctx).call` instead of
21
+ `Textus::Builder.new(store).build`. The `Textus::Builder` namespace is
22
+ retained internally only for nested helpers (`Builder::Pipeline`,
23
+ `Builder::Renderer::*`).
24
+ - `Application::Context` no longer exposes `put` / `delete` / `get` / `list`
25
+ / `where` shim methods. Hook callers that receive a Context via the
26
+ `store:` hook keyword must call `ctx.store.put(...)` etc., and explicitly
27
+ pass `as: ctx.role` for write operations.
28
+ - Intake handler return values must use `_meta:` for frontmatter. The
29
+ previous `frontmatter:` legacy key is no longer accepted.
30
+
31
+ ### Fixed
32
+
33
+ - `textus reject` and `textus refresh-stale` now work correctly for stores
34
+ that use the post-0.9.2 default zone names (`review`, `output`).
35
+ Zone-kind detection is now signal-based (driven by `writable_by:`
36
+ membership), not name-based. Stores using the pre-0.9.2 names (`pending`,
37
+ `derived`) continue to work.
38
+ - Event payloads' `store:` keyword now carries a Context whose
39
+ `correlation_id` matches the event payload's top-level `correlation_id`
40
+ key. Previously the `store:` Context received a fresh, unrelated
41
+ `correlation_id`.
42
+
43
+ ### Added
44
+
45
+ - `Textus::Manifest::Entry#in_generator_zone?` and `#in_proposal_zone?`
46
+ predicates. Internal `derived?` retained as an alias of
47
+ `in_generator_zone?`.
48
+ - `:built` and `:published` events now carry `correlation_id` in the
49
+ payload, matching the existing pattern on `:put` / `:deleted` /
50
+ `:accepted`.
51
+
52
+ ### Removed
53
+
54
+ - Legacy zone-purpose annotations for `canon` / `intake` / `pending` /
55
+ `derived` removed from `Textus::Intro::ZONE_PURPOSES`. Custom-named zones
56
+ continue to get no purpose annotation (existing behavior). Stores still
57
+ using the pre-rename default names will simply not get purpose
58
+ annotations on those zones in `textus intro` output.
59
+ - Dead code: `Textus::Manifest#validate_keys!` removed (had no callers).
60
+
61
+ ### Internal
62
+
63
+ - Builder logic fully extracted into `Application::Writes::Build`.
64
+ - CLI verbs now share `context_for(store)` / `resolved_role(store)`
65
+ helpers on `CLI::Verb`.
66
+ - Internal helpers in `Manifest`, `Doctor`, and `Manifest::Entry` are
67
+ properly marked private.
68
+
69
+ ### Unchanged
70
+
71
+ - Wire protocol stays `textus/2`. Envelope shape unchanged.
72
+ - CLI verbs, their flags, and their JSON output shape — unchanged.
73
+ - Manifest YAML schema — unchanged.
74
+ - Event names — unchanged (payload gains `correlation_id` on `:built` /
75
+ `:published`, but no existing key is removed or renamed).
76
+ - Hook DSL — unchanged in shape. The `store:` keyword still passes an
77
+ object that responds to `.get`, `.list`, `.where`. The Context's
78
+ role-aware `with_role` is the recommended construction site for hook
79
+ contexts now.
80
+
81
+ ### Migration recipe
82
+
83
+ ```ruby
84
+ # Hook handlers — before 0.10.0
85
+ Textus.hook(:intake, :my_hook) do |store:, config:, args:|
86
+ store.put("inbox.foo", meta: { ... }, body: "...") # used Context shim
87
+ end
88
+
89
+ # Hook handlers — 0.10.0+
90
+ Textus.hook(:intake, :my_hook) do |store:, config:, args:|
91
+ ctx = store # rename for clarity if desired
92
+ ctx.store.put("inbox.foo", meta: { ... }, body: "...", as: ctx.role)
93
+ end
94
+
95
+ # Intake handler returns — before 0.10.0
96
+ { frontmatter: { ... }, body: "..." } # legacy key
97
+
98
+ # Intake handler returns — 0.10.0+
99
+ { _meta: { ... }, body: "..." } # _meta is the canonical key
100
+ ```
101
+
102
+ If you imported the removed constants directly:
103
+
104
+ ```ruby
105
+ # Before
106
+ Textus::Publisher # removed
107
+ Textus::Store::View # removed
108
+ Textus::Builder.new(store).build(key, ...) # removed
109
+
110
+ # After
111
+ Textus::Infra::Publisher
112
+ Textus::Application::Context # via Composition.context(store, role:)
113
+ Textus::Composition.writes_build(ctx).call(key, ...)
114
+ ```
115
+
11
116
  ## 0.9.2 — Policies, audit verbs, zone rename (2026-05-22)
12
117
 
13
118
  ### Breaking — manifest YAML
@@ -39,30 +39,6 @@ module Textus
39
39
  dry_run: @dry_run,
40
40
  )
41
41
  end
42
-
43
- # Backward-compat for intake handlers receiving a Context (was Store::View)
44
- # that call store.put/get/delete on it. Slated for removal in 0.10.0.
45
- def put(key, **opts)
46
- opts[:as] ||= role
47
- store.put(key, **opts)
48
- end
49
-
50
- def delete(key, **opts)
51
- opts[:as] ||= role
52
- store.delete(key, **opts)
53
- end
54
-
55
- def get(key, **)
56
- store.get(key, **)
57
- end
58
-
59
- def list(*, **)
60
- store.list(*, **)
61
- end
62
-
63
- def where(*, **)
64
- store.where(*, **)
65
- end
66
42
  end
67
43
  end
68
44
  end
@@ -2,11 +2,12 @@ module Textus
2
2
  module Application
3
3
  module Refresh
4
4
  class Orchestrator
5
- def initialize(worker:, bus:, store_root:, store: nil, detached_spawner: nil)
5
+ def initialize(worker:, bus:, store_root:, store: nil, role: "human", detached_spawner: nil)
6
6
  @worker = worker
7
7
  @bus = bus
8
8
  @store_root = store_root
9
9
  @store = store
10
+ @role = role
10
11
  @detached_spawner = detached_spawner || default_spawner
11
12
  end
12
13
 
@@ -46,7 +47,7 @@ module Textus
46
47
 
47
48
  if thread.alive?
48
49
  thread.kill
49
- store_view = @store ? Textus::Store::View.new(@store) : nil
50
+ store_view = @store ? Textus::Application::Context.new(store: @store, role: @role) : nil
50
51
  payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
51
52
  payload[:store] = store_view if store_view
52
53
  @bus.publish(:refresh_detached, **payload)
@@ -23,7 +23,7 @@ module Textus
23
23
  private
24
24
 
25
25
  def read_view
26
- Store::View.new(@ctx.store)
26
+ Application::Context.new(store: @ctx.store, role: @ctx.role)
27
27
  end
28
28
 
29
29
  def fetch_with_bus(key, mentry)
@@ -17,6 +17,8 @@ module Textus
17
17
 
18
18
  case action
19
19
  when "put"
20
+ # Nested proposal "frontmatter" — the meta to write to the accepted
21
+ # target. Not related to the removed intake-handler legacy bridge.
20
22
  target_meta = env["_meta"]["frontmatter"] || {}
21
23
  target_body = env["body"]
22
24
  Composition.writes_put(@ctx).call(target, meta: target_meta, body: target_body)
@@ -28,9 +30,8 @@ module Textus
28
30
 
29
31
  Composition.writes_delete(@ctx).call(pending_key)
30
32
 
31
- store_view = Store::View.new(@ctx.store)
32
33
  @bus.publish(:accepted,
33
- store: store_view,
34
+ store: @ctx.with_role(@ctx.role),
34
35
  key: pending_key,
35
36
  target_key: target,
36
37
  correlation_id: @ctx.correlation_id)
@@ -1,6 +1,12 @@
1
+ require "fileutils"
2
+
1
3
  module Textus
2
4
  module Application
3
5
  module Writes
6
+ # Materializes generator-zone entries (template + projection) onto disk
7
+ # and copies the result to any configured `publish_to` / `publish_each`
8
+ # targets. Fires `:built` and `:published` events on the bus, tagged with
9
+ # the request's correlation_id for traceability.
4
10
  class Build
5
11
  def initialize(ctx:, bus:)
6
12
  @ctx = ctx
@@ -8,15 +14,101 @@ module Textus
8
14
  end
9
15
 
10
16
  def call(prefix: nil)
11
- # Delegate to legacy Builder for the materialization/projection logic.
12
- # Builder fires its own events through @store.fire_event; we do NOT
13
- # double-fire from here. Full extraction of Builder internals into
14
- # Writes::Build is deferred to 0.10.0.
15
- #
16
- # TODO(0.10.0): propagate @ctx.correlation_id through :built/:published
17
- # events once Builder internals are extracted into this use case.
18
- legacy = Textus::Builder.new(@ctx.store)
19
- legacy.build(prefix: prefix)
17
+ built = []
18
+ manifest.entries.each do |mentry|
19
+ next unless mentry.in_generator_zone?
20
+ next unless mentry.projection || mentry.template
21
+ next if prefix && !mentry.key.start_with?(prefix)
22
+
23
+ built << materialize(mentry)
24
+ end
25
+ published_leaves = publish_leaves(prefix: prefix)
26
+ { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => published_leaves }
27
+ end
28
+
29
+ private
30
+
31
+ def store = @ctx.store
32
+ def manifest = store.manifest
33
+ def root = store.root
34
+
35
+ def publish_leaves(prefix: nil)
36
+ repo_root = File.dirname(root)
37
+ out = []
38
+ manifest.entries.each do |mentry|
39
+ next unless mentry.nested && mentry.publish_each
40
+ next if prefix && !mentry.key.start_with?(prefix) && !prefix.start_with?("#{mentry.key}.")
41
+
42
+ manifest.enumerate(prefix: mentry.key).each do |row|
43
+ next unless row[:manifest_entry].equal?(mentry)
44
+ next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
45
+
46
+ out << publish_leaf(mentry, row, repo_root)
47
+ end
48
+ end
49
+ out
50
+ end
51
+
52
+ def publish_leaf(mentry, row, repo_root)
53
+ target_rel = mentry.publish_target_for(row[:key])
54
+ target_abs = File.expand_path(File.join(repo_root, target_rel))
55
+ unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
56
+ raise PublishError.new(
57
+ "entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
58
+ )
59
+ end
60
+
61
+ Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: root)
62
+ publish_event(:published,
63
+ key: row[:key],
64
+ envelope: store.get(row[:key]),
65
+ source: row[:path],
66
+ target: target_abs)
67
+ { "key" => row[:key], "source" => row[:path], "target" => target_abs }
68
+ end
69
+
70
+ def materialize(mentry)
71
+ target_path = Builder::Pipeline.run(
72
+ store: store,
73
+ mentry: mentry,
74
+ template_loader: ->(name) { read_template(name) },
75
+ )
76
+ publish_and_fire(mentry, target_path)
77
+ { "key" => mentry.key, "path" => target_path, "published_to" => mentry.publish_to }
78
+ end
79
+
80
+ def read_template(name)
81
+ tpl_path = File.join(root, "templates", name)
82
+ raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
83
+
84
+ File.read(tpl_path)
85
+ end
86
+
87
+ def publish_and_fire(mentry, target_path)
88
+ envelope = store.get(mentry.key)
89
+ repo_root = File.dirname(root)
90
+
91
+ mentry.publish_to.each do |rel|
92
+ target_abs = File.join(repo_root, rel)
93
+ Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: root)
94
+ publish_event(:published,
95
+ key: mentry.key,
96
+ envelope: envelope,
97
+ source: target_path,
98
+ target: target_abs)
99
+ end
100
+
101
+ publish_event(:built,
102
+ key: mentry.key,
103
+ envelope: envelope,
104
+ sources: Array(mentry.projection&.fetch("select", nil)).compact)
105
+ end
106
+
107
+ def publish_event(event, **payload)
108
+ # `with_role` returns a Context that preserves the original
109
+ # correlation_id, so hooks reading `store.correlation_id` see the
110
+ # same value as the event's top-level correlation_id key.
111
+ @bus.publish(event, store: @ctx.with_role(@ctx.role), correlation_id: @ctx.correlation_id, **payload)
20
112
  end
21
113
  end
22
114
  end
@@ -22,9 +22,8 @@ module Textus
22
22
  )
23
23
 
24
24
  unless suppress_events
25
- store_view = Store::View.new(@ctx.store)
26
25
  @bus.publish(:deleted,
27
- store: store_view,
26
+ store: @ctx.with_role(@ctx.role),
28
27
  key: key,
29
28
  correlation_id: @ctx.correlation_id)
30
29
  end
@@ -28,9 +28,8 @@ module Textus
28
28
  )
29
29
 
30
30
  unless suppress_events
31
- store_view = Store::View.new(@ctx.store)
32
31
  @bus.publish(:put,
33
- store: store_view,
32
+ store: @ctx.with_role(@ctx.role),
34
33
  key: key,
35
34
  envelope: envelope,
36
35
  correlation_id: @ctx.correlation_id)
@@ -2,7 +2,7 @@ require "fileutils"
2
2
  require "time"
3
3
 
4
4
  module Textus
5
- class Builder
5
+ module Builder
6
6
  module InjectMeta
7
7
  # Returns a new hash with _meta as the first key, per SPEC §6 ordering.
8
8
  def self.call(content_hash, mentry)
@@ -1,7 +1,7 @@
1
1
  require "json"
2
2
 
3
3
  module Textus
4
- class Builder
4
+ module Builder
5
5
  class Renderer
6
6
  class Json < Renderer
7
7
  def call(mentry:, data:)
@@ -1,7 +1,7 @@
1
1
  require "time"
2
2
 
3
3
  module Textus
4
- class Builder
4
+ module Builder
5
5
  class Renderer
6
6
  class Markdown < Renderer
7
7
  def call(mentry:, data:)
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- class Builder
2
+ module Builder
3
3
  class Renderer
4
4
  class Text < Renderer
5
5
  def call(mentry:, data:)
@@ -1,7 +1,7 @@
1
1
  require "yaml"
2
2
 
3
3
  module Textus
4
- class Builder
4
+ module Builder
5
5
  class Renderer
6
6
  class Yaml < Renderer
7
7
  def call(mentry:, data:)
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- class Builder
2
+ module Builder
3
3
  # Abstract base for output renderers. Each concrete renderer owns
4
4
  # producing the bytes for one manifest format (markdown/json/yaml/text).
5
5
  class Renderer
@@ -6,8 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("accept requires a key")
9
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
- ctx = Textus::Composition.context(store, role: role)
9
+ ctx = context_for(store)
11
10
  emit(Textus::Composition.writes_accept(ctx).call(key))
12
11
  end
13
12
  end
@@ -11,8 +11,7 @@ module Textus
11
11
  option :limit, "--limit=N"
12
12
 
13
13
  def call(store)
14
- role = Role.resolve(flag: nil, env: ENV, root: store.root)
15
- ctx = Textus::Composition.context(store, role: role)
14
+ ctx = context_for(store)
16
15
  since_time = since && Textus::Application::Reads::Audit.parse_since(since, now: ctx.now)
17
16
  rows = Textus::Composition.audit(ctx).call(
18
17
  key: key_filter,
@@ -6,8 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("blame requires a key")
9
- role = Role.resolve(flag: nil, env: ENV, root: store.root)
10
- ctx = Textus::Composition.context(store, role: role)
9
+ ctx = context_for(store)
11
10
  rows = Textus::Composition.blame(ctx).call(key: key, limit: limit&.to_i)
12
11
  emit({ "verb" => "blame", "key" => key, "rows" => rows })
13
12
  end
@@ -7,8 +7,7 @@ module Textus
7
7
 
8
8
  def call(store)
9
9
  key = positional.shift or raise UsageError.new("delete requires a key")
10
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
11
- ctx = Textus::Composition.context(store, role: role)
10
+ ctx = context_for(store)
12
11
  emit(Textus::Composition.writes_delete(ctx).call(key, if_etag: if_etag))
13
12
  end
14
13
  end
@@ -6,8 +6,7 @@ module Textus
6
6
  option :zone, "--zone=Z"
7
7
 
8
8
  def call(store)
9
- role = Role.resolve(flag: nil, env: ENV, root: store.root)
10
- ctx = Textus::Composition.context(store, role: role)
9
+ ctx = context_for(store)
11
10
  rows = Textus::Composition.freshness(ctx).call(prefix: prefix, zone: zone)
12
11
  emit({ "verb" => "freshness", "rows" => rows })
13
12
  end
@@ -6,8 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("get requires a key")
9
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
- ctx = Textus::Composition.context(store, role: role)
9
+ ctx = context_for(store)
11
10
  result = Textus::Composition.reads_get(ctx).call(key)
12
11
  raise Textus::UnknownKey.new(key, suggestions: store.manifest.suggestions_for(key)) if result.nil?
13
12
 
@@ -24,7 +24,7 @@ module Textus
24
24
 
25
25
  role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
26
26
  callable = store.registry.rpc_callable(:intake, name)
27
- view = Store::View.new(store, writable: true, as: role)
27
+ view = Application::Context.new(store: store, role: role)
28
28
 
29
29
  begin
30
30
  Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
@@ -8,8 +8,7 @@ module Textus
8
8
  def call(store)
9
9
  old_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
10
10
  new_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
11
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
12
- emit(store.mv(old_key, new_key, as: role, dry_run: dry_run || false))
11
+ emit(store.mv(old_key, new_key, as: resolved_role(store), dry_run: dry_run || false))
13
12
  end
14
13
  end
15
14
  end
@@ -4,8 +4,7 @@ module Textus
4
4
  class PolicyExplain < Verb
5
5
  def call(store)
6
6
  key = positional.shift or raise UsageError.new("policy explain requires a KEY")
7
- role = Role.resolve(flag: nil, env: ENV, root: store.root)
8
- ctx = Textus::Composition.context(store, role: role)
7
+ ctx = context_for(store)
9
8
  result = Textus::Composition.policy_explain(ctx).call(key: key)
10
9
  emit({ "verb" => "policy_explain" }.merge(result.transform_keys(&:to_s)))
11
10
  end
@@ -10,7 +10,7 @@ module Textus
10
10
  key = positional.shift or raise UsageError.new("put requires a key")
11
11
  raise UsageError.new("put requires --stdin in v1") unless use_stdin
12
12
 
13
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
13
+ role = resolved_role(store)
14
14
 
15
15
  raw = @stdin.read
16
16
  payload =
@@ -19,7 +19,8 @@ module Textus
19
19
  result =
20
20
  begin
21
21
  Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
22
- callable.call(config: { "bytes" => raw }, store: Textus::Store::View.new(store), args: {})
22
+ callable.call(config: { "bytes" => raw },
23
+ store: Textus::Application::Context.new(store: store, role: role), args: {})
23
24
  end
24
25
  rescue Timeout::Error
25
26
  raise UsageError.new(
@@ -32,14 +33,14 @@ module Textus
32
33
  "name" => basename,
33
34
  "last_refreshed_at" => Time.now.utc.iso8601,
34
35
  "fetched_with" => fetch_name,
35
- }.merge(result[:_meta] || result["_meta"] || result[:frontmatter] || result["frontmatter"] || {}),
36
+ }.merge(result[:_meta] || result["_meta"] || {}),
36
37
  "body" => result[:body] || result["body"] || "",
37
38
  }
38
39
  else
39
40
  JSON.parse(raw)
40
41
  end
41
42
 
42
- meta = payload["_meta"] || payload["frontmatter"] || {}
43
+ meta = payload["_meta"] || {}
43
44
  body = payload["body"] || ""
44
45
  if_etag = payload["if_etag"]
45
46
  ctx = Textus::Composition.context(store, role: role)
@@ -6,8 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("refresh requires a key")
9
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
- ctx = Textus::Composition.context(store, role: role)
9
+ ctx = context_for(store)
11
10
  emit(Textus::Composition.refresh_worker(ctx).run(key))
12
11
  end
13
12
  end
@@ -7,8 +7,7 @@ module Textus
7
7
  option :as_flag, "--as=ROLE"
8
8
 
9
9
  def call(store)
10
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
11
- ctx = Textus::Composition.context(store, role: role)
10
+ ctx = context_for(store)
12
11
  result = Textus::Application::Refresh::All.call(ctx, prefix: prefix, zone: zone)
13
12
  emit(result)
14
13
  exit(1) unless result["ok"]
@@ -6,8 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("reject requires a key")
9
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
- emit(store.reject(key, as: role))
9
+ emit(store.reject(key, as: resolved_role(store)))
11
10
  end
12
11
  end
13
12
  end
@@ -57,6 +57,20 @@ module Textus
57
57
  @stdout.puts(JSON.generate(payload))
58
58
  exit_code
59
59
  end
60
+
61
+ # Resolves the active role for this invocation. Honors the verb's
62
+ # `--as` flag if declared, then TEXTUS_ROLE, then the project default.
63
+ def resolved_role(store)
64
+ flag = respond_to?(:as_flag) ? as_flag : nil
65
+ Role.resolve(flag: flag, env: ENV, root: store.root)
66
+ end
67
+
68
+ # Returns an Application::Context bound to the resolved role.
69
+ # Convenience for verbs whose only pre-call boilerplate is
70
+ # resolving the role and wrapping it in a context.
71
+ def context_for(store)
72
+ Textus::Composition.context(store, role: resolved_role(store))
73
+ end
60
74
  end
61
75
  end
62
76
  end
@@ -41,6 +41,7 @@ module Textus
41
41
  bus: ctx.store.bus,
42
42
  store_root: ctx.store.root,
43
43
  store: ctx.store,
44
+ role: ctx.role,
44
45
  )
45
46
  end
46
47
 
data/lib/textus/doctor.rb CHANGED
@@ -52,7 +52,7 @@ module Textus
52
52
 
53
53
  def run_registered_checks(store)
54
54
  out = []
55
- view = Store::View.new(store)
55
+ view = Application::Context.new(store: store, role: "human")
56
56
  store.registry.rpc_names(:check).each do |name|
57
57
  callable = store.registry.rpc_callable(:check, name)
58
58
  begin
@@ -86,5 +86,7 @@ module Textus
86
86
  "fix" => fix,
87
87
  }
88
88
  end
89
+
90
+ private_class_method :run_registered_checks, :fail_issue
89
91
  end
90
92
  end
data/lib/textus/intro.rb CHANGED
@@ -16,11 +16,6 @@ module Textus
16
16
  "inbox" => "declared external inputs; script-refreshed via actions",
17
17
  "review" => "AI proposals awaiting human accept",
18
18
  "output" => "build-computed outputs; never hand-edited",
19
- # legacy 0.9.1 zone names — kept so intro still annotates pre-rename stores
20
- "canon" => "slow-changing identity; human-only writes",
21
- "intake" => "declared external inputs; script-refreshed via actions",
22
- "pending" => "AI proposals awaiting human accept",
23
- "derived" => "build-computed outputs; never hand-edited",
24
19
  }.freeze
25
20
 
26
21
  WRITE_FLOWS = {
@@ -56,15 +56,32 @@ module Textus
56
56
  @publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
57
57
  end
58
58
 
59
+ # Signal-based zone-kind predicates: derive the "kind" of a zone from its
60
+ # writable_by signals rather than its literal name. This keeps detection
61
+ # working when users rename the default zones (canon/intake/pending/derived
62
+ # → identity/inbox/review/output, etc.).
63
+ def in_generator_zone?
64
+ zone_writers.include?("build")
65
+ end
66
+
67
+ def in_proposal_zone?
68
+ zone_writers.include?("ai")
69
+ end
70
+
71
+ # Legacy alias for in_generator_zone?. Retained because internal validation
72
+ # callers (and external tools) read more naturally as `derived?`.
59
73
  def derived?
60
- writers = @manifest.zone_writers(@zone)
61
- writers.include?("build")
62
- rescue UsageError => e
63
- raise UsageError.new("entry '#{@key}': #{e.message}")
74
+ in_generator_zone?
64
75
  end
65
76
 
66
77
  private
67
78
 
79
+ def zone_writers
80
+ @manifest.zone_writers(@zone)
81
+ rescue UsageError => e
82
+ raise UsageError.new("entry '#{@key}': #{e.message}")
83
+ end
84
+
68
85
  def validate_inject_intro!
69
86
  return unless @inject_intro
70
87
 
@@ -136,17 +136,6 @@ module Textus
136
136
  end
137
137
  # rubocop:enable Metrics/AbcSize
138
138
 
139
- # Validates all declared entry keys; raises UsageError listing all offenders.
140
- def validate_keys!
141
- offenders = []
142
- @entries.each do |entry|
143
- validate_key!(entry.key)
144
- rescue UsageError => e
145
- offenders << e.message
146
- end
147
- raise UsageError.new("invalid manifest keys: #{offenders.join("; ")}") unless offenders.empty?
148
- end
149
-
150
139
  def validate_key!(key)
151
140
  raise UsageError.new("empty key") if key.nil? || key.empty?
152
141
 
@@ -40,7 +40,7 @@ module Textus
40
40
  def apply_reducer(rows)
41
41
  name = @spec["reduce"] or return rows
42
42
  callable = @store.registry.rpc_callable(:reduce, name)
43
- view = Store::View.new(@store)
43
+ view = Application::Context.new(store: @store, role: "human")
44
44
  Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
45
45
  callable.call(store: view, rows: rows, config: @spec["reduce_config"] || {})
46
46
  end
@@ -15,8 +15,7 @@ module Textus
15
15
  def self.normalize_action_result(res, format:)
16
16
  res = res.transform_keys(&:to_s) if res.is_a?(Hash)
17
17
  res ||= {}
18
- # Accept both legacy :frontmatter/:_meta key names from intake hooks.
19
- meta_val = res["_meta"] || res["frontmatter"]
18
+ meta_val = res["_meta"]
20
19
  body = res["body"]
21
20
  content = res["content"]
22
21
 
@@ -11,7 +11,7 @@ module Textus
11
11
  def call(prefix: nil, zone: nil)
12
12
  out = []
13
13
  @manifest.entries.each do |mentry|
14
- next unless mentry.zone == "derived"
14
+ next unless mentry.in_generator_zone?
15
15
  next if zone && mentry.zone != zone
16
16
 
17
17
  gen = mentry.generator
@@ -152,7 +152,7 @@ module Textus
152
152
  raise ProposalError.new("only human role can reject proposals; got '#{as}'") unless as == "human"
153
153
 
154
154
  mentry, = @store.manifest.resolve(pending_key)
155
- raise ProposalError.new("reject: '#{pending_key}' is not a pending entry (zone=#{mentry.zone})") unless mentry.zone == "pending"
155
+ raise ProposalError.new("reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.zone})") unless mentry.in_proposal_zone?
156
156
 
157
157
  env = @store.get(pending_key)
158
158
  proposal = env.dig("_meta", "proposal") or
data/lib/textus/store.rb CHANGED
@@ -102,7 +102,7 @@ module Textus
102
102
  end
103
103
 
104
104
  def fire_event(event, **)
105
- view = Store::View.new(self)
105
+ view = Textus::Application::Context.new(store: self, role: "human")
106
106
  @bus.publish(event, store: view, **)
107
107
  end
108
108
 
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.9.2"
2
+ VERSION = "0.10.0"
3
3
  PROTOCOL = "textus/2"
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.9.2
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -123,7 +123,6 @@ files:
123
123
  - lib/textus/application/writes/delete.rb
124
124
  - lib/textus/application/writes/publish.rb
125
125
  - lib/textus/application/writes/put.rb
126
- - lib/textus/builder.rb
127
126
  - lib/textus/builder/pipeline.rb
128
127
  - lib/textus/builder/renderer.rb
129
128
  - lib/textus/builder/renderer/json.rb
@@ -226,7 +225,6 @@ files:
226
225
  - lib/textus/mustache.rb
227
226
  - lib/textus/projection.rb
228
227
  - lib/textus/proposal.rb
229
- - lib/textus/publisher.rb
230
228
  - lib/textus/refresh.rb
231
229
  - lib/textus/role.rb
232
230
  - lib/textus/schema.rb
@@ -237,7 +235,6 @@ files:
237
235
  - lib/textus/store/reader.rb
238
236
  - lib/textus/store/staleness.rb
239
237
  - lib/textus/store/validator.rb
240
- - lib/textus/store/view.rb
241
238
  - lib/textus/store/writer.rb
242
239
  - lib/textus/version.rb
243
240
  homepage: https://github.com/patrick204nqh/textus
@@ -1,99 +0,0 @@
1
- require "fileutils"
2
-
3
- # As of 0.9.1, Textus::Application::Writes::Build is the preferred public
4
- # entry point. This class remains as the implementation home of materialization
5
- # and projection logic; full extraction is deferred to 0.10.0.
6
- module Textus
7
- class Builder
8
- def initialize(store)
9
- @store = store
10
- @manifest = store.manifest
11
- @root = store.root
12
- end
13
-
14
- def build(prefix: nil)
15
- built = []
16
- @manifest.entries.each do |mentry|
17
- next unless derived_zone?(mentry)
18
- next unless mentry.projection || mentry.template
19
- next if prefix && !mentry.key.start_with?(prefix)
20
-
21
- result = materialize(mentry)
22
- built << result
23
- end
24
- published_leaves = publish_leaves(prefix: prefix)
25
- { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => published_leaves }
26
- end
27
-
28
- private
29
-
30
- def publish_leaves(prefix: nil)
31
- repo_root = File.dirname(@root)
32
- out = []
33
- @manifest.entries.each do |mentry|
34
- next unless mentry.nested && mentry.publish_each
35
- next if prefix && !mentry.key.start_with?(prefix) && !prefix.start_with?("#{mentry.key}.")
36
-
37
- @manifest.enumerate(prefix: mentry.key).each do |row|
38
- next unless row[:manifest_entry].equal?(mentry)
39
- next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
40
-
41
- out << publish_leaf(mentry, row, repo_root)
42
- end
43
- end
44
- out
45
- end
46
-
47
- def publish_leaf(mentry, row, repo_root)
48
- target_rel = mentry.publish_target_for(row[:key])
49
- target_abs = File.expand_path(File.join(repo_root, target_rel))
50
- unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
51
- raise PublishError.new(
52
- "entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
53
- )
54
- end
55
-
56
- Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: @root)
57
- @store.fire_event(:published, key: row[:key], envelope: @store.get(row[:key]),
58
- source: row[:path], target: target_abs)
59
- { "key" => row[:key], "source" => row[:path], "target" => target_abs }
60
- end
61
-
62
- def derived_zone?(mentry)
63
- writers = @manifest.zone_writers(mentry.zone)
64
- writers.include?("build")
65
- end
66
-
67
- def materialize(mentry)
68
- target_path = Pipeline.run(
69
- store: @store,
70
- mentry: mentry,
71
- template_loader: ->(name) { read_template(name) },
72
- )
73
- publish_and_fire(mentry, target_path)
74
- { "key" => mentry.key, "path" => target_path, "published_to" => mentry.publish_to }
75
- end
76
-
77
- def read_template(name)
78
- tpl_path = File.join(@root, "templates", name)
79
- raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
80
-
81
- File.read(tpl_path)
82
- end
83
-
84
- def publish_and_fire(mentry, target_path)
85
- envelope = @store.get(mentry.key)
86
- repo_root = File.dirname(@root)
87
-
88
- mentry.publish_to.each do |rel|
89
- target_abs = File.join(repo_root, rel)
90
- Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: @root)
91
- @store.fire_event(:published, key: mentry.key, envelope: envelope,
92
- source: target_path, target: target_abs)
93
- end
94
-
95
- @store.fire_event(:built, key: mentry.key, envelope: envelope,
96
- sources: Array(mentry.projection&.fetch("select", nil)).compact)
97
- end
98
- end
99
- end
@@ -1,6 +0,0 @@
1
- # Deprecated as of 0.9.1: use Textus::Infra::Publisher (or
2
- # Textus::Application::Writes::Publish for the use-case entry point).
3
- # Slated for removal in 0.10.0.
4
- module Textus
5
- Publisher = Infra::Publisher
6
- end
@@ -1,18 +0,0 @@
1
- module Textus
2
- class Store
3
- # Deprecated as of 0.9.1: use Textus::Application::Context instead.
4
- # Removal scheduled for 0.10.0.
5
- class View
6
- def self.new(store, writable: false, as: nil)
7
- unless @warned_once
8
- warn "[textus] Store::View is deprecated; use Application::Context (will be removed in 0.10.0)"
9
- @warned_once = true
10
- end
11
-
12
- raise UsageError.new("writable Store::View requires an as: role") if writable && (as.nil? || as.to_s.empty?)
13
-
14
- Textus::Application::Context.new(store: store, role: as || "human")
15
- end
16
- end
17
- end
18
- end