textus 0.18.0 → 0.20.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 +43 -48
- data/CHANGELOG.md +173 -0
- data/lib/textus/application/context.rb +20 -58
- data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
- data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
- data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
- data/lib/textus/application/projection.rb +91 -0
- data/lib/textus/application/reads/audit.rb +4 -4
- data/lib/textus/application/reads/blame.rb +9 -8
- data/lib/textus/application/reads/deps.rb +14 -3
- data/lib/textus/application/reads/freshness.rb +10 -8
- data/lib/textus/application/reads/get.rb +10 -8
- data/lib/textus/application/reads/get_or_refresh.rb +3 -3
- data/lib/textus/application/reads/list.rb +3 -3
- data/lib/textus/application/reads/policy_explain.rb +3 -3
- data/lib/textus/application/reads/published.rb +5 -3
- data/lib/textus/application/reads/rdeps.rb +15 -3
- data/lib/textus/application/reads/schema_envelope.rb +5 -4
- data/lib/textus/application/reads/stale.rb +3 -3
- data/lib/textus/application/reads/uid.rb +11 -3
- data/lib/textus/application/reads/validate_all.rb +10 -6
- data/lib/textus/application/reads/validator.rb +2 -2
- data/lib/textus/application/reads/where.rb +3 -3
- data/lib/textus/application/refresh/all.rb +15 -11
- data/lib/textus/application/refresh/orchestrator.rb +9 -8
- data/lib/textus/application/refresh/worker.rb +56 -32
- data/lib/textus/application/tools/migrate_keys.rb +191 -0
- data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
- data/lib/textus/application/writes/accept.rb +38 -15
- data/lib/textus/application/writes/delete.rb +13 -10
- data/lib/textus/application/writes/envelope_io.rb +64 -4
- data/lib/textus/application/writes/materializer.rb +50 -0
- data/lib/textus/application/writes/mv.rb +57 -94
- data/lib/textus/application/writes/publish.rb +132 -26
- data/lib/textus/application/writes/put.rb +15 -14
- data/lib/textus/application/writes/reject.rb +20 -11
- data/lib/textus/builder/pipeline.rb +21 -15
- data/lib/textus/builder/renderer/json.rb +4 -1
- data/lib/textus/builder/renderer/markdown.rb +7 -1
- data/lib/textus/builder/renderer/yaml.rb +4 -1
- data/lib/textus/cli/verb/build.rb +2 -5
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -4
- data/lib/textus/cli/verb/hooks.rb +5 -5
- data/lib/textus/cli/verb/key_normalize.rb +32 -3
- data/lib/textus/cli/verb/put.rb +2 -3
- data/lib/textus/cli/verb/refresh_stale.rb +1 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
- data/lib/textus/doctor/check/hooks.rb +2 -2
- data/lib/textus/doctor/check/illegal_keys.rb +6 -5
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +1 -1
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +4 -3
- data/lib/textus/doctor.rb +3 -4
- data/lib/textus/domain/authorizer.rb +37 -0
- data/lib/textus/domain/staleness/generator_check.rb +8 -7
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- data/lib/textus/hooks/builtin.rb +6 -6
- data/lib/textus/hooks/bus.rb +155 -0
- data/lib/textus/hooks/context.rb +38 -0
- data/lib/textus/hooks/fire_report.rb +23 -0
- data/lib/textus/hooks/loader.rb +3 -3
- data/lib/textus/infra/audit_subscriber.rb +4 -4
- data/lib/textus/infra/event_bus.rb +3 -3
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/init.rb +3 -2
- data/lib/textus/intro.rb +7 -7
- data/lib/textus/manifest/entry/base.rb +38 -0
- data/lib/textus/manifest/entry/derived.rb +25 -0
- data/lib/textus/manifest/entry/intake.rb +19 -0
- data/lib/textus/manifest/entry/leaf.rb +16 -0
- data/lib/textus/manifest/entry/nested.rb +39 -0
- data/lib/textus/manifest/entry/parser.rb +64 -31
- data/lib/textus/manifest/entry/validators/events.rb +3 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
- data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
- data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
- data/lib/textus/manifest/entry.rb +0 -72
- data/lib/textus/manifest/resolver.rb +109 -0
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/manifest.rb +3 -100
- data/lib/textus/operations.rb +131 -74
- data/lib/textus/schema/tools.rb +2 -2
- data/lib/textus/store.rb +6 -6
- data/lib/textus/version.rb +1 -1
- metadata +18 -11
- data/lib/textus/application/writes/build.rb +0 -78
- data/lib/textus/dependencies.rb +0 -23
- data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
- data/lib/textus/hooks/dispatcher.rb +0 -71
- data/lib/textus/hooks/registry.rb +0 -85
- data/lib/textus/migrate_keys.rb +0 -187
- data/lib/textus/projection.rb +0 -89
- data/lib/textus/refresh.rb +0 -39
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Infra
|
|
3
3
|
class EventBus
|
|
4
|
-
def initialize(
|
|
5
|
-
@
|
|
4
|
+
def initialize(bus:)
|
|
5
|
+
@bus = bus
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def publish(event, **payload)
|
|
9
|
-
@
|
|
9
|
+
@bus.pubsub_handlers(event).each do |entry|
|
|
10
10
|
next unless entry[:keys].nil? || matches?(entry[:keys], payload[:key])
|
|
11
11
|
|
|
12
12
|
entry[:callable].call(**payload)
|
|
@@ -21,7 +21,7 @@ module Textus
|
|
|
21
21
|
|
|
22
22
|
begin
|
|
23
23
|
store = Textus::Store.new(store_root)
|
|
24
|
-
Textus::
|
|
24
|
+
Textus::Operations.for(store, role: "runner").refresh(key)
|
|
25
25
|
rescue StandardError
|
|
26
26
|
# Already logged via :refresh_failed; exit cleanly.
|
|
27
27
|
ensure
|
data/lib/textus/init.rb
CHANGED
|
@@ -13,8 +13,8 @@ module Textus
|
|
|
13
13
|
- { name: review, write_policy: [agent, human], read_policy: [all] }
|
|
14
14
|
- { name: output, write_policy: [builder], read_policy: [all] }
|
|
15
15
|
entries:
|
|
16
|
-
- { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self }
|
|
17
|
-
- { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true }
|
|
16
|
+
- { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self, kind: leaf }
|
|
17
|
+
- { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true, kind: nested }
|
|
18
18
|
YAML
|
|
19
19
|
|
|
20
20
|
HOOKS_README = <<~MD
|
|
@@ -51,6 +51,7 @@ module Textus
|
|
|
51
51
|
```yaml
|
|
52
52
|
entries:
|
|
53
53
|
- key: intake.foo
|
|
54
|
+
kind: intake
|
|
54
55
|
path: intake/foo.md
|
|
55
56
|
zone: intake
|
|
56
57
|
intake:
|
data/lib/textus/intro.rb
CHANGED
|
@@ -135,26 +135,26 @@ module Textus
|
|
|
135
135
|
"key" => e.key,
|
|
136
136
|
"zone" => e.zone,
|
|
137
137
|
"schema" => e.schema,
|
|
138
|
-
"nested" => e.
|
|
138
|
+
"nested" => e.is_a?(Textus::Manifest::Entry::Nested),
|
|
139
139
|
"owner" => e.owner,
|
|
140
140
|
"format" => e.format,
|
|
141
141
|
"derived" => derived,
|
|
142
|
-
"intake" =>
|
|
142
|
+
"intake" => e.is_a?(Textus::Manifest::Entry::Intake),
|
|
143
143
|
"publish_to" => Array(e.publish_to),
|
|
144
|
-
"publish_each" => e.publish_each,
|
|
144
|
+
"publish_each" => e.respond_to?(:publish_each) ? e.publish_each : nil,
|
|
145
145
|
}
|
|
146
146
|
end
|
|
147
147
|
end
|
|
148
148
|
|
|
149
149
|
def self.hooks_for(store)
|
|
150
|
-
|
|
150
|
+
bus = store.bus
|
|
151
151
|
sections = {}
|
|
152
|
-
Hooks::
|
|
152
|
+
Hooks::Bus::EVENTS.each do |event, spec|
|
|
153
153
|
case spec[:mode]
|
|
154
154
|
when :rpc
|
|
155
|
-
sections[event.to_s] =
|
|
155
|
+
sections[event.to_s] = bus.rpc_names(event).map(&:to_s).sort
|
|
156
156
|
when :pubsub
|
|
157
|
-
sections[event.to_s] =
|
|
157
|
+
sections[event.to_s] = bus.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
|
|
158
158
|
end
|
|
159
159
|
end
|
|
160
160
|
sections
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
class Base < Entry
|
|
5
|
+
attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :manifest
|
|
6
|
+
|
|
7
|
+
# rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
|
|
8
|
+
def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:)
|
|
9
|
+
@manifest = manifest
|
|
10
|
+
@raw = raw
|
|
11
|
+
@key = key
|
|
12
|
+
@path = path
|
|
13
|
+
@zone = zone
|
|
14
|
+
@schema = schema
|
|
15
|
+
@owner = owner
|
|
16
|
+
@format = format
|
|
17
|
+
end
|
|
18
|
+
# rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
|
|
19
|
+
|
|
20
|
+
def kind = self.class.name.split("::").last.downcase.to_sym
|
|
21
|
+
|
|
22
|
+
def zone_writers
|
|
23
|
+
@manifest.zone_writers(@zone)
|
|
24
|
+
rescue UsageError => e
|
|
25
|
+
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def in_generator_zone? = zone_writers.include?("builder")
|
|
29
|
+
def in_proposal_zone? = zone_writers.include?("agent")
|
|
30
|
+
|
|
31
|
+
def nested? = false
|
|
32
|
+
def derived? = false
|
|
33
|
+
def intake? = false
|
|
34
|
+
def leaf? = false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
class Derived < Base
|
|
5
|
+
Projection = Data.define(:select, :pluck, :sort_by, :transform)
|
|
6
|
+
External = Data.define(:sources, :runner)
|
|
7
|
+
|
|
8
|
+
attr_reader :source, :template, :inject_intro, :publish_to, :events
|
|
9
|
+
|
|
10
|
+
def initialize(source:, template: nil, inject_intro: false, publish_to: [], events: {}, **rest)
|
|
11
|
+
super(**rest)
|
|
12
|
+
@source = source
|
|
13
|
+
@template = template
|
|
14
|
+
@inject_intro = inject_intro
|
|
15
|
+
@publish_to = Array(publish_to)
|
|
16
|
+
@events = events || {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def derived? = true
|
|
20
|
+
def projection? = @source.is_a?(Projection)
|
|
21
|
+
def external? = @source.is_a?(External)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
class Intake < Base
|
|
5
|
+
attr_reader :handler, :config, :events, :publish_to
|
|
6
|
+
|
|
7
|
+
def initialize(handler:, config: {}, events: {}, **rest)
|
|
8
|
+
super(**rest)
|
|
9
|
+
@handler = handler
|
|
10
|
+
@config = config || {}
|
|
11
|
+
@events = events || {}
|
|
12
|
+
@publish_to = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def intake? = true
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require_relative "validators/publish_each"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Manifest
|
|
5
|
+
class Entry
|
|
6
|
+
class Nested < Base
|
|
7
|
+
PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
|
|
8
|
+
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
9
|
+
|
|
10
|
+
attr_reader :index_filename, :publish_each, :publish_to
|
|
11
|
+
|
|
12
|
+
def initialize(index_filename: nil, publish_each: nil, publish_to: [], **rest)
|
|
13
|
+
super(**rest)
|
|
14
|
+
@index_filename = index_filename
|
|
15
|
+
@publish_each = publish_each
|
|
16
|
+
@publish_to = Array(publish_to)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def nested? = true
|
|
20
|
+
|
|
21
|
+
def publish_target_for(full_key)
|
|
22
|
+
return nil if @publish_each.nil?
|
|
23
|
+
|
|
24
|
+
entry_segs = @key.split(".")
|
|
25
|
+
key_segs = full_key.split(".")
|
|
26
|
+
raise UsageError.new("key '#{full_key}' is not under entry '#{@key}'") unless key_segs[0, entry_segs.length] == entry_segs
|
|
27
|
+
|
|
28
|
+
remaining = key_segs[entry_segs.length..] || []
|
|
29
|
+
leaf = remaining.join("/")
|
|
30
|
+
basename = remaining.last || ""
|
|
31
|
+
ext = Textus::Entry.for_format(@format).extensions.first.to_s.sub(/^\./, "")
|
|
32
|
+
|
|
33
|
+
vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
|
|
34
|
+
@publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -9,61 +9,94 @@ module Textus
|
|
|
9
9
|
path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
|
|
10
10
|
zone = raw["zone"] or raise UsageError.new("manifest entry '#{key}' missing zone")
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
format = resolve_format(raw, path, nested)
|
|
12
|
+
raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (leaf|nested|derived|intake)")
|
|
13
|
+
kind = raw_kind.to_sym
|
|
14
|
+
format = resolve_format(raw, path)
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
common = {
|
|
18
17
|
manifest: manifest, raw: raw,
|
|
19
18
|
key: key, path: path, zone: zone,
|
|
20
19
|
schema: raw["schema"], owner: raw["owner"],
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
format: format
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
case kind
|
|
24
|
+
when :leaf then build_leaf(common, raw)
|
|
25
|
+
when :nested then build_nested(common, raw)
|
|
26
|
+
when :derived then build_derived(common, raw, key)
|
|
27
|
+
when :intake then build_intake(common, raw, key)
|
|
28
|
+
else raise BadManifest.new("entry '#{key}': unknown kind: #{kind.inspect}")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.build_leaf(common, raw)
|
|
33
|
+
Leaf.new(publish_to: raw["publish_to"], **common)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.build_nested(common, raw)
|
|
37
|
+
Nested.new(
|
|
38
|
+
index_filename: raw["index_filename"],
|
|
24
39
|
publish_each: raw["publish_each"],
|
|
25
|
-
|
|
40
|
+
publish_to: raw["publish_to"],
|
|
41
|
+
**common,
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.build_derived(common, raw, key)
|
|
46
|
+
source = parse_source(raw, key)
|
|
47
|
+
Derived.new(
|
|
48
|
+
source: source,
|
|
49
|
+
template: raw["template"],
|
|
26
50
|
inject_intro: raw["inject_intro"] == true,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
intake_handler: intake_handler, intake_config: intake_config
|
|
51
|
+
publish_to: raw["publish_to"],
|
|
52
|
+
events: raw["events"] || {},
|
|
53
|
+
**common,
|
|
31
54
|
)
|
|
32
55
|
end
|
|
33
56
|
|
|
34
|
-
def self.
|
|
35
|
-
|
|
36
|
-
|
|
57
|
+
def self.build_intake(common, raw, key)
|
|
58
|
+
intake = raw["intake"] || {}
|
|
59
|
+
handler = intake["handler"] || raw["intake_handler"] or
|
|
60
|
+
raise UsageError.new("intake entry '#{key}' missing handler")
|
|
61
|
+
config = intake["config"] || raw["intake_config"] || {}
|
|
62
|
+
Intake.new(handler: handler, config: config, events: raw["events"] || {}, **common)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.parse_source(raw, key)
|
|
66
|
+
compute = raw["compute"]
|
|
67
|
+
if compute.nil?
|
|
68
|
+
# Tolerate legacy derived entries with bare template (no compute block):
|
|
69
|
+
# treat as projection with no select.
|
|
70
|
+
return Derived::Projection.new(select: nil, pluck: nil, sort_by: nil, transform: nil) if raw["template"]
|
|
37
71
|
|
|
38
|
-
|
|
39
|
-
|
|
72
|
+
raise BadManifest.new("derived entry '#{key}' requires compute: { kind: projection|external } or template:")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
unless COMPUTE_KINDS.include?(compute["kind"])
|
|
40
76
|
raise BadManifest.new(
|
|
41
|
-
"entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{kind.inspect})",
|
|
77
|
+
"entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{compute["kind"].inspect})",
|
|
42
78
|
)
|
|
43
79
|
end
|
|
44
80
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
81
|
+
if compute["kind"] == "projection"
|
|
82
|
+
Derived::Projection.new(
|
|
83
|
+
select: compute["select"],
|
|
84
|
+
pluck: compute["pluck"],
|
|
85
|
+
sort_by: compute["sort_by"],
|
|
86
|
+
transform: compute["transform"],
|
|
87
|
+
)
|
|
48
88
|
else
|
|
49
|
-
[
|
|
89
|
+
Derived::External.new(sources: compute["sources"], runner: compute["runner"])
|
|
50
90
|
end
|
|
51
91
|
end
|
|
52
92
|
|
|
53
|
-
def self.
|
|
54
|
-
src ||= {}
|
|
55
|
-
[src["handler"], src["config"] || {}]
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def self.resolve_format(raw, path, nested)
|
|
93
|
+
def self.resolve_format(raw, path)
|
|
59
94
|
declared = raw["format"]
|
|
60
95
|
ext = File.extname(path)
|
|
61
96
|
inferred = Textus::Entry.infer_from_extension(ext)
|
|
62
97
|
|
|
63
98
|
if declared.nil?
|
|
64
99
|
return inferred if inferred
|
|
65
|
-
return "markdown" if ext == "" && nested
|
|
66
|
-
return "markdown" if ext == ""
|
|
67
100
|
|
|
68
101
|
return "markdown"
|
|
69
102
|
end
|
|
@@ -4,8 +4,9 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module Events
|
|
6
6
|
def self.call(entry)
|
|
7
|
-
pubsub_events = Textus::Hooks::
|
|
8
|
-
entry.events.
|
|
7
|
+
pubsub_events = Textus::Hooks::Bus::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
|
|
8
|
+
events = entry.respond_to?(:events) ? entry.events : {}
|
|
9
|
+
events.each_key do |evt|
|
|
9
10
|
next if pubsub_events.include?(evt.to_sym)
|
|
10
11
|
|
|
11
12
|
raise UsageError.new(
|
|
@@ -5,7 +5,7 @@ module Textus
|
|
|
5
5
|
module FormatMatrix
|
|
6
6
|
def self.call(entry)
|
|
7
7
|
begin
|
|
8
|
-
Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested)
|
|
8
|
+
Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested?)
|
|
9
9
|
rescue UsageError => e
|
|
10
10
|
raise UsageError.new("entry '#{entry.key}': #{e.message}")
|
|
11
11
|
end
|
|
@@ -14,8 +14,10 @@ module Textus
|
|
|
14
14
|
raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
has_template = entry.respond_to?(:template) && !entry.template.nil?
|
|
18
|
+
is_external = entry.derived? && entry.external?
|
|
19
|
+
return unless entry.in_generator_zone? && !has_template && !is_external &&
|
|
20
|
+
%w[markdown text].include?(entry.format) && !entry.nested?
|
|
19
21
|
|
|
20
22
|
raise UsageError.new("entry '#{entry.key}': derived #{entry.format} entries require a template")
|
|
21
23
|
end
|
|
@@ -4,31 +4,32 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module IndexFilename
|
|
6
6
|
def self.call(entry)
|
|
7
|
-
|
|
7
|
+
index_filename = entry.respond_to?(:index_filename) ? entry.index_filename : entry.raw["index_filename"]
|
|
8
|
+
return if index_filename.nil?
|
|
8
9
|
|
|
9
|
-
check_shape!(entry)
|
|
10
|
-
check_extension!(entry)
|
|
10
|
+
check_shape!(entry, index_filename)
|
|
11
|
+
check_extension!(entry, index_filename)
|
|
11
12
|
end
|
|
12
13
|
|
|
13
|
-
def self.check_shape!(entry)
|
|
14
|
-
raise UsageError.new("entry '#{entry.key}': index_filename requires nested: true") unless entry.nested
|
|
14
|
+
def self.check_shape!(entry, index_filename)
|
|
15
|
+
raise UsageError.new("entry '#{entry.key}': index_filename requires nested: true") unless entry.nested?
|
|
15
16
|
|
|
16
|
-
unless
|
|
17
|
+
unless index_filename.is_a?(String) && !index_filename.empty?
|
|
17
18
|
raise UsageError.new("entry '#{entry.key}': index_filename must be a non-empty string")
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
return unless
|
|
21
|
+
return unless index_filename.include?("/") || File.basename(index_filename) != index_filename
|
|
21
22
|
|
|
22
23
|
raise UsageError.new("entry '#{entry.key}': index_filename must be a bare basename (no slashes)")
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
def self.check_extension!(entry)
|
|
26
|
-
ext = File.extname(
|
|
26
|
+
def self.check_extension!(entry, index_filename)
|
|
27
|
+
ext = File.extname(index_filename)
|
|
27
28
|
inferred = Textus::Entry.infer_from_extension(ext)
|
|
28
29
|
|
|
29
30
|
if inferred.nil?
|
|
30
31
|
raise UsageError.new(
|
|
31
|
-
"entry '#{entry.key}': index_filename #{
|
|
32
|
+
"entry '#{entry.key}': index_filename #{index_filename.inspect} has unknown extension #{ext.inspect}",
|
|
32
33
|
)
|
|
33
34
|
end
|
|
34
35
|
return if inferred == entry.format
|
|
@@ -4,10 +4,13 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module InjectIntro
|
|
6
6
|
def self.call(entry)
|
|
7
|
-
|
|
7
|
+
inject_intro = entry.respond_to?(:inject_intro) ? entry.inject_intro : false
|
|
8
|
+
return unless inject_intro
|
|
8
9
|
|
|
9
10
|
raise UsageError.new("entry '#{entry.key}': inject_intro: is only valid on derived entries") unless entry.in_generator_zone?
|
|
10
|
-
|
|
11
|
+
|
|
12
|
+
has_template = entry.respond_to?(:template) && !entry.template.nil?
|
|
13
|
+
return if has_template
|
|
11
14
|
|
|
12
15
|
raise UsageError.new("entry '#{entry.key}': inject_intro: requires a template:")
|
|
13
16
|
end
|
|
@@ -7,14 +7,17 @@ module Textus
|
|
|
7
7
|
VAR_RE = /\{([a-z]+)\}/
|
|
8
8
|
REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
|
|
9
9
|
|
|
10
|
-
def self.call(entry)
|
|
11
|
-
|
|
10
|
+
def self.call(entry) # rubocop:disable Metrics/AbcSize
|
|
11
|
+
publish_each = entry.respond_to?(:publish_each) ? entry.publish_each : entry.raw["publish_each"]
|
|
12
|
+
return if publish_each.nil?
|
|
12
13
|
|
|
13
|
-
raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested
|
|
14
|
-
raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless entry.publish_to.empty?
|
|
15
|
-
raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless entry.publish_each.is_a?(String)
|
|
14
|
+
raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested?
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
publish_to = entry.respond_to?(:publish_to) ? entry.publish_to : Array(entry.raw["publish_to"])
|
|
17
|
+
raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless publish_to.empty?
|
|
18
|
+
raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless publish_each.is_a?(String)
|
|
19
|
+
|
|
20
|
+
used_vars = publish_each.scan(VAR_RE).flatten
|
|
18
21
|
unknown = used_vars - KNOWN_VARS
|
|
19
22
|
unless unknown.empty?
|
|
20
23
|
raise UsageError.new(
|
|
@@ -5,78 +5,6 @@ module Textus
|
|
|
5
5
|
# constants on Entry. Canonical source is the PublishEach validator.
|
|
6
6
|
PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
|
|
7
7
|
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
8
|
-
|
|
9
|
-
attr_reader :raw, :key, :path, :zone, :schema, :owner, :nested,
|
|
10
|
-
:template, :publish_to, :publish_each,
|
|
11
|
-
:events, :inject_intro, :index_filename, :format,
|
|
12
|
-
:compute, :projection, :generator,
|
|
13
|
-
:intake_handler, :intake_config
|
|
14
|
-
|
|
15
|
-
# rubocop:disable Metrics/ParameterLists
|
|
16
|
-
def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, nested:,
|
|
17
|
-
template:, publish_to:, publish_each:, events:, inject_intro:,
|
|
18
|
-
index_filename:, format:, compute:, projection:, generator:,
|
|
19
|
-
intake_handler:, intake_config:)
|
|
20
|
-
@manifest = manifest
|
|
21
|
-
@raw = raw
|
|
22
|
-
@key = key
|
|
23
|
-
@path = path
|
|
24
|
-
@zone = zone
|
|
25
|
-
@schema = schema
|
|
26
|
-
@owner = owner
|
|
27
|
-
@nested = nested
|
|
28
|
-
@template = template
|
|
29
|
-
@publish_to = publish_to
|
|
30
|
-
@publish_each = publish_each
|
|
31
|
-
@events = events
|
|
32
|
-
@inject_intro = inject_intro
|
|
33
|
-
@index_filename = index_filename
|
|
34
|
-
@format = format
|
|
35
|
-
@compute = compute
|
|
36
|
-
@projection = projection
|
|
37
|
-
@generator = generator
|
|
38
|
-
@intake_handler = intake_handler
|
|
39
|
-
@intake_config = intake_config
|
|
40
|
-
end
|
|
41
|
-
# rubocop:enable Metrics/ParameterLists
|
|
42
|
-
|
|
43
|
-
# Resolves the per-leaf target path (relative to repo root) for a full
|
|
44
|
-
# dotted key under this entry's prefix. Returns nil if this entry has no
|
|
45
|
-
# publish_each template.
|
|
46
|
-
def publish_target_for(full_key)
|
|
47
|
-
return nil if @publish_each.nil?
|
|
48
|
-
|
|
49
|
-
entry_segs = @key.split(".")
|
|
50
|
-
key_segs = full_key.split(".")
|
|
51
|
-
raise UsageError.new("key '#{full_key}' is not under entry '#{@key}'") unless key_segs[0, entry_segs.length] == entry_segs
|
|
52
|
-
|
|
53
|
-
remaining = key_segs[entry_segs.length..] || []
|
|
54
|
-
leaf = remaining.join("/")
|
|
55
|
-
basename = remaining.last || ""
|
|
56
|
-
ext = Textus::Entry.for_format(@format).extensions.first.to_s.sub(/^\./, "")
|
|
57
|
-
|
|
58
|
-
vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
|
|
59
|
-
@publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Signal-based zone-kind predicates: derive the "kind" of a zone from its
|
|
63
|
-
# write_policy signals rather than its literal name, so detection keeps
|
|
64
|
-
# working when users rename the default zones.
|
|
65
|
-
def in_generator_zone?
|
|
66
|
-
zone_writers.include?("builder")
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def in_proposal_zone?
|
|
70
|
-
zone_writers.include?("agent")
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
private
|
|
74
|
-
|
|
75
|
-
def zone_writers
|
|
76
|
-
@manifest.zone_writers(@zone)
|
|
77
|
-
rescue UsageError => e
|
|
78
|
-
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
79
|
-
end
|
|
80
8
|
end
|
|
81
9
|
end
|
|
82
10
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Resolver
|
|
4
|
+
def initialize(manifest)
|
|
5
|
+
@manifest = manifest
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def resolve(key)
|
|
9
|
+
@manifest.validate_key!(key)
|
|
10
|
+
segments = key.split(".")
|
|
11
|
+
candidates = @manifest.entries
|
|
12
|
+
.map { |e| [e, e.key.split(".")] }
|
|
13
|
+
.select { |(_, esegs)| esegs == segments[0, esegs.length] }
|
|
14
|
+
.sort_by { |(_, esegs)| -esegs.length }
|
|
15
|
+
raise UnknownKey.new(key, suggestions: suggestions_for(key)) if candidates.empty?
|
|
16
|
+
|
|
17
|
+
entry, esegs = candidates.first
|
|
18
|
+
remaining = segments[esegs.length..]
|
|
19
|
+
build_resolution(entry, remaining, key)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def suggestions_for(key)
|
|
23
|
+
candidates = enumerate.map { |r| r[:key] }
|
|
24
|
+
candidates.concat(@manifest.entries.reject { |e| nested_entry?(e) }.map(&:key))
|
|
25
|
+
candidates.uniq!
|
|
26
|
+
Key::Distance.suggest(key, candidates, limit: 5)
|
|
27
|
+
rescue StandardError
|
|
28
|
+
[]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def enumerate(prefix: nil)
|
|
32
|
+
out = @manifest.entries.flat_map { |entry| nested_entry?(entry) ? enumerate_nested(entry) : enumerate_leaf(entry) }
|
|
33
|
+
out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
|
|
34
|
+
out.sort_by { |row| row[:key] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Returns true for entries that behave as nested (Nested subclass, or any
|
|
40
|
+
# entry with nested: true in the raw YAML — e.g. Intake entries covering
|
|
41
|
+
# a directory of leaf files).
|
|
42
|
+
def nested_entry?(entry)
|
|
43
|
+
entry.is_a?(Textus::Manifest::Entry::Nested) || entry.raw["nested"] == true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_resolution(entry, remaining, key)
|
|
47
|
+
if remaining.empty?
|
|
48
|
+
Resolution.new(entry: entry, path: resolve_leaf_path(entry), remaining: [])
|
|
49
|
+
else
|
|
50
|
+
raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless nested_entry?(entry)
|
|
51
|
+
|
|
52
|
+
index_fn = entry.respond_to?(:index_filename) ? entry.index_filename : nil
|
|
53
|
+
path = if index_fn
|
|
54
|
+
File.join(@manifest.root, "zones", entry.path, *remaining, index_fn)
|
|
55
|
+
else
|
|
56
|
+
primary_ext = Textus::Entry.for_format(entry.format).extensions.first
|
|
57
|
+
File.join(@manifest.root, "zones", entry.path, *remaining) + primary_ext
|
|
58
|
+
end
|
|
59
|
+
Resolution.new(entry: entry, path: path, remaining: remaining)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def enumerate_leaf(entry)
|
|
64
|
+
fp = resolve_leaf_path(entry)
|
|
65
|
+
File.exist?(fp) ? [{ key: entry.key, path: fp, manifest_entry: entry }] : []
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def enumerate_nested(entry)
|
|
69
|
+
base = File.join(@manifest.root, "zones", entry.path)
|
|
70
|
+
return [] unless File.directory?(base)
|
|
71
|
+
|
|
72
|
+
entry_index_filename = entry.respond_to?(:index_filename) ? entry.index_filename : nil
|
|
73
|
+
glob_pattern = entry_index_filename ? "**/#{entry_index_filename}" : nested_glob(entry.format)
|
|
74
|
+
Dir.glob(File.join(base, glob_pattern)).filter_map { |path| nested_row_for(entry, base, path) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def nested_row_for(entry, base, path)
|
|
78
|
+
rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
79
|
+
entry_if = entry.respond_to?(:index_filename) ? entry.index_filename : nil
|
|
80
|
+
stripped = entry_if ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
81
|
+
segs = stripped.split("/").reject { |s| s.empty? || s == "." }
|
|
82
|
+
return nil if segs.empty?
|
|
83
|
+
|
|
84
|
+
illegal = segs.find { |s| !valid_segment?(s) }
|
|
85
|
+
if illegal
|
|
86
|
+
warn("textus: skipping illegal key segment '#{illegal}' at #{path} — run 'textus key normalize --dry-run'")
|
|
87
|
+
return nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
{ key: (entry.key.split(".") + segs).join("."), path: path, manifest_entry: entry }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def valid_segment?(seg)
|
|
94
|
+
return false if seg.nil? || seg.empty?
|
|
95
|
+
return false if seg.length > Key::Grammar::MAX_SEGMENT_LEN
|
|
96
|
+
|
|
97
|
+
seg.match?(Key::Grammar::SEGMENT)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def resolve_leaf_path(entry)
|
|
101
|
+
Textus::Key::Path.resolve(@manifest, entry)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def nested_glob(format)
|
|
105
|
+
Textus::Entry.for_format(format).nested_glob
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|