textus 0.26.0 → 0.30.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 (157) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +118 -68
  3. data/CHANGELOG.md +132 -0
  4. data/README.md +61 -19
  5. data/SPEC.md +107 -46
  6. data/docs/conventions.md +4 -4
  7. data/lib/textus/boot.rb +18 -12
  8. data/lib/textus/builder/pipeline.rb +13 -12
  9. data/lib/textus/call.rb +28 -0
  10. data/lib/textus/cli/verb/audit.rb +1 -1
  11. data/lib/textus/cli/verb/boot.rb +1 -1
  12. data/lib/textus/cli/verb/build.rb +2 -2
  13. data/lib/textus/cli/verb/doctor.rb +1 -1
  14. data/lib/textus/cli/verb/hook_run.rb +2 -6
  15. data/lib/textus/cli/verb/put.rb +5 -14
  16. data/lib/textus/cli/verb/retain.rb +19 -0
  17. data/lib/textus/cli/verb/rule_list.rb +1 -1
  18. data/lib/textus/cli/verb.rb +6 -6
  19. data/lib/textus/cli.rb +19 -23
  20. data/lib/textus/container.rb +23 -0
  21. data/lib/textus/dispatcher.rb +57 -0
  22. data/lib/textus/doctor/check/audit_log.rb +1 -1
  23. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  24. data/lib/textus/doctor/check/sentinels.rb +10 -8
  25. data/lib/textus/doctor/check.rb +15 -5
  26. data/lib/textus/doctor.rb +7 -7
  27. data/lib/textus/domain/authorizer.rb +2 -2
  28. data/lib/textus/domain/duration.rb +22 -0
  29. data/lib/textus/domain/policy/refresh.rb +1 -15
  30. data/lib/textus/domain/policy/retention.rb +26 -0
  31. data/lib/textus/domain/retention.rb +44 -0
  32. data/lib/textus/domain/sentinel.rb +9 -65
  33. data/lib/textus/domain/staleness/generator_check.rb +46 -26
  34. data/lib/textus/domain/staleness/intake_check.rb +18 -10
  35. data/lib/textus/domain/staleness.rb +3 -3
  36. data/lib/textus/{application/envelope → envelope/io}/reader.rb +6 -2
  37. data/lib/textus/{application/envelope → envelope/io}/writer.rb +19 -11
  38. data/lib/textus/hooks/context.rb +30 -13
  39. data/lib/textus/hooks/event_bus.rb +8 -20
  40. data/lib/textus/hooks/rpc_registry.rb +9 -35
  41. data/lib/textus/hooks/signature.rb +31 -0
  42. data/lib/textus/init.rb +7 -6
  43. data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
  44. data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
  45. data/lib/textus/maintenance/migrate.rb +51 -0
  46. data/lib/textus/maintenance/rule_lint.rb +56 -0
  47. data/lib/textus/maintenance/zone_mv.rb +51 -0
  48. data/lib/textus/maintenance.rb +15 -0
  49. data/lib/textus/manifest/data.rb +9 -4
  50. data/lib/textus/manifest/entry/base.rb +38 -18
  51. data/lib/textus/manifest/entry/derived.rb +6 -6
  52. data/lib/textus/manifest/entry/nested.rb +7 -9
  53. data/lib/textus/manifest/entry/parser.rb +2 -2
  54. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  55. data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
  56. data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
  57. data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
  58. data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
  59. data/lib/textus/manifest/entry/validators.rb +2 -2
  60. data/lib/textus/manifest/entry.rb +0 -5
  61. data/lib/textus/manifest/policy.rb +34 -7
  62. data/lib/textus/manifest/rules.rb +10 -1
  63. data/lib/textus/manifest/schema.rb +54 -4
  64. data/lib/textus/manifest.rb +4 -8
  65. data/lib/textus/mcp/server.rb +2 -11
  66. data/lib/textus/mcp/session.rb +13 -20
  67. data/lib/textus/mcp/tools.rb +2 -2
  68. data/lib/textus/mcp.rb +1 -1
  69. data/lib/textus/{infra → ports}/audit_log.rb +1 -1
  70. data/lib/textus/{infra → ports}/audit_subscriber.rb +2 -2
  71. data/lib/textus/{infra → ports}/build_lock.rb +1 -1
  72. data/lib/textus/{infra → ports}/clock.rb +1 -1
  73. data/lib/textus/{infra → ports}/publisher.rb +6 -6
  74. data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
  75. data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
  76. data/lib/textus/ports/sentinel_store.rb +67 -0
  77. data/lib/textus/ports/storage/file_stat.rb +19 -0
  78. data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
  79. data/lib/textus/projection.rb +91 -0
  80. data/lib/textus/read/audit.rb +111 -0
  81. data/lib/textus/read/blame.rb +81 -0
  82. data/lib/textus/read/boot.rb +18 -0
  83. data/lib/textus/read/deps.rb +24 -0
  84. data/lib/textus/read/doctor.rb +19 -0
  85. data/lib/textus/read/freshness.rb +101 -0
  86. data/lib/textus/read/get.rb +66 -0
  87. data/lib/textus/read/get_or_refresh.rb +69 -0
  88. data/lib/textus/read/list.rb +15 -0
  89. data/lib/textus/read/policy_explain.rb +42 -0
  90. data/lib/textus/read/published.rb +15 -0
  91. data/lib/textus/read/pulse.rb +89 -0
  92. data/lib/textus/read/rdeps.rb +25 -0
  93. data/lib/textus/read/retainable.rb +17 -0
  94. data/lib/textus/read/schema_envelope.rb +16 -0
  95. data/lib/textus/read/stale.rb +17 -0
  96. data/lib/textus/read/uid.rb +20 -0
  97. data/lib/textus/read/validate_all.rb +22 -0
  98. data/lib/textus/read/validator.rb +84 -0
  99. data/lib/textus/read/where.rb +16 -0
  100. data/lib/textus/role_scope.rb +50 -0
  101. data/lib/textus/schema/tools.rb +3 -3
  102. data/lib/textus/store.rb +16 -7
  103. data/lib/textus/version.rb +1 -1
  104. data/lib/textus/write/accept.rb +86 -0
  105. data/lib/textus/write/authority_gate.rb +24 -0
  106. data/lib/textus/write/delete.rb +40 -0
  107. data/lib/textus/write/intake_fetch.rb +23 -0
  108. data/lib/textus/write/materializer.rb +48 -0
  109. data/lib/textus/write/mv.rb +113 -0
  110. data/lib/textus/write/publish.rb +66 -0
  111. data/lib/textus/write/put.rb +45 -0
  112. data/lib/textus/write/refresh_all.rb +44 -0
  113. data/lib/textus/write/refresh_orchestrator.rb +102 -0
  114. data/lib/textus/write/refresh_worker.rb +124 -0
  115. data/lib/textus/write/reject.rb +54 -0
  116. data/lib/textus/write/retention_sweep.rb +55 -0
  117. data/lib/textus.rb +1 -2
  118. metadata +62 -50
  119. data/lib/textus/application/caps.rb +0 -49
  120. data/lib/textus/application/context.rb +0 -34
  121. data/lib/textus/application/maintenance/key_delete_prefix.rb +0 -44
  122. data/lib/textus/application/maintenance/key_mv_prefix.rb +0 -57
  123. data/lib/textus/application/maintenance/migrate.rb +0 -59
  124. data/lib/textus/application/maintenance/rule_lint.rb +0 -65
  125. data/lib/textus/application/maintenance/zone_mv.rb +0 -60
  126. data/lib/textus/application/maintenance.rb +0 -17
  127. data/lib/textus/application/projection.rb +0 -93
  128. data/lib/textus/application/read/audit.rb +0 -106
  129. data/lib/textus/application/read/blame.rb +0 -91
  130. data/lib/textus/application/read/deps.rb +0 -34
  131. data/lib/textus/application/read/freshness.rb +0 -110
  132. data/lib/textus/application/read/get.rb +0 -75
  133. data/lib/textus/application/read/get_or_refresh.rb +0 -63
  134. data/lib/textus/application/read/list.rb +0 -25
  135. data/lib/textus/application/read/policy_explain.rb +0 -47
  136. data/lib/textus/application/read/published.rb +0 -25
  137. data/lib/textus/application/read/pulse.rb +0 -101
  138. data/lib/textus/application/read/rdeps.rb +0 -35
  139. data/lib/textus/application/read/schema_envelope.rb +0 -26
  140. data/lib/textus/application/read/stale.rb +0 -23
  141. data/lib/textus/application/read/uid.rb +0 -30
  142. data/lib/textus/application/read/validate_all.rb +0 -32
  143. data/lib/textus/application/read/validator.rb +0 -86
  144. data/lib/textus/application/read/where.rb +0 -26
  145. data/lib/textus/application/use_case.rb +0 -22
  146. data/lib/textus/application/write/accept.rb +0 -102
  147. data/lib/textus/application/write/authority_gate.rb +0 -26
  148. data/lib/textus/application/write/delete.rb +0 -45
  149. data/lib/textus/application/write/materializer.rb +0 -49
  150. data/lib/textus/application/write/mv.rb +0 -118
  151. data/lib/textus/application/write/publish.rb +0 -96
  152. data/lib/textus/application/write/put.rb +0 -49
  153. data/lib/textus/application/write/refresh_all.rb +0 -63
  154. data/lib/textus/application/write/refresh_orchestrator.rb +0 -102
  155. data/lib/textus/application/write/refresh_worker.rb +0 -134
  156. data/lib/textus/application/write/reject.rb +0 -62
  157. data/lib/textus/session.rb +0 -84
data/lib/textus/cli.rb CHANGED
@@ -14,13 +14,6 @@ module Textus
14
14
  .to_h { |k| [k.command_name, k] }
15
15
  end
16
16
 
17
- # Backward-compat constant; callers should prefer `CLI.verbs`.
18
- def self.const_missing(name)
19
- return verbs.freeze if name == :VERBS
20
-
21
- super
22
- end
23
-
24
17
  def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
25
18
  new(stdin: stdin, stdout: stdout, stderr: stderr, cwd: cwd).run(argv)
26
19
  end
@@ -34,22 +27,25 @@ module Textus
34
27
  end
35
28
 
36
29
  def run(argv)
37
- OptionParser.new { |o| o.on("--root=PATH") { |v| @root_arg = v } }.order!(argv)
30
+ # Define --version/--help ourselves so OptionParser doesn't intercept them
31
+ # with its built-in handlers (which print "version unknown" and a bare usage
32
+ # line, then exit before we ever reach the verb dispatch below).
33
+ show_version = false
34
+ show_help = false
35
+ OptionParser.new do |o|
36
+ o.on("--root=PATH") { |v| @root_arg = v }
37
+ o.on("--version", "-v") { show_version = true }
38
+ o.on("--help", "-h") { show_help = true }
39
+ end.order!(argv)
40
+
41
+ return @stdout.puts(VERSION) || 0 if show_version
42
+ return print_help || 0 if show_help
43
+
38
44
  verb = argv.shift
39
45
  raise UsageError.new("missing verb") if verb.nil?
40
46
 
41
- result =
42
- case verb
43
- when "--version", "-v" then @stdout.puts(VERSION)
44
- 0
45
- when "--help", "-h" then print_help
46
- 0
47
- else
48
- klass = self.class.verbs[verb] or raise UsageError.new("unknown verb: #{verb}")
49
- dispatch(klass, argv)
50
- end
51
-
52
- coerce_exit_code(result)
47
+ klass = self.class.verbs[verb] or raise UsageError.new("unknown verb: #{verb}")
48
+ coerce_exit_code(dispatch(klass, argv))
53
49
  rescue Textus::Error => e
54
50
  emit_error(e)
55
51
  end
@@ -101,9 +97,9 @@ module Textus
101
97
  textus doctor
102
98
  textus boot
103
99
 
104
- textus key {mv,uid,normalize}
105
- textus rule {list,explain}
106
- textus schema {show,init,diff,migrate}
100
+ textus key {delete,mv,uid}
101
+ textus rule {explain,lint,list}
102
+ textus schema {diff,init,migrate,show}
107
103
  textus hook {list,run}
108
104
  HELP
109
105
  end
@@ -0,0 +1,23 @@
1
+ module Textus
2
+ # Single capability record handed to every use case. Replaces the
3
+ # ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store.
4
+ Container = Data.define(
5
+ :manifest, :file_store, :schemas, :root,
6
+ :audit_log, :events, :rpc, :authorizer
7
+ )
8
+
9
+ class Container
10
+ def self.from_store(store)
11
+ new(
12
+ manifest: store.manifest,
13
+ file_store: store.file_store,
14
+ schemas: store.schemas,
15
+ root: store.root,
16
+ audit_log: store.audit_log,
17
+ events: store.events,
18
+ rpc: store.rpc,
19
+ authorizer: Textus::Domain::Authorizer.new(manifest: store.manifest),
20
+ )
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,57 @@
1
+ module Textus
2
+ # Static verb → use-case map. Canonical lookup as of 0.27.0; replaces the
3
+ # Application::UseCase registry whose entries were populated by file-load
4
+ # side effects in 0.26.x.
5
+ module Dispatcher
6
+ VERBS = {
7
+ # Write
8
+ put: Textus::Write::Put,
9
+ delete: Textus::Write::Delete,
10
+ mv: Textus::Write::Mv,
11
+ accept: Textus::Write::Accept,
12
+ reject: Textus::Write::Reject,
13
+ publish: Textus::Write::Publish,
14
+ refresh: Textus::Write::RefreshWorker,
15
+ refresh_all: Textus::Write::RefreshAll,
16
+ retention_sweep: Textus::Write::RetentionSweep,
17
+
18
+ # Read
19
+ get: Textus::Read::Get,
20
+ get_or_refresh: Textus::Read::GetOrRefresh,
21
+ list: Textus::Read::List,
22
+ where: Textus::Read::Where,
23
+ uid: Textus::Read::Uid,
24
+ blame: Textus::Read::Blame,
25
+ audit: Textus::Read::Audit,
26
+ freshness: Textus::Read::Freshness,
27
+ stale: Textus::Read::Stale,
28
+ deps: Textus::Read::Deps,
29
+ rdeps: Textus::Read::Rdeps,
30
+ pulse: Textus::Read::Pulse,
31
+ policy_explain: Textus::Read::PolicyExplain,
32
+ published: Textus::Read::Published,
33
+ schema_envelope: Textus::Read::SchemaEnvelope,
34
+ validate_all: Textus::Read::ValidateAll,
35
+ doctor: Textus::Read::Doctor,
36
+ boot: Textus::Read::Boot,
37
+ retainable: Textus::Read::Retainable,
38
+
39
+ # Maintenance
40
+ migrate: Textus::Maintenance::Migrate,
41
+ zone_mv: Textus::Maintenance::ZoneMv,
42
+ key_mv_prefix: Textus::Maintenance::KeyMvPrefix,
43
+ key_delete_prefix: Textus::Maintenance::KeyDeletePrefix,
44
+ rule_lint: Textus::Maintenance::RuleLint,
45
+ }.freeze
46
+
47
+ def self.fetch(verb)
48
+ VERBS.fetch(verb.to_sym) { raise UsageError.new("unknown verb: #{verb.inspect}") }
49
+ end
50
+
51
+ # Single home for the uniform use-case invocation protocol (ADR 0023):
52
+ # look up the verb, construct on (container:, call:), and invoke #call.
53
+ def self.invoke(verb, container:, call:, args: [], kwargs: {})
54
+ fetch(verb).new(container: container, call: call).call(*args, **kwargs)
55
+ end
56
+ end
57
+ end
@@ -4,7 +4,7 @@ module Textus
4
4
  class AuditLog < Check
5
5
  def call
6
6
  path = File.join(root, "audit.log")
7
- Textus::Infra::AuditLog.new(root).verify_integrity.map do |v|
7
+ Textus::Ports::AuditLog.new(root).verify_integrity.map do |v|
8
8
  {
9
9
  "code" => "audit.parse_error",
10
10
  "level" => "warning",
@@ -3,7 +3,7 @@ module Textus
3
3
  class Check
4
4
  class SchemaViolations < Check
5
5
  def call
6
- res = @session.validate_all
6
+ res = dispatch(:validate_all)
7
7
  res["violations"].map do |v|
8
8
  fix = v["expected"] &&
9
9
  "field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"
@@ -3,22 +3,24 @@ module Textus
3
3
  class Check
4
4
  class Sentinels < Check
5
5
  def call
6
- dir = File.join(root, "sentinels")
7
- return [] unless File.directory?(dir)
6
+ store = Textus::Ports::SentinelStore.new
7
+ file_stat = Textus::Ports::Storage::FileStat.new
8
+ dir = File.join(root, "sentinels")
9
+ return [] unless file_stat.directory?(dir)
8
10
 
9
11
  repo_root = File.dirname(root)
10
- Dir.glob(File.join(dir, "**", "*#{Textus::Domain::Sentinel::SUFFIX}")).flat_map do |sentinel_path|
11
- inspect_sentinel(sentinel_path, repo_root)
12
+ file_stat.glob(File.join(dir, "**", "*#{Textus::Ports::SentinelStore::SUFFIX}")).flat_map do |sentinel_path|
13
+ inspect_sentinel(sentinel_path, repo_root, store, file_stat)
12
14
  end
13
15
  end
14
16
 
15
17
  private
16
18
 
17
- def inspect_sentinel(sentinel_path, repo_root)
18
- sentinel = Textus::Domain::Sentinel.load(sentinel_path, repo_root)
19
+ def inspect_sentinel(sentinel_path, repo_root, store, file_stat)
20
+ sentinel = store.load(sentinel_path, repo_root)
19
21
  return [parse_error_issue(sentinel_path)] if sentinel.nil?
20
- return [orphan_issue(sentinel_path, sentinel)] if sentinel.orphan?
21
- return [drift_issue(sentinel)] if sentinel.drift?
22
+ return [orphan_issue(sentinel_path, sentinel)] if sentinel.orphan?(file_stat)
23
+ return [drift_issue(sentinel)] if sentinel.drift?(file_stat)
22
24
 
23
25
  []
24
26
  end
@@ -14,8 +14,8 @@ module Textus
14
14
  .downcase
15
15
  end
16
16
 
17
- def initialize(session)
18
- @session = session
17
+ def initialize(container)
18
+ @container = container
19
19
  end
20
20
 
21
21
  def call
@@ -24,9 +24,19 @@ module Textus
24
24
 
25
25
  protected
26
26
 
27
- def root = @session.read_caps.root
28
- def manifest = @session.read_caps.manifest
29
- def rpc = @session.rpc
27
+ def root = @container.root
28
+ def manifest = @container.manifest
29
+ def rpc = @container.rpc
30
+
31
+ # Dispatch a verb through the single use-case invocation seam (ADR 0026).
32
+ def dispatch(verb, *args, **kwargs)
33
+ Textus::Dispatcher.invoke(
34
+ verb,
35
+ container: @container,
36
+ call: Textus::Call.build(role: Textus::Role::DEFAULT),
37
+ args: args, kwargs: kwargs
38
+ )
39
+ end
30
40
  end
31
41
  end
32
42
  end
data/lib/textus/doctor.rb CHANGED
@@ -30,7 +30,7 @@ module Textus
30
30
 
31
31
  module_function
32
32
 
33
- def run(session, checks: nil)
33
+ def build(container:, checks: nil)
34
34
  selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
35
35
  unknown = selected_keys - ALL_CHECKS
36
36
  unless unknown.empty?
@@ -40,8 +40,8 @@ module Textus
40
40
  end
41
41
 
42
42
  selected = CHECKS.select { |c| selected_keys.include?(c.name_key) }
43
- issues = selected.flat_map { |c| c.new(session).call }
44
- issues.concat(run_registered_checks(session))
43
+ issues = selected.flat_map { |c| c.new(container).call }
44
+ issues.concat(run_registered_checks(container))
45
45
 
46
46
  summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
47
47
  {
@@ -52,13 +52,13 @@ module Textus
52
52
  }
53
53
  end
54
54
 
55
- def run_registered_checks(session)
56
- session.rpc.names(:validate).flat_map { |name| invoke_registered_check(session, name) }
55
+ def run_registered_checks(container)
56
+ container.rpc.names(:validate).flat_map { |name| invoke_registered_check(container, name) }
57
57
  end
58
58
 
59
- def invoke_registered_check(session, name)
59
+ def invoke_registered_check(container, name)
60
60
  result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) do
61
- session.rpc.invoke(:validate, name, caps: session.write_caps)
61
+ container.rpc.invoke(:validate, name, caps: container)
62
62
  end
63
63
  return result.map { |h| h.transform_keys(&:to_s) } if result.is_a?(Array)
64
64
 
@@ -3,8 +3,8 @@
3
3
  module Textus
4
4
  module Domain
5
5
  # Authorization service. Single source of truth for "given a manifest
6
- # entry and a role, may this caller read/write?". Extracted from
7
- # Application::Context so the rule lives in Domain alongside Permission.
6
+ # entry and a role, may this caller read/write?". Lives in Domain
7
+ # alongside Permission.
8
8
  class Authorizer
9
9
  def initialize(manifest:)
10
10
  @manifest = manifest
@@ -0,0 +1,22 @@
1
+ module Textus
2
+ module Domain
3
+ # Parses a duration value into whole seconds. Accepts a bare integer (or
4
+ # integer-string) of seconds, or `<n><unit>` with unit s/m/h/d. Returns
5
+ # nil for nil or any unparseable value.
6
+ module Duration
7
+ UNIT_SECONDS = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
8
+
9
+ def self.seconds(value)
10
+ return nil if value.nil?
11
+
12
+ str = value.to_s.strip
13
+ return str.to_i if str.match?(/\A\d+\z/)
14
+
15
+ m = str.match(/\A(\d+)\s*([smhd])\z/)
16
+ return nil unless m
17
+
18
+ m[1].to_i * UNIT_SECONDS.fetch(m[2])
19
+ end
20
+ end
21
+ end
22
+ end
@@ -21,21 +21,7 @@ module Textus
21
21
  end
22
22
 
23
23
  def ttl_seconds
24
- return nil if @ttl.nil?
25
-
26
- str = @ttl.to_s.strip
27
- return str.to_i if str.match?(/\A\d+\z/)
28
-
29
- m = str.match(/\A(\d+)\s*([smhd])\z/)
30
- return nil unless m
31
-
32
- n = m[1].to_i
33
- case m[2]
34
- when "s" then n
35
- when "m" then n * 60
36
- when "h" then n * 3600
37
- when "d" then n * 86_400
38
- end
24
+ Textus::Domain::Duration.seconds(@ttl)
39
25
  end
40
26
 
41
27
  def to_freshness_policy
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ # Lifetime policy for queue/quarantine leaves. Both windows are optional
5
+ # durations (see Domain::Duration). `expire_after` deletes; `archive_after`
6
+ # moves the leaf aside. When both are set, expire wins once its (longer)
7
+ # window is exceeded.
8
+ class Retention
9
+ attr_reader :expire_after, :archive_after
10
+
11
+ def initialize(expire_after: nil, archive_after: nil)
12
+ @expire_after = Textus::Domain::Duration.seconds(expire_after)
13
+ @archive_after = Textus::Domain::Duration.seconds(archive_after)
14
+ end
15
+
16
+ # :expire | :archive | nil for a leaf of the given age (seconds).
17
+ def action_for(age_seconds)
18
+ return :expire if @expire_after && age_seconds > @expire_after
19
+ return :archive if @archive_after && age_seconds > @archive_after
20
+
21
+ nil
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,44 @@
1
+ module Textus
2
+ module Domain
3
+ # Reports leaves whose age (now - file mtime) exceeds a retention window.
4
+ # Each row is { "key", "path", "action" => "expire"|"archive", "age_seconds" }.
5
+ class Retention
6
+ def initialize(manifest:, file_stat:, clock:)
7
+ @manifest = manifest
8
+ @file_stat = file_stat
9
+ @clock = clock
10
+ end
11
+
12
+ def call(prefix: nil, zone: nil)
13
+ @manifest.data.entries
14
+ .select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
15
+ .flat_map { |m| rows_for(m) }
16
+ end
17
+
18
+ private
19
+
20
+ def rows_for(mentry)
21
+ policy = @manifest.rules.for(mentry.key).retention
22
+ return [] if policy.nil?
23
+
24
+ @manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
25
+ path = row[:path]
26
+ next unless @file_stat.exists?(path)
27
+
28
+ age = (@clock.now - @file_stat.mtime(path)).to_i
29
+ action = policy.action_for(age)
30
+ next if action.nil?
31
+
32
+ { "key" => row[:key], "path" => path, "action" => action.to_s, "age_seconds" => age }
33
+ end
34
+ end
35
+
36
+ def entry_matches?(mentry, prefix:, zone:)
37
+ return false if zone && mentry.zone != zone
38
+ return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
39
+
40
+ true
41
+ end
42
+ end
43
+ end
44
+ end
@@ -1,69 +1,15 @@
1
- require "json"
2
1
  require "digest"
3
- require "fileutils"
4
2
 
5
3
  module Textus
6
4
  module Domain
7
- # Value object for sentinel files written by Infra::Publisher and inspected
8
- # by Doctor::Check::Sentinels. Owns the JSON shape ({source, target,
9
- # sha256, mode}) and the on-disk path layout (<store_root>/sentinels/
10
- # <target-rel-to-repo>.textus-managed.json). Target/source are repo-relative
11
- # when the published file is under the repo root, absolute otherwise.
5
+ # Pure value object representing a published-file sentinel. Holds the
6
+ # recorded target path, source path, sha256 checksum, and publish mode.
7
+ # Has no filesystem I/O path layout and persistence live in
8
+ # Ports::SentinelStore; predicate methods accept a FileStat port for
9
+ # existence and content checks.
12
10
  class Sentinel
13
- SUFFIX = ".textus-managed.json".freeze
14
- DIR = "sentinels".freeze
15
-
16
11
  attr_reader :target, :source, :sha256, :mode
17
12
 
18
- def self.write!(target:, source:, store_root:)
19
- path = sentinel_path(target, store_root)
20
- FileUtils.mkdir_p(File.dirname(path))
21
- repo_root = File.dirname(store_root)
22
- File.write(path, JSON.generate(
23
- "source" => rel_or_abs(source, repo_root),
24
- "target" => rel_or_abs(target, repo_root),
25
- "sha256" => Digest::SHA256.hexdigest(File.binread(target)),
26
- "mode" => "copy",
27
- ))
28
- end
29
-
30
- def self.load(path, repo_root)
31
- raw = JSON.parse(File.read(path))
32
- new(
33
- target: absolutize(raw["target"], repo_root),
34
- source: absolutize(raw["source"], repo_root),
35
- sha256: raw["sha256"],
36
- mode: raw["mode"],
37
- )
38
- rescue JSON::ParserError, Errno::ENOENT
39
- nil
40
- end
41
-
42
- def self.sentinel_path(target, store_root)
43
- repo_root = File.dirname(store_root)
44
- rel = relative_to(target, repo_root) || File.basename(target)
45
- File.join(store_root, DIR, rel + SUFFIX)
46
- end
47
-
48
- def self.rel_or_abs(path, repo_root)
49
- relative_to(path, repo_root) || File.expand_path(path)
50
- end
51
-
52
- def self.relative_to(path, repo_root)
53
- path = File.expand_path(path)
54
- base = File.expand_path(repo_root)
55
- return nil unless path.start_with?(base + File::SEPARATOR)
56
-
57
- path[(base.length + 1)..]
58
- end
59
-
60
- def self.absolutize(path, repo_root)
61
- return path if path.nil?
62
- return path if File.absolute_path?(path)
63
-
64
- File.expand_path(path, repo_root)
65
- end
66
-
67
13
  def initialize(target:, source:, sha256:, mode:)
68
14
  @target = target
69
15
  @source = source
@@ -71,15 +17,13 @@ module Textus
71
17
  @mode = mode
72
18
  end
73
19
 
74
- def orphan?
75
- @target.nil? || !File.exist?(@target)
76
- end
20
+ def orphan?(file_stat) = @target.nil? || !file_stat.exists?(@target)
77
21
 
78
- def drift?
79
- return false if orphan?
22
+ def drift?(file_stat)
23
+ return false if orphan?(file_stat)
80
24
  return false if @sha256.nil?
81
25
 
82
- Digest::SHA256.hexdigest(File.binread(@target)) != @sha256
26
+ Digest::SHA256.hexdigest(file_stat.read(@target)) != @sha256
83
27
  end
84
28
  end
85
29
  end
@@ -8,34 +8,43 @@ module Textus
8
8
  # entry's `_meta.generated.at` timestamp. Returns an Array of row hashes
9
9
  # (possibly empty) per entry.
10
10
  class GeneratorCheck
11
- def initialize(manifest:)
12
- @manifest = manifest
11
+ def initialize(manifest:, file_stat:)
12
+ @manifest = manifest
13
+ @file_stat = file_stat
13
14
  end
14
15
 
15
16
  def rows_for(mentry)
16
- return [] unless mentry.in_generator_zone?
17
- return [] unless mentry.is_a?(Textus::Manifest::Entry::Derived)
18
-
19
- src = mentry.source
20
- return [] unless src.is_a?(Textus::Manifest::Entry::Derived::External)
17
+ return [] unless applicable?(mentry)
21
18
 
22
19
  path = Textus::Key::Path.resolve(@manifest.data, mentry)
23
- return [stale_row(mentry, path, "derived entry has never been generated")] unless File.exist?(path)
20
+ reason = stale_reason(mentry, path)
21
+ reason ? [stale_row(mentry, path, reason)] : []
22
+ end
24
23
 
25
- parsed = Entry.for_format(mentry.format).parse(File.binread(path), path: path)
26
- generated_at = parsed["_meta"].dig("generated", "at")
27
- return [stale_row(mentry, path, "missing generated.at frontmatter")] unless generated_at
24
+ private
28
25
 
29
- gen_time = parse_time(generated_at)
30
- return [stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")] unless gen_time
26
+ def applicable?(mentry)
27
+ mentry.in_generator_zone?(@manifest.policy) &&
28
+ mentry.is_a?(Textus::Manifest::Entry::Derived) &&
29
+ mentry.source.is_a?(Textus::Manifest::Entry::Derived::External)
30
+ end
31
31
 
32
- offender = newest_source_after(src, gen_time)
33
- return [stale_row(mentry, path, "source '#{offender}' modified after generated.at")] if offender
32
+ def stale_reason(mentry, path)
33
+ return "derived entry has never been generated" unless @file_stat.exists?(path)
34
34
 
35
- []
35
+ generated_at = generated_at_of(mentry, path)
36
+ return "missing generated.at frontmatter" unless generated_at
37
+
38
+ gen_time = parse_time(generated_at)
39
+ return "unparseable generated.at: #{generated_at.inspect}" unless gen_time
40
+
41
+ offender = newest_source_after(mentry.source, gen_time)
42
+ "source '#{offender}' modified after generated.at" if offender
36
43
  end
37
44
 
38
- private
45
+ def generated_at_of(mentry, path)
46
+ Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"].dig("generated", "at")
47
+ end
39
48
 
40
49
  def parse_time(str)
41
50
  Time.parse(str.to_s)
@@ -54,7 +63,7 @@ module Textus
54
63
  def check_source(src, gen_time)
55
64
  if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
56
65
  @manifest.resolver.enumerate(prefix: src).each do |row|
57
- return src if File.mtime(row[:path]) > gen_time
66
+ return src if @file_stat.mtime(row[:path]) > gen_time
58
67
  end
59
68
  nil
60
69
  else
@@ -63,18 +72,29 @@ module Textus
63
72
  end
64
73
 
65
74
  def check_filesystem_source(src, gen_time)
66
- abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.data.root), src)
67
- if File.directory?(abs)
68
- Dir.glob(File.join(abs, "**", "*")).each do |fp|
69
- next unless File.file?(fp)
70
- return src if File.mtime(fp) > gen_time
71
- end
72
- nil
73
- elsif File.exist?(abs) && File.mtime(abs) > gen_time
75
+ abs = absolutize_source(src)
76
+ if @file_stat.directory?(abs)
77
+ dir_has_newer_file?(abs, gen_time) ? src : nil
78
+ elsif @file_stat.exists?(abs) && @file_stat.mtime(abs) > gen_time
74
79
  src
75
80
  end
76
81
  end
77
82
 
83
+ def absolutize_source(src)
84
+ File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.data.root), src)
85
+ end
86
+
87
+ def dir_has_newer_file?(abs, gen_time)
88
+ @file_stat.glob(File.join(abs, "**", "*")).any? do |fpath|
89
+ file?(fpath) && @file_stat.mtime(fpath) > gen_time
90
+ end
91
+ end
92
+
93
+ # FileStat substitute for File.file?: excludes directories but treats
94
+ # special files (FIFOs/sockets/devices) as regular files — acceptable
95
+ # because a generator source tree won't contain them.
96
+ def file?(fpath) = !@file_stat.directory?(fpath) && @file_stat.exists?(fpath)
97
+
78
98
  def stale_row(mentry, path, reason)
79
99
  {
80
100
  "key" => mentry.key,
@@ -6,8 +6,10 @@ module Textus
6
6
  # Reports TTL-exceeded staleness for intake-handler entries. Returns an
7
7
  # Array of row hashes (possibly empty) per entry.
8
8
  class IntakeCheck
9
- def initialize(manifest:)
10
- @manifest = manifest
9
+ def initialize(manifest:, file_stat:, clock:)
10
+ @manifest = manifest
11
+ @file_stat = file_stat
12
+ @clock = clock
11
13
  end
12
14
 
13
15
  def rows_for(mentry)
@@ -17,19 +19,25 @@ module Textus
17
19
  return [] unless ttl
18
20
 
19
21
  path = Textus::Key::Path.resolve(@manifest.data, mentry)
20
- return [row(mentry, path, "never refreshed")] unless File.exist?(path)
22
+ reason = ttl_reason(mentry, path, ttl)
23
+ reason ? [row(mentry, path, reason)] : []
24
+ end
25
+
26
+ private
21
27
 
22
- meta = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["_meta"]
23
- last_str = meta["last_refreshed_at"]
24
- return [row(mentry, path, "never refreshed (no last_refreshed_at)")] if last_str.nil?
28
+ def ttl_reason(mentry, path, ttl)
29
+ return "never refreshed" unless @file_stat.exists?(path)
25
30
 
26
- last = parse_time(last_str)
27
- return [row(mentry, path, "ttl exceeded (#{ttl}s)")] if last.nil? || (Time.now - last) > ttl
31
+ last_str = last_refreshed_of(mentry, path)
32
+ return "never refreshed (no last_refreshed_at)" if last_str.nil?
28
33
 
29
- []
34
+ last = parse_time(last_str)
35
+ "ttl exceeded (#{ttl}s)" if last.nil? || (@clock.now - last) > ttl
30
36
  end
31
37
 
32
- private
38
+ def last_refreshed_of(mentry, path)
39
+ Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_refreshed_at"]
40
+ end
33
41
 
34
42
  def parse_time(str)
35
43
  Time.parse(str.to_s)
@@ -1,10 +1,10 @@
1
1
  module Textus
2
2
  module Domain
3
3
  class Staleness
4
- def initialize(manifest:)
4
+ def initialize(manifest:, file_stat:, clock:)
5
5
  @manifest = manifest
6
- @generator_check = GeneratorCheck.new(manifest: manifest)
7
- @intake_check = IntakeCheck.new(manifest: manifest)
6
+ @generator_check = GeneratorCheck.new(manifest: manifest, file_stat: file_stat)
7
+ @intake_check = IntakeCheck.new(manifest: manifest, file_stat: file_stat, clock: clock)
8
8
  end
9
9
 
10
10
  def call(prefix: nil, zone: nil)