textus 0.4.0 → 0.8.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/CHANGELOG.md +147 -2
- data/README.md +38 -28
- data/SPEC.md +84 -147
- data/docs/architecture.md +82 -28
- data/lib/textus/builder/pipeline.rb +56 -0
- data/lib/textus/builder/renderer/json.rb +42 -0
- data/lib/textus/builder/renderer/markdown.rb +22 -0
- data/lib/textus/builder/renderer/text.rb +14 -0
- data/lib/textus/builder/renderer/yaml.rb +42 -0
- data/lib/textus/builder/renderer.rb +17 -0
- data/lib/textus/builder.rb +9 -114
- data/lib/textus/cli/group/hook.rb +11 -0
- data/lib/textus/cli/group/key.rb +12 -0
- data/lib/textus/cli/group/schema.rb +13 -0
- data/lib/textus/cli/group.rb +51 -0
- data/lib/textus/cli/verb/accept.rb +15 -0
- data/lib/textus/cli/verb/build.rb +13 -0
- data/lib/textus/cli/verb/delete.rb +16 -0
- data/lib/textus/cli/verb/deps.rb +12 -0
- data/lib/textus/cli/verb/doctor.rb +15 -0
- data/lib/textus/cli/verb/get.rb +12 -0
- data/lib/textus/cli/verb/hook_run.rb +48 -0
- data/lib/textus/cli/verb/hooks.rb +50 -0
- data/lib/textus/cli/verb/init.rb +14 -0
- data/lib/textus/cli/verb/intro.rb +11 -0
- data/lib/textus/cli/verb/list.rb +14 -0
- data/lib/textus/cli/verb/migrate_keys.rb +16 -0
- data/lib/textus/cli/verb/mv.rb +17 -0
- data/lib/textus/cli/verb/published.rb +11 -0
- data/lib/textus/cli/verb/put.rb +50 -0
- data/lib/textus/cli/verb/rdeps.rb +12 -0
- data/lib/textus/cli/verb/refresh.rb +15 -0
- data/lib/textus/cli/verb/schema.rb +12 -0
- data/lib/textus/cli/verb/schema_diff.rb +12 -0
- data/lib/textus/cli/verb/schema_init.rb +16 -0
- data/lib/textus/cli/verb/schema_migrate.rb +16 -0
- data/lib/textus/cli/verb/stale.rb +14 -0
- data/lib/textus/cli/verb/uid.rb +12 -0
- data/lib/textus/cli/verb/where.rb +12 -0
- data/lib/textus/cli/verb.rb +62 -0
- data/lib/textus/cli.rb +44 -385
- data/lib/textus/doctor/check/audit_log.rb +50 -0
- data/lib/textus/doctor/check/hooks.rb +29 -0
- data/lib/textus/doctor/check/illegal_keys.rb +49 -0
- data/lib/textus/doctor/check/manifest_files.rb +38 -0
- data/lib/textus/doctor/check/schema_violations.rb +22 -0
- data/lib/textus/doctor/check/schemas.rb +26 -0
- data/lib/textus/doctor/check/sentinels.rb +57 -0
- data/lib/textus/doctor/check/templates.rb +26 -0
- data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
- data/lib/textus/doctor/check.rb +30 -0
- data/lib/textus/doctor.rb +29 -264
- data/lib/textus/entry/base.rb +30 -0
- data/lib/textus/entry/json.rb +11 -5
- data/lib/textus/entry/markdown.rb +5 -5
- data/lib/textus/entry/text.rb +4 -4
- data/lib/textus/entry/yaml.rb +11 -5
- data/lib/textus/entry.rb +2 -7
- data/lib/textus/envelope.rb +30 -0
- data/lib/textus/errors.rb +2 -2
- data/lib/textus/hooks/builtin.rb +70 -0
- data/lib/textus/hooks/dispatcher.rb +49 -0
- data/lib/textus/hooks/loader.rb +26 -0
- data/lib/textus/hooks/registry.rb +73 -0
- data/lib/textus/init.rb +14 -11
- data/lib/textus/intro.rb +16 -18
- data/lib/textus/key/distance.rb +55 -0
- data/lib/textus/key/grammar.rb +33 -0
- data/lib/textus/key/path.rb +17 -0
- data/lib/textus/manifest/entry.rb +199 -0
- data/lib/textus/manifest.rb +20 -254
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/projection.rb +6 -5
- data/lib/textus/proposal.rb +4 -4
- data/lib/textus/refresh.rb +17 -17
- data/lib/textus/schema/tools.rb +89 -0
- data/lib/textus/store/audit_log.rb +71 -0
- data/lib/textus/store/mover.rb +121 -0
- data/lib/textus/store/reader.rb +67 -0
- data/lib/textus/store/staleness.rb +133 -0
- data/lib/textus/store/validator.rb +56 -0
- data/lib/textus/store/view.rb +29 -0
- data/lib/textus/store/writer.rb +132 -0
- data/lib/textus/store.rb +26 -527
- data/lib/textus/version.rb +2 -2
- data/lib/textus.rb +14 -29
- metadata +78 -8
- data/lib/textus/audit_log.rb +0 -32
- data/lib/textus/builtin_actions.rb +0 -68
- data/lib/textus/extension_registry.rb +0 -61
- data/lib/textus/extensions.rb +0 -33
- data/lib/textus/key_distance.rb +0 -53
- data/lib/textus/schema_tools.rb +0 -87
- data/lib/textus/store_view.rb +0 -27
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Envelope
|
|
5
|
+
# rubocop:disable Metrics/ParameterLists
|
|
6
|
+
def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil)
|
|
7
|
+
# rubocop:enable Metrics/ParameterLists
|
|
8
|
+
env = {
|
|
9
|
+
"protocol" => PROTOCOL,
|
|
10
|
+
"key" => key,
|
|
11
|
+
"zone" => mentry.zone,
|
|
12
|
+
"owner" => mentry.owner,
|
|
13
|
+
"path" => path,
|
|
14
|
+
"format" => mentry.format,
|
|
15
|
+
"_meta" => meta,
|
|
16
|
+
"body" => body,
|
|
17
|
+
"etag" => etag,
|
|
18
|
+
"schema_ref" => mentry.schema,
|
|
19
|
+
"uid" => extract_uid(meta),
|
|
20
|
+
}
|
|
21
|
+
env["content"] = content unless content.nil?
|
|
22
|
+
env
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.extract_uid(meta)
|
|
26
|
+
v = meta.is_a?(Hash) ? meta["uid"] : nil
|
|
27
|
+
v.is_a?(String) ? v : nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/textus/errors.rb
CHANGED
|
@@ -51,10 +51,10 @@ module Textus
|
|
|
51
51
|
private
|
|
52
52
|
|
|
53
53
|
def default_hint_for(path, m)
|
|
54
|
-
if m.is_a?(String) && (match = m.match(/
|
|
54
|
+
if m.is_a?(String) && (match = m.match(/name '([^']+)' does not match basename '([^']+)'/))
|
|
55
55
|
name, basename = match.captures
|
|
56
56
|
ext = File.extname(path)
|
|
57
|
-
"rename the file to '#{name}#{ext}' or change
|
|
57
|
+
"rename the file to '#{name}#{ext}' or change _meta.name to '#{basename}'"
|
|
58
58
|
else
|
|
59
59
|
"open #{path} and check the YAML frontmatter for syntax errors"
|
|
60
60
|
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "csv"
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "rexml/document"
|
|
5
|
+
|
|
6
|
+
module Textus
|
|
7
|
+
module Hooks
|
|
8
|
+
module Builtin
|
|
9
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
10
|
+
def self.register_all
|
|
11
|
+
Textus.hook(:fetch, :json) do |store:, config:, args:|
|
|
12
|
+
_ = store
|
|
13
|
+
_ = args
|
|
14
|
+
data = JSON.parse(config["bytes"].to_s)
|
|
15
|
+
{ _meta: {}, body: YAML.dump(data) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Textus.hook(:fetch, :csv) do |store:, config:, args:|
|
|
19
|
+
_ = store
|
|
20
|
+
_ = args
|
|
21
|
+
rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
|
|
22
|
+
{ _meta: {}, body: YAML.dump(rows) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Textus.hook(:fetch, :"markdown-links") do |store:, config:, args:|
|
|
26
|
+
_ = store
|
|
27
|
+
_ = args
|
|
28
|
+
links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
|
|
29
|
+
{ "text" => text, "href" => href }
|
|
30
|
+
end
|
|
31
|
+
{ _meta: {}, body: YAML.dump(links) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Textus.hook(:fetch, :"ical-events") do |store:, config:, args:|
|
|
35
|
+
_ = store
|
|
36
|
+
_ = args
|
|
37
|
+
events = []
|
|
38
|
+
current = nil
|
|
39
|
+
config["bytes"].to_s.each_line do |line|
|
|
40
|
+
line = line.strip
|
|
41
|
+
case line
|
|
42
|
+
when "BEGIN:VEVENT" then current = {}
|
|
43
|
+
when "END:VEVENT"
|
|
44
|
+
events << current if current
|
|
45
|
+
current = nil
|
|
46
|
+
when /\A(SUMMARY|DTSTART|DTEND|UID|LOCATION|DESCRIPTION):(.*)\z/
|
|
47
|
+
current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
{ _meta: {}, body: YAML.dump(events) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Textus.hook(:fetch, :rss) do |store:, config:, args:|
|
|
54
|
+
_ = store
|
|
55
|
+
_ = args
|
|
56
|
+
doc = REXML::Document.new(config["bytes"].to_s)
|
|
57
|
+
items = doc.elements.to_a("//item").map do |item|
|
|
58
|
+
{
|
|
59
|
+
"title" => item.elements["title"]&.text,
|
|
60
|
+
"link" => item.elements["link"]&.text,
|
|
61
|
+
"pubDate" => item.elements["pubDate"]&.text,
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
{ _meta: {}, body: YAML.dump(items) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
module Hooks
|
|
7
|
+
class Dispatcher
|
|
8
|
+
HOOK_TIMEOUT_SECONDS = 2
|
|
9
|
+
|
|
10
|
+
def initialize(audit_log:)
|
|
11
|
+
@audit_log = audit_log
|
|
12
|
+
@subscribers = Hash.new { |h, k| h[k] = [] }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def subscribe(event, name, keys: nil, &block)
|
|
16
|
+
@subscribers[event.to_sym] << { name: name.to_sym, callable: block, keys: keys }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def publish(event, **kwargs)
|
|
20
|
+
key = kwargs[:key] || "-"
|
|
21
|
+
@subscribers[event.to_sym].each do |sub|
|
|
22
|
+
next unless match?(sub[:keys], key)
|
|
23
|
+
|
|
24
|
+
invoke(event, sub, key, kwargs)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def invoke(event, sub, key, kwargs)
|
|
31
|
+
Timeout.timeout(HOOK_TIMEOUT_SECONDS) { sub[:callable].call(**kwargs) }
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
extras = { "event" => event.to_s, "hook" => sub[:name].to_s, "error" => "#{e.class}: #{e.message}" }
|
|
34
|
+
extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
|
|
35
|
+
extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
|
|
36
|
+
@audit_log.append(
|
|
37
|
+
role: "script", verb: "event_error", key: key,
|
|
38
|
+
etag_before: nil, etag_after: nil, extras: extras
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def match?(globs, key)
|
|
43
|
+
return true if globs.nil?
|
|
44
|
+
|
|
45
|
+
Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Hooks
|
|
3
|
+
module Loader
|
|
4
|
+
THREAD_REGISTRY_KEY = :__textus_active_registry__
|
|
5
|
+
private_constant :THREAD_REGISTRY_KEY
|
|
6
|
+
|
|
7
|
+
def self.with_registry(registry)
|
|
8
|
+
prev = Thread.current[THREAD_REGISTRY_KEY]
|
|
9
|
+
Thread.current[THREAD_REGISTRY_KEY] = registry
|
|
10
|
+
yield
|
|
11
|
+
ensure
|
|
12
|
+
Thread.current[THREAD_REGISTRY_KEY] = prev
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.current_registry
|
|
16
|
+
Thread.current[THREAD_REGISTRY_KEY] or
|
|
17
|
+
raise UsageError.new("no active registry; hook code must be loaded by a Store")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Public DSL — unchanged surface
|
|
23
|
+
def self.with_registry(registry, &) = Hooks::Loader.with_registry(registry, &)
|
|
24
|
+
def self.current_registry = Hooks::Loader.current_registry
|
|
25
|
+
def self.hook(event, name, **, &) = Hooks::Loader.current_registry.register(event, name, **, &)
|
|
26
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Hooks
|
|
3
|
+
class Registry
|
|
4
|
+
EVENTS = {
|
|
5
|
+
# RPC: exactly 1 handler per name; return value flows into store; failure aborts.
|
|
6
|
+
fetch: { mode: :rpc, args: %i[store config args] },
|
|
7
|
+
reduce: { mode: :rpc, args: %i[store rows config] },
|
|
8
|
+
check: { mode: :rpc, args: %i[store] },
|
|
9
|
+
|
|
10
|
+
# Pub-sub: 0..N handlers per event; return discarded; failure logged to audit.
|
|
11
|
+
put: { mode: :pubsub, args: %i[store key envelope] },
|
|
12
|
+
delete: { mode: :pubsub, args: %i[store key] },
|
|
13
|
+
refresh: { mode: :pubsub, args: %i[store key envelope change] },
|
|
14
|
+
build: { mode: :pubsub, args: %i[store key envelope sources] },
|
|
15
|
+
accept: { mode: :pubsub, args: %i[store key target_key] },
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(dispatcher: nil)
|
|
19
|
+
@rpc = Hash.new { |h, k| h[k] = {} } # event => { name => callable }
|
|
20
|
+
@pubsub = Hash.new { |h, k| h[k] = [] } # event => [{name:, callable:, keys:}]
|
|
21
|
+
@dispatcher = dispatcher
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def register(event, name, keys: nil, &blk)
|
|
25
|
+
spec = EVENTS[event.to_sym] or raise UsageError.new("unknown event: #{event}")
|
|
26
|
+
shape_check!(event, spec, blk)
|
|
27
|
+
name = name.to_sym
|
|
28
|
+
|
|
29
|
+
case spec[:mode]
|
|
30
|
+
when :rpc
|
|
31
|
+
raise UsageError.new("#{event} '#{name}' already registered") if @rpc[event.to_sym].key?(name)
|
|
32
|
+
|
|
33
|
+
@rpc[event.to_sym][name] = blk
|
|
34
|
+
when :pubsub
|
|
35
|
+
raise UsageError.new("#{event} hook '#{name}' already registered") if @pubsub[event.to_sym].any? { |h| h[:name] == name }
|
|
36
|
+
|
|
37
|
+
@pubsub[event.to_sym] << { name: name, callable: blk, keys: keys }
|
|
38
|
+
@dispatcher&.subscribe(event, name, keys: keys, &blk)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def rpc_callable(event, name)
|
|
43
|
+
@rpc[event.to_sym][name.to_sym] or
|
|
44
|
+
raise UsageError.new("unknown #{event}: #{name}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def listeners(event, key:)
|
|
48
|
+
@pubsub[event.to_sym].select { |h| h[:keys].nil? || matches_any?(h[:keys], key) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def rpc_names(event) = @rpc[event.to_sym].keys
|
|
52
|
+
def pubsub_handlers(event) = @pubsub[event.to_sym]
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def shape_check!(event, spec, blk)
|
|
57
|
+
required = spec[:args]
|
|
58
|
+
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
59
|
+
keyrest = provided.any? { |t, _| t == :keyrest }
|
|
60
|
+
missing = required - provided.map { |_, n| n }
|
|
61
|
+
return if keyrest || missing.empty?
|
|
62
|
+
|
|
63
|
+
raise UsageError.new(
|
|
64
|
+
"#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})",
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def matches_any?(globs, key)
|
|
69
|
+
Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Textus
|
|
|
5
5
|
ZONES = %w[canon working intake pending derived].freeze
|
|
6
6
|
|
|
7
7
|
DEFAULT_MANIFEST = <<~YAML
|
|
8
|
-
version: textus/
|
|
8
|
+
version: textus/2
|
|
9
9
|
zones:
|
|
10
10
|
- { name: canon, writable_by: [human] }
|
|
11
11
|
- { name: working, writable_by: [human, ai, script] }
|
|
@@ -22,27 +22,30 @@ module Textus
|
|
|
22
22
|
|
|
23
23
|
FileUtils.mkdir_p(File.join(target_root, "schemas"))
|
|
24
24
|
FileUtils.mkdir_p(File.join(target_root, "templates"))
|
|
25
|
-
FileUtils.mkdir_p(File.join(target_root, "
|
|
25
|
+
FileUtils.mkdir_p(File.join(target_root, "hooks"))
|
|
26
26
|
ZONES.each do |z|
|
|
27
27
|
dir = File.join(target_root, "zones", z)
|
|
28
28
|
FileUtils.mkdir_p(dir)
|
|
29
29
|
File.write(File.join(dir, ".gitkeep"), "")
|
|
30
30
|
end
|
|
31
|
-
File.write(File.join(target_root, "
|
|
32
|
-
#
|
|
31
|
+
File.write(File.join(target_root, "hooks", "README.md"), <<~MD)
|
|
32
|
+
# Hooks
|
|
33
33
|
|
|
34
|
-
Drop one Ruby file per
|
|
34
|
+
Drop one Ruby file per hook. All extensions register through one DSL.
|
|
35
|
+
Every handler receives `store:` as its first kwarg, then event-specific args.
|
|
35
36
|
|
|
36
37
|
```ruby
|
|
37
|
-
Textus.
|
|
38
|
-
Textus.
|
|
39
|
-
Textus.hook(:
|
|
40
|
-
Textus.
|
|
38
|
+
Textus.hook(:fetch, :name) { |store:, config:, args:| ... } # bring bytes in
|
|
39
|
+
Textus.hook(:reduce, :name) { |store:, rows:, config:| ... } # transform rows
|
|
40
|
+
Textus.hook(:check, :name) { |store:| ... } # doctor check
|
|
41
|
+
Textus.hook(:put, :name, keys: ["..."]) # lifecycle listener
|
|
42
|
+
{ |store:, key:, envelope:| ... }
|
|
41
43
|
```
|
|
42
44
|
|
|
43
|
-
Events: :
|
|
45
|
+
Events: :fetch, :reduce, :check (rpc — return value used)
|
|
46
|
+
:put, :delete, :refresh, :build, :accept (pub-sub — return discarded)
|
|
44
47
|
|
|
45
|
-
See SPEC.md §5.
|
|
48
|
+
See SPEC.md §5.10 for the full table.
|
|
46
49
|
MD
|
|
47
50
|
|
|
48
51
|
File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
|
data/lib/textus/intro.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Textus
|
|
|
6
6
|
#
|
|
7
7
|
# Intro is side-effect-free.
|
|
8
8
|
module Intro
|
|
9
|
-
PROTOCOL_ID =
|
|
9
|
+
PROTOCOL_ID = PROTOCOL
|
|
10
10
|
|
|
11
11
|
# Conventional zone purposes. Unknown zones (declared in the manifest
|
|
12
12
|
# but not listed here) get no `purpose` field.
|
|
@@ -32,19 +32,19 @@ module Textus
|
|
|
32
32
|
CLI_VERBS = [
|
|
33
33
|
{ "name" => "intro", "summary" => "this output — orientation for agents and tools" },
|
|
34
34
|
{ "name" => "list", "summary" => "enumerate keys (optional --prefix)" },
|
|
35
|
-
{ "name" => "get", "summary" => "read an entry; envelope with
|
|
35
|
+
{ "name" => "get", "summary" => "read an entry; envelope with _meta, body, uid, etag" },
|
|
36
36
|
{ "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
|
|
37
37
|
{ "name" => "schema", "summary" => "field shape for a key family" },
|
|
38
38
|
{ "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
|
|
39
39
|
{ "name" => "accept", "summary" => "apply a pending.* proposal; --as=human only" },
|
|
40
|
-
{ "name" => "
|
|
40
|
+
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key migrate'" },
|
|
41
41
|
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
42
42
|
{ "name" => "build", "summary" => "materialize derived entries; publish_to and publish_each fan out copies" },
|
|
43
43
|
{ "name" => "refresh", "summary" => "run an action for an intake entry" },
|
|
44
44
|
{ "name" => "stale", "summary" => "list derived/intake entries past their freshness check" },
|
|
45
45
|
{ "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
|
|
46
|
-
{ "name" => "
|
|
47
|
-
|
|
46
|
+
{ "name" => "hook",
|
|
47
|
+
"summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
48
48
|
].freeze
|
|
49
49
|
|
|
50
50
|
def self.run(store)
|
|
@@ -80,7 +80,7 @@ module Textus
|
|
|
80
80
|
"owner" => e.owner,
|
|
81
81
|
"format" => e.format,
|
|
82
82
|
"derived" => derived,
|
|
83
|
-
"intake" => !e.
|
|
83
|
+
"intake" => !e.fetch.nil?,
|
|
84
84
|
"publish_to" => Array(e.publish_to),
|
|
85
85
|
"publish_each" => e.publish_each,
|
|
86
86
|
}
|
|
@@ -89,18 +89,16 @@ module Textus
|
|
|
89
89
|
|
|
90
90
|
def self.extensions_for(store)
|
|
91
91
|
reg = store.registry
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"hooks" => hooks,
|
|
103
|
-
}
|
|
92
|
+
sections = {}
|
|
93
|
+
Hooks::Registry::EVENTS.each do |event, spec|
|
|
94
|
+
case spec[:mode]
|
|
95
|
+
when :rpc
|
|
96
|
+
sections[event.to_s] = reg.rpc_names(event).map(&:to_s).sort
|
|
97
|
+
when :pubsub
|
|
98
|
+
sections[event.to_s] = reg.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
sections
|
|
104
102
|
end
|
|
105
103
|
end
|
|
106
104
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Key
|
|
3
|
+
# Small utilities for ranking key suggestions. Bounded inputs only —
|
|
4
|
+
# Levenshtein is O(n*m) so we refuse to compute on long strings.
|
|
5
|
+
class Distance
|
|
6
|
+
MAX_LEN = 200
|
|
7
|
+
|
|
8
|
+
# Length of the shared dot-separated prefix between two dotted keys.
|
|
9
|
+
def self.shared_prefix_segments(left, right)
|
|
10
|
+
asegs = left.split(".")
|
|
11
|
+
bsegs = right.split(".")
|
|
12
|
+
n = [asegs.length, bsegs.length].min
|
|
13
|
+
i = 0
|
|
14
|
+
i += 1 while i < n && asegs[i] == bsegs[i]
|
|
15
|
+
i
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Classic iterative Levenshtein with two rows. Bounded to MAX_LEN.
|
|
19
|
+
def self.levenshtein(left, right)
|
|
20
|
+
return nil if left.length > MAX_LEN || right.length > MAX_LEN
|
|
21
|
+
return right.length if left.empty?
|
|
22
|
+
return left.length if right.empty?
|
|
23
|
+
|
|
24
|
+
prev = (0..right.length).to_a
|
|
25
|
+
curr = Array.new(right.length + 1, 0)
|
|
26
|
+
(1..left.length).each do |i|
|
|
27
|
+
curr[0] = i
|
|
28
|
+
(1..right.length).each do |j|
|
|
29
|
+
cost = left[i - 1] == right[j - 1] ? 0 : 1
|
|
30
|
+
curr[j] = [
|
|
31
|
+
curr[j - 1] + 1, # insertion
|
|
32
|
+
prev[j] + 1, # deletion
|
|
33
|
+
prev[j - 1] + cost, # substitution
|
|
34
|
+
].min
|
|
35
|
+
end
|
|
36
|
+
prev, curr = curr, prev
|
|
37
|
+
end
|
|
38
|
+
prev[right.length]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Rank candidate keys against requested. Returns up to `limit` keys.
|
|
42
|
+
# Sort: longer shared prefix first; then smaller Levenshtein distance.
|
|
43
|
+
def self.suggest(requested, candidates, limit: 5)
|
|
44
|
+
return [] if requested.nil? || requested.empty?
|
|
45
|
+
|
|
46
|
+
scored = candidates.first(200).map do |k|
|
|
47
|
+
prefix = shared_prefix_segments(requested, k)
|
|
48
|
+
dist = levenshtein(requested, k) || Float::INFINITY
|
|
49
|
+
[k, prefix, dist]
|
|
50
|
+
end
|
|
51
|
+
scored.sort_by { |(_, prefix, dist)| [-prefix, dist] }.first(limit).map(&:first)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Key
|
|
3
|
+
module Grammar
|
|
4
|
+
SEGMENT = /\A[a-z0-9][a-z0-9-]*\z/
|
|
5
|
+
MAX_SEGMENTS = 8
|
|
6
|
+
MAX_SEGMENT_LEN = 64
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def validate!(key) # rubocop:disable Naming/PredicateMethod
|
|
11
|
+
raise UsageError.new("key must be a String") unless key.is_a?(String)
|
|
12
|
+
raise UsageError.new("empty key") if key.empty?
|
|
13
|
+
|
|
14
|
+
segs = key.split(".")
|
|
15
|
+
raise UsageError.new("key '#{key}' has #{segs.length} segments (max #{MAX_SEGMENTS})") if segs.length > MAX_SEGMENTS
|
|
16
|
+
|
|
17
|
+
segs.each do |seg|
|
|
18
|
+
if seg.empty?
|
|
19
|
+
raise UsageError.new("empty segment in key '#{key}'")
|
|
20
|
+
elsif seg.length > MAX_SEGMENT_LEN
|
|
21
|
+
raise UsageError.new("segment '#{seg}' in key '#{key}' exceeds #{MAX_SEGMENT_LEN} chars")
|
|
22
|
+
elsif !seg.match?(SEGMENT)
|
|
23
|
+
raise UsageError.new(
|
|
24
|
+
"invalid key segment '#{seg}' in '#{key}': must match [a-z0-9][a-z0-9-]* " \
|
|
25
|
+
"(lowercase, digits, hyphens; no underscores or uppercase)",
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Key
|
|
3
|
+
module Path
|
|
4
|
+
# Returns the absolute filesystem path for a manifest entry (the leaf file,
|
|
5
|
+
# not a nested directory). Adds the format's primary extension when the
|
|
6
|
+
# manifest entry's `path:` is extensionless.
|
|
7
|
+
def self.resolve(manifest, mentry)
|
|
8
|
+
primary_ext = Entry.for_format(mentry.format).extensions.first
|
|
9
|
+
if File.extname(mentry.path) == ""
|
|
10
|
+
File.join(manifest.root, "zones", mentry.path + primary_ext)
|
|
11
|
+
else
|
|
12
|
+
File.join(manifest.root, "zones", mentry.path)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|