textus 0.55.1 → 0.56.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 (329) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +175 -2768
  3. data/README.md +17 -18
  4. data/lib/textus/artifact.rb +8 -0
  5. data/lib/textus/errors.rb +2 -39
  6. data/lib/textus/human.rb +7 -0
  7. data/lib/textus/infra/adapters/concurrency_adapter.rb +15 -0
  8. data/lib/textus/infra/clock.rb +7 -0
  9. data/lib/textus/infra/database.rb +127 -0
  10. data/lib/textus/infra/file_system.rb +54 -0
  11. data/lib/textus/infra/locks.rb +50 -0
  12. data/lib/textus/infra/port/clock.rb +18 -0
  13. data/lib/textus/infra/port/publisher.rb +71 -0
  14. data/lib/textus/infra/port/sentinel.rb +25 -0
  15. data/lib/textus/infra/port/sentinel_store.rb +93 -0
  16. data/lib/textus/infra/store/audit_log.rb +42 -0
  17. data/lib/textus/infra/store/base.rb +25 -0
  18. data/lib/textus/infra/store/cursor_store.rb +27 -0
  19. data/lib/textus/infra/store/entry_index.rb +31 -0
  20. data/lib/textus/infra/store/envelope/meta.rb +63 -0
  21. data/lib/textus/infra/store/file_store.rb +23 -0
  22. data/lib/textus/infra/store/index/builder.rb +28 -0
  23. data/lib/textus/infra/store/index/lookup.rb +23 -0
  24. data/lib/textus/infra/store/link_store.rb +43 -0
  25. data/lib/textus/infra/store/workflow_queue.rb +54 -0
  26. data/lib/textus/infra/store.rb +29 -0
  27. data/lib/textus/ingest.rb +8 -0
  28. data/lib/textus/lanes/artifact/cache.rb +35 -0
  29. data/lib/textus/lanes/artifact/handlers.rb +29 -0
  30. data/lib/textus/lanes/artifact/lifecycle.rb +23 -0
  31. data/lib/textus/lanes/artifact/verbs.rb +18 -0
  32. data/lib/textus/lanes/ingest/dedup.rb +17 -0
  33. data/lib/textus/lanes/ingest/entry_types/asset.rb +45 -0
  34. data/lib/textus/lanes/ingest/entry_types/link.rb +16 -0
  35. data/lib/textus/lanes/ingest/entry_types/text.rb +17 -0
  36. data/lib/textus/lanes/ingest/entry_types.rb +25 -0
  37. data/lib/textus/lanes/ingest/handlers.rb +54 -0
  38. data/lib/textus/lanes/ingest/index_rebuilder.rb +13 -0
  39. data/lib/textus/lanes/ingest/key_builder.rb +33 -0
  40. data/lib/textus/lanes/ingest/resolver.rb +21 -0
  41. data/lib/textus/lanes/knowledge/freeform.rb +15 -0
  42. data/lib/textus/lanes/knowledge/handlers.rb +53 -0
  43. data/lib/textus/lanes/knowledge/runner.rb +45 -0
  44. data/lib/textus/lanes/knowledge/verbs.rb +16 -0
  45. data/lib/textus/lanes/scratchpad/handlers.rb +164 -0
  46. data/lib/textus/lanes/scratchpad/verbs.rb +73 -0
  47. data/lib/textus/{gate → protocol}/binder.rb +2 -10
  48. data/lib/textus/protocol/boot/catalog.rb +34 -0
  49. data/lib/textus/protocol/boot/context.rb +124 -0
  50. data/lib/textus/protocol/boot/protocol.rb +33 -0
  51. data/lib/textus/protocol/boot.rb +42 -0
  52. data/lib/textus/protocol/builder/domain.rb +17 -0
  53. data/lib/textus/protocol/builder/infrastructure.rb +17 -0
  54. data/lib/textus/protocol/builder/runtime.rb +46 -0
  55. data/lib/textus/protocol/builder.rb +11 -0
  56. data/lib/textus/protocol/container.rb +22 -0
  57. data/lib/textus/protocol/diff.rb +170 -0
  58. data/lib/textus/protocol/entry_constraint.rb +54 -0
  59. data/lib/textus/protocol/envelope.rb +70 -0
  60. data/lib/textus/protocol/format/base.rb +80 -0
  61. data/lib/textus/protocol/format/bash.rb +10 -0
  62. data/lib/textus/protocol/format/javascript.rb +10 -0
  63. data/lib/textus/protocol/format/json.rb +82 -0
  64. data/lib/textus/protocol/format/markdown.rb +53 -0
  65. data/lib/textus/protocol/format/python.rb +10 -0
  66. data/lib/textus/protocol/format/ruby.rb +10 -0
  67. data/lib/textus/protocol/format/script.rb +55 -0
  68. data/lib/textus/protocol/format/text.rb +48 -0
  69. data/lib/textus/protocol/format/yaml.rb +105 -0
  70. data/lib/textus/protocol/format.rb +73 -0
  71. data/lib/textus/protocol/freshness/ttl_evaluator.rb +61 -0
  72. data/lib/textus/protocol/gate.rb +61 -0
  73. data/lib/textus/protocol/handlers/command_context.rb +19 -0
  74. data/lib/textus/protocol/handlers/concern.rb +15 -0
  75. data/lib/textus/protocol/handlers/ops/audit_filter.rb +37 -0
  76. data/lib/textus/protocol/handlers/ops/bulk_planner.rb +38 -0
  77. data/lib/textus/protocol/handlers/ops/data_mv.rb +49 -0
  78. data/lib/textus/protocol/handlers/ops/git_blame.rb +65 -0
  79. data/lib/textus/protocol/handlers/ops/pulse_coordinator.rb +37 -0
  80. data/lib/textus/protocol/handlers/ops/source_expander.rb +41 -0
  81. data/lib/textus/protocol/handlers/query_context.rb +41 -0
  82. data/lib/textus/protocol/handlers/read.rb +67 -0
  83. data/lib/textus/protocol/handlers/system.rb +93 -0
  84. data/lib/textus/protocol/handlers/write.rb +57 -0
  85. data/lib/textus/protocol/init.rb +143 -0
  86. data/lib/textus/protocol/key/grammar.rb +35 -0
  87. data/lib/textus/protocol/key/matching.rb +23 -0
  88. data/lib/textus/protocol/key/path.rb +23 -0
  89. data/lib/textus/protocol/key.rb +25 -0
  90. data/lib/textus/protocol/lane.rb +12 -0
  91. data/lib/textus/{store/geometry.rb → protocol/layout.rb} +32 -22
  92. data/lib/textus/protocol/links/resolver.rb +33 -0
  93. data/lib/textus/protocol/links/uri_rewriter.rb +30 -0
  94. data/lib/textus/protocol/links.rb +10 -0
  95. data/lib/textus/protocol/manifest/data.rb +90 -0
  96. data/lib/textus/protocol/manifest/entry/base.rb +95 -0
  97. data/lib/textus/protocol/manifest/entry/leaf.rb +19 -0
  98. data/lib/textus/protocol/manifest/entry/nested.rb +19 -0
  99. data/lib/textus/protocol/manifest/entry/parser.rb +71 -0
  100. data/lib/textus/protocol/manifest/entry/publish.rb +209 -0
  101. data/lib/textus/protocol/manifest/entry.rb +10 -0
  102. data/lib/textus/protocol/manifest/policy/publish_target.rb +36 -0
  103. data/lib/textus/protocol/manifest/policy.rb +69 -0
  104. data/lib/textus/protocol/manifest/resolver.rb +156 -0
  105. data/lib/textus/protocol/manifest/schema/contract.rb +53 -0
  106. data/lib/textus/protocol/manifest/schema/validator.rb +52 -0
  107. data/lib/textus/protocol/manifest/schema.rb +40 -0
  108. data/lib/textus/protocol/manifest.rb +62 -0
  109. data/lib/textus/protocol/produce/render/context.rb +43 -0
  110. data/lib/textus/protocol/produce/render.rb +44 -0
  111. data/lib/textus/protocol/schema/registry.rb +44 -0
  112. data/lib/textus/protocol/schema/tools.rb +104 -0
  113. data/lib/textus/protocol/schema.rb +117 -0
  114. data/lib/textus/protocol/session/bridge.rb +11 -0
  115. data/lib/textus/protocol/session/context.rb +5 -0
  116. data/lib/textus/protocol/session.rb +87 -0
  117. data/lib/textus/protocol/store_engine/delete.rb +37 -0
  118. data/lib/textus/protocol/store_engine/move.rb +109 -0
  119. data/lib/textus/protocol/store_engine/pipeline.rb +61 -0
  120. data/lib/textus/protocol/store_engine/put.rb +104 -0
  121. data/lib/textus/protocol/store_engine.rb +86 -0
  122. data/lib/textus/protocol/v4.rb +25 -0
  123. data/lib/textus/protocol/verb_registry/arg_spec.rb +19 -0
  124. data/lib/textus/protocol/verb_registry/core_verbs.rb +164 -0
  125. data/lib/textus/protocol/verb_registry/verb_spec.rb +42 -0
  126. data/lib/textus/protocol/verb_registry.rb +42 -0
  127. data/lib/textus/surface/cli/group/{rule.rb → web.rb} +2 -2
  128. data/lib/textus/surface/cli/runner.rb +25 -25
  129. data/lib/textus/surface/cli/sources.rb +1 -3
  130. data/lib/textus/surface/cli/verb/get.rb +3 -6
  131. data/lib/textus/surface/cli/verb/init.rb +1 -1
  132. data/lib/textus/surface/cli/verb/put.rb +8 -6
  133. data/lib/textus/surface/cli/verb/web_serve.rb +22 -0
  134. data/lib/textus/surface/cli.rb +25 -6
  135. data/lib/textus/surface/dispatch.rb +17 -0
  136. data/lib/textus/surface/mcp/adapters/mcp_adapter.rb +28 -0
  137. data/lib/textus/surface/mcp/catalog.rb +26 -62
  138. data/lib/textus/surface/mcp/cursor_manager.rb +22 -0
  139. data/lib/textus/surface/mcp/drift_annotation.rb +19 -0
  140. data/lib/textus/surface/mcp/errors.rb +0 -10
  141. data/lib/textus/surface/mcp/server.rb +46 -46
  142. data/lib/textus/surface/watcher.rb +18 -12
  143. data/lib/textus/surface/web.rb +50 -0
  144. data/lib/textus/value/call.rb +1 -1
  145. data/lib/textus/{core → value}/duration.rb +1 -4
  146. data/lib/textus/value/etag.rb +7 -7
  147. data/lib/textus/value/payload.rb +7 -0
  148. data/lib/textus/version.rb +1 -1
  149. data/lib/textus/workflow/consumer.rb +39 -0
  150. data/lib/textus/workflow/dsl/lifecycle.rb +32 -0
  151. data/lib/textus/workflow/dsl/pattern_matcher.rb +20 -0
  152. data/lib/textus/workflow/dsl/step_builder.rb +56 -0
  153. data/lib/textus/workflow/dsl.rb +111 -21
  154. data/lib/textus/workflow/event_emitter.rb +21 -0
  155. data/lib/textus/workflow/publisher.rb +59 -0
  156. data/lib/textus/workflow/registry.rb +31 -8
  157. data/lib/textus/workflow/runner.rb +25 -61
  158. data/lib/textus/workflow/scheduler.rb +45 -0
  159. data/lib/textus/workflow/step_executor.rb +78 -0
  160. data/lib/textus/workflow/step_helpers.rb +102 -0
  161. data/lib/textus/workflow.rb +2 -5
  162. data/lib/textus.rb +16 -80
  163. metadata +167 -183
  164. data/SPEC.md +0 -908
  165. data/docs/architecture/README.md +0 -273
  166. data/docs/reference/conventions.md +0 -138
  167. data/lib/textus/action/accept.rb +0 -46
  168. data/lib/textus/action/audit.rb +0 -94
  169. data/lib/textus/action/base.rb +0 -42
  170. data/lib/textus/action/blame.rb +0 -79
  171. data/lib/textus/action/boot.rb +0 -15
  172. data/lib/textus/action/data_mv.rb +0 -58
  173. data/lib/textus/action/deps.rb +0 -19
  174. data/lib/textus/action/doctor.rb +0 -17
  175. data/lib/textus/action/drain.rb +0 -31
  176. data/lib/textus/action/enqueue.rb +0 -37
  177. data/lib/textus/action/get.rb +0 -34
  178. data/lib/textus/action/ingest.rb +0 -199
  179. data/lib/textus/action/jobs.rb +0 -27
  180. data/lib/textus/action/key_delete.rb +0 -26
  181. data/lib/textus/action/key_delete_prefix.rb +0 -35
  182. data/lib/textus/action/key_mv.rb +0 -122
  183. data/lib/textus/action/key_mv_prefix.rb +0 -48
  184. data/lib/textus/action/list.rb +0 -28
  185. data/lib/textus/action/propose.rb +0 -42
  186. data/lib/textus/action/published.rb +0 -22
  187. data/lib/textus/action/pulse.rb +0 -49
  188. data/lib/textus/action/put.rb +0 -38
  189. data/lib/textus/action/rdeps.rb +0 -24
  190. data/lib/textus/action/reject.rb +0 -28
  191. data/lib/textus/action/rule_explain.rb +0 -81
  192. data/lib/textus/action/rule_lint.rb +0 -62
  193. data/lib/textus/action/rule_list.rb +0 -38
  194. data/lib/textus/action/schema_envelope.rb +0 -22
  195. data/lib/textus/action/uid.rb +0 -19
  196. data/lib/textus/action/where.rb +0 -21
  197. data/lib/textus/boot.rb +0 -202
  198. data/lib/textus/contract/arg.rb +0 -10
  199. data/lib/textus/contract/dsl.rb +0 -88
  200. data/lib/textus/contract/spec.rb +0 -25
  201. data/lib/textus/contract.rb +0 -12
  202. data/lib/textus/core/freshness/evaluator.rb +0 -150
  203. data/lib/textus/core/freshness/verdict.rb +0 -34
  204. data/lib/textus/core/freshness.rb +0 -11
  205. data/lib/textus/core/retention/sweep.rb +0 -57
  206. data/lib/textus/core/retention.rb +0 -11
  207. data/lib/textus/core/sentinel.rb +0 -30
  208. data/lib/textus/doctor/check/audit_log.rb +0 -34
  209. data/lib/textus/doctor/check/generator_drift.rb +0 -29
  210. data/lib/textus/doctor/check/illegal_keys.rb +0 -60
  211. data/lib/textus/doctor/check/manifest_files.rb +0 -25
  212. data/lib/textus/doctor/check/notebook_sources.rb +0 -50
  213. data/lib/textus/doctor/check/orphaned_publish_targets.rb +0 -35
  214. data/lib/textus/doctor/check/proposal_targets.rb +0 -45
  215. data/lib/textus/doctor/check/protocol_version.rb +0 -47
  216. data/lib/textus/doctor/check/publish_tree_index_overlap.rb +0 -48
  217. data/lib/textus/doctor/check/raw_asset_paths.rb +0 -50
  218. data/lib/textus/doctor/check/rule_ambiguity.rb +0 -49
  219. data/lib/textus/doctor/check/schema_parse_error.rb +0 -28
  220. data/lib/textus/doctor/check/schema_violations.rb +0 -28
  221. data/lib/textus/doctor/check/schemas.rb +0 -26
  222. data/lib/textus/doctor/check/sentinels.rb +0 -60
  223. data/lib/textus/doctor/check/stale_reviewed_stamp.rb +0 -54
  224. data/lib/textus/doctor/check/templates.rb +0 -29
  225. data/lib/textus/doctor/check/unowned_schema_fields.rb +0 -40
  226. data/lib/textus/doctor/check.rb +0 -41
  227. data/lib/textus/doctor/validator.rb +0 -93
  228. data/lib/textus/doctor.rb +0 -54
  229. data/lib/textus/format/base.rb +0 -49
  230. data/lib/textus/format/json.rb +0 -102
  231. data/lib/textus/format/markdown.rb +0 -80
  232. data/lib/textus/format/shared.rb +0 -17
  233. data/lib/textus/format/text.rb +0 -55
  234. data/lib/textus/format/yaml.rb +0 -125
  235. data/lib/textus/format.rb +0 -46
  236. data/lib/textus/gate/auth.rb +0 -212
  237. data/lib/textus/gate.rb +0 -92
  238. data/lib/textus/init.rb +0 -157
  239. data/lib/textus/jobs.rb +0 -9
  240. data/lib/textus/key/distance.rb +0 -55
  241. data/lib/textus/key/grammar.rb +0 -33
  242. data/lib/textus/key/matching.rb +0 -24
  243. data/lib/textus/key/path.rb +0 -28
  244. data/lib/textus/manifest/capabilities.rb +0 -29
  245. data/lib/textus/manifest/data.rb +0 -102
  246. data/lib/textus/manifest/entry/base.rb +0 -105
  247. data/lib/textus/manifest/entry/ignore_matcher.rb +0 -46
  248. data/lib/textus/manifest/entry/leaf.rb +0 -17
  249. data/lib/textus/manifest/entry/nested.rb +0 -37
  250. data/lib/textus/manifest/entry/parser.rb +0 -99
  251. data/lib/textus/manifest/entry/produced.rb +0 -36
  252. data/lib/textus/manifest/entry/publish/mode.rb +0 -45
  253. data/lib/textus/manifest/entry/publish/none.rb +0 -14
  254. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +0 -73
  255. data/lib/textus/manifest/entry/publish/template.rb +0 -16
  256. data/lib/textus/manifest/entry/publish/to_paths.rb +0 -76
  257. data/lib/textus/manifest/entry/publish/tree.rb +0 -50
  258. data/lib/textus/manifest/entry/publish.rb +0 -45
  259. data/lib/textus/manifest/entry/validators/format_matrix.rb +0 -21
  260. data/lib/textus/manifest/entry/validators/ignore.rb +0 -28
  261. data/lib/textus/manifest/entry/validators/publish.rb +0 -30
  262. data/lib/textus/manifest/entry/validators.rb +0 -18
  263. data/lib/textus/manifest/entry.rb +0 -8
  264. data/lib/textus/manifest/policy/matcher.rb +0 -51
  265. data/lib/textus/manifest/policy/publish_target.rb +0 -34
  266. data/lib/textus/manifest/policy/react.rb +0 -30
  267. data/lib/textus/manifest/policy/retention.rb +0 -29
  268. data/lib/textus/manifest/policy/source.rb +0 -30
  269. data/lib/textus/manifest/policy.rb +0 -95
  270. data/lib/textus/manifest/resolver.rb +0 -118
  271. data/lib/textus/manifest/rules.rb +0 -86
  272. data/lib/textus/manifest/schema/contract.rb +0 -61
  273. data/lib/textus/manifest/schema/keys.rb +0 -90
  274. data/lib/textus/manifest/schema/semantics/cross_field.rb +0 -53
  275. data/lib/textus/manifest/schema/semantics/invariants.rb +0 -125
  276. data/lib/textus/manifest/schema/semantics/migration.rb +0 -83
  277. data/lib/textus/manifest/schema/semantics.rb +0 -27
  278. data/lib/textus/manifest/schema/validator.rb +0 -48
  279. data/lib/textus/manifest/schema/vocabulary.rb +0 -25
  280. data/lib/textus/manifest/schema.rb +0 -30
  281. data/lib/textus/manifest.rb +0 -65
  282. data/lib/textus/meta.rb +0 -54
  283. data/lib/textus/port/audit_log.rb +0 -268
  284. data/lib/textus/port/build_lock.rb +0 -64
  285. data/lib/textus/port/clock.rb +0 -10
  286. data/lib/textus/port/publisher.rb +0 -60
  287. data/lib/textus/port/sentinel_store.rb +0 -83
  288. data/lib/textus/port/storage/file_stat.rb +0 -19
  289. data/lib/textus/port/storage/file_store.rb +0 -26
  290. data/lib/textus/port/store.rb +0 -93
  291. data/lib/textus/port/watcher_lock.rb +0 -48
  292. data/lib/textus/produce/engine.rb +0 -50
  293. data/lib/textus/produce/render.rb +0 -20
  294. data/lib/textus/schema/tools.rb +0 -115
  295. data/lib/textus/schema.rb +0 -100
  296. data/lib/textus/schemas.rb +0 -54
  297. data/lib/textus/store/compositor.rb +0 -34
  298. data/lib/textus/store/container.rb +0 -43
  299. data/lib/textus/store/cursor.rb +0 -26
  300. data/lib/textus/store/envelope/reader.rb +0 -43
  301. data/lib/textus/store/envelope/writer.rb +0 -195
  302. data/lib/textus/store/index/builder.rb +0 -74
  303. data/lib/textus/store/index/lookup.rb +0 -60
  304. data/lib/textus/store/jobs/base.rb +0 -13
  305. data/lib/textus/store/jobs/index.rb +0 -15
  306. data/lib/textus/store/jobs/materialize.rb +0 -15
  307. data/lib/textus/store/jobs/plan.rb +0 -11
  308. data/lib/textus/store/jobs/planner.rb +0 -104
  309. data/lib/textus/store/jobs/queue.rb +0 -154
  310. data/lib/textus/store/jobs/registry.rb +0 -19
  311. data/lib/textus/store/jobs/retention.rb +0 -50
  312. data/lib/textus/store/jobs/sweep.rb +0 -21
  313. data/lib/textus/store/jobs/worker.rb +0 -64
  314. data/lib/textus/store/session.rb +0 -37
  315. data/lib/textus/store.rb +0 -99
  316. data/lib/textus/surface/cli/verb/doctor.rb +0 -19
  317. data/lib/textus/surface/cli/verb/schema_diff.rb +0 -17
  318. data/lib/textus/surface/cli/verb/schema_init.rb +0 -21
  319. data/lib/textus/surface/cli/verb/schema_migrate.rb +0 -21
  320. data/lib/textus/surface/mcp.rb +0 -8
  321. data/lib/textus/surface/projector.rb +0 -27
  322. data/lib/textus/surface/role_scope.rb +0 -34
  323. data/lib/textus/value/command.rb +0 -16
  324. data/lib/textus/value/envelope.rb +0 -89
  325. data/lib/textus/value/result.rb +0 -26
  326. data/lib/textus/workflow/collector.rb +0 -27
  327. data/lib/textus/workflow/context.rb +0 -5
  328. data/lib/textus/workflow/loader.rb +0 -17
  329. data/lib/textus/workflow/pattern.rb +0 -18
@@ -1,65 +0,0 @@
1
- require "yaml"
2
-
3
- module Textus
4
- # Manifest is the composition record for a parsed manifest. It bundles
5
- # four collaborators:
6
- #
7
- # * data — frozen value: raw, root, zones, entries, audit_config, role_caps
8
- # * resolver — resolves keys → entry + path
9
- # * policy — zone/role authority (lane_writers, declared_kind, derived_entry?,
10
- # queue_lane?, permission_for, …)
11
- # * rules — match-block rule engine (lifecycle, handler allowlist, materialize, …)
12
- #
13
- # Use `manifest.data.entries`, `manifest.policy.declared_kind(z)`, etc.
14
- Manifest = Data.define(:data, :resolver, :policy, :rules)
15
- end
16
-
17
- require_relative "manifest/schema"
18
- require_relative "manifest/data"
19
- require_relative "manifest/policy"
20
- require_relative "manifest/resolver"
21
- require_relative "manifest/capabilities"
22
-
23
- # Reopen Textus::Manifest (defined above as a Data.define) to attach
24
- # class-level loaders and helpers.
25
- module Textus # rubocop:disable Style/OneClassPerFile
26
- class Manifest
27
- class << self
28
- def parse(yaml_text, root: ".")
29
- raw = YAML.safe_load(yaml_text, aliases: false)
30
- check_version!(raw, "<string>")
31
- build(raw, root)
32
- end
33
-
34
- def load(root)
35
- manifest_path = File.join(root, "manifest.yaml")
36
- raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
37
-
38
- raw = YAML.safe_load_file(manifest_path, aliases: false)
39
- check_version!(raw, manifest_path)
40
- build(raw, root)
41
- end
42
-
43
- private
44
-
45
- def build(raw, root)
46
- data = Manifest::Data.parse(raw, root: root)
47
- new(
48
- data: data,
49
- resolver: Manifest::Resolver.new(data),
50
- policy: data.policy,
51
- rules: Manifest::Rules.parse(raw["rules"] || []),
52
- )
53
- end
54
-
55
- def check_version!(raw, source)
56
- return if raw["version"] == PROTOCOL
57
-
58
- raise BadFrontmatter.new(
59
- source,
60
- "unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
61
- )
62
- end
63
- end
64
- end
65
- end
data/lib/textus/meta.rb DELETED
@@ -1,54 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
-
5
- module Textus
6
- module Meta
7
- NO_META_FORMATS = %w[text].freeze
8
-
9
- FIELDS = {
10
- "uid" => {
11
- inject: lambda { |meta, content, existing_meta|
12
- m = meta.is_a?(Hash) ? meta.dup : {}
13
- existing = existing_meta.is_a?(Hash) ? existing_meta["uid"] : nil
14
- m["uid"] = existing || Textus::Value::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
15
- [m, content]
16
- },
17
- },
18
- "sources" => {
19
- inject: lambda { |meta, content, existing_meta|
20
- m = meta.is_a?(Hash) ? meta.dup : {}
21
- existing = existing_meta.is_a?(Hash) ? existing_meta["sources"] : nil
22
-
23
- if m.key?("sources")
24
- raise Textus::BadContent.new(nil, "_meta.sources must be an array") unless m["sources"].is_a?(Array)
25
-
26
- m["sources"] = m["sources"].map { |s| validate_source_shape!(s) }
27
- elsif existing.is_a?(Array) && !existing.empty?
28
- m["sources"] = existing
29
- end
30
-
31
- [m, content]
32
- },
33
- },
34
- }.freeze
35
-
36
- def self.inject_all(meta, content, existing_meta = {}, format: nil)
37
- return [meta, content] if NO_META_FORMATS.include?(format)
38
-
39
- FIELDS.each_value do |field|
40
- meta, content = field[:inject].call(meta, content, existing_meta)
41
- end
42
-
43
- [meta, content]
44
- end
45
-
46
- def self.validate_source_shape!(src)
47
- raise Textus::BadContent.new(nil, "each source must be a string") unless src.is_a?(String)
48
-
49
- raise Textus::BadContent.new(nil, "each source must start with 'raw.', got #{src.inspect}") unless src.match?(/\Araw\./)
50
-
51
- src
52
- end
53
- end
54
- end
@@ -1,268 +0,0 @@
1
- require "fileutils"
2
- require "json"
3
- require "time"
4
-
5
- module Textus
6
- module Port
7
- # Append-only audit log adapter: writes and rotates the on-disk audit JSONL
8
- # under the store root. An instantiable class — it holds collaborators (the
9
- # root path + size/keep config), so each store binds its own instance. It
10
- # already satisfied ADR 0109's single-shape rule (every port is an
11
- # instantiable class) before that ADR's Clock/Publisher conversions, so it
12
- # was unchanged by them.
13
- class AuditLog
14
- DEFAULT_MAX_SIZE = 10_485_760
15
- DEFAULT_KEEP = 5
16
-
17
- def initialize(root, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
18
- @root = root
19
- @path = Textus::Store::Geometry.new(root).audit_log_path
20
- @max_size = max_size
21
- @keep = keep
22
- end
23
-
24
- def last_writer_for(key)
25
- return nil unless File.exist?(@path)
26
-
27
- last_role = nil
28
- File.foreach(@path) do |line|
29
- parsed = parse_row(line.chomp)
30
- next unless parsed
31
- next unless parsed["key"] == key
32
- next unless %w[put delete key_delete].include?(parsed["verb"])
33
-
34
- last_role = parsed["role"]
35
- end
36
- last_role
37
- end
38
-
39
- def latest_seq
40
- return scan_max_seq(@path) if File.exist?(@path) && File.size(@path).positive?
41
-
42
- # Active log is empty/missing — consult the most recent rotated file's sidecar.
43
- meta = read_meta(1)
44
- return meta["max_seq"] if meta
45
-
46
- 0
47
- end
48
-
49
- def min_available_seq
50
- rotated_metas = (1..@keep).map { |n| read_meta(n) }.compact
51
- if rotated_metas.any?
52
- rotated_metas.map { |m| m["min_seq"] }.min
53
- elsif File.exist?(@path)
54
- File.foreach(@path) do |line|
55
- parsed = parse_row(line.chomp)
56
- return parsed["seq"] if parsed && parsed["seq"]
57
- end
58
- nil
59
- end
60
- end
61
-
62
- def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
63
- FileUtils.mkdir_p(File.dirname(@path))
64
- File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
65
- f.flock(File::LOCK_EX)
66
- next_seq = current_max_seq_unlocked + 1
67
- row = assemble_row(next_seq, { role: role, verb: verb, key: key,
68
- etag_before: etag_before, etag_after: etag_after }, extras)
69
- f.write(JSON.generate(row) + "\n")
70
- f.flush
71
- rotate!(f) if f.size > @max_size
72
- end
73
- end
74
-
75
- # Scan log files with optional filters. Returns parsed row hashes.
76
- # Lane and timestamp filters are left to the caller (they need manifest
77
- # resolution and Time parsing the port shouldn't know about).
78
- def scan(seq_since: nil, key: nil, role: nil, verb: nil,
79
- correlation_id: nil, limit: nil)
80
- files = all_log_files
81
- return [] if files.empty?
82
-
83
- rows = []
84
- files.each do |file|
85
- File.foreach(file) do |line|
86
- parsed = parse_row(line.chomp)
87
- next unless parsed && matches?(parsed, seq_since:, key:, role:, verb:, correlation_id:)
88
-
89
- rows << parsed
90
- break if limit && rows.length >= limit
91
- end
92
- break if limit && rows.length >= limit
93
- end
94
- rows
95
- end
96
-
97
- # Returns an array of integrity-violation descriptors for the on-disk log.
98
- # Each entry is { "lineno" => Integer, "reason" => String, "detail" => String }.
99
- # Empty array means the log is well-formed (or doesn't exist yet).
100
- def verify_integrity
101
- return [] unless File.exist?(@path)
102
-
103
- [].tap do |out|
104
- iterate_with_prev_seq do |line, lineno, prev_seq|
105
- check_line_integrity(line, lineno, prev_seq, out)
106
- end
107
- end
108
- end
109
-
110
- private
111
-
112
- def iterate_with_prev_seq
113
- prev_seq = nil
114
- File.foreach(@path).with_index(1) do |line, lineno|
115
- yield line.chomp, lineno, prev_seq
116
- parsed = parse_row(line.chomp)
117
- prev_seq = parsed["seq"] if parsed&.dig("seq").is_a?(Integer)
118
- end
119
- end
120
-
121
- def check_line_integrity(line, lineno, prev_seq, out)
122
- violation = check_line(line, lineno)
123
- if violation
124
- out << violation
125
- return
126
- end
127
-
128
- parsed = parse_row(line)
129
- return unless parsed && (seq = parsed["seq"]).is_a?(Integer)
130
- return unless prev_seq && seq != prev_seq + 1
131
-
132
- out << {
133
- "lineno" => lineno,
134
- "reason" => "seq_gap",
135
- "detail" => "expected #{prev_seq + 1}, got #{seq}",
136
- }
137
- end
138
-
139
- def rotated(n)
140
- File.join(Textus::Store::Geometry.new(@root).audit_dir_path, "audit.log.#{n}")
141
- end
142
-
143
- def rotated_meta(n)
144
- File.join(Textus::Store::Geometry.new(@root).audit_dir_path, "audit.log.#{n}.meta.json")
145
- end
146
-
147
- # Caller holds the flock. Returns the highest seq across the active log,
148
- # OR the most-recent rotated file's max_seq if the active log is empty.
149
- def current_max_seq_unlocked
150
- return scan_max_seq(@path) if File.exist?(@path) && File.size(@path).positive?
151
-
152
- meta = read_meta(1)
153
- meta ? meta["max_seq"] : 0
154
- end
155
-
156
- def scan_max_seq(file)
157
- last = 0
158
- File.foreach(file) do |line|
159
- parsed = parse_row(line.chomp)
160
- last = parsed["seq"] if parsed && parsed["seq"].is_a?(Integer)
161
- end
162
- last
163
- end
164
-
165
- def scan_seq_range(file)
166
- min = nil
167
- max = 0
168
- File.foreach(file) do |line|
169
- parsed = parse_row(line.chomp)
170
- next unless parsed && parsed["seq"]
171
-
172
- min ||= parsed["seq"]
173
- max = parsed["seq"]
174
- end
175
- [min, max]
176
- end
177
-
178
- def read_meta(n)
179
- path = rotated_meta(n)
180
- return nil unless File.exist?(path)
181
-
182
- JSON.parse(File.read(path))
183
- rescue JSON::ParserError
184
- nil
185
- end
186
-
187
- def assemble_row(seq, fields, extras = nil)
188
- row = {
189
- "seq" => seq,
190
- "ts" => Time.now.utc.iso8601,
191
- "role" => fields[:role],
192
- "verb" => fields[:verb],
193
- "key" => fields[:key],
194
- "etag_before" => fields[:etag_before],
195
- "etag_after" => fields[:etag_after],
196
- }
197
-
198
- if extras.is_a?(Hash) && !extras.empty?
199
- extras = extras.dup
200
- %w[from_key to_key uid].each do |k|
201
- row[k] = extras.delete(k) if extras.key?(k)
202
- end
203
- row["extras"] = extras unless extras.empty?
204
- end
205
-
206
- row
207
- end
208
-
209
- # Called inside the flock, after a successful write that pushed size over max.
210
- # Renames audit.log → audit.log.1 (shifting older files), writes sidecar meta.
211
- def rotate!(open_file)
212
- open_file.flush
213
- min_seq, max_seq = scan_seq_range(@path)
214
- meta = { "min_seq" => min_seq, "max_seq" => max_seq, "rotated_at" => Time.now.utc.iso8601 }
215
-
216
- # Drop the file that would be shifted past @keep.
217
- FileUtils.rm_f(rotated(@keep))
218
- FileUtils.rm_f(rotated_meta(@keep))
219
-
220
- # Shift .N → .(N+1) for N = keep-1 down to 1.
221
- (@keep - 1).downto(1) do |n|
222
- File.rename(rotated(n), rotated(n + 1)) if File.exist?(rotated(n))
223
- File.rename(rotated_meta(n), rotated_meta(n + 1)) if File.exist?(rotated_meta(n))
224
- end
225
-
226
- # Active log → .1
227
- File.rename(@path, rotated(1))
228
- File.write(rotated_meta(1), JSON.generate(meta) + "\n")
229
- # Next append will create a fresh audit.log via File::CREAT.
230
- end
231
-
232
- def parse_row(line)
233
- return nil if line.empty?
234
-
235
- JSON.parse(line)
236
- rescue JSON::ParserError
237
- nil
238
- end
239
-
240
- def matches?(row, seq_since: nil, key: nil, role: nil, verb: nil, correlation_id: nil)
241
- return false if seq_since && row["seq"] <= seq_since
242
- return false if key && row["key"] != key
243
- return false if role && row["role"] != role
244
- return false if verb && row["verb"] != verb
245
- return false if correlation_id && row.dig("extras", "correlation_id") != correlation_id
246
-
247
- true
248
- end
249
-
250
- def all_log_files
251
- rotated = Dir.glob(File.join(Textus::Store::Geometry.new(@root).audit_dir_path, "audit.log.*"))
252
- .reject { |path| path.end_with?(".meta.json") }
253
- .sort_by { |path| -path.scan(/\d+$/).first.to_i }
254
- active_log = File.exist?(@path) ? [@path] : []
255
- rotated + active_log
256
- end
257
-
258
- def check_line(stripped, lineno)
259
- return nil if stripped.empty?
260
-
261
- JSON.parse(stripped)
262
- nil
263
- rescue JSON::ParserError => e
264
- { "lineno" => lineno, "reason" => "invalid_json", "detail" => e.message }
265
- end
266
- end
267
- end
268
- end
@@ -1,64 +0,0 @@
1
- require "fileutils"
2
- require "socket"
3
- require "time"
4
-
5
- module Textus
6
- module Port
7
- # Cross-process build lock: a pid/host-stamped lockfile under the store root
8
- # that serializes converge's produce/sweep. An instantiable class — it holds
9
- # the root and lock state; `self.with(root:)` is a convenience that constructs
10
- # one and runs the block under the held lock. It already satisfied ADR 0109's
11
- # single-shape rule (every port is an instantiable class) before that ADR's
12
- # Clock/Publisher conversions, so it was unchanged by them.
13
- class BuildLock
14
- MAX_HOLDER_BYTES = 512
15
-
16
- def self.with(root:, &)
17
- new(root: root).acquire_or_raise(&)
18
- end
19
-
20
- def initialize(root:)
21
- @path = Textus::Store::Geometry.new(root).lock_path("build")
22
- @file = nil
23
- end
24
-
25
- def acquire_or_raise
26
- FileUtils.mkdir_p(File.dirname(@path))
27
- @file = File.open(@path, File::RDWR | File::CREAT, 0o644)
28
- @file.close_on_exec = true
29
-
30
- unless @file.flock(File::LOCK_EX | File::LOCK_NB)
31
- holder = read_holder_safely
32
- @file.close
33
- @file = nil
34
- raise Textus::BuildInProgress.new(holder)
35
- end
36
-
37
- @file.truncate(0)
38
- @file.write("pid=#{Process.pid} started=#{Time.now.utc.iso8601} host=#{Socket.gethostname}\n")
39
- @file.flush
40
-
41
- yield
42
- ensure
43
- release
44
- end
45
-
46
- private
47
-
48
- def release
49
- return unless @file
50
-
51
- @file.flock(File::LOCK_UN)
52
- @file.close
53
- @file = nil
54
- end
55
-
56
- def read_holder_safely
57
- content = File.read(@path, MAX_HOLDER_BYTES)
58
- content.gsub(/[^[:print:]\t ]/, "").strip
59
- rescue StandardError
60
- "unknown"
61
- end
62
- end
63
- end
64
- end
@@ -1,10 +0,0 @@
1
- module Textus
2
- module Port
3
- # The wall clock. An instantiable class (ADR 0109) — uniform with the other
4
- # ports; `now` reads the system time. Callers that need a fixed time still
5
- # pass it as data via `Call#now`.
6
- class Clock
7
- def now = Time.now
8
- end
9
- end
10
- end
@@ -1,60 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Port
5
- # Publishes built artifacts from the store to repo-relative consumer paths.
6
- # Publish = copy + sentinel. The in-store file is already the consumer-shaped
7
- # artifact; no parsing or stripping.
8
- #
9
- # Sentinel I/O is delegated to Textus::Port::SentinelStore. Sentinels live
10
- # under `<store_root>/.run/sentinels/` (runtime, git-ignored — ADR 0070) and
11
- # mirror the target's repo-relative layout so consumer directories aren't
12
- # polluted with `.textus-managed.json` siblings.
13
- #
14
- # An instantiable class (ADR 0109).
15
- class Publisher
16
- def publish(source:, target:, store_root:, provenance_source: source)
17
- FileUtils.mkdir_p(File.dirname(target))
18
- guard_clobber(source, target, store_root)
19
- File.delete(target) if File.symlink?(target)
20
- FileUtils.cp(source, target)
21
- Textus::Port::SentinelStore.new.write!(target: target, source: provenance_source, store_root: store_root)
22
- end
23
-
24
- # Removes a previously-published file and its sentinel. No-op unless the
25
- # target is textus-managed — never deletes an unmanaged file.
26
- def unpublish(target:, store_root:)
27
- return unless managed?(target, store_root)
28
-
29
- FileUtils.rm_f(target)
30
- sentinel = Textus::Port::SentinelStore.new.sentinel_path(target, store_root)
31
- FileUtils.rm_f(sentinel)
32
- end
33
-
34
- private
35
-
36
- # Refuse to clobber an unmanaged target — EXCEPT adopt one whose bytes
37
- # already equal the source (ADR 0050: a migration copies files into the
38
- # store and publishes them back to where they already live, so the target
39
- # is byte-identical — nothing is at risk). Adoption writes no sentinel
40
- # here; the normal publish path below does, and the cp is a content no-op.
41
- # An unmanaged target whose content DIFFERS, or any unmanaged symlink, is
42
- # still refused — that is the guard's real job.
43
- def guard_clobber(source, target, store_root)
44
- return unless File.exist?(target) || File.symlink?(target)
45
- return if managed?(target, store_root)
46
- return if adoptable?(source, target)
47
-
48
- raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
49
- end
50
-
51
- def adoptable?(source, target)
52
- !File.symlink?(target) && File.file?(target) && FileUtils.identical?(source, target)
53
- end
54
-
55
- def managed?(target, store_root)
56
- File.exist?(Textus::Port::SentinelStore.new.sentinel_path(target, store_root))
57
- end
58
- end
59
- end
60
- end
@@ -1,83 +0,0 @@
1
- require "json"
2
- require "digest"
3
- require "fileutils"
4
-
5
- module Textus
6
- module Port
7
- # Persistence adapter for sentinel files. Owns the on-disk JSON shape, the
8
- # path layout (<store_root>/.run/sentinels/<target-rel-to-repo>.textus-managed.json
9
- # — runtime, git-ignored, ADR 0070), and all File/FileUtils I/O.
10
- # Core::Sentinel is a pure value object that depends on this port for
11
- # reads and writes.
12
- class SentinelStore
13
- SUFFIX = ".textus-managed.json".freeze
14
-
15
- def write!(target:, source:, store_root:)
16
- path = sentinel_path(target, store_root)
17
- FileUtils.mkdir_p(File.dirname(path))
18
- repo_root = File.dirname(store_root)
19
- File.write(path, JSON.generate(
20
- "source" => rel_or_abs(source, repo_root),
21
- "target" => rel_or_abs(target, repo_root),
22
- "sha256" => Digest::SHA256.hexdigest(File.binread(target)),
23
- "mode" => "copy",
24
- ))
25
- end
26
-
27
- def load(path, repo_root)
28
- raw = JSON.parse(File.read(path))
29
- Textus::Core::Sentinel.new(
30
- target: absolutize(raw["target"], repo_root),
31
- source: absolutize(raw["source"], repo_root),
32
- sha256: raw["sha256"],
33
- mode: raw["mode"],
34
- )
35
- rescue JSON::ParserError, Errno::ENOENT
36
- nil
37
- end
38
-
39
- def sentinel_path(target, store_root)
40
- repo_root = File.dirname(store_root)
41
- rel = relative_to(target, repo_root) || File.basename(target)
42
- File.join(Textus::Store::Geometry.new(store_root).sentinels_root, rel + SUFFIX)
43
- end
44
-
45
- # Absolute target paths of every sentinel recorded under `target_dir`.
46
- def targets_under(target_dir, store_root)
47
- repo_root = File.dirname(store_root)
48
- rel = relative_to(target_dir, repo_root) or return []
49
- root = Textus::Store::Geometry.new(store_root).sentinels_root
50
- sdir = File.join(root, rel)
51
- return [] unless File.directory?(sdir)
52
-
53
- prefix = root + "/"
54
- Dir.glob(File.join(sdir, "**", "*#{SUFFIX}")).map do |spath|
55
- # strip the sentinel-store prefix and the .textus-managed.json suffix to recover the repo-relative target path
56
- trel = spath.delete_prefix(prefix).delete_suffix(SUFFIX)
57
- File.join(repo_root, trel)
58
- end
59
- end
60
-
61
- private
62
-
63
- def rel_or_abs(path, repo_root)
64
- relative_to(path, repo_root) || File.expand_path(path)
65
- end
66
-
67
- def relative_to(path, repo_root)
68
- path = File.expand_path(path)
69
- base = File.expand_path(repo_root)
70
- return nil unless path.start_with?(base + File::SEPARATOR)
71
-
72
- path[(base.length + 1)..]
73
- end
74
-
75
- def absolutize(path, repo_root)
76
- return path if path.nil?
77
- return path if File.absolute_path?(path)
78
-
79
- File.expand_path(path, repo_root)
80
- end
81
- end
82
- end
83
- end
@@ -1,19 +0,0 @@
1
- module Textus
2
- module Port
3
- module Storage
4
- # Read-only filesystem query port. The narrow interface that pure
5
- # domain logic (staleness checks, sentinel value) depends on, so the
6
- # domain never touches File/Dir directly. FileStore owns the write side.
7
- class FileStat
8
- def exists?(path) = File.exist?(path)
9
- def directory?(path) = File.directory?(path)
10
- def read(path) = File.binread(path)
11
- def mtime(path) = File.mtime(path)
12
-
13
- # Ruby 3.3+ guarantees Dir.glob returns a sorted Array; no explicit sort
14
- # needed, but callers can rely on ordered results for stable behaviour.
15
- def glob(pattern) = Dir.glob(pattern)
16
- end
17
- end
18
- end
19
- end
@@ -1,26 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Port
5
- module Storage
6
- # Pure filesystem I/O port. Wraps File/FileUtils/Etag with no knowledge
7
- # of envelopes, entries, schemas, or audit.
8
- class FileStore
9
- def read(path) = File.binread(path)
10
-
11
- def write(path, bytes)
12
- FileUtils.mkdir_p(File.dirname(path))
13
- File.binwrite(path, bytes)
14
- end
15
-
16
- # Raises Errno::ENOENT if absent — mirrors File.delete and matches the
17
- # semantics used by Store::Writer (which guards with File.exist? first).
18
- def delete(path) = File.delete(path)
19
-
20
- def exists?(path) = File.exist?(path)
21
-
22
- def etag(path) = Value::Etag.for_file(path)
23
- end
24
- end
25
- end
26
- end