textus 0.55.1 → 0.55.2

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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +9 -9
  4. data/SPEC.md +14 -13
  5. data/docs/architecture/README.md +3 -3
  6. data/docs/reference/conventions.md +5 -2
  7. data/lib/textus/boot.rb +64 -85
  8. data/lib/textus/{gate → dispatch}/binder.rb +8 -10
  9. data/lib/textus/dispatch/contracts.rb +63 -0
  10. data/lib/textus/dispatch/handler_registry.rb +21 -0
  11. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  12. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  13. data/lib/textus/dispatch/middleware/base.rb +26 -0
  14. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  15. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  16. data/lib/textus/dispatch/pipeline.rb +35 -0
  17. data/lib/textus/doctor/check/audit_log.rb +1 -1
  18. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  19. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  20. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  21. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  22. data/lib/textus/doctor/check/sentinels.rb +1 -1
  23. data/lib/textus/doctor/check.rb +8 -6
  24. data/lib/textus/doctor.rb +1 -1
  25. data/lib/textus/errors.rb +2 -0
  26. data/lib/textus/format/base.rb +36 -8
  27. data/lib/textus/format/json.rb +0 -21
  28. data/lib/textus/format/markdown.rb +0 -21
  29. data/lib/textus/format/yaml.rb +0 -21
  30. data/lib/textus/format.rb +16 -1
  31. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  32. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  33. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  34. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  35. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  36. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  37. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  38. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  39. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  40. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  41. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  42. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  43. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  44. data/lib/textus/handlers/read/get_entry.rb +68 -0
  45. data/lib/textus/handlers/read/list_keys.rb +36 -0
  46. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  47. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  48. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  49. data/lib/textus/handlers/read/where_entry.rb +18 -0
  50. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  51. data/lib/textus/handlers/write/data_mv.rb +55 -0
  52. data/lib/textus/handlers/write/delete_key.rb +17 -0
  53. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  54. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  55. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  56. data/lib/textus/handlers/write/move_key.rb +80 -0
  57. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  58. data/lib/textus/handlers/write/put_entry.rb +29 -0
  59. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  60. data/lib/textus/init.rb +5 -5
  61. data/lib/textus/manifest/capabilities.rb +1 -1
  62. data/lib/textus/manifest/entry/base.rb +3 -3
  63. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  64. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  65. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  66. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  67. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  68. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  69. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  70. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  71. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  72. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  73. data/lib/textus/manifest/policy/predicates.rb +54 -0
  74. data/lib/textus/manifest/policy/retention.rb +1 -1
  75. data/lib/textus/orchestration.rb +55 -0
  76. data/lib/textus/port/audit_log.rb +6 -6
  77. data/lib/textus/port/build_lock.rb +1 -1
  78. data/lib/textus/{core → port}/sentinel.rb +1 -6
  79. data/lib/textus/port/sentinel_store.rb +3 -3
  80. data/lib/textus/port/storage/file_store.rb +23 -0
  81. data/lib/textus/port/storage/interface.rb +17 -0
  82. data/lib/textus/port/store.rb +58 -2
  83. data/lib/textus/port/watcher_lock.rb +2 -2
  84. data/lib/textus/produce/engine.rb +1 -11
  85. data/lib/textus/produce/publisher.rb +21 -0
  86. data/lib/textus/schema/registry.rb +42 -0
  87. data/lib/textus/schema/tools.rb +3 -10
  88. data/lib/textus/store/container.rb +140 -10
  89. data/lib/textus/store/cursor.rb +1 -1
  90. data/lib/textus/store/{envelope → entry}/reader.rb +8 -4
  91. data/lib/textus/store/{envelope → entry}/writer.rb +53 -29
  92. data/lib/textus/store/envelope/meta.rb +61 -0
  93. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  94. data/lib/textus/store/freshness/evaluator.rb +20 -0
  95. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  96. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  97. data/lib/textus/store/freshness.rb +8 -0
  98. data/lib/textus/store/index/builder.rb +5 -3
  99. data/lib/textus/store/jobs/planner.rb +27 -7
  100. data/lib/textus/store/jobs/queue.rb +9 -1
  101. data/lib/textus/store/jobs/retention/base.rb +52 -0
  102. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  103. data/lib/textus/store/jobs/retention.rb +1 -43
  104. data/lib/textus/store/jobs/sweep.rb +2 -2
  105. data/lib/textus/store/{geometry.rb → layout.rb} +19 -3
  106. data/lib/textus/store.rb +53 -30
  107. data/lib/textus/surface/cli/runner.rb +8 -9
  108. data/lib/textus/surface/cli/verb/doctor.rb +3 -2
  109. data/lib/textus/surface/cli/verb/get.rb +5 -3
  110. data/lib/textus/surface/cli/verb/put.rb +5 -3
  111. data/lib/textus/surface/mcp/catalog.rb +26 -62
  112. data/lib/textus/surface/mcp/errors.rb +0 -10
  113. data/lib/textus/surface/mcp/projector.rb +20 -0
  114. data/lib/textus/surface/mcp/server.rb +20 -31
  115. data/lib/textus/{core → value}/duration.rb +1 -4
  116. data/lib/textus/value/envelope.rb +5 -4
  117. data/lib/textus/value/etag.rb +1 -1
  118. data/lib/textus/value/payload.rb +7 -0
  119. data/lib/textus/value/result.rb +36 -16
  120. data/lib/textus/verb_registry.rb +417 -0
  121. data/lib/textus/version.rb +1 -1
  122. data/lib/textus/workflow/loader.rb +1 -1
  123. data/lib/textus/workflow/runner.rb +10 -18
  124. data/lib/textus.rb +0 -64
  125. metadata +70 -70
  126. data/lib/textus/action/accept.rb +0 -46
  127. data/lib/textus/action/audit.rb +0 -94
  128. data/lib/textus/action/base.rb +0 -42
  129. data/lib/textus/action/blame.rb +0 -79
  130. data/lib/textus/action/boot.rb +0 -15
  131. data/lib/textus/action/data_mv.rb +0 -58
  132. data/lib/textus/action/deps.rb +0 -19
  133. data/lib/textus/action/doctor.rb +0 -17
  134. data/lib/textus/action/drain.rb +0 -31
  135. data/lib/textus/action/enqueue.rb +0 -37
  136. data/lib/textus/action/get.rb +0 -34
  137. data/lib/textus/action/ingest.rb +0 -199
  138. data/lib/textus/action/jobs.rb +0 -27
  139. data/lib/textus/action/key_delete.rb +0 -26
  140. data/lib/textus/action/key_delete_prefix.rb +0 -35
  141. data/lib/textus/action/key_mv.rb +0 -122
  142. data/lib/textus/action/key_mv_prefix.rb +0 -48
  143. data/lib/textus/action/list.rb +0 -28
  144. data/lib/textus/action/propose.rb +0 -42
  145. data/lib/textus/action/published.rb +0 -22
  146. data/lib/textus/action/pulse.rb +0 -49
  147. data/lib/textus/action/put.rb +0 -38
  148. data/lib/textus/action/rdeps.rb +0 -24
  149. data/lib/textus/action/reject.rb +0 -28
  150. data/lib/textus/action/rule_explain.rb +0 -81
  151. data/lib/textus/action/rule_lint.rb +0 -62
  152. data/lib/textus/action/rule_list.rb +0 -38
  153. data/lib/textus/action/schema_envelope.rb +0 -22
  154. data/lib/textus/action/uid.rb +0 -19
  155. data/lib/textus/action/where.rb +0 -21
  156. data/lib/textus/contract/arg.rb +0 -10
  157. data/lib/textus/contract/dsl.rb +0 -88
  158. data/lib/textus/contract/spec.rb +0 -25
  159. data/lib/textus/contract.rb +0 -12
  160. data/lib/textus/core/freshness/evaluator.rb +0 -150
  161. data/lib/textus/core/freshness.rb +0 -11
  162. data/lib/textus/core/retention/sweep.rb +0 -57
  163. data/lib/textus/core/retention.rb +0 -11
  164. data/lib/textus/format/shared.rb +0 -17
  165. data/lib/textus/gate/auth.rb +0 -212
  166. data/lib/textus/gate.rb +0 -92
  167. data/lib/textus/meta.rb +0 -54
  168. data/lib/textus/schemas.rb +0 -54
  169. data/lib/textus/store/compositor.rb +0 -34
  170. data/lib/textus/store/session.rb +0 -37
  171. data/lib/textus/surface/projector.rb +0 -27
  172. data/lib/textus/surface/role_scope.rb +0 -34
@@ -1,38 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Put < Base
6
- verb :put
7
- summary "Create or update an entry. Schema-validated. Returns {uid, etag}."
8
- surfaces :cli, :mcp
9
- arg :key, String, required: true, positional: true,
10
- description: "dotted entry key, e.g. 'knowledge.project'; must resolve to a zone the role may write"
11
- arg :meta, Hash, required: false, wire_name: :_meta,
12
- description: "frontmatter; reads back as `_meta` from `get`. Schema-validated — call `schema KEY` first"
13
- arg :body, String,
14
- description: "markdown/text payload for markdown-format entries; omit (use `content`) for json/yaml entries. Do not send both"
15
- arg :content, Hash,
16
- description: "structured payload for json/yaml-format entries; omit (use `body`) for markdown entries. Do not send both"
17
- arg :if_etag, String,
18
- description: "optimistic-concurrency guard: the etag you last read; the write is rejected if the entry changed since"
19
- view { |env| { "uid" => env.uid, "etag" => env.etag } }
20
-
21
- def self.call(container:, call:, key:, meta: nil, body: nil, content: nil, if_etag: nil) # rubocop:disable Metrics/ParameterLists
22
- Textus::Manifest::Data.validate_key!(key)
23
- mentry = container.manifest.resolver.resolve(key).entry
24
- Success(container.compositor.write(
25
- key,
26
- mentry: mentry,
27
- payload: Textus::Store::Envelope::Writer::Payload.new(
28
- meta: meta,
29
- body: body,
30
- content: content,
31
- ),
32
- if_etag: if_etag,
33
- call: call,
34
- ))
35
- end
36
- end
37
- end
38
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Rdeps < Base
6
- verb :rdeps
7
- summary "List the derived entries that depend on a key (reverse deps / impact set)."
8
- surfaces :cli, :mcp
9
- arg :key, String, required: true, positional: true,
10
- description: "dotted key whose dependents (what would be stranded if it moved) you want"
11
-
12
- def self.call(container:, key:, **)
13
- manifest = container.manifest
14
- rdeps = manifest.data.entries.each_with_object([]) do |entry, acc|
15
- next unless entry.external?
16
-
17
- sources = Array(entry.source&.sources).compact
18
- acc << entry.key if sources.any? { |source| source == key || key.start_with?("#{source}.") }
19
- end
20
- Success({ "key" => key, "rdeps" => rdeps })
21
- end
22
- end
23
- end
24
- end
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Reject < Base
6
- verb :reject
7
- summary "discard a queued proposal without applying it"
8
- surfaces :cli, :mcp
9
- cli "reject"
10
- arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
11
-
12
- def self.call(container:, call:, pending_key:)
13
- mentry = container.manifest.resolver.resolve(pending_key).entry
14
- unless mentry.in_proposal_lane?(container.manifest.policy)
15
- return Failure(code: :proposal_error, message: "reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.lane})")
16
- end
17
-
18
- env = container.compositor.read(pending_key)
19
- parsed = proposal_from(env, key: pending_key)
20
- return parsed if parsed.is_a?(Dry::Monads::Result::Failure)
21
-
22
- target_key = parsed[:target_key]
23
- container.compositor.delete(pending_key, mentry: mentry, call: call)
24
- Success("protocol" => Textus::PROTOCOL, "rejected" => pending_key, "target_key" => target_key)
25
- end
26
- end
27
- end
28
- end
@@ -1,81 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class RuleExplain < Base
6
- verb :rule_explain
7
- summary "Effective rules for a key. Lean {lifecycle, guard} by default; detail: true adds matched blocks + guard predicates."
8
- surfaces :cli, :mcp
9
- cli "rule explain"
10
- arg :key, String, required: true, positional: true,
11
- description: "dotted key whose effective rules you want (lifecycle ttl/action, write guard, ...)"
12
- arg :detail, :boolean,
13
- description: "defaults false: lean {lifecycle, guard}. detail: true adds matched blocks + guard predicates per transition."
14
- view(:cli) { |r| { "verb" => "rule_explain" }.merge(r.transform_keys(&:to_s)) }
15
-
16
- def self.call(container:, call:, key:, detail: nil) # rubocop:disable Lint/UnusedMethodArgument
17
- manifest = container.manifest
18
- Success(detail ? explain(manifest, key) : effective(manifest, key))
19
- end
20
-
21
- REGISTRY = Textus::Manifest::Schema::FIELD_REGISTRY
22
- LEAN_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:lean) }.keys.freeze
23
- DETAIL_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:detail) }.keys.freeze
24
- EFFECTIVE_FIELDS = DETAIL_FIELDS.select { |f| REGISTRY[f][:policy_class] }.freeze
25
-
26
- def self.effective(manifest, key)
27
- set = manifest.rules.for(key)
28
- LEAN_FIELDS.each_with_object({}) do |field, out|
29
- value = set.public_send(field)
30
- out[field.to_s] = lean_value(field, value) unless value.nil?
31
- end
32
- end
33
-
34
- def self.lean_value(field, value)
35
- case field
36
- when :retention
37
- retention_hash(value, string_keys: true)
38
- when :react
39
- value.to_h
40
- else
41
- value
42
- end
43
- end
44
-
45
- def self.explain(manifest, key)
46
- matching = manifest.rules.explain(key)
47
- winners = manifest.rules.for(key)
48
- {
49
- key: key,
50
- matched_blocks: matching.map do |block|
51
- { match: block.match }.merge(DETAIL_FIELDS.to_h { |f| [f, !block.public_send(f).nil?] })
52
- end,
53
- effective: EFFECTIVE_FIELDS.to_h { |f| [f, effective_value(f, winners.public_send(f))] },
54
- guards: Textus::Gate::Auth::FLOOR.keys.to_h do |action|
55
- floor = Textus::Gate::Auth::FLOOR.fetch(action, [])
56
- rule = Array(manifest.rules.for(key).guard&.dig(action.to_s))
57
- [action, { floor: floor, rule: rule }]
58
- end,
59
- }
60
- end
61
-
62
- def self.effective_value(field, value)
63
- return nil if value.nil?
64
-
65
- case field
66
- when :retention
67
- retention_hash(value, string_keys: false)
68
- when :react
69
- value.to_h
70
- else
71
- value
72
- end
73
- end
74
-
75
- def self.retention_hash(retention, string_keys:)
76
- h = { ttl_seconds: retention.ttl_seconds, action: retention.action }
77
- string_keys ? h.transform_keys(&:to_s) : h
78
- end
79
- end
80
- end
81
- end
@@ -1,62 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
-
5
- module Textus
6
- module Action
7
- class RuleLint < Base
8
- verb :rule_lint
9
- summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
10
- surfaces :cli, :mcp
11
- cli "rule lint"
12
- arg :candidate_yaml, String, required: true, wire_name: :against, source: :file,
13
- description: "path to candidate manifest YAML; its `rules:` block is diffed against the live manifest"
14
- view { |v, _i| v.to_h }
15
-
16
- def self.call(container:, call:, candidate_yaml:) # rubocop:disable Lint/UnusedMethodArgument
17
- root = container.root
18
- live_rules = current_rules(root)
19
- candidate_result = parse_candidate(candidate_yaml)
20
- return candidate_result if candidate_result.is_a?(Dry::Monads::Result::Failure)
21
-
22
- candidate_rules = candidate_result
23
-
24
- live_by_match = live_rules.to_h { |rule| [rule["match"], rule] }
25
- candidate_by_match = candidate_rules.to_h { |rule| [rule["match"], rule] }
26
-
27
- steps = (candidate_by_match.keys - live_by_match.keys).map do |match|
28
- { "op" => "add_rule", "match" => match, "rule" => candidate_by_match[match] }
29
- end
30
- (live_by_match.keys - candidate_by_match.keys).each do |match|
31
- steps << { "op" => "remove_rule", "match" => match }
32
- end
33
- (live_by_match.keys & candidate_by_match.keys).each do |match|
34
- next if live_by_match[match] == candidate_by_match[match]
35
-
36
- steps << {
37
- "op" => "change_rule",
38
- "match" => match,
39
- "from" => live_by_match[match],
40
- "to" => candidate_by_match[match],
41
- }
42
- end
43
-
44
- Success(Textus::Store::Jobs::Plan.new(steps: steps, warnings: []))
45
- end
46
-
47
- def self.current_rules(root)
48
- raw = YAML.safe_load_file(File.join(root, "manifest.yaml"), permitted_classes: [Symbol], aliases: false)
49
- Array(raw["rules"])
50
- end
51
-
52
- def self.parse_candidate(yaml_text)
53
- raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
54
- return Failure(code: :usage_error, message: "candidate is not a YAML mapping") unless raw.is_a?(Hash)
55
-
56
- Array(raw["rules"])
57
- rescue Psych::Exception => e
58
- Failure(code: :usage_error, message: "candidate YAML parse error: #{e.message}")
59
- end
60
- end
61
- end
62
- end
@@ -1,38 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class RuleList < Base
6
- verb :rule_list
7
- summary "List every rule block in the manifest."
8
- surfaces :cli
9
- cli "rule list"
10
- view(:cli) { |policies| { "verb" => "rule_list", "policies" => policies } }
11
-
12
- def self.call(container:, call:, **_options) # rubocop:disable Lint/UnusedMethodArgument
13
- manifest = container.manifest
14
- Success(manifest.rules.blocks.map do |block|
15
- row = { "match" => block.match }
16
- LIST_FIELDS.each do |field|
17
- value = block.public_send(field)
18
- row[field.to_s] = serialize(field, value) unless value.nil?
19
- end
20
- row
21
- end)
22
- end
23
-
24
- LIST_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY.select { |_, m| m[:in_rule_list] }.keys.freeze
25
-
26
- def self.serialize(field, value)
27
- case field
28
- when :retention
29
- { "ttl_seconds" => value.ttl_seconds, "action" => value.action.to_s }
30
- when :react
31
- value.to_h
32
- else
33
- value
34
- end
35
- end
36
- end
37
- end
38
- end
@@ -1,22 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class SchemaEnvelope < Base
6
- verb :schema_show
7
- summary "Return the schema (field shape) for an entry's family, by key."
8
- surfaces :cli, :mcp
9
- cli "schema show"
10
- arg :key, String, required: true, positional: true,
11
- description: "any key in the family whose schema you want; returns required/optional fields and their types"
12
-
13
- def self.call(container:, key:, **)
14
- manifest = container.manifest
15
- schemas = container.schemas
16
- mentry = manifest.resolver.resolve(key).entry
17
- schema = schemas.fetch_or_nil(mentry.schema)
18
- Success({ "protocol" => Textus::PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h })
19
- end
20
- end
21
- end
22
- end
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Uid < Base
6
- verb :uid
7
- summary "Return the stable UID of an entry without reading its body."
8
- surfaces :cli
9
- cli "key uid"
10
- arg :key, String, required: true, positional: true, description: "entry key"
11
- view(:cli) { |uid, inputs| { "key" => inputs[:key], "uid" => uid } }
12
-
13
- def self.call(container:, call:, key:)
14
- envelope = Value::Result.unwrap(Textus::Action::Get.call(container: container, call: call, key: key))
15
- Success(envelope.uid)
16
- end
17
- end
18
- end
19
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Where < Base
6
- verb :where
7
- summary "Resolve a key to its zone, owner, and path without reading the body."
8
- surfaces :cli, :mcp
9
- arg :key, String, required: true, positional: true,
10
- description: "dotted key to locate (returns zone, owner, path; does not read content)"
11
-
12
- def self.call(container:, key:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
13
- manifest = container.manifest
14
- res = manifest.resolver.resolve(key)
15
- mentry = res.entry
16
- path = res.path
17
- Success({ "protocol" => Textus::PROTOCOL, "key" => key, "lane" => mentry.lane, "owner" => mentry.owner, "path" => path })
18
- end
19
- end
20
- end
21
- end
@@ -1,10 +0,0 @@
1
- module Textus
2
- module Contract
3
- Arg = Data.define(
4
- :name, :type, :required, :positional, :session_default,
5
- :description, :wire_name, :default, :source, :coerce, :cli_default
6
- ) do
7
- def wire = wire_name || name
8
- end
9
- end
10
- end
@@ -1,88 +0,0 @@
1
- module Textus
2
- module Contract
3
- module DSL
4
- def verb(name = nil)
5
- if name
6
- raise "contract already built; declare verb before reading .contract" if defined?(@contract) && @contract
7
-
8
- @__verb = name
9
- else
10
- @__verb
11
- end
12
- end
13
-
14
- def summary(text = nil)
15
- if text
16
- raise "contract already built; declare summary before reading .contract" if defined?(@contract) && @contract
17
-
18
- @__summary = text
19
- else
20
- @__summary
21
- end
22
- end
23
-
24
- def surfaces(*list)
25
- if list.empty?
26
- @__surfaces ||= []
27
- else
28
- raise "contract already built; declare surfaces before reading .contract" if defined?(@contract) && @contract
29
-
30
- @__surfaces = list
31
- end
32
- end
33
-
34
- def cli(path = nil)
35
- if path
36
- raise "contract already built; declare cli before reading .contract" if defined?(@contract) && @contract
37
-
38
- @__cli = path.to_s
39
- else
40
- @__cli
41
- end
42
- end
43
-
44
- def arg(name, type, required: false, positional: false, session_default: nil, description: nil, wire_name: nil, default: nil, source: nil, coerce: nil, cli_default: :__unset) # rubocop:disable Metrics/ParameterLists,Layout/LineLength
45
- raise "contract already built; declare args before reading .contract" if defined?(@contract) && @contract
46
-
47
- (@__args ||= []) << Arg.new(
48
- name: name, type: type, required: required,
49
- positional: positional, session_default: session_default,
50
- description: description, wire_name: wire_name, default: default,
51
- source: source, coerce: coerce, cli_default: cli_default
52
- )
53
- end
54
-
55
- def cli_stdin(mode = :__read)
56
- return @__cli_stdin if mode == :__read
57
-
58
- raise "contract already built; declare cli_stdin before reading .contract" if defined?(@contract) && @contract
59
-
60
- @__cli_stdin = mode
61
- end
62
-
63
- def view(surface = :default, &blk)
64
- return (@__views ||= {})[surface] unless blk
65
-
66
- raise "contract already built; declare view before reading .contract" if defined?(@contract) && @contract
67
-
68
- (@__views ||= {})[surface] = blk
69
- end
70
-
71
- def contract?
72
- !@__verb.nil?
73
- end
74
-
75
- def contract
76
- @contract ||= Spec.new(
77
- verb: @__verb,
78
- summary: @__summary,
79
- args: (@__args || []).freeze,
80
- surfaces: (@__surfaces || []).freeze,
81
- views: ((@__views ||= {})[:default] ||= ->(v, _i) { v }) && @__views,
82
- cli: @__cli,
83
- cli_stdin: @__cli_stdin,
84
- )
85
- end
86
- end
87
- end
88
- end
@@ -1,25 +0,0 @@
1
- module Textus
2
- module Contract
3
- Spec = Data.define(:verb, :summary, :args, :surfaces, :views, :cli, :cli_stdin) do
4
- def mcp? = surfaces.include?(:mcp)
5
- def cli? = surfaces.include?(:cli)
6
-
7
- def view(surface = :default) = views[surface] || views.fetch(:default)
8
- def cli_path = cli || verb.to_s
9
- def cli_words = cli_path.split
10
- def cli_group = cli_words.size > 1 ? cli_words.first : nil
11
- def cli_leaf = cli_words.last
12
-
13
- def required_args = args.select(&:required)
14
-
15
- def input_schema
16
- props = args.to_h do |a|
17
- h = { "type" => Contract.json_type(a.type) }
18
- h["description"] = a.description if a.description
19
- [a.wire.to_s, h]
20
- end
21
- { type: "object", properties: props, required: required_args.map { |a| a.wire.to_s } }
22
- end
23
- end
24
- end
25
- end
@@ -1,12 +0,0 @@
1
- module Textus
2
- module Contract
3
- JSON_TYPES = {
4
- String => "string", Integer => "integer", Hash => "object",
5
- Array => "array", :boolean => "boolean"
6
- }.freeze
7
-
8
- def self.json_type(type)
9
- JSON_TYPES.fetch(type) { raise ArgumentError.new("no JSON type mapping for #{type.inspect}") }
10
- end
11
- end
12
- end
@@ -1,150 +0,0 @@
1
- require "time"
2
-
3
- module Textus
4
- module Core
5
- module Freshness
6
- # The single currency evaluator (ADR 0099). Answers "is the stored data
7
- # stale relative to its retention rule?" and detects generator drift for
8
- # external entries.
9
- # - retention rule TTL -> AGE signal: now - file_basis > ttl_seconds
10
- # - external -> DRIFT signal: a source changed since generated.at
11
- # (surfaced by the doctor generator_drift check).
12
- class Evaluator
13
- def initialize(manifest:, file_stat:, clock:)
14
- @manifest = manifest
15
- @file_stat = file_stat
16
- @clock = clock
17
- end
18
-
19
- # Per-entry currency Verdict driven by the retention rule TTL (if any).
20
- def verdict(mentry)
21
- ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
22
- return fresh if ttl.nil?
23
-
24
- stale = age_stale?(file_basis(mentry), ttl)
25
- Verdict.build(stale: stale, reason: stale ? "ttl exceeded" : nil, fetching: false)
26
- end
27
-
28
- # Keys of entries past their retention rule TTL — the refresh produce scope.
29
- def stale_keys(prefix: nil, lane: nil)
30
- @manifest.data.entries.select { |m| due?(m, prefix: prefix, lane: lane) }.map(&:key)
31
- end
32
-
33
- alias stale_intake_keys stale_keys
34
-
35
- # File basis as a Time (or nil): file mtime when present, else nil.
36
- def file_basis(mentry)
37
- path = @manifest.resolver.resolve(mentry.key).path
38
- return nil unless @file_stat.exists?(path)
39
-
40
- @file_stat.mtime(path)
41
- end
42
-
43
- # Generator-drift rows for one entry (replaces Staleness::GeneratorCheck#
44
- # rows_for) — consumed by the doctor generator_drift check.
45
- def drift_rows(mentry)
46
- return [] unless drift_applicable?(mentry)
47
-
48
- path = Textus::Key::Path.resolve(@manifest.data, mentry)
49
- reason = drift_reason(mentry, path)
50
- reason ? [drift_row(mentry, path, reason)] : []
51
- end
52
-
53
- private
54
-
55
- def fresh = Verdict.build(stale: false, reason: nil, fetching: false)
56
-
57
- def due?(mentry, prefix:, lane:)
58
- return false if lane && mentry.lane != lane
59
- return false if prefix && !mentry.key.start_with?(prefix)
60
-
61
- ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
62
- return false if ttl.nil?
63
-
64
- path = @manifest.resolver.resolve(mentry.key).path
65
- return true unless @file_stat.exists?(path)
66
-
67
- age_stale?(file_basis(mentry), ttl)
68
- end
69
-
70
- # The one age comparison. A never-recorded entry (nil basis) is stale.
71
- def age_stale?(basis, ttl)
72
- return true if basis.nil?
73
-
74
- (@clock.now - basis).to_i > ttl
75
- end
76
-
77
- # --- generator drift (lifted from Staleness::GeneratorCheck) ---
78
-
79
- def drift_applicable?(mentry) = mentry.external?
80
-
81
- def drift_reason(mentry, path)
82
- return "derived entry has never been generated" unless @file_stat.exists?(path)
83
-
84
- generated_at = generated_at_of(mentry, path)
85
- return "missing generated.at frontmatter" unless generated_at
86
-
87
- gen_time = parse_time(generated_at)
88
- return "unparseable generated.at: #{generated_at.inspect}" unless gen_time
89
-
90
- offender = newest_source_after(mentry.source, gen_time)
91
- "source '#{offender}' modified after generated.at" if offender
92
- end
93
-
94
- def generated_at_of(mentry, path)
95
- Format.for(mentry.format).parse(@file_stat.read(path), path: path)["_meta"].dig("generated", "at")
96
- end
97
-
98
- def parse_time(str)
99
- Time.parse(str.to_s)
100
- rescue StandardError
101
- nil
102
- end
103
-
104
- def newest_source_after(external_src, gen_time)
105
- Array(external_src.sources).each do |src|
106
- offender = check_source(src, gen_time)
107
- return offender if offender
108
- end
109
- nil
110
- end
111
-
112
- def check_source(src, gen_time)
113
- if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
114
- @manifest.resolver.enumerate(prefix: src).each do |row|
115
- return src if @file_stat.mtime(row[:path]) > gen_time
116
- end
117
- nil
118
- else
119
- check_filesystem_source(src, gen_time)
120
- end
121
- end
122
-
123
- def check_filesystem_source(src, gen_time)
124
- abs = absolutize_source(src)
125
- if @file_stat.directory?(abs)
126
- dir_has_newer_file?(abs, gen_time) ? src : nil
127
- elsif @file_stat.exists?(abs) && @file_stat.mtime(abs) > gen_time
128
- src
129
- end
130
- end
131
-
132
- def absolutize_source(src)
133
- File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.data.root), src)
134
- end
135
-
136
- def dir_has_newer_file?(abs, gen_time)
137
- @file_stat.glob(File.join(abs, "**", "*")).any? do |fpath|
138
- file?(fpath) && @file_stat.mtime(fpath) > gen_time
139
- end
140
- end
141
-
142
- def file?(fpath) = !@file_stat.directory?(fpath) && @file_stat.exists?(fpath)
143
-
144
- def drift_row(mentry, path, reason)
145
- { "key" => mentry.key, "path" => path, "generator" => mentry.source.command, "reason" => reason }
146
- end
147
- end
148
- end
149
- end
150
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Core
5
- # Currency — "is the stored data stale relative to its source?" (ADR 0099).
6
- # The home of the single Freshness evaluator and its Verdict value object.
7
- # Distinct from Core::Retention (GC dueness, Q2).
8
- module Freshness
9
- end
10
- end
11
- end