textus 0.10.4 → 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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -3
  3. data/README.md +45 -86
  4. data/SPEC.md +266 -138
  5. data/docs/conventions.md +47 -15
  6. data/lib/textus/application/reads/freshness.rb +2 -2
  7. data/lib/textus/application/reads/get.rb +1 -1
  8. data/lib/textus/application/reads/policy_explain.rb +2 -2
  9. data/lib/textus/application/refresh/orchestrator.rb +1 -1
  10. data/lib/textus/application/refresh/worker.rb +5 -5
  11. data/lib/textus/application/writes/accept.rb +19 -1
  12. data/lib/textus/application/writes/build.rb +5 -5
  13. data/lib/textus/application/writes/delete.rb +2 -3
  14. data/lib/textus/application/writes/publish.rb +1 -1
  15. data/lib/textus/application/writes/put.rb +3 -6
  16. data/lib/textus/builder/pipeline.rb +1 -1
  17. data/lib/textus/builder/renderer/json.rb +1 -1
  18. data/lib/textus/builder/renderer/yaml.rb +1 -1
  19. data/lib/textus/cli/group/key.rb +1 -1
  20. data/lib/textus/cli/group/refresh.rb +21 -0
  21. data/lib/textus/cli/group/rule.rb +11 -0
  22. data/lib/textus/cli/verb/build.rb +1 -1
  23. data/lib/textus/cli/verb/hook_run.rb +3 -2
  24. data/lib/textus/cli/verb/hooks.rb +1 -1
  25. data/lib/textus/cli/verb/{migrate_keys.rb → key_normalize.rb} +1 -1
  26. data/lib/textus/cli/verb/put.rb +1 -1
  27. data/lib/textus/cli/verb/{policy_explain.rb → rule_explain.rb} +1 -1
  28. data/lib/textus/cli/verb/{policy_list.rb → rule_list.rb} +3 -3
  29. data/lib/textus/cli/verb.rb +3 -2
  30. data/lib/textus/cli.rb +6 -6
  31. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  32. data/lib/textus/doctor/check/illegal_keys.rb +39 -16
  33. data/lib/textus/doctor/check/intake_registration.rb +4 -4
  34. data/lib/textus/doctor/check/protocol_version.rb +47 -0
  35. data/lib/textus/doctor/check/{policy_ambiguity.rb → rule_ambiguity.rb} +6 -6
  36. data/lib/textus/doctor.rb +5 -4
  37. data/lib/textus/domain/permission.rb +4 -4
  38. data/lib/textus/domain/policy/predicates/human_accept.rb +31 -0
  39. data/lib/textus/domain/policy/predicates/schema_valid.rb +50 -0
  40. data/lib/textus/domain/policy/promotion.rb +45 -0
  41. data/lib/textus/errors.rb +24 -5
  42. data/lib/textus/hooks/builtin.rb +5 -5
  43. data/lib/textus/hooks/dispatcher.rb +1 -1
  44. data/lib/textus/hooks/dsl.rb +3 -10
  45. data/lib/textus/hooks/loader.rb +1 -2
  46. data/lib/textus/hooks/registry.rb +22 -21
  47. data/lib/textus/infra/refresh/detached.rb +1 -1
  48. data/lib/textus/init.rb +25 -34
  49. data/lib/textus/intro.rb +9 -9
  50. data/lib/textus/manifest/entry.rb +66 -6
  51. data/lib/textus/manifest/{policies.rb → rules.rb} +12 -10
  52. data/lib/textus/manifest/schema.rb +49 -0
  53. data/lib/textus/manifest.rb +79 -39
  54. data/lib/textus/migrate_keys.rb +1 -1
  55. data/lib/textus/projection.rb +4 -4
  56. data/lib/textus/refresh.rb +1 -1
  57. data/lib/textus/store/mover.rb +91 -50
  58. data/lib/textus/store/staleness/generator_check.rb +88 -0
  59. data/lib/textus/store/staleness/intake_check.rb +46 -0
  60. data/lib/textus/store/staleness.rb +9 -104
  61. data/lib/textus/store/writer.rb +14 -12
  62. data/lib/textus/store.rb +1 -1
  63. data/lib/textus/version.rb +2 -2
  64. data/lib/textus.rb +1 -0
  65. metadata +15 -7
  66. data/lib/textus/cli/group/policy.rb +0 -11
data/docs/conventions.md CHANGED
@@ -44,34 +44,66 @@ The `owner:` field in the manifest is **advisory metadata**, not an ACL. Use it
44
44
 
45
45
  Tooling around `git blame` or audit logs may filter on owner; the gem itself only echoes it back in envelopes.
46
46
 
47
- ## Derived entries and build runners
47
+ ## Derived entries
48
48
 
49
- **Always** declare `generator:` on derived entries that participate in any build pipeline. Without it, `textus freshness` cannot help — the entry is just an opaque file.
49
+ textus supports two shapes for derived entries:
50
+
51
+ **`projection:`** — textus computes the entry on `textus build` from other store entries. Declarative; nothing shells out.
50
52
 
51
53
  ```yaml
52
- - key: output.catalogs.skills
53
- path: output/catalogs/skills
54
+ - key: output.catalogs.people
55
+ path: output/catalogs/people.md
54
56
  zone: output
55
57
  schema: null
58
+ owner: build:catalog-people
59
+ projection:
60
+ select: working.network.org # prefix or list of prefixes
61
+ pluck: [name, relationship, org]
62
+ sort_by: name
63
+ template: people.mustache # under .textus/templates/
64
+ publish_to: [docs/people.md] # optional repo-relative byte-copy targets
65
+ ```
66
+
67
+ **`generator:`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `textus freshness` can compare source mtimes against the file's `_meta.generated.at`.
68
+
69
+ ```yaml
70
+ - key: output.catalogs.skills
71
+ path: output/catalogs/skills.md
72
+ zone: output
56
73
  owner: build:catalog-skills
57
74
  generator:
58
- command: "rake catalog:skills"
59
- sources:
60
- - working.projects
61
- - working.network
75
+ command: "rake catalog:skills" # informational; the runner invokes it
76
+ sources: [working.projects, working.network]
77
+ ```
78
+
79
+ The build runner is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `generator.sources` — same list, recorded twice so a diff proves what was consumed.
80
+
81
+ Full contract for both shapes is in [`../SPEC.md` §5.2 and §5.2.1](../SPEC.md). Reducers (`projection.reduce:`) and per-leaf publishing (`publish_each:`) are also covered there.
82
+
83
+ ## Intake and freshness
84
+
85
+ External inputs land via `:intake` hooks, not shell commands. Each inbox entry names a registered handler; refresh is on demand:
86
+
87
+ ```sh
88
+ textus refresh inbox.notion.roadmap --as=script
89
+ textus refresh-stale --zone=inbox --as=script # everything past its TTL
90
+ ```
91
+
92
+ Freshness budgets live in the top-level `policies:` block, matched by glob:
93
+
94
+ ```yaml
95
+ policies:
96
+ - match: inbox.notion.**
97
+ refresh: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
62
98
  ```
63
99
 
64
- **The build runner is responsible for writing the `generated:` frontmatter block** when it regenerates. The gem will never synthesize it. A typical lefthook / rake / just integration looks like:
100
+ A typical scheduled-refresh integration shells the `refresh-stale` sweep itself:
65
101
 
66
102
  ```sh
67
- textus freshness --format=json \
68
- | jq -r '.rows[] | select(.status == "stale") | .key' \
69
- | while read key; do
70
- textus refresh "$key" --as=script
71
- done
103
+ textus refresh-stale --zone=inbox --as=script # in cron / CI
72
104
  ```
73
105
 
74
- `generated.from` SHOULD match `generator.sources` from the manifest they're the same list, recorded in two places so a diffable file proves what was actually consumed.
106
+ See [`./zones.md` §6](zones.md) for the full intake contract and [`./events.md`](events.md) for writing custom handlers.
75
107
 
76
108
  ## Body content
77
109
 
@@ -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)
@@ -17,12 +17,11 @@ module Textus
17
17
  end
18
18
 
19
19
  @ctx.store.writer.delete_envelope_from_disk(
20
- key, if_etag: if_etag, as: @ctx.role,
21
- correlation_id: @ctx.correlation_id
20
+ key, ctx: @ctx, if_etag: if_etag
22
21
  )
23
22
 
24
23
  unless suppress_events
25
- @bus.publish(:deleted,
24
+ @bus.publish(:entry_deleted,
26
25
  store: @ctx.with_role(@ctx.role),
27
26
  key: key,
28
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,
@@ -19,16 +19,13 @@ module Textus
19
19
  envelope = @ctx.store.writer.write_envelope_to_disk(
20
20
  key,
21
21
  mentry: mentry,
22
- meta: meta,
23
- body: body,
24
- content: content,
22
+ payload: Textus::Store::Writer::Payload.new(meta: meta, body: body, content: content),
23
+ ctx: @ctx,
25
24
  if_etag: if_etag,
26
- as: @ctx.role,
27
- correlation_id: @ctx.correlation_id,
28
25
  )
29
26
 
30
27
  unless suppress_events
31
- @bus.publish(:put,
28
+ @bus.publish(:entry_put,
32
29
  store: @ctx.with_role(@ctx.role),
33
30
  key: key,
34
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