textus 0.50.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +41 -43
  4. data/SPEC.md +174 -176
  5. data/docs/architecture/README.md +46 -42
  6. data/docs/reference/conventions.md +31 -26
  7. data/lib/textus/boot.rb +13 -17
  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 -3
  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 -9
  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 +1 -1
  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/deps.rb +3 -3
  77. data/lib/textus/read/freshness.rb +61 -31
  78. data/lib/textus/read/get.rb +20 -102
  79. data/lib/textus/read/rdeps.rb +3 -3
  80. data/lib/textus/read/rule_explain.rb +41 -23
  81. data/lib/textus/read/rule_list.rb +25 -8
  82. data/lib/textus/read/validate_all.rb +14 -0
  83. data/lib/textus/role.rb +2 -1
  84. data/lib/textus/schemas.rb +8 -0
  85. data/lib/textus/store.rb +1 -0
  86. data/lib/textus/version.rb +1 -1
  87. data/lib/textus/write/put.rb +1 -1
  88. metadata +23 -30
  89. data/lib/textus/builder/pipeline.rb +0 -88
  90. data/lib/textus/builder/renderer/json.rb +0 -45
  91. data/lib/textus/builder/renderer/markdown.rb +0 -24
  92. data/lib/textus/builder/renderer/text.rb +0 -14
  93. data/lib/textus/builder/renderer/yaml.rb +0 -45
  94. data/lib/textus/builder/renderer.rb +0 -17
  95. data/lib/textus/cli/verb/boot.rb +0 -14
  96. data/lib/textus/cli/verb/build.rb +0 -15
  97. data/lib/textus/doctor/check/fetch_locks.rb +0 -49
  98. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
  99. data/lib/textus/domain/freshness/policy.rb +0 -18
  100. data/lib/textus/domain/lifecycle.rb +0 -83
  101. data/lib/textus/domain/outcome.rb +0 -10
  102. data/lib/textus/domain/policy/lifecycle.rb +0 -35
  103. data/lib/textus/domain/staleness/generator_check.rb +0 -109
  104. data/lib/textus/domain/staleness.rb +0 -29
  105. data/lib/textus/maintenance/tend.rb +0 -110
  106. data/lib/textus/manifest/entry/derived.rb +0 -67
  107. data/lib/textus/manifest/entry/intake.rb +0 -31
  108. data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
  109. data/lib/textus/mcp/tools.rb +0 -14
  110. data/lib/textus/ports/fetch/detached.rb +0 -52
  111. data/lib/textus/ports/fetch/lock.rb +0 -44
  112. data/lib/textus/write/build.rb +0 -90
  113. data/lib/textus/write/fetch_events.rb +0 -42
  114. data/lib/textus/write/fetch_orchestrator.rb +0 -101
  115. data/lib/textus/write/fetch_worker.rb +0 -127
  116. data/lib/textus/write/intake_fetch.rb +0 -25
  117. data/lib/textus/write/materializer.rb +0 -51
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.50.0
4
+ version: 0.51.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -111,12 +111,6 @@ files:
111
111
  - exe/textus
112
112
  - lib/textus.rb
113
113
  - lib/textus/boot.rb
114
- - lib/textus/builder/pipeline.rb
115
- - lib/textus/builder/renderer.rb
116
- - lib/textus/builder/renderer/json.rb
117
- - lib/textus/builder/renderer/markdown.rb
118
- - lib/textus/builder/renderer/text.rb
119
- - lib/textus/builder/renderer/yaml.rb
120
114
  - lib/textus/call.rb
121
115
  - lib/textus/cli.rb
122
116
  - lib/textus/cli/group.rb
@@ -128,8 +122,6 @@ files:
128
122
  - lib/textus/cli/group/zone.rb
129
123
  - lib/textus/cli/runner.rb
130
124
  - lib/textus/cli/verb.rb
131
- - lib/textus/cli/verb/boot.rb
132
- - lib/textus/cli/verb/build.rb
133
125
  - lib/textus/cli/verb/doctor.rb
134
126
  - lib/textus/cli/verb/get.rb
135
127
  - lib/textus/cli/verb/hook_run.rb
@@ -153,13 +145,11 @@ files:
153
145
  - lib/textus/doctor.rb
154
146
  - lib/textus/doctor/check.rb
155
147
  - lib/textus/doctor/check/audit_log.rb
156
- - lib/textus/doctor/check/fetch_locks.rb
157
148
  - lib/textus/doctor/check/generator_drift.rb
158
149
  - lib/textus/doctor/check/handler_allowlist.rb
159
150
  - lib/textus/doctor/check/hooks.rb
160
151
  - lib/textus/doctor/check/illegal_keys.rb
161
152
  - lib/textus/doctor/check/intake_registration.rb
162
- - lib/textus/doctor/check/lifecycle_action_invalid.rb
163
153
  - lib/textus/doctor/check/manifest_files.rb
164
154
  - lib/textus/doctor/check/orphaned_publish_targets.rb
165
155
  - lib/textus/doctor/check/proposal_targets.rb
@@ -176,17 +166,13 @@ files:
176
166
  - lib/textus/domain/duration.rb
177
167
  - lib/textus/domain/freshness.rb
178
168
  - lib/textus/domain/freshness/evaluator.rb
179
- - lib/textus/domain/freshness/policy.rb
180
169
  - lib/textus/domain/freshness/verdict.rb
181
- - lib/textus/domain/lifecycle.rb
182
- - lib/textus/domain/outcome.rb
183
170
  - lib/textus/domain/permission.rb
184
171
  - lib/textus/domain/policy/base_guards.rb
185
172
  - lib/textus/domain/policy/evaluation.rb
186
173
  - lib/textus/domain/policy/guard.rb
187
174
  - lib/textus/domain/policy/guard_factory.rb
188
175
  - lib/textus/domain/policy/handler_allowlist.rb
189
- - lib/textus/domain/policy/lifecycle.rb
190
176
  - lib/textus/domain/policy/matcher.rb
191
177
  - lib/textus/domain/policy/predicates/author_held.rb
192
178
  - lib/textus/domain/policy/predicates/etag_match.rb
@@ -195,9 +181,12 @@ files:
195
181
  - lib/textus/domain/policy/predicates/schema_valid.rb
196
182
  - lib/textus/domain/policy/predicates/target_is_canon.rb
197
183
  - lib/textus/domain/policy/predicates/zone_writable_by.rb
184
+ - lib/textus/domain/policy/publish_target.rb
185
+ - lib/textus/domain/policy/retention.rb
186
+ - lib/textus/domain/policy/source.rb
187
+ - lib/textus/domain/retention.rb
188
+ - lib/textus/domain/retention/sweep.rb
198
189
  - lib/textus/domain/sentinel.rb
199
- - lib/textus/domain/staleness.rb
200
- - lib/textus/domain/staleness/generator_check.rb
201
190
  - lib/textus/entry.rb
202
191
  - lib/textus/entry/base.rb
203
192
  - lib/textus/entry/json.rb
@@ -223,25 +212,25 @@ files:
223
212
  - lib/textus/init/templates/orientation_reducer.rb
224
213
  - lib/textus/key/distance.rb
225
214
  - lib/textus/key/grammar.rb
215
+ - lib/textus/key/matching.rb
226
216
  - lib/textus/key/path.rb
227
217
  - lib/textus/layout.rb
228
218
  - lib/textus/maintenance.rb
229
219
  - lib/textus/maintenance/key_delete_prefix.rb
230
220
  - lib/textus/maintenance/key_mv_prefix.rb
221
+ - lib/textus/maintenance/reconcile.rb
231
222
  - lib/textus/maintenance/rule_lint.rb
232
- - lib/textus/maintenance/tend.rb
233
223
  - lib/textus/maintenance/zone_mv.rb
234
224
  - lib/textus/manifest.rb
235
225
  - lib/textus/manifest/capabilities.rb
236
226
  - lib/textus/manifest/data.rb
237
227
  - lib/textus/manifest/entry.rb
238
228
  - lib/textus/manifest/entry/base.rb
239
- - lib/textus/manifest/entry/derived.rb
240
229
  - lib/textus/manifest/entry/ignore_matcher.rb
241
- - lib/textus/manifest/entry/intake.rb
242
230
  - lib/textus/manifest/entry/leaf.rb
243
231
  - lib/textus/manifest/entry/nested.rb
244
232
  - lib/textus/manifest/entry/parser.rb
233
+ - lib/textus/manifest/entry/produced.rb
245
234
  - lib/textus/manifest/entry/publish.rb
246
235
  - lib/textus/manifest/entry/publish/mode.rb
247
236
  - lib/textus/manifest/entry/publish/none.rb
@@ -253,30 +242,40 @@ files:
253
242
  - lib/textus/manifest/entry/validators/events.rb
254
243
  - lib/textus/manifest/entry/validators/format_matrix.rb
255
244
  - lib/textus/manifest/entry/validators/ignore.rb
256
- - lib/textus/manifest/entry/validators/inject_boot.rb
257
245
  - lib/textus/manifest/entry/validators/publish.rb
258
246
  - lib/textus/manifest/policy.rb
259
247
  - lib/textus/manifest/resolver.rb
260
248
  - lib/textus/manifest/rules.rb
261
249
  - lib/textus/manifest/schema.rb
250
+ - lib/textus/manifest/schema/keys.rb
251
+ - lib/textus/manifest/schema/validator.rb
252
+ - lib/textus/manifest/schema/vocabulary.rb
262
253
  - lib/textus/mcp.rb
263
254
  - lib/textus/mcp/catalog.rb
264
255
  - lib/textus/mcp/errors.rb
265
256
  - lib/textus/mcp/server.rb
266
257
  - lib/textus/mcp/session.rb
267
258
  - lib/textus/mcp/tool_schemas.rb
268
- - lib/textus/mcp/tools.rb
269
259
  - lib/textus/mustache.rb
270
260
  - lib/textus/ports/audit_log.rb
271
261
  - lib/textus/ports/audit_subscriber.rb
272
262
  - lib/textus/ports/build_lock.rb
273
263
  - lib/textus/ports/clock.rb
274
- - lib/textus/ports/fetch/detached.rb
275
- - lib/textus/ports/fetch/lock.rb
264
+ - lib/textus/ports/produce_on_write_subscriber.rb
276
265
  - lib/textus/ports/publisher.rb
277
266
  - lib/textus/ports/sentinel_store.rb
278
267
  - lib/textus/ports/storage/file_stat.rb
279
268
  - lib/textus/ports/storage/file_store.rb
269
+ - lib/textus/produce/acquire/handler.rb
270
+ - lib/textus/produce/acquire/intake.rb
271
+ - lib/textus/produce/acquire/projection.rb
272
+ - lib/textus/produce/acquire/serializer.rb
273
+ - lib/textus/produce/acquire/serializer/json.rb
274
+ - lib/textus/produce/acquire/serializer/text.rb
275
+ - lib/textus/produce/acquire/serializer/yaml.rb
276
+ - lib/textus/produce/engine.rb
277
+ - lib/textus/produce/events.rb
278
+ - lib/textus/produce/render.rb
280
279
  - lib/textus/projection.rb
281
280
  - lib/textus/read/audit.rb
282
281
  - lib/textus/read/blame.rb
@@ -307,14 +306,8 @@ files:
307
306
  - lib/textus/uid.rb
308
307
  - lib/textus/version.rb
309
308
  - lib/textus/write/accept.rb
310
- - lib/textus/write/build.rb
311
- - lib/textus/write/fetch_events.rb
312
- - lib/textus/write/fetch_orchestrator.rb
313
- - lib/textus/write/fetch_worker.rb
314
- - lib/textus/write/intake_fetch.rb
315
309
  - lib/textus/write/key_delete.rb
316
310
  - lib/textus/write/key_mv.rb
317
- - lib/textus/write/materializer.rb
318
311
  - lib/textus/write/propose.rb
319
312
  - lib/textus/write/put.rb
320
313
  - lib/textus/write/reject.rb
@@ -1,88 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Builder
5
- module InjectMeta
6
- # Returns a new hash with _meta as the first key, per SPEC §6 ordering.
7
- # Carries only deterministic provenance (`from`/`reduce`/`template`) — the
8
- # volatile `generated_at` is deliberately NOT stamped, so the built
9
- # artifact is content-addressed and a rebuild is a byte-for-byte no-op
10
- # (ADR 0070). Build time lives out of the tracked artifact.
11
- def self.call(content_hash, mentry)
12
- meta = {}
13
- if mentry.is_a?(Textus::Manifest::Entry::Derived)
14
- src = mentry.source
15
- if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
16
- from = Array(src.select).compact
17
- meta["from"] = from unless from.empty?
18
- meta["reduce"] = src.transform if src.transform
19
- end
20
- end
21
- meta["template"] = mentry.template if mentry.template
22
-
23
- out = { "_meta" => meta }
24
- content_hash.each { |k, v| out[k] = v unless k == "_meta" }
25
- out
26
- end
27
- end
28
-
29
- module Pipeline
30
- Deps = Data.define(
31
- :manifest, :reader, :lister, :rpc, :template_loader, :transform_context, :inject_boot
32
- )
33
-
34
- def self.renderers
35
- @renderers ||= {
36
- "markdown" => Renderer::Markdown,
37
- "text" => Renderer::Text,
38
- "json" => Renderer::Json,
39
- "yaml" => Renderer::Yaml,
40
- }
41
- end
42
-
43
- def self.run(mentry:, deps:)
44
- # 1. Load sources + project + reduce. Only projection-derived entries are
45
- # buildable in-process; External entries are generated out-of-band and are
46
- # filtered out upstream (Derived#publish_via), so reaching here with a
47
- # non-projection source is a wiring bug — fail loudly rather than emit an
48
- # empty payload (and never re-stamp the volatile generated_at, ADR 0070).
49
- unless mentry.is_a?(Textus::Manifest::Entry::Derived) && mentry.projection?
50
- raise UsageError.new(
51
- "builder: '#{mentry.key}' is not a projection-derived entry; only projections are buildable",
52
- )
53
- end
54
-
55
- data =
56
- Textus::Projection.new(
57
- reader: deps.reader,
58
- spec: mentry.source.to_h.transform_keys(&:to_s),
59
- lister: deps.lister,
60
- rpc: deps.rpc,
61
- transform_context: deps.transform_context,
62
- ).run
63
- data = data.merge("boot" => deps.inject_boot.call) if mentry.inject_boot && deps.inject_boot
64
-
65
- # 2. Render
66
- klass = renderers[mentry.format] or
67
- raise UsageError.new("builder: unsupported format #{mentry.format.inspect} for '#{mentry.key}'")
68
- bytes = klass.new(template_loader: deps.template_loader).call(mentry: mentry, data: data)
69
-
70
- # 3. Write (idempotent: skip if only generated_at would differ)
71
- target_path = Key::Path.resolve(deps.manifest.data, mentry)
72
- FileUtils.mkdir_p(File.dirname(target_path))
73
- write_if_changed(target_path, bytes, mentry.format)
74
-
75
- target_path
76
- end
77
-
78
- # Built artifacts are content-addressed (no volatile timestamp, ADR 0070),
79
- # so identity is plain byte-equality: skip the write when nothing changed.
80
- # `format` is retained for signature stability across renderers.
81
- def self.write_if_changed(target_path, bytes, _format)
82
- return if File.exist?(target_path) && File.binread(target_path) == bytes
83
-
84
- File.binwrite(target_path, bytes)
85
- end
86
- end
87
- end
88
- end
@@ -1,45 +0,0 @@
1
- require "json"
2
-
3
- module Textus
4
- module Builder
5
- class Renderer
6
- class Json < Renderer
7
- def call(mentry:, data:)
8
- content = mentry.template ? parse_rendered_template!(mentry, data) : default_shape(mentry, data)
9
- final = mentry.provenance ? InjectMeta.call(content, mentry) : content
10
- Entry.for_format("json").serialize(meta: {}, body: "", content: final)
11
- end
12
-
13
- private
14
-
15
- def parse_rendered_template!(mentry, data)
16
- rendered = Mustache.render(@template_loader.call(mentry.template), data)
17
- begin
18
- parsed = ::JSON.parse(rendered)
19
- rescue ::JSON::ParserError => e
20
- raise BadRender.new("entry '#{mentry.key}': template did not render valid json: #{e.message}", format: "json")
21
- end
22
- unless parsed.is_a?(Hash)
23
- raise BadRender.new("entry '#{mentry.key}': template must render a top-level object/mapping",
24
- format: "json")
25
- end
26
-
27
- parsed
28
- end
29
-
30
- def default_shape(mentry, data)
31
- has_transform = mentry.is_a?(Textus::Manifest::Entry::Derived) &&
32
- mentry.source.is_a?(Textus::Manifest::Entry::Derived::Projection) &&
33
- mentry.source.transform
34
- if has_transform && data.is_a?(Hash) && !data.key?("entries")
35
- data
36
- elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
37
- { "entries" => data["entries"] }
38
- else
39
- data.is_a?(Hash) ? data : { "entries" => Array(data) }
40
- end
41
- end
42
- end
43
- end
44
- end
45
- end
@@ -1,24 +0,0 @@
1
- module Textus
2
- module Builder
3
- class Renderer
4
- class Markdown < Renderer
5
- def call(mentry:, data:)
6
- raise TemplateError.new("entry '#{mentry.key}': markdown build requires a template") unless mentry.template
7
-
8
- body = Mustache.render(@template_loader.call(mentry.template), data)
9
- from = if mentry.is_a?(Textus::Manifest::Entry::Derived) &&
10
- mentry.source.is_a?(Textus::Manifest::Entry::Derived::Projection)
11
- Array(mentry.source.select).compact
12
- else
13
- []
14
- end
15
- # Deterministic frontmatter only — `from` (the source keys), never a
16
- # volatile `generated.at` (ADR 0070): the artifact is content-addressed
17
- # so a rebuild is a byte-for-byte no-op and a revert never drifts.
18
- frontmatter = { "generated" => { "from" => from } }
19
- Entry.for_format("markdown").serialize(meta: frontmatter, body: body)
20
- end
21
- end
22
- end
23
- end
24
- end
@@ -1,14 +0,0 @@
1
- module Textus
2
- module Builder
3
- class Renderer
4
- class Text < Renderer
5
- def call(mentry:, data:)
6
- raise TemplateError.new("entry '#{mentry.key}': text build requires a template") unless mentry.template
7
-
8
- body = Mustache.render(@template_loader.call(mentry.template), data)
9
- Entry.for_format("text").serialize(meta: {}, body: body)
10
- end
11
- end
12
- end
13
- end
14
- end
@@ -1,45 +0,0 @@
1
- require "yaml"
2
-
3
- module Textus
4
- module Builder
5
- class Renderer
6
- class Yaml < Renderer
7
- def call(mentry:, data:)
8
- content = mentry.template ? parse_rendered_template!(mentry, data) : default_shape(mentry, data)
9
- final = InjectMeta.call(content, mentry)
10
- Entry.for_format("yaml").serialize(meta: {}, body: "", content: final)
11
- end
12
-
13
- private
14
-
15
- def parse_rendered_template!(mentry, data)
16
- rendered = Mustache.render(@template_loader.call(mentry.template), data)
17
- begin
18
- parsed = ::YAML.safe_load(rendered, permitted_classes: [Date, Time], aliases: false)
19
- rescue Psych::SyntaxError, Psych::DisallowedClass, Psych::AliasesNotEnabled => e
20
- raise BadRender.new("entry '#{mentry.key}': template did not render valid yaml: #{e.message}", format: "yaml")
21
- end
22
- unless parsed.is_a?(Hash)
23
- raise BadRender.new("entry '#{mentry.key}': template must render a top-level object/mapping",
24
- format: "yaml")
25
- end
26
-
27
- parsed
28
- end
29
-
30
- def default_shape(mentry, data)
31
- has_transform = mentry.is_a?(Textus::Manifest::Entry::Derived) &&
32
- mentry.source.is_a?(Textus::Manifest::Entry::Derived::Projection) &&
33
- mentry.source.transform
34
- if has_transform && data.is_a?(Hash) && !data.key?("entries")
35
- data
36
- elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
37
- { "entries" => data["entries"] }
38
- else
39
- data.is_a?(Hash) ? data : { "entries" => Array(data) }
40
- end
41
- end
42
- end
43
- end
44
- end
45
- end
@@ -1,17 +0,0 @@
1
- module Textus
2
- module Builder
3
- # Abstract base for output renderers. Each concrete renderer owns
4
- # producing the bytes for one manifest format (markdown/json/yaml/text).
5
- class Renderer
6
- def initialize(template_loader:)
7
- @template_loader = template_loader
8
- end
9
-
10
- def call(mentry:, data:)
11
- _ = mentry
12
- _ = data
13
- raise NotImplementedError.new("#{self.class.name}#call not implemented")
14
- end
15
- end
16
- end
17
- end
@@ -1,14 +0,0 @@
1
- module Textus
2
- class CLI
3
- class Verb
4
- class Boot < Verb
5
- command_name "boot"
6
- option :lean, "--lean"
7
-
8
- def call(store)
9
- emit(store.boot(lean: !!lean))
10
- end
11
- end
12
- end
13
- end
14
- end
@@ -1,15 +0,0 @@
1
- module Textus
2
- class CLI
3
- class Verb
4
- class Build < Runner::Base
5
- self.spec = Textus::Write::Build.contract
6
-
7
- option :prefix, "--prefix=K"
8
-
9
- def invoke(store)
10
- emit(store.as(resolved_role(store)).build(prefix: prefix))
11
- end
12
- end
13
- end
14
- end
15
- end
@@ -1,49 +0,0 @@
1
- module Textus
2
- module Doctor
3
- class Check
4
- # Lists per-key fetch lock files under <root>/.run/locks/ whose
5
- # recorded PID is no longer running. These are forensic artifacts only:
6
- # Fetch::Lock uses flock(2), which the kernel releases on process
7
- # death, so stale files do not block subsequent acquires. The check
8
- # exists to let users clean up clutter and notice unexpected accumulation
9
- # (e.g. a fetch path that crashes repeatedly).
10
- class FetchLocks < Check
11
- def call
12
- dir = Textus::Layout.locks(root)
13
- return [] unless File.directory?(dir)
14
-
15
- Dir.glob(File.join(dir, "*.lock")).filter_map { |path| inspect_lock(path) }
16
- end
17
-
18
- private
19
-
20
- def inspect_lock(path)
21
- pid = File.read(path).strip.to_i
22
- return nil if pid.zero?
23
- return nil if pid_alive?(pid)
24
-
25
- {
26
- "code" => "fetch_lock.stale",
27
- "level" => "info",
28
- "subject" => path,
29
- "message" => "fetch lock file at #{path} records dead PID #{pid} " \
30
- "(does not block fetch; flock is kernel-released on exit)",
31
- "fix" => "safe to delete: rm #{path}",
32
- }
33
- rescue Errno::ENOENT
34
- nil
35
- end
36
-
37
- def pid_alive?(pid)
38
- Process.kill(0, pid)
39
- true
40
- rescue Errno::ESRCH
41
- false
42
- rescue Errno::EPERM
43
- # Process exists but owned by another user — treat as alive.
44
- true
45
- end
46
- end
47
- end
48
- end
49
- end
@@ -1,39 +0,0 @@
1
- module Textus
2
- module Doctor
3
- class Check
4
- # ADR 0079: refresh is valid only for intake entries; drop/archive are
5
- # invalid for intake entries (they would re-fetch, not prune).
6
- class LifecycleActionInvalid < Check
7
- def call
8
- manifest.data.entries.filter_map do |mentry|
9
- policy = manifest.rules.for(mentry.key).lifecycle
10
- next if policy.nil?
11
-
12
- intake = mentry.is_a?(Textus::Manifest::Entry::Intake)
13
- bad = (policy.on_expire == :refresh && !intake) || (policy.destructive? && intake)
14
- next unless bad
15
-
16
- issue_for(mentry, policy, intake)
17
- end
18
- end
19
-
20
- private
21
-
22
- def issue_for(mentry, policy, intake)
23
- {
24
- "code" => "lifecycle.action_invalid",
25
- "level" => "error",
26
- "subject" => mentry.key,
27
- "message" => "on_expire: #{policy.on_expire} is not valid for a " \
28
- "#{intake ? "intake" : "stored"} entry",
29
- "fix" => if intake
30
- "use on_expire: refresh|warn for intake entries"
31
- else
32
- "use on_expire: drop|archive|warn for stored entries"
33
- end,
34
- }
35
- end
36
- end
37
- end
38
- end
39
- end
@@ -1,18 +0,0 @@
1
- module Textus
2
- module Domain
3
- class Freshness
4
- Policy = Data.define(:ttl_seconds, :on_stale, :sync_budget_ms) do
5
- def decide(verdict)
6
- return Action::Return.new if verdict.fresh?
7
-
8
- case on_stale
9
- when :warn then Action::Return.new
10
- when :sync then Action::FetchSync.new
11
- when :timed_sync then Action::FetchTimed.new(budget_ms: sync_budget_ms)
12
- else Action::Return.new
13
- end
14
- end
15
- end
16
- end
17
- end
18
- end
@@ -1,83 +0,0 @@
1
- require "time"
2
-
3
- module Textus
4
- module Domain
5
- # Unified lifecycle reporter (ADR 0079): which entries are past their ttl,
6
- # and the on_expire action that applies. Replaces both Staleness::IntakeCheck
7
- # and Retention. Age basis: _meta.last_fetched_at (intake) when present, else
8
- # file mtime (stored). `self.verdict` is the pure per-entry decision that BOTH
9
- # this reporter and `Read::Get` (Plan 2) call, so the basis logic lives once.
10
- class Lifecycle
11
- # Pure: is the entry past its ttl? -> [expired(bool), reason(String|nil)].
12
- def self.verdict(policy:, last_fetched_at:, mtime:, now:)
13
- ttl = policy.ttl_seconds
14
- return [false, nil] if ttl.nil?
15
-
16
- basis = parse_time(last_fetched_at) || mtime
17
- return [true, "never recorded"] if basis.nil?
18
-
19
- age = (now - basis).to_i
20
- age > ttl ? [true, "ttl exceeded (age=#{age}s, ttl=#{ttl}s)"] : [false, nil]
21
- end
22
-
23
- def self.parse_time(str)
24
- return nil if str.nil?
25
-
26
- Time.parse(str.to_s)
27
- rescue ArgumentError, TypeError
28
- nil
29
- end
30
-
31
- def initialize(manifest:, file_stat:, clock:)
32
- @manifest = manifest
33
- @file_stat = file_stat
34
- @clock = clock
35
- end
36
-
37
- def call(prefix: nil, zone: nil)
38
- @manifest.data.entries
39
- .select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
40
- .flat_map { |m| rows_for(m) }
41
- end
42
-
43
- private
44
-
45
- def entry_matches?(mentry, prefix:, zone:)
46
- return false if zone && mentry.zone != zone
47
- return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
48
-
49
- true
50
- end
51
-
52
- def rows_for(mentry)
53
- policy = @manifest.rules.for(mentry.key).lifecycle
54
- return [] if policy.nil?
55
-
56
- @manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
57
- path = row[:path]
58
- next unless @file_stat.exists?(path)
59
-
60
- expired, _reason = self.class.verdict(
61
- policy: policy,
62
- last_fetched_at: last_fetched_at_of(mentry, path),
63
- mtime: @file_stat.mtime(path),
64
- now: @clock.now,
65
- )
66
- next unless expired
67
-
68
- {
69
- "key" => row[:key], "path" => path,
70
- "action" => policy.on_expire.to_s, "expired" => true
71
- }
72
- end
73
- end
74
-
75
- # Reads _meta.last_fetched_at from the on-disk envelope (intake basis).
76
- def last_fetched_at_of(mentry, path)
77
- Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_fetched_at"]
78
- rescue StandardError
79
- nil
80
- end
81
- end
82
- end
83
- end
@@ -1,10 +0,0 @@
1
- module Textus
2
- module Domain
3
- module Outcome
4
- Skipped = Data.define
5
- Fetched = Data.define(:envelope)
6
- Detached = Data.define
7
- Failed = Data.define(:error)
8
- end
9
- end
10
- end
@@ -1,35 +0,0 @@
1
- module Textus
2
- module Domain
3
- module Policy
4
- # Unified per-entry lifecycle policy (ADR 0079): one ttl + one action.
5
- # Replaces the separate Fetch (ttl/on_stale) and Retention
6
- # (expire_after/archive_after) policies. The action's destructiveness
7
- # decides WHERE it runs: lazy actions (refresh/warn) on get/list reads;
8
- # destructive actions (drop/archive) only on the tend sweep.
9
- class Lifecycle
10
- LAZY = %i[refresh warn].freeze
11
- DESTRUCTIVE = %i[drop archive].freeze
12
- ALLOWED = (LAZY + DESTRUCTIVE).freeze
13
-
14
- attr_reader :on_expire, :budget_ms
15
-
16
- def initialize(ttl:, on_expire:, budget_ms: nil)
17
- action = on_expire.is_a?(Symbol) ? on_expire : on_expire.to_s.to_sym
18
- unless ALLOWED.include?(action)
19
- raise Textus::UsageError.new(
20
- "lifecycle on_expire must be one of #{ALLOWED.join("|")}, got #{on_expire.inspect}",
21
- )
22
- end
23
-
24
- @ttl = ttl
25
- @on_expire = action
26
- @budget_ms = budget_ms
27
- end
28
-
29
- def ttl_seconds = Textus::Domain::Duration.seconds(@ttl)
30
- def destructive? = DESTRUCTIVE.include?(@on_expire)
31
- def lazy? = LAZY.include?(@on_expire)
32
- end
33
- end
34
- end
35
- end