textus 0.49.0 → 0.51.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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +41 -43
  4. data/SPEC.md +176 -197
  5. data/docs/architecture/README.md +46 -42
  6. data/docs/reference/conventions.md +33 -28
  7. data/lib/textus/boot.rb +58 -47
  8. data/lib/textus/call.rb +1 -1
  9. data/lib/textus/cli/runner.rb +15 -10
  10. data/lib/textus/cli/verb/get.rb +1 -3
  11. data/lib/textus/cli/verb/hook_run.rb +1 -1
  12. data/lib/textus/cli/verb/put.rb +4 -20
  13. data/lib/textus/cli.rb +1 -4
  14. data/lib/textus/dispatcher.rb +1 -3
  15. data/lib/textus/doctor/check/generator_drift.rb +4 -3
  16. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  17. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  18. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  19. data/lib/textus/doctor/check/sentinels.rb +2 -2
  20. data/lib/textus/doctor/check/templates.rb +13 -11
  21. data/lib/textus/doctor.rb +0 -2
  22. data/lib/textus/domain/freshness/evaluator.rb +150 -14
  23. data/lib/textus/domain/freshness/verdict.rb +28 -6
  24. data/lib/textus/domain/freshness.rb +4 -33
  25. data/lib/textus/domain/policy/base_guards.rb +1 -1
  26. data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
  27. data/lib/textus/domain/policy/publish_target.rb +34 -0
  28. data/lib/textus/domain/policy/retention.rb +29 -0
  29. data/lib/textus/domain/policy/source.rb +79 -0
  30. data/lib/textus/domain/retention/sweep.rb +57 -0
  31. data/lib/textus/domain/retention.rb +11 -0
  32. data/lib/textus/errors.rb +4 -4
  33. data/lib/textus/hooks/builtin.rb +5 -5
  34. data/lib/textus/hooks/catalog.rb +8 -7
  35. data/lib/textus/hooks/context.rb +5 -10
  36. data/lib/textus/init/templates/machine_intake.rb +4 -4
  37. data/lib/textus/init.rb +47 -47
  38. data/lib/textus/key/matching.rb +24 -0
  39. data/lib/textus/maintenance/reconcile.rb +160 -0
  40. data/lib/textus/manifest/capabilities.rb +1 -1
  41. data/lib/textus/manifest/data.rb +2 -2
  42. data/lib/textus/manifest/entry/base.rb +28 -8
  43. data/lib/textus/manifest/entry/nested.rb +3 -4
  44. data/lib/textus/manifest/entry/parser.rb +25 -21
  45. data/lib/textus/manifest/entry/produced.rb +56 -0
  46. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
  47. data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
  48. data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
  49. data/lib/textus/manifest/entry/validators/publish.rb +3 -1
  50. data/lib/textus/manifest/entry/validators.rb +0 -1
  51. data/lib/textus/manifest/policy.rb +16 -4
  52. data/lib/textus/manifest/resolver.rb +10 -4
  53. data/lib/textus/manifest/rules.rb +37 -36
  54. data/lib/textus/manifest/schema/keys.rb +98 -0
  55. data/lib/textus/manifest/schema/validator.rb +324 -0
  56. data/lib/textus/manifest/schema/vocabulary.rb +24 -0
  57. data/lib/textus/manifest/schema.rb +27 -247
  58. data/lib/textus/manifest.rb +5 -3
  59. data/lib/textus/mcp/server.rb +9 -2
  60. data/lib/textus/ports/audit_log.rb +6 -0
  61. data/lib/textus/ports/build_lock.rb +6 -0
  62. data/lib/textus/ports/clock.rb +4 -3
  63. data/lib/textus/ports/produce_on_write_subscriber.rb +69 -0
  64. data/lib/textus/ports/publisher.rb +11 -7
  65. data/lib/textus/produce/acquire/handler.rb +29 -0
  66. data/lib/textus/produce/acquire/intake.rb +130 -0
  67. data/lib/textus/produce/acquire/projection.rb +127 -0
  68. data/lib/textus/produce/acquire/serializer/json.rb +31 -0
  69. data/lib/textus/produce/acquire/serializer/text.rb +16 -0
  70. data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
  71. data/lib/textus/produce/acquire/serializer.rb +17 -0
  72. data/lib/textus/produce/engine.rb +143 -0
  73. data/lib/textus/produce/events.rb +36 -0
  74. data/lib/textus/produce/render.rb +23 -0
  75. data/lib/textus/projection.rb +17 -6
  76. data/lib/textus/read/boot.rb +4 -2
  77. data/lib/textus/read/deps.rb +3 -3
  78. data/lib/textus/read/freshness.rb +63 -29
  79. data/lib/textus/read/get.rb +20 -102
  80. data/lib/textus/read/rdeps.rb +3 -3
  81. data/lib/textus/read/rule_explain.rb +41 -23
  82. data/lib/textus/read/rule_list.rb +25 -8
  83. data/lib/textus/read/validate_all.rb +14 -0
  84. data/lib/textus/role.rb +2 -1
  85. data/lib/textus/schemas.rb +8 -0
  86. data/lib/textus/store.rb +1 -0
  87. data/lib/textus/version.rb +1 -1
  88. data/lib/textus/write/put.rb +1 -1
  89. metadata +23 -30
  90. data/lib/textus/builder/pipeline.rb +0 -88
  91. data/lib/textus/builder/renderer/json.rb +0 -45
  92. data/lib/textus/builder/renderer/markdown.rb +0 -24
  93. data/lib/textus/builder/renderer/text.rb +0 -14
  94. data/lib/textus/builder/renderer/yaml.rb +0 -45
  95. data/lib/textus/builder/renderer.rb +0 -17
  96. data/lib/textus/cli/verb/boot.rb +0 -13
  97. data/lib/textus/cli/verb/build.rb +0 -15
  98. data/lib/textus/doctor/check/fetch_locks.rb +0 -49
  99. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
  100. data/lib/textus/domain/freshness/policy.rb +0 -18
  101. data/lib/textus/domain/lifecycle.rb +0 -83
  102. data/lib/textus/domain/outcome.rb +0 -10
  103. data/lib/textus/domain/policy/lifecycle.rb +0 -35
  104. data/lib/textus/domain/staleness/generator_check.rb +0 -109
  105. data/lib/textus/domain/staleness.rb +0 -29
  106. data/lib/textus/maintenance/tend.rb +0 -110
  107. data/lib/textus/manifest/entry/derived.rb +0 -65
  108. data/lib/textus/manifest/entry/intake.rb +0 -31
  109. data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
  110. data/lib/textus/mcp/tools.rb +0 -14
  111. data/lib/textus/ports/fetch/detached.rb +0 -52
  112. data/lib/textus/ports/fetch/lock.rb +0 -44
  113. data/lib/textus/write/build.rb +0 -90
  114. data/lib/textus/write/fetch_events.rb +0 -42
  115. data/lib/textus/write/fetch_orchestrator.rb +0 -101
  116. data/lib/textus/write/fetch_worker.rb +0 -127
  117. data/lib/textus/write/intake_fetch.rb +0 -25
  118. data/lib/textus/write/materializer.rb +0 -51
@@ -1,44 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Ports
5
- module Fetch
6
- class Lock
7
- def initialize(root:, key:)
8
- @root = root
9
- @key = key
10
- @path = File.join(Textus::Layout.locks(root), "#{safe_key}.lock")
11
- @file = nil
12
- end
13
-
14
- def try_acquire # rubocop:disable Naming/PredicateMethod
15
- FileUtils.mkdir_p(File.dirname(@path))
16
- @file = File.open(@path, File::RDWR | File::CREAT, 0o644)
17
- acquired = @file.flock(File::LOCK_EX | File::LOCK_NB)
18
- unless acquired
19
- @file.close
20
- @file = nil
21
- return false
22
- end
23
- @file.write(Process.pid.to_s)
24
- @file.flush
25
- true
26
- end
27
-
28
- def release
29
- return unless @file
30
-
31
- @file.flock(File::LOCK_UN)
32
- @file.close
33
- @file = nil
34
- end
35
-
36
- private
37
-
38
- def safe_key
39
- @key.to_s.gsub(/[^a-zA-Z0-9._-]/, "_")
40
- end
41
- end
42
- end
43
- end
44
- end
@@ -1,90 +0,0 @@
1
- module Textus
2
- module Write
3
- # Single-pass build use case (the verb `build`, ADR 0061): dispatches
4
- # polymorphically to each entry's `publish_via` method — the copy-out step
5
- # (`publish` is the output-destination concept the verb drives, not the verb).
6
- # Derived entries materialize their body via Materializer; Nested entries
7
- # mirror their subtree via publish_tree; Leaf and Intake entries copy their
8
- # stored body to publish_to targets. The Build layer owns wiring (context,
9
- # accumulation) but not per-kind logic.
10
- #
11
- # Return shape: { "protocol", "built", "published_leaves" }
12
- class Build
13
- extend Textus::Contract::DSL
14
-
15
- verb :build
16
- summary "materialize derived entries; publish_to and publish_tree fan out copies"
17
- surfaces :cli, :mcp
18
- cli "build"
19
- around :build_lock
20
- arg :prefix, String, required: false, description: "limit the build to keys under this prefix"
21
-
22
- def initialize(container:, call:)
23
- @container = container
24
- @call = call
25
- @manifest = container.manifest
26
- end
27
-
28
- def call(prefix: nil)
29
- build_role = @manifest.policy.actor_for("build") or
30
- raise Textus::UsageError.new(
31
- "no role holds the 'build' capability",
32
- hint: "declare a role with `can: [build]` in .textus/manifest.yaml",
33
- )
34
- build_call = Textus::Call.build(
35
- role: build_role,
36
- correlation_id: @call.correlation_id,
37
- dry_run: @call.dry_run,
38
- )
39
-
40
- built = []
41
- leaves = []
42
- pruned = []
43
- context = build_context(build_call)
44
-
45
- @manifest.data.entries.each do |mentry|
46
- next if prefix && !entry_matches_prefix?(mentry, prefix)
47
-
48
- result = mentry.publish_via(context, prefix: prefix)
49
- next if result.nil?
50
-
51
- case result[:kind]
52
- when :built then built << result[:value]
53
- when :leaves
54
- leaves.concat(result[:value])
55
- pruned.concat(result[:pruned]) if result[:pruned]
56
- end
57
- end
58
-
59
- { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => leaves, "pruned" => pruned }
60
- end
61
-
62
- private
63
-
64
- def build_context(call)
65
- Textus::Manifest::Entry::Base::PublishContext.new(
66
- container: @container,
67
- call: call,
68
- reader: reader(call),
69
- )
70
- end
71
-
72
- # Whether the entry should be processed for the given prefix filter.
73
- def entry_matches_prefix?(mentry, prefix)
74
- return true unless prefix
75
-
76
- case mentry
77
- when Textus::Manifest::Entry::Nested
78
- mentry.key.start_with?(prefix) ||
79
- prefix.start_with?("#{mentry.key}.")
80
- else
81
- mentry.key.start_with?(prefix)
82
- end
83
- end
84
-
85
- def reader(call)
86
- Textus::Read::Get.new(container: @container, call: call)
87
- end
88
- end
89
- end
90
- end
@@ -1,42 +0,0 @@
1
- module Textus
2
- module Write
3
- # Single home for the fetch lifecycle event vocabulary (ADR 0048 D5). Both
4
- # FetchWorker (synchronous semantics) and FetchOrchestrator (async policy)
5
- # emit through this seam so the event names and payload shapes live in one
6
- # place with one derived hook context.
7
- class FetchEvents
8
- def self.from(container:, call:)
9
- new(
10
- events: container.events,
11
- hook_context: Textus::Hooks::Context.for(container: container, call: call),
12
- )
13
- end
14
-
15
- def initialize(events:, hook_context:)
16
- @events = events
17
- @hook_context = hook_context
18
- end
19
-
20
- def started(key, mode: :sync)
21
- @events.publish(:fetch_started, ctx: @hook_context, key: key, mode: mode)
22
- end
23
-
24
- def failed(key, error)
25
- @events.publish(:fetch_failed, ctx: @hook_context, key: key,
26
- error_class: error.class.name, error_message: error.message)
27
- end
28
-
29
- def fetched(key, envelope, change)
30
- return if change == :unchanged
31
-
32
- @events.publish(:entry_fetched, ctx: @hook_context, key: key, envelope: envelope, change: change)
33
- end
34
-
35
- def backgrounded(key, started_at:, budget_ms:)
36
- payload = { key: key, started_at: started_at, budget_ms: budget_ms }
37
- payload[:ctx] = @hook_context if @hook_context
38
- @events.publish(:fetch_backgrounded, **payload)
39
- end
40
- end
41
- end
42
- end
@@ -1,101 +0,0 @@
1
- module Textus
2
- module Write
3
- class FetchOrchestrator
4
- # Collaborator (not a Dispatcher verb): constructed directly by FetchWorker /
5
- # Read::Get, which pass their derived hook_context in. That's why this takes
6
- # hook_context: explicitly while verb use cases derive their own.
7
- def initialize(worker:, store_root:, events:, hook_context: nil, detached_spawner: nil)
8
- @worker = worker
9
- @store_root = store_root
10
- @events = events
11
- @hook_context = hook_context
12
- @detached_spawner = detached_spawner || default_spawner
13
- @fetch_events = Textus::Write::FetchEvents.new(events: @events, hook_context: @hook_context)
14
- end
15
-
16
- def execute(action, key:)
17
- case action
18
- when Textus::Domain::Action::Return then Textus::Domain::Outcome::Skipped.new
19
- when Textus::Domain::Action::FetchSync then run_sync(key)
20
- when Textus::Domain::Action::FetchTimed then run_timed(action.budget_ms, key)
21
- else raise ArgumentError.new("unknown action: #{action.inspect}")
22
- end
23
- end
24
-
25
- private
26
-
27
- def run_sync(key)
28
- envelope = @worker.run(key)
29
- Textus::Domain::Outcome::Fetched.new(envelope: envelope)
30
- rescue Textus::Error => e
31
- Textus::Domain::Outcome::Failed.new(error: e)
32
- end
33
-
34
- def run_timed(budget_ms, key)
35
- return run_timed_with_fork(budget_ms, key) if Textus::Ports::Fetch::Detached.supported?
36
-
37
- run_timed_cooperative(budget_ms, key)
38
- end
39
-
40
- def run_timed_cooperative(budget_ms, key)
41
- result = nil
42
- thread = Thread.new do
43
- result = @worker.run(key)
44
- rescue Textus::Error => e
45
- result = e
46
- end
47
-
48
- thread.join(budget_ms / 1000.0)
49
- if thread.alive?
50
- thread.kill
51
- return Textus::Domain::Outcome::Failed.new(
52
- error: Textus::UsageError.new(
53
- "fetch exceeded budget #{budget_ms}ms (no fork available — cooperative cancel)",
54
- ),
55
- )
56
- end
57
-
58
- if result.is_a?(Textus::Error)
59
- Textus::Domain::Outcome::Failed.new(error: result)
60
- else
61
- Textus::Domain::Outcome::Fetched.new(envelope: result)
62
- end
63
- end
64
-
65
- def run_timed_with_fork(budget_ms, key)
66
- result = nil
67
- thread = Thread.new do
68
- result = @worker.run(key)
69
- rescue Textus::Error => e
70
- result = e
71
- end
72
-
73
- thread.join(budget_ms / 1000.0)
74
-
75
- if thread.alive?
76
- thread.kill
77
-
78
- # Single-flight: if a sibling process / earlier fork holds the
79
- # per-leaf lock, don't fork another worker — they're already
80
- # doing this work.
81
- probe = Textus::Ports::Fetch::Lock.new(root: @store_root, key: key)
82
- return Textus::Domain::Outcome::Detached.new unless probe.try_acquire
83
-
84
- probe.release
85
-
86
- @fetch_events.backgrounded(key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms)
87
- @detached_spawner.call(store_root: @store_root, key: key)
88
- Textus::Domain::Outcome::Detached.new
89
- elsif result.is_a?(Textus::Error)
90
- Textus::Domain::Outcome::Failed.new(error: result)
91
- else
92
- Textus::Domain::Outcome::Fetched.new(envelope: result)
93
- end
94
- end
95
-
96
- def default_spawner
97
- Textus::Ports::Fetch::Detached.method(:spawn)
98
- end
99
- end
100
- end
101
- end
@@ -1,127 +0,0 @@
1
- require "timeout"
2
-
3
- module Textus
4
- module Write
5
- # Internal fetch executor for one quarantine/intake entry. No longer a
6
- # public verb (ADR 0079 collapsed the `fetch` surface): used by `get`'s
7
- # orchestrator (read-through refresh) and by the `tend` sweep.
8
- class FetchWorker
9
- FETCH_TIMEOUT_SECONDS = IntakeFetch::FETCH_TIMEOUT_SECONDS
10
-
11
- def initialize(container:, call:)
12
- @container = container
13
- @call = call
14
- @manifest = container.manifest
15
- @schemas = container.schemas
16
- @rpc = container.rpc
17
- end
18
-
19
- # call(key) is the primary entry; run is kept as an alias for
20
- # Orchestrator and FetchAll which call worker.run(key).
21
- def call(key)
22
- run(key)
23
- end
24
-
25
- def run(key)
26
- res = @manifest.resolver.resolve(key)
27
- mentry = res.entry
28
- path = res.path
29
- remaining = res.remaining
30
- raise UsageError.new("no intake declared for '#{key}'") unless mentry.is_a?(Textus::Manifest::Entry::Intake)
31
-
32
- before_etag = @container.file_store.exists?(path) ? @container.file_store.etag(path) : nil
33
- result = fetch_with_events(key, mentry, remaining)
34
- persist_and_notify(key, mentry, result, before_etag)
35
- end
36
-
37
- def self.normalize_action_result(res, format:)
38
- res = res.transform_keys(&:to_s) if res.is_a?(Hash)
39
- res ||= {}
40
- meta_val = res["_meta"]
41
- body = res["body"]
42
- content = res["content"]
43
-
44
- case format
45
- when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
46
- when "text" then { meta: {}, body: body.to_s, content: nil }
47
- when "json", "yaml"
48
- if !content.nil?
49
- { meta: meta_val || {}, body: nil, content: content }
50
- elsif !body.nil?
51
- { meta: {}, body: body.to_s, content: nil }
52
- else
53
- raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
54
- end
55
- else
56
- raise Textus::UsageError.new("unknown format #{format.inspect}")
57
- end
58
- end
59
-
60
- private
61
-
62
- def fetch_events
63
- @fetch_events ||= FetchEvents.from(container: @container, call: @call)
64
- end
65
-
66
- # ADR 0079: a per-rule fetch_timeout_seconds override was an accepted loss
67
- # in the fetch:/retention: → lifecycle: collapse; the constant ceiling
68
- # applies to every intake.
69
- def fetch_timeout_for(_key)
70
- FETCH_TIMEOUT_SECONDS
71
- end
72
-
73
- def fetch_with_events(key, mentry, remaining)
74
- fetch_events.started(key)
75
- call_intake(key, mentry, remaining)
76
- end
77
-
78
- def call_intake(key, mentry, remaining)
79
- IntakeFetch.invoke(
80
- caps: @container, handler: mentry.handler,
81
- config: mentry.config,
82
- args: { trigger_key: key, leaf_segments: remaining || [] },
83
- label: "intake", timeout: fetch_timeout_for(key)
84
- )
85
- rescue Textus::Error => e
86
- fetch_events.failed(key, e)
87
- raise
88
- rescue StandardError => e
89
- fetch_events.failed(key, e)
90
- raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
91
- end
92
-
93
- def persist_and_notify(key, mentry, result, before_etag)
94
- normalized = self.class.normalize_action_result(result, format: mentry.format)
95
- Textus::Domain::Policy::GuardFactory.new(
96
- manifest: @manifest, schemas: @schemas,
97
- ).for(:fetch, key).check!(
98
- Textus::Domain::Policy::Evaluation.new(
99
- actor: @call.role, transition: :fetch, origin: nil,
100
- target: key, envelope: nil, manifest: @manifest
101
- ),
102
- )
103
- envelope = writer.put(
104
- key,
105
- mentry: mentry,
106
- payload: Textus::Envelope::IO::Writer::Payload.new(
107
- meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
108
- ),
109
- )
110
- change = detect_change(before_etag, envelope)
111
- fetch_events.fetched(key, envelope, change)
112
- envelope
113
- end
114
-
115
- def detect_change(before_etag, envelope)
116
- if before_etag.nil? then :created
117
- elsif envelope.etag == before_etag then :unchanged
118
- else :updated
119
- end
120
- end
121
-
122
- def writer
123
- @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
124
- end
125
- end
126
- end
127
- end
@@ -1,25 +0,0 @@
1
- require "timeout"
2
-
3
- module Textus
4
- module Write
5
- # Invokes a :resolve_intake hook handler by name under a timeout — the single
6
- # home for "call the intake handler under a deadline" (ADR 0048 D1). Shared by
7
- # FetchWorker (the :fetch verb), `textus put --fetch`, and `textus hook run`.
8
- # Always passes a Container as `caps:` so the hook contract (ADR 0027) is
9
- # uniform across every entry point. Maps Timeout::Error to a UsageError;
10
- # leaves any other error to the caller (call sites differ in how they wrap).
11
- module IntakeFetch
12
- FETCH_TIMEOUT_SECONDS = 30
13
-
14
- module_function
15
-
16
- def invoke(caps:, handler:, config:, args:, label:, timeout: FETCH_TIMEOUT_SECONDS)
17
- Timeout.timeout(timeout) do
18
- caps.rpc.invoke(:resolve_intake, handler, caps: caps, config: config, args: args)
19
- end
20
- rescue Timeout::Error
21
- raise Textus::UsageError.new("#{label} '#{handler}' exceeded #{timeout}s timeout")
22
- end
23
- end
24
- end
25
- end
@@ -1,51 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Write
5
- # Materializes a single projection-derived manifest entry onto disk by
6
- # running the builder pipeline (projection + template render). External
7
- # entries are NOT materialized here — they are generated by an out-of-band
8
- # runner and only staleness-tracked, so Derived#publish_via filters them out
9
- # before reaching this point.
10
- # Extracted from Write::Build so that Publish can reuse
11
- # it without creating a Build dependency.
12
- class Materializer
13
- def initialize(container:, call:)
14
- @container = container
15
- @call = call
16
- @manifest = container.manifest
17
- @file_store = container.file_store
18
- @rpc = container.rpc
19
- @root = container.root
20
- end
21
-
22
- # Runs the builder pipeline for `mentry` and returns the on-disk
23
- # target_path string.
24
- def run(mentry)
25
- reader = Textus::Read::Get.new(container: @container, call: @call)
26
- lister = Textus::Read::List.new(container: @container)
27
- Builder::Pipeline.run(
28
- mentry: mentry,
29
- deps: Builder::Pipeline::Deps.new(
30
- manifest: @manifest,
31
- reader: reader.method(:call),
32
- lister: lister.method(:call),
33
- rpc: @rpc,
34
- template_loader: ->(name) { read_template(name) },
35
- transform_context: @container,
36
- inject_boot: -> { Textus::Boot.build(container: @container) },
37
- ),
38
- )
39
- end
40
-
41
- private
42
-
43
- def read_template(name)
44
- tpl_path = File.join(@root, "templates", name)
45
- raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
46
-
47
- File.read(tpl_path)
48
- end
49
- end
50
- end
51
- end