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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +104 -3
- data/README.md +39 -26
- data/SPEC.md +222 -144
- data/lib/textus/application/reads/freshness.rb +2 -2
- data/lib/textus/application/reads/get.rb +1 -1
- data/lib/textus/application/reads/policy_explain.rb +2 -2
- data/lib/textus/application/refresh/orchestrator.rb +1 -1
- data/lib/textus/application/refresh/worker.rb +5 -5
- data/lib/textus/application/writes/accept.rb +19 -1
- data/lib/textus/application/writes/build.rb +5 -5
- data/lib/textus/application/writes/delete.rb +1 -1
- data/lib/textus/application/writes/publish.rb +1 -1
- data/lib/textus/application/writes/put.rb +1 -1
- data/lib/textus/builder/pipeline.rb +1 -1
- data/lib/textus/builder/renderer/json.rb +1 -1
- data/lib/textus/builder/renderer/yaml.rb +1 -1
- data/lib/textus/cli/group/key.rb +1 -1
- data/lib/textus/cli/group/refresh.rb +21 -0
- data/lib/textus/cli/group/rule.rb +11 -0
- data/lib/textus/cli/verb/build.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -2
- data/lib/textus/cli/verb/hooks.rb +1 -1
- data/lib/textus/cli/verb/{migrate_keys.rb → key_normalize.rb} +1 -1
- data/lib/textus/cli/verb/put.rb +1 -1
- data/lib/textus/cli/verb/{policy_explain.rb → rule_explain.rb} +1 -1
- data/lib/textus/cli/verb/{policy_list.rb → rule_list.rb} +3 -3
- data/lib/textus/cli/verb.rb +3 -2
- data/lib/textus/cli.rb +6 -6
- data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
- data/lib/textus/doctor/check/illegal_keys.rb +39 -16
- data/lib/textus/doctor/check/intake_registration.rb +4 -4
- data/lib/textus/doctor/check/protocol_version.rb +47 -0
- data/lib/textus/doctor/check/{policy_ambiguity.rb → rule_ambiguity.rb} +6 -6
- data/lib/textus/doctor.rb +5 -4
- data/lib/textus/domain/permission.rb +4 -4
- data/lib/textus/domain/policy/predicates/human_accept.rb +31 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +50 -0
- data/lib/textus/domain/policy/promotion.rb +45 -0
- data/lib/textus/errors.rb +24 -5
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/dispatcher.rb +1 -1
- data/lib/textus/hooks/dsl.rb +3 -10
- data/lib/textus/hooks/loader.rb +1 -2
- data/lib/textus/hooks/registry.rb +22 -21
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/init.rb +25 -34
- data/lib/textus/intro.rb +9 -9
- data/lib/textus/manifest/entry.rb +33 -6
- data/lib/textus/manifest/{policies.rb → rules.rb} +12 -10
- data/lib/textus/manifest/schema.rb +49 -0
- data/lib/textus/manifest.rb +45 -9
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/projection.rb +4 -4
- data/lib/textus/refresh.rb +1 -1
- data/lib/textus/store/mover.rb +1 -1
- data/lib/textus/store/staleness/intake_check.rb +1 -1
- data/lib/textus/store/writer.rb +1 -1
- data/lib/textus/store.rb +1 -1
- data/lib/textus/version.rb +2 -2
- data/lib/textus.rb +1 -0
- metadata +13 -7
- 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 `
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(:
|
|
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(:
|
|
31
|
-
@bus.publish(:
|
|
32
|
-
|
|
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(:
|
|
65
|
-
|
|
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(:
|
|
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 `:
|
|
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(:
|
|
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(:
|
|
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(:
|
|
101
|
+
publish_event(:build_completed,
|
|
102
102
|
key: mentry.key,
|
|
103
103
|
envelope: envelope,
|
|
104
104
|
sources: Array(mentry.projection&.fetch("select", nil)).compact)
|
|
@@ -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("
|
|
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["
|
|
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["
|
|
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"] }
|
data/lib/textus/cli/group/key.rb
CHANGED
|
@@ -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
|
|
@@ -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--
|
|
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(:
|
|
27
|
+
callable = store.registry.rpc_callable(:resolve_intake, name)
|
|
27
28
|
view = Application::Context.new(store: store, role: role)
|
|
28
29
|
|
|
29
30
|
begin
|
data/lib/textus/cli/verb/put.rb
CHANGED
|
@@ -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(:
|
|
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,9 +1,9 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
|
-
class
|
|
4
|
+
class RuleList < Verb
|
|
5
5
|
def call(store)
|
|
6
|
-
policies = store.manifest.
|
|
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["
|
|
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
|
data/lib/textus/cli/verb.rb
CHANGED
|
@@ -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("--
|
|
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 --
|
|
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" =>
|
|
29
|
-
"
|
|
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
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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(:
|
|
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.
|
|
17
|
-
"fix" => "create .textus/hooks/#{name}.rb with `Textus.
|
|
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.
|
|
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
|
|
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
|
|
8
|
+
class RuleAmbiguity < Check
|
|
9
9
|
SLOTS = %i[refresh handler_allowlist promote].freeze
|
|
10
10
|
|
|
11
11
|
def call
|
|
12
12
|
out = []
|
|
13
|
-
|
|
13
|
+
rules = store.manifest.rules
|
|
14
14
|
store.manifest.entries.each do |mentry|
|
|
15
|
-
matches =
|
|
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" => "
|
|
37
|
+
"code" => "rule.ambiguity",
|
|
38
38
|
"level" => "warning",
|
|
39
39
|
"subject" => mentry.key,
|
|
40
|
-
"message" => "entry '#{mentry.key}' matches #{group.length}
|
|
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::
|
|
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(:
|
|
57
|
-
callable = store.registry.rpc_callable(:
|
|
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 :
|
|
75
|
+
fix: "fix the :validate hook in .textus/hooks/")
|
|
75
76
|
end
|
|
76
77
|
end
|
|
77
78
|
out
|