textus 0.12.1 → 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 +214 -0
- data/README.md +6 -12
- data/SPEC.md +4 -1
- 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 +2 -2
- data/lib/textus/application/reads/get.rb +8 -11
- data/lib/textus/application/reads/list.rb +15 -0
- 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/worker.rb +3 -3
- data/lib/textus/application/writes/accept.rb +7 -7
- data/lib/textus/application/writes/build.rb +10 -47
- data/lib/textus/application/writes/mv.rb +144 -0
- data/lib/textus/application/writes/publish.rb +41 -9
- data/lib/textus/application/writes/reject.rb +37 -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/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 +2 -2
- 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/rule_explain.rb +1 -2
- 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 +6 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor.rb +1 -1
- data/lib/textus/domain/freshness/evaluator.rb +1 -1
- data/lib/textus/domain/policy/predicates/schema_valid.rb +3 -3
- 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/intro.rb +56 -0
- 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 +35 -213
- data/lib/textus/manifest.rb +6 -16
- 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 +5 -4
- data/lib/textus/refresh.rb +3 -4
- data/lib/textus/schema/tools.rb +8 -7
- data/lib/textus/store/reader.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 +1 -1
- metadata +23 -4
- 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/verb/uid.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Textus
|
|
|
4
4
|
class Uid < Verb
|
|
5
5
|
def call(store)
|
|
6
6
|
key = positional.shift or raise UsageError.new("uid requires a key")
|
|
7
|
-
emit({ "key" => key, "uid" => store.uid(key) })
|
|
7
|
+
emit({ "key" => key, "uid" => operations_for(store).reads.uid.call(key) })
|
|
8
8
|
end
|
|
9
9
|
end
|
|
10
10
|
end
|
data/lib/textus/cli/verb.rb
CHANGED
|
@@ -70,7 +70,12 @@ module Textus
|
|
|
70
70
|
# Convenience for verbs whose only pre-call boilerplate is
|
|
71
71
|
# resolving the role and wrapping it in a context.
|
|
72
72
|
def context_for(store)
|
|
73
|
-
Textus::
|
|
73
|
+
Textus::Operations.for(store, role: resolved_role(store)).ctx
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns an Operations instance bound to the resolved role.
|
|
77
|
+
def operations_for(store)
|
|
78
|
+
Textus::Operations.for(store, role: resolved_role(store))
|
|
74
79
|
end
|
|
75
80
|
end
|
|
76
81
|
end
|
|
@@ -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
|
@@ -53,7 +53,7 @@ module Textus
|
|
|
53
53
|
|
|
54
54
|
def run_registered_checks(store)
|
|
55
55
|
out = []
|
|
56
|
-
view = Application::Context.
|
|
56
|
+
view = Application::Context.system(store)
|
|
57
57
|
store.registry.rpc_names(:validate).each do |name|
|
|
58
58
|
callable = store.registry.rpc_callable(:validate, name)
|
|
59
59
|
begin
|
|
@@ -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
|
|
@@ -9,10 +9,10 @@ module Textus
|
|
|
9
9
|
"schema_valid"
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def call(entry:, store:)
|
|
12
|
+
def call(entry:, store:) # rubocop:disable Metrics/PerceivedComplexity
|
|
13
13
|
return true if entry.nil? || store.nil?
|
|
14
14
|
|
|
15
|
-
target_key = entry.dig("
|
|
15
|
+
target_key = entry.meta&.dig("proposal", "target_key")
|
|
16
16
|
return true unless target_key
|
|
17
17
|
|
|
18
18
|
mentry, = store.manifest.resolve(target_key)
|
|
@@ -22,7 +22,7 @@ module Textus
|
|
|
22
22
|
schema = store.schema_for(schema_ref)
|
|
23
23
|
return true unless schema
|
|
24
24
|
|
|
25
|
-
frontmatter = entry.dig("
|
|
25
|
+
frontmatter = entry.meta&.dig("frontmatter") || {}
|
|
26
26
|
begin
|
|
27
27
|
schema.validate!(frontmatter)
|
|
28
28
|
rescue Textus::SchemaViolation => e
|
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
|
data/lib/textus/entry/yaml.rb
CHANGED
|
@@ -40,6 +40,65 @@ module Textus
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def self.extensions = [".yaml", ".yml"]
|
|
43
|
+
|
|
44
|
+
def self.nested_glob = "**/*.{yaml,yml}"
|
|
45
|
+
|
|
46
|
+
def self.serialize_for_put(meta:, body:, content:, path:)
|
|
47
|
+
raise UsageError.new("put for yaml requires content: or body:") if content.nil? && (body.nil? || body.to_s.empty?)
|
|
48
|
+
|
|
49
|
+
if content.nil?
|
|
50
|
+
begin
|
|
51
|
+
parsed = parse(body.to_s, path: path)
|
|
52
|
+
rescue BadFrontmatter => e
|
|
53
|
+
raise BadContent.new(path, "bad_content: #{e.message}")
|
|
54
|
+
end
|
|
55
|
+
[body.to_s, parsed["_meta"], body.to_s, parsed["content"]]
|
|
56
|
+
else
|
|
57
|
+
bytes = serialize(meta: meta, body: "", content: content)
|
|
58
|
+
[bytes, meta, bytes, content]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Mutating filesystem op; returns true if a write happened.
|
|
63
|
+
def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
|
|
64
|
+
raw = File.binread(path)
|
|
65
|
+
parsed = parse(raw, path: path)
|
|
66
|
+
meta = parsed["_meta"]
|
|
67
|
+
return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
|
|
68
|
+
|
|
69
|
+
new_meta = meta.merge("name" => basename)
|
|
70
|
+
File.binwrite(path, serialize(meta: new_meta, body: "", content: parsed["content"]))
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.enforce_name_match!(path, meta)
|
|
75
|
+
return unless meta.is_a?(Hash) && meta["name"]
|
|
76
|
+
|
|
77
|
+
ext = extensions.first
|
|
78
|
+
basename = File.basename(path, ext)
|
|
79
|
+
return if meta["name"] == basename
|
|
80
|
+
|
|
81
|
+
raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.inject_uid(meta, content, existing_uid)
|
|
85
|
+
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
86
|
+
m["uid"] = existing_uid || Textus::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
87
|
+
[m, content]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.validate_path_extension(path, nested)
|
|
91
|
+
ext = File.extname(path)
|
|
92
|
+
if nested
|
|
93
|
+
return if ext == ""
|
|
94
|
+
|
|
95
|
+
raise UsageError.new("nested yaml path must not have an extension")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
return if [".yaml", ".yml"].include?(ext)
|
|
99
|
+
|
|
100
|
+
raise UsageError.new("yaml format requires '.yaml' or '.yml' path (got #{ext.inspect})")
|
|
101
|
+
end
|
|
43
102
|
end
|
|
44
103
|
end
|
|
45
104
|
end
|
data/lib/textus/entry.rb
CHANGED
|
@@ -10,10 +10,26 @@ module Textus
|
|
|
10
10
|
"text" => Text,
|
|
11
11
|
}.freeze
|
|
12
12
|
|
|
13
|
+
EXT_TO_FORMAT = {
|
|
14
|
+
".md" => "markdown",
|
|
15
|
+
".json" => "json",
|
|
16
|
+
".yaml" => "yaml",
|
|
17
|
+
".yml" => "yaml",
|
|
18
|
+
".txt" => "text",
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
13
21
|
def self.for_format(format)
|
|
14
22
|
STRATEGIES.fetch(format.to_s) { raise UsageError.new("unknown entry format: #{format.inspect}") }
|
|
15
23
|
end
|
|
16
24
|
|
|
25
|
+
def self.infer_from_extension(ext)
|
|
26
|
+
EXT_TO_FORMAT[ext]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.formats
|
|
30
|
+
EXT_TO_FORMAT.values.uniq
|
|
31
|
+
end
|
|
32
|
+
|
|
17
33
|
def self.parse(raw, path: nil, format: "markdown")
|
|
18
34
|
for_format(format).parse(raw, path: path)
|
|
19
35
|
end
|
data/lib/textus/envelope.rb
CHANGED
|
@@ -1,30 +1,60 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
|
|
4
|
+
Envelope = Data.define(
|
|
5
|
+
:protocol, :key, :zone, :owner, :path, :format,
|
|
6
|
+
:uid, :etag, :schema_ref, :meta, :body, :content, :freshness
|
|
7
|
+
) do
|
|
5
8
|
# rubocop:disable Metrics/ParameterLists
|
|
6
|
-
def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil)
|
|
9
|
+
def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
|
|
7
10
|
# rubocop:enable Metrics/ParameterLists
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
new(
|
|
12
|
+
protocol: Textus::PROTOCOL,
|
|
13
|
+
key: key,
|
|
14
|
+
zone: mentry.zone,
|
|
15
|
+
owner: mentry.owner,
|
|
16
|
+
path: path,
|
|
17
|
+
format: mentry.format,
|
|
18
|
+
uid: extract_uid(meta),
|
|
19
|
+
etag: etag,
|
|
20
|
+
schema_ref: mentry.schema,
|
|
21
|
+
meta: meta,
|
|
22
|
+
body: body,
|
|
23
|
+
content: content,
|
|
24
|
+
freshness: freshness,
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.extract_uid(meta)
|
|
29
|
+
v = meta.is_a?(Hash) ? meta["uid"] : nil
|
|
30
|
+
v.is_a?(String) ? v : nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h_for_wire
|
|
34
|
+
h = {
|
|
35
|
+
"protocol" => protocol,
|
|
10
36
|
"key" => key,
|
|
11
|
-
"zone" =>
|
|
12
|
-
"owner" =>
|
|
37
|
+
"zone" => zone,
|
|
38
|
+
"owner" => owner,
|
|
13
39
|
"path" => path,
|
|
14
|
-
"format" =>
|
|
40
|
+
"format" => format,
|
|
15
41
|
"_meta" => meta,
|
|
16
42
|
"body" => body,
|
|
17
43
|
"etag" => etag,
|
|
18
|
-
"schema_ref" =>
|
|
19
|
-
"uid" =>
|
|
44
|
+
"schema_ref" => schema_ref,
|
|
45
|
+
"uid" => uid,
|
|
20
46
|
}
|
|
21
|
-
|
|
22
|
-
|
|
47
|
+
h["content"] = content unless content.nil?
|
|
48
|
+
freshness.each { |k, v| h[k.to_s] = v } if freshness.is_a?(Hash)
|
|
49
|
+
h
|
|
23
50
|
end
|
|
24
51
|
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
|
|
52
|
+
def stale?
|
|
53
|
+
freshness.is_a?(Hash) && (freshness["stale"] == true || freshness[:stale] == true)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def refreshing?
|
|
57
|
+
freshness.is_a?(Hash) && (freshness["refreshing"] == true || freshness[:refreshing] == true)
|
|
28
58
|
end
|
|
29
59
|
end
|
|
30
60
|
end
|
data/lib/textus/intro.rb
CHANGED
|
@@ -26,6 +26,61 @@ module Textus
|
|
|
26
26
|
"builder" => "'textus build' computes output entries from projections; output files are never hand-edited",
|
|
27
27
|
}.freeze
|
|
28
28
|
|
|
29
|
+
# Static, store-independent guide to the agent-facing protocol. Surfaced
|
|
30
|
+
# under the new top-level `agent_protocol` key in Intro.run. Recipes
|
|
31
|
+
# describe CLI verbs (not Ruby Operations) because the audience is an
|
|
32
|
+
# agent driving textus from the command line.
|
|
33
|
+
AGENT_PROTOCOL = {
|
|
34
|
+
"envelope_shape" => {
|
|
35
|
+
"summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
|
|
36
|
+
"fields" => {
|
|
37
|
+
"_meta" => "hash of structured frontmatter; schema-validated per entry family",
|
|
38
|
+
"body" => "string payload (markdown/text) or nil for json/yaml formats where body lives in _meta",
|
|
39
|
+
"uid" => "stable 16-char hex identifier; preserved across writes and key renames",
|
|
40
|
+
"etag" => "content hash; pass back on writes to detect concurrent edits",
|
|
41
|
+
},
|
|
42
|
+
"ref" => "SPEC.md §8",
|
|
43
|
+
},
|
|
44
|
+
"role_resolution" => {
|
|
45
|
+
"summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, default human",
|
|
46
|
+
"roles" => %w[human agent runner builder],
|
|
47
|
+
"ref" => "SPEC.md §5",
|
|
48
|
+
},
|
|
49
|
+
"recipes" => {
|
|
50
|
+
"read" => {
|
|
51
|
+
"purpose" => "find and read an entry",
|
|
52
|
+
"steps" => [
|
|
53
|
+
"textus list --zone=ZONE --prefix=PREFIX # discover keys",
|
|
54
|
+
"textus get KEY # returns envelope JSON",
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
"write" => {
|
|
58
|
+
"purpose" => "create or update an entry",
|
|
59
|
+
"steps" => [
|
|
60
|
+
"textus schema get FAMILY # learn the _meta field shape",
|
|
61
|
+
"build an envelope JSON: {_meta: {...}, body: \"...\"}",
|
|
62
|
+
"echo ENVELOPE | textus put KEY --as=ROLE --stdin",
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
"propose" => {
|
|
66
|
+
"purpose" => "agent suggests a change for human review",
|
|
67
|
+
"agent_steps" => [
|
|
68
|
+
"echo ENVELOPE | textus put review.KEY --as=agent --stdin",
|
|
69
|
+
],
|
|
70
|
+
"human_steps" => [
|
|
71
|
+
"textus accept review.KEY --as=human # promotes the proposal to its target zone",
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
"refresh" => {
|
|
75
|
+
"purpose" => "rebuild stale intake-zone caches from their declared actions",
|
|
76
|
+
"steps" => [
|
|
77
|
+
"textus freshness --zone=intake # report fresh/stale per entry",
|
|
78
|
+
"textus refresh stale --zone=intake --as=runner",
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
}.freeze
|
|
83
|
+
|
|
29
84
|
# The CLI verb catalog. Truth lives here; do not derive dynamically.
|
|
30
85
|
# Agents that read intro should see a stable shape regardless of how
|
|
31
86
|
# verb implementations evolve.
|
|
@@ -59,6 +114,7 @@ module Textus
|
|
|
59
114
|
"hooks" => hooks_for(store),
|
|
60
115
|
"write_flows" => WRITE_FLOWS.dup,
|
|
61
116
|
"cli_verbs" => CLI_VERBS.map(&:dup),
|
|
117
|
+
"agent_protocol" => AGENT_PROTOCOL,
|
|
62
118
|
"docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
|
|
63
119
|
}
|
|
64
120
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Parser
|
|
5
|
+
COMPUTE_KINDS = %w[projection external].freeze
|
|
6
|
+
|
|
7
|
+
def self.call(manifest, raw)
|
|
8
|
+
key = raw["key"] or raise UsageError.new("manifest entry missing key")
|
|
9
|
+
path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
|
|
10
|
+
zone = raw["zone"] or raise UsageError.new("manifest entry '#{key}' missing zone")
|
|
11
|
+
|
|
12
|
+
nested = raw["nested"] == true
|
|
13
|
+
compute, projection, generator = parse_compute(raw, key)
|
|
14
|
+
intake_handler, intake_config = parse_intake(raw["intake"])
|
|
15
|
+
format = resolve_format(raw, path, nested)
|
|
16
|
+
|
|
17
|
+
Textus::Manifest::Entry.new(
|
|
18
|
+
manifest: manifest, raw: raw,
|
|
19
|
+
key: key, path: path, zone: zone,
|
|
20
|
+
schema: raw["schema"], owner: raw["owner"],
|
|
21
|
+
nested: nested,
|
|
22
|
+
template: raw["template"],
|
|
23
|
+
publish_to: Array(raw["publish_to"]),
|
|
24
|
+
publish_each: raw["publish_each"],
|
|
25
|
+
events: raw["events"] || {},
|
|
26
|
+
inject_intro: raw["inject_intro"] == true,
|
|
27
|
+
index_filename: raw["index_filename"],
|
|
28
|
+
format: format,
|
|
29
|
+
compute: compute, projection: projection, generator: generator,
|
|
30
|
+
intake_handler: intake_handler, intake_config: intake_config
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.parse_compute(raw, key)
|
|
35
|
+
src = raw["compute"]
|
|
36
|
+
return [nil, nil, nil] if src.nil?
|
|
37
|
+
|
|
38
|
+
kind = src["kind"]
|
|
39
|
+
unless COMPUTE_KINDS.include?(kind)
|
|
40
|
+
raise BadManifest.new(
|
|
41
|
+
"entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{kind.inspect})",
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
frozen = src.freeze
|
|
46
|
+
if kind == "projection"
|
|
47
|
+
[frozen, frozen, nil]
|
|
48
|
+
else
|
|
49
|
+
[frozen, nil, frozen]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.parse_intake(src)
|
|
54
|
+
src ||= {}
|
|
55
|
+
[src["handler"], src["config"] || {}]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.resolve_format(raw, path, nested)
|
|
59
|
+
declared = raw["format"]
|
|
60
|
+
ext = File.extname(path)
|
|
61
|
+
inferred = Textus::Entry.infer_from_extension(ext)
|
|
62
|
+
|
|
63
|
+
if declared.nil?
|
|
64
|
+
return inferred if inferred
|
|
65
|
+
return "markdown" if ext == "" && nested
|
|
66
|
+
return "markdown" if ext == ""
|
|
67
|
+
|
|
68
|
+
return "markdown"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
raise UsageError.new("entry '#{raw["key"]}': unknown format #{declared.inspect}") unless Textus::Entry.formats.include?(declared)
|
|
72
|
+
|
|
73
|
+
if ext != "" && inferred && inferred != declared
|
|
74
|
+
raise UsageError.new(
|
|
75
|
+
"entry '#{raw["key"]}': path extension #{ext.inspect} does not match declared format #{declared.inspect}",
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
declared
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Validators
|
|
5
|
+
module Events
|
|
6
|
+
def self.call(entry)
|
|
7
|
+
pubsub_events = Textus::Hooks::Registry::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
|
|
8
|
+
entry.events.each_key do |evt|
|
|
9
|
+
next if pubsub_events.include?(evt.to_sym)
|
|
10
|
+
|
|
11
|
+
raise UsageError.new(
|
|
12
|
+
"entry '#{entry.key}': unknown event '#{evt}' in events: block. " \
|
|
13
|
+
"Known events: #{pubsub_events.join(", ")}.",
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Validators
|
|
5
|
+
module FormatMatrix
|
|
6
|
+
def self.call(entry)
|
|
7
|
+
begin
|
|
8
|
+
Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested)
|
|
9
|
+
rescue UsageError => e
|
|
10
|
+
raise UsageError.new("entry '#{entry.key}': #{e.message}")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
if entry.format == "text" && !entry.schema.nil?
|
|
14
|
+
raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
return unless entry.in_generator_zone? && entry.template.nil? && entry.generator.nil? &&
|
|
18
|
+
%w[markdown text].include?(entry.format) && !entry.nested
|
|
19
|
+
|
|
20
|
+
raise UsageError.new("entry '#{entry.key}': derived #{entry.format} entries require a template")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|