textus 0.10.5 → 0.12.1

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -3
  3. data/README.md +39 -26
  4. data/SPEC.md +222 -144
  5. data/lib/textus/application/reads/freshness.rb +2 -2
  6. data/lib/textus/application/reads/get.rb +1 -1
  7. data/lib/textus/application/reads/policy_explain.rb +2 -2
  8. data/lib/textus/application/refresh/orchestrator.rb +1 -1
  9. data/lib/textus/application/refresh/worker.rb +5 -5
  10. data/lib/textus/application/writes/accept.rb +19 -1
  11. data/lib/textus/application/writes/build.rb +5 -5
  12. data/lib/textus/application/writes/delete.rb +1 -1
  13. data/lib/textus/application/writes/publish.rb +1 -1
  14. data/lib/textus/application/writes/put.rb +1 -1
  15. data/lib/textus/builder/pipeline.rb +1 -1
  16. data/lib/textus/builder/renderer/json.rb +1 -1
  17. data/lib/textus/builder/renderer/yaml.rb +1 -1
  18. data/lib/textus/cli/group/key.rb +1 -1
  19. data/lib/textus/cli/group/refresh.rb +21 -0
  20. data/lib/textus/cli/group/rule.rb +11 -0
  21. data/lib/textus/cli/verb/build.rb +1 -1
  22. data/lib/textus/cli/verb/hook_run.rb +3 -2
  23. data/lib/textus/cli/verb/hooks.rb +1 -1
  24. data/lib/textus/cli/verb/{migrate_keys.rb → key_normalize.rb} +1 -1
  25. data/lib/textus/cli/verb/put.rb +1 -1
  26. data/lib/textus/cli/verb/{policy_explain.rb → rule_explain.rb} +1 -1
  27. data/lib/textus/cli/verb/{policy_list.rb → rule_list.rb} +3 -3
  28. data/lib/textus/cli/verb.rb +3 -2
  29. data/lib/textus/cli.rb +6 -6
  30. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  31. data/lib/textus/doctor/check/illegal_keys.rb +39 -16
  32. data/lib/textus/doctor/check/intake_registration.rb +4 -4
  33. data/lib/textus/doctor/check/protocol_version.rb +47 -0
  34. data/lib/textus/doctor/check/{policy_ambiguity.rb → rule_ambiguity.rb} +6 -6
  35. data/lib/textus/doctor.rb +5 -4
  36. data/lib/textus/domain/permission.rb +4 -4
  37. data/lib/textus/domain/policy/predicates/human_accept.rb +31 -0
  38. data/lib/textus/domain/policy/predicates/schema_valid.rb +50 -0
  39. data/lib/textus/domain/policy/promotion.rb +45 -0
  40. data/lib/textus/errors.rb +24 -5
  41. data/lib/textus/hooks/builtin.rb +5 -5
  42. data/lib/textus/hooks/dispatcher.rb +1 -1
  43. data/lib/textus/hooks/dsl.rb +3 -10
  44. data/lib/textus/hooks/loader.rb +1 -2
  45. data/lib/textus/hooks/registry.rb +22 -21
  46. data/lib/textus/infra/refresh/detached.rb +1 -1
  47. data/lib/textus/init.rb +25 -34
  48. data/lib/textus/intro.rb +9 -9
  49. data/lib/textus/manifest/entry.rb +33 -6
  50. data/lib/textus/manifest/{policies.rb → rules.rb} +12 -10
  51. data/lib/textus/manifest/schema.rb +49 -0
  52. data/lib/textus/manifest.rb +45 -9
  53. data/lib/textus/migrate_keys.rb +1 -1
  54. data/lib/textus/projection.rb +4 -4
  55. data/lib/textus/refresh.rb +1 -1
  56. data/lib/textus/store/mover.rb +1 -1
  57. data/lib/textus/store/staleness/intake_check.rb +1 -1
  58. data/lib/textus/store/writer.rb +1 -1
  59. data/lib/textus/store.rb +1 -1
  60. data/lib/textus/version.rb +2 -2
  61. data/lib/textus.rb +1 -0
  62. metadata +13 -7
  63. data/lib/textus/cli/group/policy.rb +0 -11
@@ -4,7 +4,7 @@ module Textus
4
4
  module Application
5
5
  module Reads
6
6
  # Per-entry freshness report. Walks every entry declared in the manifest,
7
- # consults `policies_for(key)` for a refresh policy, and reports the
7
+ # consults `rules_for(key)` for a refresh rule, and reports the
8
8
  # current status. Status is one of :fresh, :stale, :never_refreshed, or
9
9
  # :no_policy.
10
10
  class Freshness
@@ -27,7 +27,7 @@ module Textus
27
27
  private
28
28
 
29
29
  def row_for(mentry)
30
- set = @ctx.store.manifest.policies_for(mentry.key)
30
+ set = @ctx.store.manifest.rules_for(mentry.key)
31
31
  refresh = set.refresh
32
32
  envelope = safe_get(mentry.key)
33
33
  last = envelope&.dig("_meta", "last_refreshed_at")
@@ -12,7 +12,7 @@ module Textus
12
12
  envelope = @ctx.store.reader.read_raw_envelope(key)
13
13
  return nil if envelope.nil?
14
14
 
15
- policy_set = @ctx.store.manifest.policies_for(key)
15
+ policy_set = @ctx.store.manifest.rules_for(key)
16
16
  refresh_policy = policy_set.refresh
17
17
  return annotate_fresh(envelope) if refresh_policy.nil?
18
18
 
@@ -9,7 +9,7 @@ module Textus
9
9
  end
10
10
 
11
11
  def call(key:)
12
- policies = @ctx.store.manifest.policies
12
+ policies = @ctx.store.manifest.rules
13
13
  matching = policies.explain(key)
14
14
  winners = policies.for(key)
15
15
 
@@ -29,7 +29,7 @@ module Textus
29
29
  on_stale: winners.refresh.on_stale,
30
30
  },
31
31
  handler_allowlist: winners.handler_allowlist&.handlers,
32
- promote_requires: winners.promote&.requires,
32
+ promotion: winners.promote && { requires: winners.promote.requires },
33
33
  },
34
34
  }
35
35
  end
@@ -50,7 +50,7 @@ module Textus
50
50
  store_view = @store ? Textus::Application::Context.new(store: @store, role: @role) : nil
51
51
  payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
52
52
  payload[:store] = store_view if store_view
53
- @bus.publish(:refresh_detached, **payload)
53
+ @bus.publish(:refresh_backgrounded, **payload)
54
54
  @detached_spawner.call(store_root: @store_root, key: key)
55
55
  Textus::Domain::Outcome::Detached.new
56
56
  elsif result.is_a?(Textus::Error)
@@ -27,9 +27,9 @@ module Textus
27
27
  end
28
28
 
29
29
  def fetch_with_bus(key, mentry)
30
- callable = @ctx.store.registry.rpc_callable(:intake, mentry.intake_handler)
31
- @bus.publish(:refresh_began, store: read_view, key: key, mode: :sync,
32
- correlation_id: @ctx.correlation_id)
30
+ callable = @ctx.store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)
31
+ @bus.publish(:refresh_started, store: read_view, key: key, mode: :sync,
32
+ correlation_id: @ctx.correlation_id)
33
33
  call_intake(key, mentry, callable)
34
34
  end
35
35
 
@@ -61,8 +61,8 @@ module Textus
61
61
  )
62
62
  change = detect_change(before_etag, envelope)
63
63
  unless change == :unchanged
64
- @bus.publish(:refreshed, store: read_view, key: key, envelope: envelope, change: change,
65
- correlation_id: @ctx.correlation_id)
64
+ @bus.publish(:entry_refreshed, store: read_view, key: key, envelope: envelope, change: change,
65
+ correlation_id: @ctx.correlation_id)
66
66
  end
67
67
  envelope
68
68
  end
@@ -15,6 +15,8 @@ module Textus
15
15
  target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
16
16
  action = proposal["action"] || "put"
17
17
 
18
+ evaluate_promotion!(env, target)
19
+
18
20
  case action
19
21
  when "put"
20
22
  # Nested proposal "frontmatter" — the meta to write to the accepted
@@ -30,7 +32,7 @@ module Textus
30
32
 
31
33
  Composition.writes_delete(@ctx).call(pending_key)
32
34
 
33
- @bus.publish(:accepted,
35
+ @bus.publish(:proposal_accepted,
34
36
  store: @ctx.with_role(@ctx.role),
35
37
  key: pending_key,
36
38
  target_key: target,
@@ -38,6 +40,22 @@ module Textus
38
40
 
39
41
  { "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
40
42
  end
43
+
44
+ private
45
+
46
+ def evaluate_promotion!(env, target_key)
47
+ rules = @ctx.store.manifest.rules_for(target_key)
48
+ promote = rules.promote
49
+ return if promote.nil? || promote.requires.empty?
50
+
51
+ policy = Textus::Domain::Policy::Promotion.from_names(promote.requires)
52
+ result = policy.evaluate(entry: env, store: @ctx.store)
53
+ return if result.ok?
54
+
55
+ raise ProposalError.new(
56
+ "promotion gate failed: #{result.reasons.join("; ")}",
57
+ )
58
+ end
41
59
  end
42
60
  end
43
61
  end
@@ -5,8 +5,8 @@ module Textus
5
5
  module Writes
6
6
  # Materializes generator-zone entries (template + projection) onto disk
7
7
  # and copies the result to any configured `publish_to` / `publish_each`
8
- # targets. Fires `:built` and `:published` events on the bus, tagged with
9
- # the request's correlation_id for traceability.
8
+ # targets. Fires `:build_completed` and `:file_published` events on the bus,
9
+ # tagged with the request's correlation_id for traceability.
10
10
  class Build
11
11
  def initialize(ctx:, bus:)
12
12
  @ctx = ctx
@@ -59,7 +59,7 @@ module Textus
59
59
  end
60
60
 
61
61
  Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: root)
62
- publish_event(:published,
62
+ publish_event(:file_published,
63
63
  key: row[:key],
64
64
  envelope: store.get(row[:key]),
65
65
  source: row[:path],
@@ -91,14 +91,14 @@ module Textus
91
91
  mentry.publish_to.each do |rel|
92
92
  target_abs = File.join(repo_root, rel)
93
93
  Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: root)
94
- publish_event(:published,
94
+ publish_event(:file_published,
95
95
  key: mentry.key,
96
96
  envelope: envelope,
97
97
  source: target_path,
98
98
  target: target_abs)
99
99
  end
100
100
 
101
- publish_event(:built,
101
+ publish_event(:build_completed,
102
102
  key: mentry.key,
103
103
  envelope: envelope,
104
104
  sources: Array(mentry.projection&.fetch("select", nil)).compact)
@@ -21,7 +21,7 @@ module Textus
21
21
  )
22
22
 
23
23
  unless suppress_events
24
- @bus.publish(:deleted,
24
+ @bus.publish(:entry_deleted,
25
25
  store: @ctx.with_role(@ctx.role),
26
26
  key: key,
27
27
  correlation_id: @ctx.correlation_id)
@@ -13,7 +13,7 @@ module Textus
13
13
  target: target,
14
14
  store_root: @ctx.store.root,
15
15
  )
16
- @bus.publish(:published,
16
+ @bus.publish(:file_published,
17
17
  key: key,
18
18
  source: source,
19
19
  target: target,
@@ -25,7 +25,7 @@ module Textus
25
25
  )
26
26
 
27
27
  unless suppress_events
28
- @bus.publish(:put,
28
+ @bus.publish(:entry_put,
29
29
  store: @ctx.with_role(@ctx.role),
30
30
  key: key,
31
31
  envelope: envelope,
@@ -10,7 +10,7 @@ module Textus
10
10
  from = Array(mentry.projection&.fetch("select", nil)).compact
11
11
  meta["from"] = from unless from.empty?
12
12
  meta["template"] = mentry.template if mentry.template
13
- reduce = mentry.projection&.dig("reduce")
13
+ reduce = mentry.projection&.dig("transform")
14
14
  meta["reduce"] = reduce if reduce
15
15
 
16
16
  out = { "_meta" => meta }
@@ -28,7 +28,7 @@ module Textus
28
28
  end
29
29
 
30
30
  def default_shape(mentry, data)
31
- if mentry.projection && mentry.projection["reduce"] && data.is_a?(Hash) && !data.key?("entries")
31
+ if mentry.projection && mentry.projection["transform"] && data.is_a?(Hash) && !data.key?("entries")
32
32
  data
33
33
  elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
34
34
  { "entries" => data["entries"] }
@@ -28,7 +28,7 @@ module Textus
28
28
  end
29
29
 
30
30
  def default_shape(mentry, data)
31
- if mentry.projection && mentry.projection["reduce"] && data.is_a?(Hash) && !data.key?("entries")
31
+ if mentry.projection && mentry.projection["transform"] && data.is_a?(Hash) && !data.key?("entries")
32
32
  data
33
33
  elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
34
34
  { "entries" => data["entries"] }
@@ -5,7 +5,7 @@ module Textus
5
5
  self.cli_name = "key"
6
6
  subcommands["mv"] = Verb::Mv
7
7
  subcommands["uid"] = Verb::Uid
8
- subcommands["migrate"] = Verb::MigrateKeys
8
+ subcommands["normalize"] = Verb::KeyNormalize
9
9
  end
10
10
  end
11
11
  end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ class CLI
3
+ class Group
4
+ class Refresh < Group
5
+ self.cli_name = "refresh"
6
+ subcommands["stale"] = Verb::RefreshStale
7
+
8
+ def parse(argv)
9
+ if argv.first == "stale"
10
+ argv.shift
11
+ @sub_klass = Verb::RefreshStale
12
+ else
13
+ @sub_klass = Verb::Refresh
14
+ end
15
+ @sub = @sub_klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
16
+ @sub.parse(argv)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ class CLI
3
+ class Group
4
+ class Rule < Group
5
+ self.cli_name = "rule"
6
+ subcommands["list"] = Verb::RuleList
7
+ subcommands["explain"] = Verb::RuleExplain
8
+ end
9
+ end
10
+ end
11
+ end
@@ -5,7 +5,7 @@ module Textus
5
5
  option :prefix, "--prefix=K"
6
6
 
7
7
  def call(store)
8
- ctx = Textus::Composition.context(store, role: "build")
8
+ ctx = Textus::Composition.context(store, role: "builder")
9
9
  emit(Textus::Composition.writes_build(ctx).call(prefix: prefix))
10
10
  end
11
11
  end
@@ -15,7 +15,8 @@ module Textus
15
15
  @raw_argv.each do |tok|
16
16
  case tok
17
17
  when /\A--as=(.+)\z/ then as_flag = ::Regexp.last_match(1)
18
- when /\A--format=/ then next
18
+ when /\A--output=/ then next
19
+ when /\A--format=/ then raise FlagRenamed.new("--format", "--output")
19
20
  when /\A--([\w-]+)=(.*)\z/ then args[::Regexp.last_match(1)] = ::Regexp.last_match(2)
20
21
  else
21
22
  raise UsageError.new("unknown arg to 'hook run #{name}': #{tok}")
@@ -23,7 +24,7 @@ module Textus
23
24
  end
24
25
 
25
26
  role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
26
- callable = store.registry.rpc_callable(:intake, name)
27
+ callable = store.registry.rpc_callable(:resolve_intake, name)
27
28
  view = Application::Context.new(store: store, role: role)
28
29
 
29
30
  begin
@@ -35,7 +35,7 @@ module Textus
35
35
 
36
36
  rows << {
37
37
  "event" => evt.to_s, "mode" => "manifest", "exec" => defn["exec"],
38
- "key" => e.key, "as" => defn["as"] || "script"
38
+ "key" => e.key, "as" => defn["as"] || "runner"
39
39
  }
40
40
  end
41
41
  end
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  class CLI
3
3
  class Verb
4
- class MigrateKeys < Verb
4
+ class KeyNormalize < Verb
5
5
  option :write, "--write"
6
6
  option :dry_run, "--dry-run"
7
7
 
@@ -15,7 +15,7 @@ module Textus
15
15
  raw = @stdin.read
16
16
  payload =
17
17
  if fetch_name
18
- callable = store.registry.rpc_callable(:intake, fetch_name)
18
+ callable = store.registry.rpc_callable(:resolve_intake, fetch_name)
19
19
  result =
20
20
  begin
21
21
  Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  class CLI
3
3
  class Verb
4
- class PolicyExplain < Verb
4
+ class RuleExplain < Verb
5
5
  def call(store)
6
6
  key = positional.shift or raise UsageError.new("policy explain requires a KEY")
7
7
  ctx = context_for(store)
@@ -1,9 +1,9 @@
1
1
  module Textus
2
2
  class CLI
3
3
  class Verb
4
- class PolicyList < Verb
4
+ class RuleList < Verb
5
5
  def call(store)
6
- policies = store.manifest.policies.blocks.map do |b|
6
+ policies = store.manifest.rules.blocks.map do |b|
7
7
  row = { "match" => b.match }
8
8
  if b.refresh
9
9
  row["refresh"] = {
@@ -13,7 +13,7 @@ module Textus
13
13
  }
14
14
  end
15
15
  row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
16
- row["promote_requires"] = b.promote.requires if b.promote
16
+ row["promotion"] = { "requires" => b.promote.requires } if b.promote
17
17
  row["retention"] = b.retention if b.retention
18
18
  row
19
19
  end
@@ -41,9 +41,10 @@ module Textus
41
41
  self.class.options.each do |name, optspec|
42
42
  o.on(optspec) { |v| public_send(:"#{name}=", v) }
43
43
  end
44
- o.on("--format=FMT") { |v| fmt = v }
44
+ o.on("--output=FMT") { |v| fmt = v }
45
+ o.on("--format=FMT") { |_v| raise FlagRenamed.new("--format", "--output") }
45
46
  end.permute!(argv)
46
- raise UsageError.new("only --format=json is supported in v1") unless fmt == "json"
47
+ raise UsageError.new("only --output=json is supported in v1") unless fmt == "json"
47
48
 
48
49
  @positional = argv.dup
49
50
  end
data/lib/textus/cli.rb CHANGED
@@ -21,12 +21,11 @@ module Textus
21
21
  "intro" => Verb::Intro,
22
22
  "key" => Group::Key,
23
23
  "list" => Verb::List,
24
- "policy" => Group::Policy,
25
24
  "published" => Verb::Published,
26
25
  "put" => Verb::Put,
27
26
  "rdeps" => Verb::Rdeps,
28
- "refresh" => Verb::Refresh,
29
- "refresh-stale" => Verb::RefreshStale,
27
+ "refresh" => Group::Refresh,
28
+ "rule" => Group::Rule,
30
29
  "schema" => Group::Schema,
31
30
  "where" => Verb::Where,
32
31
  }.freeze
@@ -90,16 +89,17 @@ module Textus
90
89
  textus get KEY
91
90
  textus put KEY --stdin [--fetch=NAME] --as=ROLE
92
91
  textus freshness [--prefix=KEY] [--zone=Z]
93
- textus refresh-stale [--prefix=KEY] [--zone=Z]
92
+ textus refresh KEY
93
+ textus refresh stale [--prefix=KEY] [--zone=Z]
94
94
  textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
95
95
  textus blame KEY [--limit=N]
96
96
  textus doctor
97
97
  textus intro
98
98
 
99
- textus key {mv,uid,migrate}
99
+ textus key {mv,uid,normalize}
100
+ textus rule {list,explain}
100
101
  textus schema {show,init,diff,migrate}
101
102
  textus hook {list,run}
102
- textus policy {list,explain}
103
103
  HELP
104
104
  end
105
105
  end
@@ -11,7 +11,7 @@ module Textus
11
11
  handler = mentry.intake_handler
12
12
  next if handler.nil?
13
13
 
14
- allow = store.manifest.policies_for(mentry.key).handler_allowlist
14
+ allow = store.manifest.rules_for(mentry.key).handler_allowlist
15
15
  next if allow.nil?
16
16
  next if allow.allows?(handler)
17
17
 
@@ -10,28 +10,51 @@ module Textus
10
10
  base = File.join(store.root, "zones", entry.path)
11
11
  next unless File.directory?(base)
12
12
 
13
- walk_nested(base) do |abs_path, is_dir|
14
- basename = File.basename(abs_path)
15
- stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
16
- next if stem.match?(Key::Grammar::SEGMENT)
17
-
18
- proposed = Textus::MigrateKeys.normalize(stem)
19
- out << {
20
- "code" => "key.illegal",
21
- "level" => "error",
22
- "subject" => abs_path,
23
- "path" => abs_path,
24
- "proposed_key" => proposed,
25
- "message" => "illegal key segment '#{stem}' at #{abs_path}",
26
- "fix" => "run 'textus key migrate --dry-run' then '--write' to rename to '#{proposed}'",
27
- }
28
- end
13
+ entry.index_filename ? check_index_paths(entry, base, out) : check_all_paths(base, out)
29
14
  end
30
15
  out
31
16
  end
32
17
 
33
18
  private
34
19
 
20
+ def check_all_paths(base, out)
21
+ walk_nested(base) do |abs_path, is_dir|
22
+ basename = File.basename(abs_path)
23
+ stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
24
+ next if stem.match?(Key::Grammar::SEGMENT)
25
+
26
+ out << issue(abs_path, stem)
27
+ end
28
+ end
29
+
30
+ # When the entry uses `index_filename:`, only the parent-directory
31
+ # segments leading to each index file participate in keys. Sibling
32
+ # files and unrelated subtrees are not enumerated and must not be
33
+ # flagged. Each illegal segment is reported once per path.
34
+ def check_index_paths(entry, base, out)
35
+ Dir.glob(File.join(base, "**", entry.index_filename)).each do |fp|
36
+ rel = fp.sub(%r{\A#{Regexp.escape(base)}/?}, "")
37
+ File.dirname(rel).split("/").reject { |s| s.empty? || s == "." }.each do |seg|
38
+ next if seg.match?(Key::Grammar::SEGMENT)
39
+
40
+ out << issue(fp, seg)
41
+ end
42
+ end
43
+ end
44
+
45
+ def issue(abs_path, stem)
46
+ proposed = Textus::MigrateKeys.normalize(stem)
47
+ {
48
+ "code" => "key.illegal",
49
+ "level" => "error",
50
+ "subject" => abs_path,
51
+ "path" => abs_path,
52
+ "proposed_key" => proposed,
53
+ "message" => "illegal key segment '#{stem}' at #{abs_path}",
54
+ "fix" => "run 'textus key normalize --dry-run' then '--write' to rename to '#{proposed}'",
55
+ }
56
+ end
57
+
35
58
  def walk_nested(root, &block)
36
59
  Dir.each_child(root) do |name|
37
60
  abs = File.join(root, name)
@@ -6,15 +6,15 @@ module Textus
6
6
 
7
7
  def call
8
8
  declared = collect_declared_handlers
9
- registered = store.registry.rpc_names(:intake).to_set
9
+ registered = store.registry.rpc_names(:resolve_intake).to_set
10
10
 
11
11
  out = (declared - registered).map do |name|
12
12
  {
13
13
  "code" => "intake.handler_missing",
14
14
  "level" => "error",
15
15
  "subject" => name.to_s,
16
- "message" => "manifest references intake handler '#{name}' but no Textus.intake(:#{name}) is registered",
17
- "fix" => "create .textus/hooks/#{name}.rb with `Textus.intake(:#{name}) { ... }`",
16
+ "message" => "manifest references intake handler '#{name}' but no Textus.on(:resolve_intake, :#{name}) is registered",
17
+ "fix" => "create .textus/hooks/#{name}.rb with `Textus.on(:resolve_intake, :#{name}) { ... }`",
18
18
  }
19
19
  end
20
20
 
@@ -23,7 +23,7 @@ module Textus
23
23
  "code" => "intake.handler_orphan",
24
24
  "level" => "warning",
25
25
  "subject" => name.to_s,
26
- "message" => "Textus.intake(:#{name}) is registered but no manifest entry references it",
26
+ "message" => "Textus.on(:resolve_intake, :#{name}) is registered but no manifest entry references it",
27
27
  "fix" => "remove the unused handler, or add an entry with `intake.handler: #{name}`",
28
28
  }
29
29
  end
@@ -0,0 +1,47 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Doctor
5
+ class Check
6
+ # Runs as a standalone module (Check::ProtocolVersion.run(root:)) and also
7
+ # as a class-based doctor check (ProtocolVersion.new(store).call).
8
+ class ProtocolVersion < Check
9
+ # Standalone interface: root is the project root (parent of .textus/).
10
+ def self.run(root:)
11
+ path = File.join(root, ".textus/manifest.yaml")
12
+ return [] unless File.exist?(path)
13
+
14
+ doc = YAML.safe_load_file(path, aliases: false) || {}
15
+ version = doc["version"]
16
+ return [] if version == "textus/3"
17
+
18
+ [{
19
+ "code" => "protocol_mismatch",
20
+ "severity" => "error",
21
+ "message" => "Store reports version=#{version.inspect}; this gem expects textus/3.",
22
+ "hint" => "Install textus 0.11.x to run the migrator, then upgrade to this version. See https://github.com/patrick204nqh/textus/blob/main/CHANGELOG.md#0110",
23
+ }]
24
+ end
25
+
26
+ # Doctor check interface: store.root is the .textus/ directory itself,
27
+ # so manifest.yaml lives directly inside it.
28
+ def call
29
+ path = File.join(store.root, "manifest.yaml")
30
+ return [] unless File.exist?(path)
31
+
32
+ doc = YAML.safe_load_file(path, aliases: false) || {}
33
+ version = doc["version"]
34
+ return [] if version == "textus/3"
35
+
36
+ [{
37
+ "code" => "protocol_mismatch",
38
+ "level" => "error",
39
+ "subject" => path,
40
+ "message" => "Store reports version=#{version.inspect}; this gem expects textus/3.",
41
+ "fix" => "Install textus 0.11.x to run the migrator, then upgrade to this version. See https://github.com/patrick204nqh/textus/blob/main/CHANGELOG.md#0110",
42
+ }]
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,18 +1,18 @@
1
1
  module Textus
2
2
  module Doctor
3
3
  class Check
4
- # Flags entries whose key is matched by two or more policy blocks of the
4
+ # Flags entries whose key is matched by two or more rule blocks of the
5
5
  # SAME specificity in the same slot (refresh / handler_allowlist /
6
6
  # promote). Ties are non-deterministic in the parser's pick step, so
7
7
  # they're a configuration smell — surface them.
8
- class PolicyAmbiguity < Check
8
+ class RuleAmbiguity < Check
9
9
  SLOTS = %i[refresh handler_allowlist promote].freeze
10
10
 
11
11
  def call
12
12
  out = []
13
- policies = store.manifest.policies
13
+ rules = store.manifest.rules
14
14
  store.manifest.entries.each do |mentry|
15
- matches = policies.explain(mentry.key)
15
+ matches = rules.explain(mentry.key)
16
16
  next if matches.length < 2
17
17
 
18
18
  SLOTS.each { |slot| out.concat(ambiguities_for(mentry, slot, matches)) }
@@ -34,10 +34,10 @@ module Textus
34
34
  def issue_for(mentry, slot, group)
35
35
  globs = group.map(&:match).sort
36
36
  {
37
- "code" => "policy.ambiguity",
37
+ "code" => "rule.ambiguity",
38
38
  "level" => "warning",
39
39
  "subject" => mentry.key,
40
- "message" => "entry '#{mentry.key}' matches #{group.length} policy blocks at the same " \
40
+ "message" => "entry '#{mentry.key}' matches #{group.length} rule blocks at the same " \
41
41
  "specificity for #{slot}: #{globs.join(", ")}",
42
42
  "fix" => "narrow one of the conflicting match: globs in .textus/manifest.yaml so a single " \
43
43
  "block wins for this key",
data/lib/textus/doctor.rb CHANGED
@@ -9,6 +9,7 @@ module Textus
9
9
  DOCTOR_CHECK_TIMEOUT_SECONDS = 2
10
10
 
11
11
  CHECKS = [
12
+ Check::ProtocolVersion,
12
13
  Check::ManifestFiles,
13
14
  Check::Schemas,
14
15
  Check::SchemaParseError,
@@ -20,7 +21,7 @@ module Textus
20
21
  Check::AuditLog,
21
22
  Check::UnownedSchemaFields,
22
23
  Check::SchemaViolations,
23
- Check::PolicyAmbiguity,
24
+ Check::RuleAmbiguity,
24
25
  Check::HandlerAllowlist,
25
26
  ].freeze
26
27
 
@@ -53,8 +54,8 @@ module Textus
53
54
  def run_registered_checks(store)
54
55
  out = []
55
56
  view = Application::Context.new(store: store, role: "human")
56
- store.registry.rpc_names(:check).each do |name|
57
- callable = store.registry.rpc_callable(:check, name)
57
+ store.registry.rpc_names(:validate).each do |name|
58
+ callable = store.registry.rpc_callable(:validate, name)
58
59
  begin
59
60
  result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: view) }
60
61
  if result.is_a?(Array)
@@ -71,7 +72,7 @@ module Textus
71
72
  rescue StandardError => e
72
73
  out << fail_issue(name, code: "doctor_check.failed",
73
74
  message: "#{e.class}: #{e.message}",
74
- fix: "fix the :check hook in .textus/hooks/")
75
+ fix: "fix the :validate hook in .textus/hooks/")
75
76
  end
76
77
  end
77
78
  out