textus 0.10.5 → 0.14.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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +60 -40
- data/CHANGELOG.md +318 -3
- data/README.md +34 -27
- data/SPEC.md +226 -145
- data/docs/conventions.md +8 -8
- data/lib/textus/application/context.rb +4 -0
- data/lib/textus/application/reads/blame.rb +1 -1
- data/lib/textus/application/reads/deps.rb +15 -0
- data/lib/textus/application/reads/freshness.rb +4 -4
- data/lib/textus/application/reads/get.rb +9 -12
- data/lib/textus/application/reads/list.rb +15 -0
- data/lib/textus/application/reads/policy_explain.rb +2 -2
- data/lib/textus/application/reads/published.rb +15 -0
- data/lib/textus/application/reads/rdeps.rb +15 -0
- data/lib/textus/application/reads/schema_envelope.rb +15 -0
- data/lib/textus/application/reads/stale.rb +15 -0
- data/lib/textus/application/reads/uid.rb +15 -0
- data/lib/textus/application/reads/validate_all.rb +15 -0
- data/lib/textus/application/reads/where.rb +15 -0
- data/lib/textus/application/refresh/all.rb +2 -2
- data/lib/textus/application/refresh/orchestrator.rb +1 -1
- data/lib/textus/application/refresh/worker.rb +8 -8
- data/lib/textus/application/writes/accept.rb +26 -8
- data/lib/textus/application/writes/build.rb +12 -49
- data/lib/textus/application/writes/delete.rb +1 -1
- data/lib/textus/application/writes/mv.rb +144 -0
- data/lib/textus/application/writes/publish.rb +42 -10
- data/lib/textus/application/writes/put.rb +1 -1
- data/lib/textus/application/writes/reject.rb +37 -0
- 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/accept.rb +1 -2
- data/lib/textus/cli/verb/audit.rb +3 -3
- data/lib/textus/cli/verb/blame.rb +1 -2
- data/lib/textus/cli/verb/build.rb +6 -2
- data/lib/textus/cli/verb/delete.rb +1 -2
- data/lib/textus/cli/verb/deps.rb +1 -1
- data/lib/textus/cli/verb/freshness.rb +1 -2
- data/lib/textus/cli/verb/get.rb +2 -3
- 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/list.rb +1 -1
- data/lib/textus/cli/verb/mv.rb +1 -1
- data/lib/textus/cli/verb/published.rb +1 -1
- data/lib/textus/cli/verb/put.rb +3 -3
- data/lib/textus/cli/verb/rdeps.rb +1 -1
- data/lib/textus/cli/verb/refresh.rb +1 -2
- data/lib/textus/cli/verb/reject.rb +1 -1
- data/lib/textus/cli/verb/{policy_explain.rb → rule_explain.rb} +2 -3
- data/lib/textus/cli/verb/{policy_list.rb → rule_list.rb} +3 -3
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb/uid.rb +1 -1
- data/lib/textus/cli/verb/where.rb +1 -1
- data/lib/textus/cli/verb.rb +9 -3
- 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/check/schema_violations.rb +1 -1
- data/lib/textus/doctor.rb +6 -5
- data/lib/textus/domain/freshness/evaluator.rb +1 -1
- 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/entry/base.rb +28 -0
- data/lib/textus/entry/json.rb +59 -0
- data/lib/textus/entry/markdown.rb +46 -0
- data/lib/textus/entry/text.rb +35 -0
- data/lib/textus/entry/yaml.rb +59 -0
- data/lib/textus/entry.rb +16 -0
- data/lib/textus/envelope.rb +44 -14
- 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 +65 -9
- data/lib/textus/manifest/entry/parser.rb +84 -0
- data/lib/textus/manifest/entry/validators/events.rb +21 -0
- data/lib/textus/manifest/entry/validators/format_matrix.rb +26 -0
- data/lib/textus/manifest/entry/validators/index_filename.rb +45 -0
- data/lib/textus/manifest/entry/validators/inject_intro.rb +18 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +37 -0
- data/lib/textus/manifest/entry/validators.rb +20 -0
- data/lib/textus/manifest/entry.rb +38 -189
- data/lib/textus/manifest/{policies.rb → rules.rb} +12 -10
- data/lib/textus/manifest/schema.rb +49 -0
- data/lib/textus/manifest.rb +50 -24
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/operations/reads.rb +39 -0
- data/lib/textus/operations/refresh.rb +27 -0
- data/lib/textus/operations/writes.rb +21 -0
- data/lib/textus/operations.rb +44 -0
- data/lib/textus/projection.rb +9 -8
- data/lib/textus/refresh.rb +4 -5
- data/lib/textus/schema/tools.rb +8 -7
- data/lib/textus/store/reader.rb +1 -1
- data/lib/textus/store/staleness/intake_check.rb +1 -1
- data/lib/textus/store/validator.rb +3 -3
- data/lib/textus/store/writer.rb +5 -74
- data/lib/textus/store.rb +1 -55
- data/lib/textus/version.rb +2 -2
- data/lib/textus.rb +1 -0
- metadata +35 -10
- data/lib/textus/cli/group/policy.rb +0 -11
- data/lib/textus/composition.rb +0 -72
- data/lib/textus/proposal.rb +0 -10
- data/lib/textus/store/mover.rb +0 -167
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",
|
|
@@ -3,7 +3,7 @@ module Textus
|
|
|
3
3
|
class Check
|
|
4
4
|
class SchemaViolations < Check
|
|
5
5
|
def call
|
|
6
|
-
res = store.validate_all
|
|
6
|
+
res = Textus::Operations.for(store).reads.validate_all.call
|
|
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"]})"
|
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
|
|
|
@@ -52,9 +53,9 @@ module Textus
|
|
|
52
53
|
|
|
53
54
|
def run_registered_checks(store)
|
|
54
55
|
out = []
|
|
55
|
-
view = Application::Context.
|
|
56
|
-
store.registry.rpc_names(:
|
|
57
|
-
callable = store.registry.rpc_callable(:
|
|
56
|
+
view = Application::Context.system(store)
|
|
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
|
|
@@ -9,7 +9,7 @@ module Textus
|
|
|
9
9
|
def call(policy, envelope, now:)
|
|
10
10
|
return Verdict.fresh if policy.ttl_seconds.nil?
|
|
11
11
|
|
|
12
|
-
last_str = envelope
|
|
12
|
+
last_str = envelope&.meta&.dig("last_refreshed_at")
|
|
13
13
|
return Verdict.stale("never refreshed") if last_str.nil?
|
|
14
14
|
|
|
15
15
|
last = begin
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Domain
|
|
3
|
-
Permission = Data.define(:zone, :
|
|
3
|
+
Permission = Data.define(:zone, :write_policy, :read_policy) do
|
|
4
4
|
def allows_write?(role)
|
|
5
|
-
|
|
5
|
+
write_policy.include?(role.to_s)
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def allows_read?(role)
|
|
9
|
-
return true if
|
|
9
|
+
return true if [:all, ["all"]].include?(read_policy)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
read_policy.include?(role.to_s)
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class HumanAccept
|
|
6
|
+
attr_reader :reason
|
|
7
|
+
|
|
8
|
+
def name
|
|
9
|
+
"human_accept"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# The role is passed via `store` (an Application::Context-like object
|
|
13
|
+
# with a `role` reader) or through the entry metadata. In practice,
|
|
14
|
+
# Accept already enforces role == "human" before reaching the
|
|
15
|
+
# promotion gate, so this predicate trivially passes. It documents
|
|
16
|
+
# intent and future-proofs multi-actor accept flows.
|
|
17
|
+
def call(store:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
18
|
+
role = store.respond_to?(:role) ? store.role.to_s : nil
|
|
19
|
+
# If we cannot determine the role (e.g. store doesn't expose it),
|
|
20
|
+
# we trust that Accept has already checked — allow through.
|
|
21
|
+
return true if role.nil?
|
|
22
|
+
|
|
23
|
+
ok = (role == "human")
|
|
24
|
+
@reason = "current role is '#{role}', expected 'human'" unless ok
|
|
25
|
+
ok
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class SchemaValid
|
|
6
|
+
attr_reader :reason
|
|
7
|
+
|
|
8
|
+
def name
|
|
9
|
+
"schema_valid"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(entry:, store:) # rubocop:disable Metrics/PerceivedComplexity
|
|
13
|
+
return true if entry.nil? || store.nil?
|
|
14
|
+
|
|
15
|
+
target_key = entry.meta&.dig("proposal", "target_key")
|
|
16
|
+
return true unless target_key
|
|
17
|
+
|
|
18
|
+
mentry, = store.manifest.resolve(target_key)
|
|
19
|
+
schema_ref = mentry&.schema
|
|
20
|
+
return true unless schema_ref
|
|
21
|
+
|
|
22
|
+
schema = store.schema_for(schema_ref)
|
|
23
|
+
return true unless schema
|
|
24
|
+
|
|
25
|
+
frontmatter = entry.meta&.dig("frontmatter") || {}
|
|
26
|
+
begin
|
|
27
|
+
schema.validate!(frontmatter)
|
|
28
|
+
rescue Textus::SchemaViolation => e
|
|
29
|
+
@reason = e.message.dup
|
|
30
|
+
d = e.details
|
|
31
|
+
if d.is_a?(Hash)
|
|
32
|
+
if d["missing"]
|
|
33
|
+
@reason = "missing required fields: #{Array(d["missing"]).join(", ")}"
|
|
34
|
+
elsif d["field"]
|
|
35
|
+
@reason = "field '#{d["field"]}': #{d["reason"]}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
return false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
true
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
@reason = "schema validation error: #{e.message}"
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Policy
|
|
4
|
+
# Promotion evaluates a list of named predicates against a pending-proposal
|
|
5
|
+
# entry and returns a Result indicating whether all requirements are met.
|
|
6
|
+
class Promotion
|
|
7
|
+
Result = Struct.new(:ok?, :reasons, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
REGISTRY = {
|
|
10
|
+
"schema_valid" => -> { Predicates::SchemaValid.new },
|
|
11
|
+
"human_accept" => -> { Predicates::HumanAccept.new },
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def self.from_names(names)
|
|
15
|
+
predicates = Array(names).map do |n|
|
|
16
|
+
ctor = REGISTRY[n.to_s] or raise Textus::UsageError.new(
|
|
17
|
+
"unknown promotion predicate: '#{n}' (known: #{REGISTRY.keys.join(", ")})",
|
|
18
|
+
)
|
|
19
|
+
ctor.call
|
|
20
|
+
end
|
|
21
|
+
new(predicates: predicates)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :predicates
|
|
25
|
+
|
|
26
|
+
def initialize(predicates:)
|
|
27
|
+
@predicates = predicates
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def predicate_names
|
|
31
|
+
@predicates.map(&:name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def evaluate(entry:, store:)
|
|
35
|
+
reasons = []
|
|
36
|
+
@predicates.each do |pred|
|
|
37
|
+
ok = pred.call(entry: entry, store: store)
|
|
38
|
+
reasons << "#{pred.name}: #{pred.reason || "predicate failed"}" unless ok
|
|
39
|
+
end
|
|
40
|
+
Result.new(ok?: reasons.empty?, reasons: reasons)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/textus/entry/base.rb
CHANGED
|
@@ -25,6 +25,34 @@ module Textus
|
|
|
25
25
|
def self.validate_against(schema, parsed)
|
|
26
26
|
schema.validate!(parsed["_meta"] || {})
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
def self.nested_glob
|
|
30
|
+
raise NotImplementedError.new("#{name}.nested_glob not implemented")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.validate_path_extension(_path, _nested)
|
|
34
|
+
raise NotImplementedError.new("#{name}.validate_path_extension not implemented")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.inject_uid(_meta, _content, _existing_uid)
|
|
38
|
+
raise NotImplementedError.new("#{name}.inject_uid not implemented")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.enforce_name_match!(_path, _meta)
|
|
42
|
+
raise NotImplementedError.new("#{name}.enforce_name_match! not implemented")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.serialize_for_put(meta:, body:, content:, path:)
|
|
46
|
+
_ = meta
|
|
47
|
+
_ = body
|
|
48
|
+
_ = content
|
|
49
|
+
_ = path
|
|
50
|
+
raise NotImplementedError.new("#{name}.serialize_for_put not implemented")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.rewrite_name(_path, _basename)
|
|
54
|
+
raise NotImplementedError.new("#{name}.rewrite_name not implemented")
|
|
55
|
+
end
|
|
28
56
|
end
|
|
29
57
|
end
|
|
30
58
|
end
|
data/lib/textus/entry/json.rb
CHANGED
|
@@ -42,6 +42,65 @@ module Textus
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def self.extensions = [".json"]
|
|
45
|
+
|
|
46
|
+
def self.nested_glob = "**/*.json"
|
|
47
|
+
|
|
48
|
+
def self.serialize_for_put(meta:, body:, content:, path:)
|
|
49
|
+
raise UsageError.new("put for json requires content: or body:") if content.nil? && (body.nil? || body.to_s.empty?)
|
|
50
|
+
|
|
51
|
+
if content.nil?
|
|
52
|
+
begin
|
|
53
|
+
parsed = parse(body.to_s, path: path)
|
|
54
|
+
rescue BadFrontmatter => e
|
|
55
|
+
raise BadContent.new(path, "bad_content: #{e.message}")
|
|
56
|
+
end
|
|
57
|
+
[body.to_s, parsed["_meta"], body.to_s, parsed["content"]]
|
|
58
|
+
else
|
|
59
|
+
bytes = serialize(meta: meta, body: "", content: content)
|
|
60
|
+
[bytes, meta, bytes, content]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Mutating filesystem op; returns true if a write happened.
|
|
65
|
+
def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
|
|
66
|
+
raw = File.binread(path)
|
|
67
|
+
parsed = parse(raw, path: path)
|
|
68
|
+
meta = parsed["_meta"]
|
|
69
|
+
return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
|
|
70
|
+
|
|
71
|
+
new_meta = meta.merge("name" => basename)
|
|
72
|
+
File.binwrite(path, serialize(meta: new_meta, body: "", content: parsed["content"]))
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.enforce_name_match!(path, meta)
|
|
77
|
+
return unless meta.is_a?(Hash) && meta["name"]
|
|
78
|
+
|
|
79
|
+
ext = extensions.first
|
|
80
|
+
basename = File.basename(path, ext)
|
|
81
|
+
return if meta["name"] == basename
|
|
82
|
+
|
|
83
|
+
raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.inject_uid(meta, content, existing_uid)
|
|
87
|
+
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
88
|
+
m["uid"] = existing_uid || Textus::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
89
|
+
[m, content]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.validate_path_extension(path, nested)
|
|
93
|
+
ext = File.extname(path)
|
|
94
|
+
if nested
|
|
95
|
+
return if ext == ""
|
|
96
|
+
|
|
97
|
+
raise UsageError.new("nested json path must not have an extension")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
return if ext == ".json"
|
|
101
|
+
|
|
102
|
+
raise UsageError.new("json format requires '.json' path (got #{ext.inspect})")
|
|
103
|
+
end
|
|
45
104
|
end
|
|
46
105
|
end
|
|
47
106
|
end
|
|
@@ -34,6 +34,52 @@ module Textus
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def self.extensions = [".md"]
|
|
37
|
+
|
|
38
|
+
def self.nested_glob = "**/*.md"
|
|
39
|
+
|
|
40
|
+
def self.serialize_for_put(meta:, body:, content:, path:)
|
|
41
|
+
_ = path
|
|
42
|
+
_ = content
|
|
43
|
+
bytes = serialize(meta: meta || {}, body: body.to_s)
|
|
44
|
+
[bytes, meta, body.to_s, nil]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Mutating filesystem op; returns true if a write happened (boolean is
|
|
48
|
+
# informational, not a predicate). Rubocop's predicate-name heuristic
|
|
49
|
+
# disabled here on purpose.
|
|
50
|
+
def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
|
|
51
|
+
raw = File.binread(path)
|
|
52
|
+
parsed = parse(raw, path: path)
|
|
53
|
+
meta = parsed["_meta"] || {}
|
|
54
|
+
return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
|
|
55
|
+
|
|
56
|
+
new_meta = meta.merge("name" => basename)
|
|
57
|
+
File.binwrite(path, serialize(meta: new_meta, body: parsed["body"]))
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.enforce_name_match!(path, meta)
|
|
62
|
+
return unless meta.is_a?(Hash) && meta["name"]
|
|
63
|
+
|
|
64
|
+
ext = extensions.first
|
|
65
|
+
basename = File.basename(path, ext)
|
|
66
|
+
return if meta["name"] == basename
|
|
67
|
+
|
|
68
|
+
raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.inject_uid(meta, content, existing_uid)
|
|
72
|
+
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
73
|
+
m["uid"] = existing_uid || Textus::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
74
|
+
[m, content]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.validate_path_extension(path, _nested)
|
|
78
|
+
ext = File.extname(path)
|
|
79
|
+
return if ["", ".md"].include?(ext)
|
|
80
|
+
|
|
81
|
+
raise UsageError.new("markdown format requires '.md' path (got #{ext.inspect})")
|
|
82
|
+
end
|
|
37
83
|
end
|
|
38
84
|
end
|
|
39
85
|
end
|
data/lib/textus/entry/text.rb
CHANGED
|
@@ -18,6 +18,41 @@ module Textus
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def self.extensions = [".txt"]
|
|
21
|
+
|
|
22
|
+
def self.nested_glob = "**/*.txt"
|
|
23
|
+
|
|
24
|
+
def self.inject_uid(meta, content, _existing_uid)
|
|
25
|
+
[meta, content]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.enforce_name_match!(_path, _meta)
|
|
29
|
+
# text has no meta home; no-op
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.serialize_for_put(meta:, body:, content:, path:)
|
|
33
|
+
_ = path
|
|
34
|
+
_ = content
|
|
35
|
+
bytes = serialize(meta: meta || {}, body: body.to_s)
|
|
36
|
+
[bytes, meta, body.to_s, nil]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# No-op; text has no meta. Returns false (never writes).
|
|
40
|
+
def self.rewrite_name(_path, _basename) # rubocop:disable Naming/PredicateMethod
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.validate_path_extension(path, nested)
|
|
45
|
+
ext = File.extname(path)
|
|
46
|
+
if nested
|
|
47
|
+
return if ext == ""
|
|
48
|
+
|
|
49
|
+
raise UsageError.new("nested text path must not have an extension")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
return if [".txt", ""].include?(ext)
|
|
53
|
+
|
|
54
|
+
raise UsageError.new("text format requires '.txt' or no extension (got #{ext.inspect})")
|
|
55
|
+
end
|
|
21
56
|
end
|
|
22
57
|
end
|
|
23
58
|
end
|