textus 0.50.0 → 0.51.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 +26 -0
- data/README.md +41 -43
- data/SPEC.md +174 -176
- data/docs/architecture/README.md +46 -42
- data/docs/reference/conventions.md +31 -26
- data/lib/textus/boot.rb +13 -17
- data/lib/textus/call.rb +1 -1
- data/lib/textus/cli/runner.rb +15 -10
- data/lib/textus/cli/verb/get.rb +1 -3
- data/lib/textus/cli/verb/hook_run.rb +1 -1
- data/lib/textus/cli/verb/put.rb +4 -20
- data/lib/textus/cli.rb +1 -3
- data/lib/textus/dispatcher.rb +1 -3
- data/lib/textus/doctor/check/generator_drift.rb +4 -3
- data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +13 -11
- data/lib/textus/doctor.rb +0 -2
- data/lib/textus/domain/freshness/evaluator.rb +150 -14
- data/lib/textus/domain/freshness/verdict.rb +28 -6
- data/lib/textus/domain/freshness.rb +4 -33
- data/lib/textus/domain/policy/base_guards.rb +1 -1
- data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
- data/lib/textus/domain/policy/publish_target.rb +34 -0
- data/lib/textus/domain/policy/retention.rb +29 -0
- data/lib/textus/domain/policy/source.rb +79 -0
- data/lib/textus/domain/retention/sweep.rb +57 -0
- data/lib/textus/domain/retention.rb +11 -0
- data/lib/textus/errors.rb +4 -4
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/catalog.rb +8 -7
- data/lib/textus/hooks/context.rb +5 -10
- data/lib/textus/init/templates/machine_intake.rb +4 -4
- data/lib/textus/init.rb +47 -47
- data/lib/textus/key/matching.rb +24 -0
- data/lib/textus/maintenance/reconcile.rb +160 -0
- data/lib/textus/manifest/capabilities.rb +1 -1
- data/lib/textus/manifest/data.rb +2 -2
- data/lib/textus/manifest/entry/base.rb +28 -9
- data/lib/textus/manifest/entry/nested.rb +3 -4
- data/lib/textus/manifest/entry/parser.rb +25 -21
- data/lib/textus/manifest/entry/produced.rb +56 -0
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
- data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
- data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
- data/lib/textus/manifest/entry/validators/publish.rb +3 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/policy.rb +16 -4
- data/lib/textus/manifest/resolver.rb +10 -4
- data/lib/textus/manifest/rules.rb +37 -36
- data/lib/textus/manifest/schema/keys.rb +98 -0
- data/lib/textus/manifest/schema/validator.rb +324 -0
- data/lib/textus/manifest/schema/vocabulary.rb +24 -0
- data/lib/textus/manifest/schema.rb +27 -247
- data/lib/textus/manifest.rb +5 -3
- data/lib/textus/mcp/server.rb +1 -1
- data/lib/textus/ports/audit_log.rb +6 -0
- data/lib/textus/ports/build_lock.rb +6 -0
- data/lib/textus/ports/clock.rb +4 -3
- data/lib/textus/ports/produce_on_write_subscriber.rb +69 -0
- data/lib/textus/ports/publisher.rb +11 -7
- data/lib/textus/produce/acquire/handler.rb +29 -0
- data/lib/textus/produce/acquire/intake.rb +130 -0
- data/lib/textus/produce/acquire/projection.rb +127 -0
- data/lib/textus/produce/acquire/serializer/json.rb +31 -0
- data/lib/textus/produce/acquire/serializer/text.rb +16 -0
- data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
- data/lib/textus/produce/acquire/serializer.rb +17 -0
- data/lib/textus/produce/engine.rb +143 -0
- data/lib/textus/produce/events.rb +36 -0
- data/lib/textus/produce/render.rb +23 -0
- data/lib/textus/projection.rb +17 -6
- data/lib/textus/read/deps.rb +3 -3
- data/lib/textus/read/freshness.rb +61 -31
- data/lib/textus/read/get.rb +20 -102
- data/lib/textus/read/rdeps.rb +3 -3
- data/lib/textus/read/rule_explain.rb +41 -23
- data/lib/textus/read/rule_list.rb +25 -8
- data/lib/textus/read/validate_all.rb +14 -0
- data/lib/textus/role.rb +2 -1
- data/lib/textus/schemas.rb +8 -0
- data/lib/textus/store.rb +1 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/put.rb +1 -1
- metadata +23 -30
- data/lib/textus/builder/pipeline.rb +0 -88
- data/lib/textus/builder/renderer/json.rb +0 -45
- data/lib/textus/builder/renderer/markdown.rb +0 -24
- data/lib/textus/builder/renderer/text.rb +0 -14
- data/lib/textus/builder/renderer/yaml.rb +0 -45
- data/lib/textus/builder/renderer.rb +0 -17
- data/lib/textus/cli/verb/boot.rb +0 -14
- data/lib/textus/cli/verb/build.rb +0 -15
- data/lib/textus/doctor/check/fetch_locks.rb +0 -49
- data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
- data/lib/textus/domain/freshness/policy.rb +0 -18
- data/lib/textus/domain/lifecycle.rb +0 -83
- data/lib/textus/domain/outcome.rb +0 -10
- data/lib/textus/domain/policy/lifecycle.rb +0 -35
- data/lib/textus/domain/staleness/generator_check.rb +0 -109
- data/lib/textus/domain/staleness.rb +0 -29
- data/lib/textus/maintenance/tend.rb +0 -110
- data/lib/textus/manifest/entry/derived.rb +0 -67
- data/lib/textus/manifest/entry/intake.rb +0 -31
- data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
- data/lib/textus/mcp/tools.rb +0 -14
- data/lib/textus/ports/fetch/detached.rb +0 -52
- data/lib/textus/ports/fetch/lock.rb +0 -44
- data/lib/textus/write/build.rb +0 -90
- data/lib/textus/write/fetch_events.rb +0 -42
- data/lib/textus/write/fetch_orchestrator.rb +0 -101
- data/lib/textus/write/fetch_worker.rb +0 -127
- data/lib/textus/write/intake_fetch.rb +0 -25
- data/lib/textus/write/materializer.rb +0 -51
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
# A produced entry (ADR 0095) — anything with a `source:`. The produce
|
|
5
|
+
# method (intake/derived/external) is read from source.from; there is no
|
|
6
|
+
# separate kind for it. Merges the former Derived + Intake classes.
|
|
7
|
+
class Produced < Base
|
|
8
|
+
attr_reader :source, :events
|
|
9
|
+
|
|
10
|
+
def initialize(source:, events: {}, **rest)
|
|
11
|
+
super(**rest)
|
|
12
|
+
@source = source
|
|
13
|
+
@events = events || {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def intake? = @source.kind == :intake
|
|
17
|
+
def derived? = @source.kind == :derived
|
|
18
|
+
def external? = @source.external?
|
|
19
|
+
def projection? = @source.projection?
|
|
20
|
+
def nested? = !!@raw["nested"]
|
|
21
|
+
def handler = @source.handler
|
|
22
|
+
def config = @source.config
|
|
23
|
+
|
|
24
|
+
KIND = :produced
|
|
25
|
+
|
|
26
|
+
# ADR 0094/0095: projection (from: project) sources build their DATA
|
|
27
|
+
# artifact here, then publish via the ONE shared mode (Publish::ToPaths).
|
|
28
|
+
# Intake bytes come from Produce::Acquire::Intake and command (external) bytes from the
|
|
29
|
+
# out-of-band runner — neither builds, but both still publish their
|
|
30
|
+
# existing store bytes through the same mode. A projection entry with no
|
|
31
|
+
# targets is a terminal data node: it produced data, so report :built
|
|
32
|
+
# even though nothing was emitted.
|
|
33
|
+
def publish_via(pctx, prefix: nil)
|
|
34
|
+
built = false
|
|
35
|
+
if projection?
|
|
36
|
+
Textus::Produce::Acquire::Projection.new(container: pctx.container, call: pctx.call).run(self)
|
|
37
|
+
built = true
|
|
38
|
+
pctx.emit(:entry_produced, key: @key, envelope: pctx.reader.call(@key), sources: Array(@source.select).compact)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
emitted = publish_mode.publish(pctx, prefix: prefix)
|
|
42
|
+
return emitted if emitted
|
|
43
|
+
return nil unless built
|
|
44
|
+
|
|
45
|
+
{ kind: :built, value: { "key" => @key, "path" => Key::Path.resolve(pctx.manifest.data, self), "published_to" => [] } }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.from_raw(common, raw)
|
|
49
|
+
new(source: Parser.parse_source(raw, common[:key]), events: raw["events"] || {}, **common)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Entry::REGISTRY[KIND] = self
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -9,9 +9,10 @@ module Textus
|
|
|
9
9
|
# shared shape — Tree always walks at `base` and honors `ignore` in the
|
|
10
10
|
# prune (ADR 0047 D4, so a derived index in the mirrored dir survives).
|
|
11
11
|
class SubtreeMirror
|
|
12
|
-
def initialize(entry, pctx)
|
|
13
|
-
@entry
|
|
14
|
-
@pctx
|
|
12
|
+
def initialize(entry, pctx, publisher: Textus::Ports::Publisher.new)
|
|
13
|
+
@entry = entry
|
|
14
|
+
@pctx = pctx
|
|
15
|
+
@publisher = publisher
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
# base: store dir the entry owns — the root `ignored?` globs are
|
|
@@ -40,8 +41,8 @@ module Textus
|
|
|
40
41
|
next nil if @entry.ignored?(relative(src, base))
|
|
41
42
|
|
|
42
43
|
dst = File.join(target_dir, relative(src, walk_root))
|
|
43
|
-
|
|
44
|
-
@pctx.emit(:
|
|
44
|
+
@publisher.publish(source: src, target: dst, store_root: @pctx.root)
|
|
45
|
+
@pctx.emit(:entry_published, key: key, envelope: envelope, source: src, target: dst)
|
|
45
46
|
{ "key" => key, "source" => src, "target" => dst }
|
|
46
47
|
end
|
|
47
48
|
end
|
|
@@ -57,7 +58,7 @@ module Textus
|
|
|
57
58
|
next nil if kept.include?(abs)
|
|
58
59
|
next nil if honor_ignore && @entry.ignored?(relative(abs, target_dir))
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
@publisher.unpublish(target: managed, store_root: @pctx.root)
|
|
61
62
|
managed
|
|
62
63
|
end
|
|
63
64
|
end
|
|
@@ -1,24 +1,75 @@
|
|
|
1
|
+
require "tempfile"
|
|
2
|
+
|
|
1
3
|
module Textus
|
|
2
4
|
class Manifest
|
|
3
5
|
class Entry
|
|
4
6
|
module Publish
|
|
5
|
-
# publish.to: copy the entry's
|
|
6
|
-
# The behaviour of any entry that declares `publish: { to:
|
|
7
|
+
# publish.to: render or copy the entry's stored data to each fixed repo path.
|
|
8
|
+
# The behaviour of any entry that declares `publish: [{ to: ... }, ...]`.
|
|
9
|
+
# ADR 0094: iterates publish_targets (to-targets), rendering through a
|
|
10
|
+
# template when the target declares one, or copying verbatim otherwise.
|
|
7
11
|
class ToPaths < Mode
|
|
8
|
-
def
|
|
9
|
-
|
|
12
|
+
def initialize(entry, publisher: Textus::Ports::Publisher.new)
|
|
13
|
+
super(entry)
|
|
14
|
+
@publisher = publisher
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument,Metrics/AbcSize
|
|
18
|
+
targets = entry.publish_targets.select(&:to_target?)
|
|
10
19
|
return nil if targets.empty?
|
|
11
20
|
|
|
12
|
-
|
|
13
|
-
envelope
|
|
21
|
+
data_path = pctx.manifest.resolver.resolve(entry.key).path
|
|
22
|
+
envelope = pctx.reader.call(entry.key)
|
|
23
|
+
renderer = Textus::Produce::Render.new(template_loader: ->(n) { pctx.read_template(n) })
|
|
24
|
+
content = nil # parsed lazily; the data's `content` (always _meta-free)
|
|
14
25
|
|
|
15
|
-
targets.each do |
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
targets.each do |t|
|
|
27
|
+
if t.renders?
|
|
28
|
+
content ||= Textus::Entry.for_format(entry.format).parse(File.read(data_path), path: data_path)["content"]
|
|
29
|
+
publish_bytes(render_bytes(t, content, renderer, pctx), entry.key, t, pctx, data_path, envelope)
|
|
30
|
+
elsif strip_meta?(entry)
|
|
31
|
+
content ||= Textus::Entry.for_format(entry.format).parse(File.read(data_path), path: data_path)["content"]
|
|
32
|
+
bytes = Textus::Entry.for_format(entry.format).serialize(meta: {}, body: "", content: content)
|
|
33
|
+
publish_bytes(bytes, entry.key, t, pctx, data_path, envelope)
|
|
34
|
+
else
|
|
35
|
+
# opaque / command / non-structured — publish the stored file as-is
|
|
36
|
+
target_abs = File.join(pctx.repo_root, t.to)
|
|
37
|
+
@publisher.publish(source: data_path, target: target_abs, store_root: pctx.root)
|
|
38
|
+
pctx.emit(:entry_published, key: entry.key, envelope: envelope, source: data_path, target: target_abs)
|
|
39
|
+
end
|
|
19
40
|
end
|
|
20
41
|
|
|
21
|
-
{ kind: :built, value: { "key" => entry.key, "path" =>
|
|
42
|
+
{ kind: :built, value: { "key" => entry.key, "path" => data_path, "published_to" => targets.map(&:to) } }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# A structured-data entry that textus owns: its `_meta` stays in the
|
|
48
|
+
# store, so the published file is the re-serialized meta-free content.
|
|
49
|
+
# An external (command) entry is opaque — never parse/re-serialize it.
|
|
50
|
+
def strip_meta?(entry)
|
|
51
|
+
!entry.external? && %w[json yaml].include?(entry.format.to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def render_bytes(target, content, renderer, pctx)
|
|
55
|
+
boot = target.inject_boot ? Textus::Boot.build(container: pctx.container) : nil
|
|
56
|
+
renderer.bytes_for(target: target, data: content, boot: boot)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Write bytes to a system temp, publish (recording the persistent data
|
|
60
|
+
# file as the sentinel source), then remove the temp — the store is
|
|
61
|
+
# never polluted with render artifacts.
|
|
62
|
+
def publish_bytes(bytes, key, target, pctx, data_path, envelope)
|
|
63
|
+
target_abs = File.join(pctx.repo_root, target.to)
|
|
64
|
+
Tempfile.create(["textus-publish", File.extname(target.to)]) do |f|
|
|
65
|
+
f.binmode
|
|
66
|
+
f.write(bytes)
|
|
67
|
+
f.flush
|
|
68
|
+
@publisher.publish(
|
|
69
|
+
source: f.path, target: target_abs, store_root: pctx.root, provenance_source: data_path,
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
pctx.emit(:entry_published, key: key, envelope: envelope, source: data_path, target: target_abs)
|
|
22
73
|
end
|
|
23
74
|
end
|
|
24
75
|
end
|
|
@@ -3,24 +3,16 @@ module Textus
|
|
|
3
3
|
class Entry
|
|
4
4
|
module Validators
|
|
5
5
|
module FormatMatrix
|
|
6
|
-
def self.call(entry, policy:)
|
|
6
|
+
def self.call(entry, policy:) # rubocop:disable Lint/UnusedMethodArgument
|
|
7
7
|
begin
|
|
8
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
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
has_template = !entry.template.nil?
|
|
18
|
-
is_external = entry.derived? && entry.external?
|
|
19
|
-
is_intake = entry.intake?
|
|
20
|
-
return unless entry.in_generator_zone?(policy) && !has_template && !is_external && !is_intake &&
|
|
21
|
-
%w[markdown text].include?(entry.format) && !entry.nested?
|
|
13
|
+
return unless entry.format == "text" && !entry.schema.nil?
|
|
22
14
|
|
|
23
|
-
raise UsageError.new("entry '#{entry.key}':
|
|
15
|
+
raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
|
|
24
16
|
end
|
|
25
17
|
end
|
|
26
18
|
end
|
|
@@ -14,7 +14,9 @@ module Textus
|
|
|
14
14
|
module Publish
|
|
15
15
|
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
16
16
|
unless entry.nested?
|
|
17
|
-
|
|
17
|
+
# ADR 0094: publish: is now a list; use publish_tree (derived reader)
|
|
18
|
+
# rather than raw.dig("publish", "tree") which breaks on an Array.
|
|
19
|
+
raise UsageError.new("entry '#{entry.key}': publish.tree requires nested: true") if entry.publish_tree
|
|
18
20
|
|
|
19
21
|
return
|
|
20
22
|
end
|
|
@@ -7,7 +7,7 @@ module Textus
|
|
|
7
7
|
# (Schema::KIND_REQUIRES_VERB) and a role may write a zone iff its caps
|
|
8
8
|
# include that verb (verb_for_zone, roles_with_capability). Derived /
|
|
9
9
|
# proposal-queue status is authoritative via the declared-kind family
|
|
10
|
-
# (declared_kind,
|
|
10
|
+
# (declared_kind, derived_entry?, queue_zone?, queue_zone).
|
|
11
11
|
class Policy
|
|
12
12
|
def initialize(data)
|
|
13
13
|
@data = data
|
|
@@ -72,9 +72,21 @@ module Textus
|
|
|
72
72
|
@data.declared_zone_kinds.key(:queue)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
# ADR 0091: derived-ness is a property of the ENTRY, not its zone (one
|
|
76
|
+
# machine zone holds both intake and derived entries). Resolve the entry
|
|
77
|
+
# and ask it directly. Returns false if entries are not yet built
|
|
78
|
+
# (validator phase during Data#initialize) — validators must not rely on
|
|
79
|
+
# cross-entry state during construction.
|
|
80
|
+
def derived_entry?(key)
|
|
81
|
+
return false if @data.entries.nil?
|
|
82
|
+
|
|
83
|
+
entry = @data.entries.find { |e| e.key == key } or return false
|
|
84
|
+
entry.derived?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The single zone declaring kind: machine, or nil.
|
|
88
|
+
def machine_zone
|
|
89
|
+
@data.declared_zone_kinds.key(:machine)
|
|
78
90
|
end
|
|
79
91
|
|
|
80
92
|
# A zone is a proposal queue iff it declares kind: queue.
|
|
@@ -30,8 +30,10 @@ module Textus
|
|
|
30
30
|
[]
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def enumerate(prefix: nil)
|
|
34
|
-
out = @data.entries.flat_map
|
|
33
|
+
def enumerate(prefix: nil, include_keyless: false)
|
|
34
|
+
out = @data.entries.flat_map do |entry|
|
|
35
|
+
nested_entry?(entry) ? enumerate_nested(entry, include_keyless: include_keyless) : enumerate_leaf(entry)
|
|
36
|
+
end
|
|
35
37
|
out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
|
|
36
38
|
out.sort_by { |row| row[:key] }
|
|
37
39
|
end
|
|
@@ -62,10 +64,14 @@ module Textus
|
|
|
62
64
|
File.exist?(fp) ? [{ key: entry.key, path: fp, manifest_entry: entry }] : []
|
|
63
65
|
end
|
|
64
66
|
|
|
65
|
-
def enumerate_nested(entry)
|
|
67
|
+
def enumerate_nested(entry, include_keyless: false)
|
|
66
68
|
# publish_tree mirrors opaque payload by path — its files are never
|
|
67
69
|
# enumerated as keys (ADR 0047). Ask the resolved mode, not the path.
|
|
68
|
-
|
|
70
|
+
# The `include_keyless:` override is used only by the projection lister
|
|
71
|
+
# so that `from: project` selects can read source data from keyless
|
|
72
|
+
# nested entries (e.g. knowledge.decisions) without exposing them as
|
|
73
|
+
# addressable store keys in the public `list` surface.
|
|
74
|
+
return [] if entry.publish_mode.keyless? && !include_keyless
|
|
69
75
|
|
|
70
76
|
base = File.join(@data.root, "zones", entry.path)
|
|
71
77
|
return [] unless File.directory?(base)
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
class Rules
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# Every structural member here derives from Schema::FIELD_REGISTRY (WS3),
|
|
5
|
+
# so a new rule field is added in one place. `in_pick` selects the fields
|
|
6
|
+
# that participate in the most-specific `for(key)` resolution.
|
|
7
|
+
PICK_FIELDS = Schema::FIELD_REGISTRY.select { |_, m| m[:in_pick] }.keys.freeze
|
|
8
|
+
|
|
9
|
+
RuleSet = ::Data.define(*PICK_FIELDS)
|
|
10
|
+
EMPTY_SET = RuleSet.new(**PICK_FIELDS.to_h { |f| [f, nil] })
|
|
6
11
|
|
|
7
12
|
def self.parse(raw)
|
|
8
13
|
new(Array(raw).map { |b| Block.new(b) })
|
|
@@ -15,17 +20,13 @@ module Textus
|
|
|
15
20
|
attr_reader :blocks
|
|
16
21
|
|
|
17
22
|
def for(key)
|
|
18
|
-
slots = {
|
|
23
|
+
slots = PICK_FIELDS.to_h { |f| [f, []] }
|
|
19
24
|
@blocks.each do |b|
|
|
20
25
|
next unless Textus::Domain::Policy::Matcher.matches?(b.match, key)
|
|
21
26
|
|
|
22
27
|
slots.each_key { |slot| slots[slot] << b if b.public_send(slot) }
|
|
23
28
|
end
|
|
24
|
-
RuleSet.new(
|
|
25
|
-
handler_allowlist: pick(slots[:handler_allowlist], :handler_allowlist, key),
|
|
26
|
-
guard: pick(slots[:guard], :guard, key),
|
|
27
|
-
lifecycle: pick(slots[:lifecycle], :lifecycle, key),
|
|
28
|
-
)
|
|
29
|
+
RuleSet.new(**slots.to_h { |slot, blocks| [slot, pick(blocks, slot, key)] })
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def explain(key)
|
|
@@ -43,41 +44,41 @@ module Textus
|
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
class Block
|
|
46
|
-
attr_reader :match,
|
|
47
|
+
attr_reader :match, *Schema::FIELD_REGISTRY.keys
|
|
47
48
|
|
|
48
49
|
def initialize(raw)
|
|
49
50
|
@match = raw["match"] or raise Textus::UsageError.new("rule block missing match:")
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
Schema::FIELD_REGISTRY.each do |field, meta|
|
|
52
|
+
instance_variable_set("@#{field}", parse_field(meta, raw[meta[:yaml_key]]))
|
|
53
|
+
end
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
private
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
58
|
+
# One dispatch over the registry, replacing the four bespoke parse_*
|
|
59
|
+
# methods. :deferred carries the raw Hash after a shape check (its
|
|
60
|
+
# contents validate later — guard predicates at GuardFactory build time,
|
|
61
|
+
# ADR 0031); :immediate instantiates the policy class now. :tagged passes
|
|
62
|
+
# the raw Hash straight to a policy class that is a tagged union and
|
|
63
|
+
# dispatches on its discriminator field (e.g. upkeep's on:). A mapping
|
|
64
|
+
# field (sub_keys) splats its nested keys as kwargs; a scalar/array
|
|
65
|
+
# field passes its raw value under arg_key.
|
|
66
|
+
def parse_field(meta, value)
|
|
67
|
+
return nil if value.nil?
|
|
68
|
+
|
|
69
|
+
if meta[:validation] == :deferred
|
|
70
|
+
raise Textus::BadManifest.new("#{meta[:yaml_key]}: must be a map of transition => [predicates]") unless value.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
return value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
return meta[:policy_class].new(value) if meta[:validation] == :tagged
|
|
76
|
+
|
|
77
|
+
if meta[:sub_keys]
|
|
78
|
+
meta[:policy_class].new(**meta[:sub_keys].to_h { |k| [k.to_sym, value[k]] })
|
|
79
|
+
else
|
|
80
|
+
meta[:policy_class].new(meta[:arg_key] => value)
|
|
81
|
+
end
|
|
81
82
|
end
|
|
82
83
|
end
|
|
83
84
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
module Schema
|
|
4
|
+
# The manifest's key whitelists and the rule-field registry — the schema's
|
|
5
|
+
# data tables (ADR 0109; the vocabulary lives in Schema::Vocabulary).
|
|
6
|
+
module Keys
|
|
7
|
+
ROOT_KEYS = %w[version roles zones entries rules audit].freeze
|
|
8
|
+
ROLE_KEYS = %w[name can].freeze
|
|
9
|
+
ZONE_KEYS = %w[name kind owner desc].freeze
|
|
10
|
+
ENTRY_KEYS = %w[
|
|
11
|
+
key path zone kind schema owner nested format
|
|
12
|
+
source publish
|
|
13
|
+
events ignore tracked
|
|
14
|
+
].freeze
|
|
15
|
+
# ADR 0052: the typed publish block — `publish: { to: [...] }` (file
|
|
16
|
+
# fan-out) xor `publish: { tree: "dir" }` (subtree mirror).
|
|
17
|
+
PUBLISH_KEYS = %w[to tree].freeze
|
|
18
|
+
# ADR 0093/0094: entry-level acquisition block. `from: project` sources
|
|
19
|
+
# expose flat projection fields (select/pluck/sort_by/transform) directly
|
|
20
|
+
# on the source block (ADR 0094). Render fields (template/inject_boot/
|
|
21
|
+
# provenance) that were formerly on the source are retired — they live on
|
|
22
|
+
# publish targets. The legacy `project:` free hash and `template`/
|
|
23
|
+
# `inject_boot`/`provenance` fields are kept here so the schema walk can
|
|
24
|
+
# still emit the migration hint rather than a bare "unknown key".
|
|
25
|
+
SOURCE_KEYS = %w[
|
|
26
|
+
from handler config template project command sources ttl on_write inject_boot provenance
|
|
27
|
+
select pluck sort_by transform
|
|
28
|
+
].freeze
|
|
29
|
+
# ADR 0093: rule-level GC slot. drop/archive only (refresh gone).
|
|
30
|
+
RETENTION_KEYS = %w[ttl action].freeze
|
|
31
|
+
|
|
32
|
+
# The ONE source of truth for the rule-block field set (WS3). Adding a
|
|
33
|
+
# rule field means adding one entry here; everything downstream derives
|
|
34
|
+
# from it so the ~9 enumeration sites the audit found can't drift:
|
|
35
|
+
# - Schema::RULE_KEYS and the per-field sub-key walk (Schema::Validator)
|
|
36
|
+
# - Rules: the RuleSet members, EMPTY_SET, the `for` slots accumulator,
|
|
37
|
+
# Block's attr_readers, and the parse dispatch
|
|
38
|
+
# - Doctor::Check::RuleAmbiguity SLOTS (in_ambiguity)
|
|
39
|
+
# - Read::RuleList / Read::RuleExplain field membership
|
|
40
|
+
# (in_rule_list / in_rule_explain)
|
|
41
|
+
#
|
|
42
|
+
# Per field:
|
|
43
|
+
# yaml_key manifest key (handler_allowlist's intake_ prefix
|
|
44
|
+
# disambiguates from entry-level intake:, ADR 0059)
|
|
45
|
+
# policy_class the Domain::Policy backing the field (nil = raw value)
|
|
46
|
+
# validation :immediate (instantiate the policy at parse, surfacing
|
|
47
|
+
# shape errors eagerly), :deferred (shape-check + carry
|
|
48
|
+
# the raw Hash; guard predicates validate at GuardFactory
|
|
49
|
+
# build time, ADR 0031), or :tagged (pass the raw Hash to a
|
|
50
|
+
# tagged-union policy that dispatches on its discriminator
|
|
51
|
+
# field, e.g. upkeep's on:)
|
|
52
|
+
# sub_keys allowed nested keys for a mapping field (drives both the
|
|
53
|
+
# schema sub-key walk and the kwargs splat into policy_class)
|
|
54
|
+
# arg_key for an immediate non-mapping field, the single kwarg the
|
|
55
|
+
# raw value is passed under
|
|
56
|
+
# in_pick participates in the most-specific `for(key)` resolution
|
|
57
|
+
# in_ambiguity linted by doctor's same-specificity tie check
|
|
58
|
+
# in_rule_list shown in the whole-manifest rule_list view
|
|
59
|
+
# in_rule_explain depths the field shows at: :lean and/or :detail
|
|
60
|
+
#
|
|
61
|
+
# Key order here fixes the order of RULE_KEYS (after match), the slots,
|
|
62
|
+
# the RuleSet members, and the doctor SLOTS.
|
|
63
|
+
FIELD_REGISTRY = {
|
|
64
|
+
handler_allowlist: {
|
|
65
|
+
yaml_key: "intake_handler_allowlist",
|
|
66
|
+
policy_class: Textus::Domain::Policy::HandlerAllowlist,
|
|
67
|
+
validation: :immediate, sub_keys: nil, arg_key: :handlers,
|
|
68
|
+
in_pick: true, in_ambiguity: true,
|
|
69
|
+
in_rule_list: true, in_rule_explain: %i[detail]
|
|
70
|
+
},
|
|
71
|
+
guard: {
|
|
72
|
+
yaml_key: "guard",
|
|
73
|
+
policy_class: nil,
|
|
74
|
+
validation: :deferred, sub_keys: nil, arg_key: nil,
|
|
75
|
+
in_pick: true, in_ambiguity: true,
|
|
76
|
+
in_rule_list: true, in_rule_explain: %i[lean detail]
|
|
77
|
+
},
|
|
78
|
+
retention: {
|
|
79
|
+
yaml_key: "retention",
|
|
80
|
+
policy_class: Textus::Domain::Policy::Retention,
|
|
81
|
+
validation: :tagged, sub_keys: RETENTION_KEYS, arg_key: nil,
|
|
82
|
+
in_pick: true, in_ambiguity: true,
|
|
83
|
+
in_rule_list: true, in_rule_explain: %i[lean detail]
|
|
84
|
+
},
|
|
85
|
+
}.freeze
|
|
86
|
+
|
|
87
|
+
RULE_KEYS = (["match"] + FIELD_REGISTRY.values.map { |m| m[:yaml_key] }).freeze
|
|
88
|
+
AUDIT_KEYS = %w[max_size keep].freeze
|
|
89
|
+
# Syntactic shape of an `owner:` subject token (the `patrick` in
|
|
90
|
+
# `human:patrick`) — the subject half of the owner-validation rule below.
|
|
91
|
+
# Role supplies the archetype set (Role::NAMES); this pattern is the
|
|
92
|
+
# owner-specific part, so it lives with the rule that composes them
|
|
93
|
+
# (ADR 0045 D1). Acting-role *names* are gated by Role::NAMES, not a regex.
|
|
94
|
+
OWNER_SUBJECT_PATTERN = /\A[a-z][a-z0-9_-]*\z/
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|