textus 0.10.5 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +60 -40
  3. data/CHANGELOG.md +318 -3
  4. data/README.md +34 -27
  5. data/SPEC.md +226 -145
  6. data/docs/conventions.md +8 -8
  7. data/lib/textus/application/context.rb +4 -0
  8. data/lib/textus/application/reads/blame.rb +1 -1
  9. data/lib/textus/application/reads/deps.rb +15 -0
  10. data/lib/textus/application/reads/freshness.rb +4 -4
  11. data/lib/textus/application/reads/get.rb +9 -12
  12. data/lib/textus/application/reads/list.rb +15 -0
  13. data/lib/textus/application/reads/policy_explain.rb +2 -2
  14. data/lib/textus/application/reads/published.rb +15 -0
  15. data/lib/textus/application/reads/rdeps.rb +15 -0
  16. data/lib/textus/application/reads/schema_envelope.rb +15 -0
  17. data/lib/textus/application/reads/stale.rb +15 -0
  18. data/lib/textus/application/reads/uid.rb +15 -0
  19. data/lib/textus/application/reads/validate_all.rb +15 -0
  20. data/lib/textus/application/reads/where.rb +15 -0
  21. data/lib/textus/application/refresh/all.rb +2 -2
  22. data/lib/textus/application/refresh/orchestrator.rb +1 -1
  23. data/lib/textus/application/refresh/worker.rb +8 -8
  24. data/lib/textus/application/writes/accept.rb +26 -8
  25. data/lib/textus/application/writes/build.rb +12 -49
  26. data/lib/textus/application/writes/delete.rb +1 -1
  27. data/lib/textus/application/writes/mv.rb +144 -0
  28. data/lib/textus/application/writes/publish.rb +42 -10
  29. data/lib/textus/application/writes/put.rb +1 -1
  30. data/lib/textus/application/writes/reject.rb +37 -0
  31. data/lib/textus/builder/pipeline.rb +1 -1
  32. data/lib/textus/builder/renderer/json.rb +1 -1
  33. data/lib/textus/builder/renderer/yaml.rb +1 -1
  34. data/lib/textus/cli/group/key.rb +1 -1
  35. data/lib/textus/cli/group/refresh.rb +21 -0
  36. data/lib/textus/cli/group/rule.rb +11 -0
  37. data/lib/textus/cli/verb/accept.rb +1 -2
  38. data/lib/textus/cli/verb/audit.rb +3 -3
  39. data/lib/textus/cli/verb/blame.rb +1 -2
  40. data/lib/textus/cli/verb/build.rb +6 -2
  41. data/lib/textus/cli/verb/delete.rb +1 -2
  42. data/lib/textus/cli/verb/deps.rb +1 -1
  43. data/lib/textus/cli/verb/freshness.rb +1 -2
  44. data/lib/textus/cli/verb/get.rb +2 -3
  45. data/lib/textus/cli/verb/hook_run.rb +3 -2
  46. data/lib/textus/cli/verb/hooks.rb +1 -1
  47. data/lib/textus/cli/verb/{migrate_keys.rb → key_normalize.rb} +1 -1
  48. data/lib/textus/cli/verb/list.rb +1 -1
  49. data/lib/textus/cli/verb/mv.rb +1 -1
  50. data/lib/textus/cli/verb/published.rb +1 -1
  51. data/lib/textus/cli/verb/put.rb +3 -3
  52. data/lib/textus/cli/verb/rdeps.rb +1 -1
  53. data/lib/textus/cli/verb/refresh.rb +1 -2
  54. data/lib/textus/cli/verb/reject.rb +1 -1
  55. data/lib/textus/cli/verb/{policy_explain.rb → rule_explain.rb} +2 -3
  56. data/lib/textus/cli/verb/{policy_list.rb → rule_list.rb} +3 -3
  57. data/lib/textus/cli/verb/schema.rb +1 -1
  58. data/lib/textus/cli/verb/uid.rb +1 -1
  59. data/lib/textus/cli/verb/where.rb +1 -1
  60. data/lib/textus/cli/verb.rb +9 -3
  61. data/lib/textus/cli.rb +6 -6
  62. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  63. data/lib/textus/doctor/check/illegal_keys.rb +39 -16
  64. data/lib/textus/doctor/check/intake_registration.rb +4 -4
  65. data/lib/textus/doctor/check/protocol_version.rb +47 -0
  66. data/lib/textus/doctor/check/{policy_ambiguity.rb → rule_ambiguity.rb} +6 -6
  67. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  68. data/lib/textus/doctor.rb +6 -5
  69. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  70. data/lib/textus/domain/permission.rb +4 -4
  71. data/lib/textus/domain/policy/predicates/human_accept.rb +31 -0
  72. data/lib/textus/domain/policy/predicates/schema_valid.rb +50 -0
  73. data/lib/textus/domain/policy/promotion.rb +45 -0
  74. data/lib/textus/entry/base.rb +28 -0
  75. data/lib/textus/entry/json.rb +59 -0
  76. data/lib/textus/entry/markdown.rb +46 -0
  77. data/lib/textus/entry/text.rb +35 -0
  78. data/lib/textus/entry/yaml.rb +59 -0
  79. data/lib/textus/entry.rb +16 -0
  80. data/lib/textus/envelope.rb +44 -14
  81. data/lib/textus/errors.rb +24 -5
  82. data/lib/textus/hooks/builtin.rb +5 -5
  83. data/lib/textus/hooks/dispatcher.rb +1 -1
  84. data/lib/textus/hooks/dsl.rb +3 -10
  85. data/lib/textus/hooks/loader.rb +1 -2
  86. data/lib/textus/hooks/registry.rb +22 -21
  87. data/lib/textus/infra/refresh/detached.rb +1 -1
  88. data/lib/textus/init.rb +25 -34
  89. data/lib/textus/intro.rb +65 -9
  90. data/lib/textus/manifest/entry/parser.rb +84 -0
  91. data/lib/textus/manifest/entry/validators/events.rb +21 -0
  92. data/lib/textus/manifest/entry/validators/format_matrix.rb +26 -0
  93. data/lib/textus/manifest/entry/validators/index_filename.rb +45 -0
  94. data/lib/textus/manifest/entry/validators/inject_intro.rb +18 -0
  95. data/lib/textus/manifest/entry/validators/publish_each.rb +37 -0
  96. data/lib/textus/manifest/entry/validators.rb +20 -0
  97. data/lib/textus/manifest/entry.rb +38 -189
  98. data/lib/textus/manifest/{policies.rb → rules.rb} +12 -10
  99. data/lib/textus/manifest/schema.rb +49 -0
  100. data/lib/textus/manifest.rb +50 -24
  101. data/lib/textus/migrate_keys.rb +1 -1
  102. data/lib/textus/operations/reads.rb +39 -0
  103. data/lib/textus/operations/refresh.rb +27 -0
  104. data/lib/textus/operations/writes.rb +21 -0
  105. data/lib/textus/operations.rb +44 -0
  106. data/lib/textus/projection.rb +9 -8
  107. data/lib/textus/refresh.rb +4 -5
  108. data/lib/textus/schema/tools.rb +8 -7
  109. data/lib/textus/store/reader.rb +1 -1
  110. data/lib/textus/store/staleness/intake_check.rb +1 -1
  111. data/lib/textus/store/validator.rb +3 -3
  112. data/lib/textus/store/writer.rb +5 -74
  113. data/lib/textus/store.rb +1 -55
  114. data/lib/textus/version.rb +2 -2
  115. data/lib/textus.rb +1 -0
  116. metadata +35 -10
  117. data/lib/textus/cli/group/policy.rb +0 -11
  118. data/lib/textus/composition.rb +0 -72
  119. data/lib/textus/proposal.rb +0 -10
  120. data/lib/textus/store/mover.rb +0 -167
@@ -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
@@ -0,0 +1,45 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ module IndexFilename
6
+ def self.call(entry)
7
+ return if entry.index_filename.nil?
8
+
9
+ check_shape!(entry)
10
+ check_extension!(entry)
11
+ end
12
+
13
+ def self.check_shape!(entry)
14
+ raise UsageError.new("entry '#{entry.key}': index_filename requires nested: true") unless entry.nested
15
+
16
+ unless entry.index_filename.is_a?(String) && !entry.index_filename.empty?
17
+ raise UsageError.new("entry '#{entry.key}': index_filename must be a non-empty string")
18
+ end
19
+
20
+ return unless entry.index_filename.include?("/") || File.basename(entry.index_filename) != entry.index_filename
21
+
22
+ raise UsageError.new("entry '#{entry.key}': index_filename must be a bare basename (no slashes)")
23
+ end
24
+
25
+ def self.check_extension!(entry)
26
+ ext = File.extname(entry.index_filename)
27
+ inferred = Textus::Entry.infer_from_extension(ext)
28
+
29
+ if inferred.nil?
30
+ raise UsageError.new(
31
+ "entry '#{entry.key}': index_filename #{entry.index_filename.inspect} has unknown extension #{ext.inspect}",
32
+ )
33
+ end
34
+ return if inferred == entry.format
35
+
36
+ raise UsageError.new(
37
+ "entry '#{entry.key}': index_filename extension #{ext.inspect} implies format #{inferred.inspect}, " \
38
+ "but entry format is #{entry.format.inspect}",
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,18 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ module InjectIntro
6
+ def self.call(entry)
7
+ return unless entry.inject_intro
8
+
9
+ raise UsageError.new("entry '#{entry.key}': inject_intro: is only valid on derived entries") unless entry.in_generator_zone?
10
+ return unless entry.template.nil?
11
+
12
+ raise UsageError.new("entry '#{entry.key}': inject_intro: requires a template:")
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,37 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ module PublishEach
6
+ KNOWN_VARS = %w[leaf basename key ext].freeze
7
+ VAR_RE = /\{([a-z]+)\}/
8
+ REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
9
+
10
+ def self.call(entry)
11
+ return if entry.publish_each.nil?
12
+
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)
16
+
17
+ used_vars = entry.publish_each.scan(VAR_RE).flatten
18
+ unknown = used_vars - KNOWN_VARS
19
+ unless unknown.empty?
20
+ raise UsageError.new(
21
+ "entry '#{entry.key}': publish_each uses unknown template variable(s) " \
22
+ "#{unknown.map { |v| "{#{v}}" }.join(", ")}. Known: #{KNOWN_VARS.map { |v| "{#{v}}" }.join(", ")}.",
23
+ )
24
+ end
25
+
26
+ return if used_vars.any? { |v| REQUIRED_DISCRIMINATOR_VARS.include?(v) }
27
+
28
+ raise UsageError.new(
29
+ "entry '#{entry.key}': publish_each must reference at least one of {leaf}, {basename}, or {key} " \
30
+ "(else every leaf would clobber the same target).",
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,20 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ REGISTERED = [
6
+ Events,
7
+ PublishEach,
8
+ InjectIntro,
9
+ IndexFilename,
10
+ FormatMatrix,
11
+ ].freeze
12
+
13
+ def self.run_all(entry)
14
+ REGISTERED.each { |v| v.call(entry) }
15
+ nil
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -1,40 +1,44 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Entry
4
- PUBLISH_EACH_VARS = %w[leaf basename key ext].freeze
5
- PUBLISH_EACH_VAR_RE = /\{([a-z]+)\}/
6
-
7
- attr_reader :key, :path, :zone, :schema, :owner, :nested, :generator, :raw, :format,
8
- :projection, :template, :publish_to, :publish_each,
9
- :intake_handler, :intake_config,
10
- :events, :inject_intro, :index_filename
11
-
12
- def initialize(manifest, raw)
4
+ # Re-exported for backward compatibility with callers that referenced these
5
+ # constants on Entry. Canonical source is the PublishEach validator.
6
+ PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
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:)
13
20
  @manifest = manifest
14
21
  @raw = raw
15
- @key = raw["key"] or raise UsageError.new("manifest entry missing key")
16
- @path = raw["path"] or raise UsageError.new("manifest entry '#{@key}' missing path")
17
- @zone = raw["zone"] or raise UsageError.new("manifest entry '#{@key}' missing zone")
18
- @schema = raw["schema"]
19
- @owner = raw["owner"]
20
- @nested = raw["nested"] == true
21
- @generator = raw["generator"]
22
- @projection = raw["projection"]
23
- @template = raw["template"]
24
- @publish_to = Array(raw["publish_to"])
25
- @publish_each = raw["publish_each"]
26
- @events = raw["events"] || {}
27
- @inject_intro = raw["inject_intro"] == true
28
- @index_filename = raw["index_filename"]
29
- @format = resolve_format!(raw["format"])
30
-
31
- validate_events!
32
- parse_intake!(raw["intake"])
33
- validate_format_matrix!
34
- validate_publish_each!
35
- validate_inject_intro!
36
- validate_index_filename!
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
37
40
  end
41
+ # rubocop:enable Metrics/ParameterLists
38
42
 
39
43
  # Resolves the per-leaf target path (relative to repo root) for a full
40
44
  # dotted key under this entry's prefix. Returns nil if this entry has no
@@ -56,178 +60,23 @@ module Textus
56
60
  end
57
61
 
58
62
  # Signal-based zone-kind predicates: derive the "kind" of a zone from its
59
- # writable_by signals rather than its literal name, so detection keeps
63
+ # write_policy signals rather than its literal name, so detection keeps
60
64
  # working when users rename the default zones.
61
65
  def in_generator_zone?
62
- zone_writers.include?("build")
66
+ zone_writers.include?("builder")
63
67
  end
64
68
 
65
69
  def in_proposal_zone?
66
- zone_writers.include?("ai")
70
+ zone_writers.include?("agent")
67
71
  end
68
72
 
69
73
  private
70
74
 
71
- # `index_filename:` makes a nested entry treat a fixed basename (e.g.
72
- # `SKILL.md`) as the per-directory row. The directory path becomes the
73
- # key suffix; sibling files are not enumerated. Allows projecting
74
- # spec-mandated filenames that would otherwise be rejected by the
75
- # lowercase-only key segment grammar.
76
- def validate_index_filename!
77
- return if @index_filename.nil?
78
-
79
- raise UsageError.new("entry '#{@key}': index_filename requires nested: true") unless @nested
80
- unless @index_filename.is_a?(String) && !@index_filename.empty?
81
- raise UsageError.new("entry '#{@key}': index_filename must be a non-empty string")
82
- end
83
- if @index_filename.include?("/") || File.basename(@index_filename) != @index_filename
84
- raise UsageError.new("entry '#{@key}': index_filename must be a bare basename (no slashes)")
85
- end
86
-
87
- ext = File.extname(@index_filename)
88
- inferred = Manifest::EXT_TO_FORMAT[ext]
89
- if inferred.nil?
90
- raise UsageError.new(
91
- "entry '#{@key}': index_filename #{@index_filename.inspect} has unknown extension #{ext.inspect}",
92
- )
93
- end
94
- return if inferred == @format
95
-
96
- raise UsageError.new(
97
- "entry '#{@key}': index_filename extension #{ext.inspect} implies format #{inferred.inspect}, " \
98
- "but entry format is #{@format.inspect}",
99
- )
100
- end
101
-
102
75
  def zone_writers
103
76
  @manifest.zone_writers(@zone)
104
77
  rescue UsageError => e
105
78
  raise UsageError.new("entry '#{@key}': #{e.message}")
106
79
  end
107
-
108
- def validate_inject_intro!
109
- return unless @inject_intro
110
-
111
- unless in_generator_zone?
112
- raise UsageError.new(
113
- "entry '#{@key}': inject_intro: is only valid on derived entries",
114
- )
115
- end
116
- return unless @template.nil?
117
-
118
- raise UsageError.new(
119
- "entry '#{@key}': inject_intro: requires a template:",
120
- )
121
- end
122
-
123
- def validate_publish_each!
124
- return if @publish_each.nil?
125
-
126
- raise UsageError.new("entry '#{@key}': publish_each requires nested: true") unless @nested
127
- raise UsageError.new("entry '#{@key}': publish_to and publish_each are mutually exclusive") unless @publish_to.empty?
128
- raise UsageError.new("entry '#{@key}': publish_each must be a string") unless @publish_each.is_a?(String)
129
-
130
- used_vars = @publish_each.scan(PUBLISH_EACH_VAR_RE).flatten
131
- unknown = used_vars - PUBLISH_EACH_VARS
132
- unless unknown.empty?
133
- raise UsageError.new(
134
- "entry '#{@key}': publish_each uses unknown template variable(s) " \
135
- "#{unknown.map { |v| "{#{v}}" }.join(", ")}. Known: #{PUBLISH_EACH_VARS.map { |v| "{#{v}}" }.join(", ")}.",
136
- )
137
- end
138
-
139
- required = %w[leaf basename key]
140
- return if used_vars.any? { |v| required.include?(v) }
141
-
142
- raise UsageError.new(
143
- "entry '#{@key}': publish_each must reference at least one of {leaf}, {basename}, or {key} " \
144
- "(else every leaf would clobber the same target).",
145
- )
146
- end
147
-
148
- def resolve_format!(declared)
149
- ext = File.extname(@path)
150
- inferred = Manifest::EXT_TO_FORMAT[ext]
151
-
152
- if declared.nil?
153
- return inferred if inferred
154
- # No extension: nested defaults to markdown, leaf with no ext also markdown.
155
- return "markdown" if ext == "" && @nested
156
- return "markdown" if ext == ""
157
- else
158
- unless Manifest::EXT_TO_FORMAT.values.include?(declared)
159
- raise UsageError.new("entry '#{@key}': unknown format #{declared.inspect}")
160
- end
161
- # If the path has an extension, the declared format must match.
162
- if ext != "" && inferred && inferred != declared
163
- raise UsageError.new(
164
- "entry '#{@key}': path extension #{ext.inspect} does not match declared format #{declared.inspect}",
165
- )
166
- end
167
- return declared
168
- end
169
-
170
- "markdown"
171
- end
172
-
173
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
174
- def validate_format_matrix!
175
- ext = File.extname(@path)
176
-
177
- case @format
178
- when "markdown"
179
- # .md, or no extension (will be appended). Anything else is a mismatch caught above.
180
- raise UsageError.new("entry '#{@key}': markdown format requires '.md' path (got #{ext.inspect})") if ext != "" && ext != ".md"
181
- when "json"
182
- if @nested
183
- # nested json: path is a directory; ext must be empty.
184
- raise UsageError.new("entry '#{@key}': nested json path must not have an extension") if ext != ""
185
- elsif ext != ".json"
186
- raise UsageError.new("entry '#{@key}': json format requires '.json' path (got #{ext.inspect})")
187
- end
188
- when "yaml"
189
- if @nested
190
- raise UsageError.new("entry '#{@key}': nested yaml path must not have an extension") if ext != ""
191
- elsif ext != ".yaml" && ext != ".yml"
192
- raise UsageError.new("entry '#{@key}': yaml format requires '.yaml' or '.yml' path (got #{ext.inspect})")
193
- end
194
- when "text"
195
- if @nested
196
- raise UsageError.new("entry '#{@key}': nested text path must not have an extension") if ext != ""
197
- elsif ext != ".txt" && ext != ""
198
- raise UsageError.new("entry '#{@key}': text format requires '.txt' or no extension (got #{ext.inspect})")
199
- end
200
- end
201
-
202
- # Schema rules.
203
- raise UsageError.new("entry '#{@key}': text format must not declare a schema") if @format == "text" && !@schema.nil?
204
-
205
- # Template-required-for-derived rules. Skipped for entries materialized by an
206
- # external generator: command (those produce the bytes themselves).
207
- if in_generator_zone? && @template.nil? && @generator.nil? &&
208
- (@format == "markdown" || @format == "text") && !@nested
209
- raise UsageError.new("entry '#{@key}': derived #{@format} entries require a template")
210
- end
211
- end
212
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
213
-
214
- def parse_intake!(src)
215
- src ||= {}
216
- @intake_handler = src["handler"]
217
- @intake_config = src["config"] || {}
218
- end
219
-
220
- def validate_events!
221
- pubsub_events = Hooks::Registry::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
222
- @events.each_key do |evt|
223
- next if pubsub_events.include?(evt.to_sym)
224
-
225
- raise UsageError.new(
226
- "entry '#{@key}': unknown event '#{evt}' in events: block. " \
227
- "Known events: #{pubsub_events.join(", ")}.",
228
- )
229
- end
230
- end
231
80
  end
232
81
  end
233
82
  end
@@ -1,8 +1,8 @@
1
1
  module Textus
2
2
  class Manifest
3
- class Policies
4
- PolicySet = Data.define(:refresh, :handler_allowlist, :promote, :retention)
5
- EMPTY_SET = PolicySet.new(refresh: nil, handler_allowlist: nil, promote: nil, retention: nil)
3
+ class Rules
4
+ RuleSet = Data.define(:refresh, :handler_allowlist, :promote, :retention)
5
+ EMPTY_SET = RuleSet.new(refresh: nil, handler_allowlist: nil, promote: nil, retention: nil)
6
6
 
7
7
  def self.parse(raw)
8
8
  new(Array(raw).map { |b| Block.new(b) })
@@ -21,7 +21,7 @@ module Textus
21
21
 
22
22
  slots.each_key { |slot| slots[slot] << b if b.public_send(slot) }
23
23
  end
24
- PolicySet.new(
24
+ RuleSet.new(
25
25
  refresh: pick(slots[:refresh], :refresh, key),
26
26
  handler_allowlist: pick(slots[:handler_allowlist], :handler_allowlist, key),
27
27
  promote: pick(slots[:promote], :promote, key),
@@ -47,10 +47,10 @@ module Textus
47
47
  attr_reader :match, :refresh, :handler_allowlist, :promote, :retention
48
48
 
49
49
  def initialize(raw)
50
- @match = raw["match"] or raise Textus::UsageError.new("policy block missing match:")
50
+ @match = raw["match"] or raise Textus::UsageError.new("rule block missing match:")
51
51
  @refresh = parse_refresh(raw["refresh"])
52
- @handler_allowlist = parse_handler_allowlist(raw["handler_allowlist"])
53
- @promote = parse_promote(raw["promote_requires"])
52
+ @handler_allowlist = parse_handler_allowlist(raw["intake_handler_allowlist"])
53
+ @promote = parse_promotion(raw["promotion"])
54
54
  @retention = raw["retention"] # reserved — passthrough only
55
55
  end
56
56
 
@@ -72,10 +72,12 @@ module Textus
72
72
  Textus::Domain::Policy::HandlerAllowlist.new(handlers: arr)
73
73
  end
74
74
 
75
- def parse_promote(arr)
76
- return nil if arr.nil?
75
+ def parse_promotion(h)
76
+ return nil if h.nil?
77
+
78
+ raise Textus::BadManifest.new("promotion: must be a hash with a 'requires:' array") unless h.is_a?(Hash) && h.key?("requires")
77
79
 
78
- Textus::Domain::Policy::Promote.new(requires: arr)
80
+ Textus::Domain::Policy::Promote.new(requires: Array(h["requires"]))
79
81
  end
80
82
  end
81
83
  end
@@ -0,0 +1,49 @@
1
+ module Textus
2
+ class Manifest
3
+ module Schema
4
+ ROOT_KEYS = %w[version zones entries rules].freeze
5
+ ZONE_KEYS = %w[name write_policy read_policy].freeze
6
+ ENTRY_KEYS = %w[
7
+ key path zone schema owner nested format
8
+ compute template publish_to publish_each
9
+ intake events inject_intro index_filename
10
+ ].freeze
11
+ COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
12
+ INTAKE_KEYS = %w[handler config].freeze
13
+ RULE_KEYS = %w[match refresh intake_handler_allowlist promotion retention].freeze
14
+ REFRESH_KEYS = %w[ttl on_stale sync_budget_ms].freeze
15
+ PROMOTION_KEYS = %w[requires].freeze
16
+
17
+ def self.validate!(raw)
18
+ raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
19
+
20
+ walk(raw, ROOT_KEYS, "$")
21
+ Array(raw["zones"]).each_with_index do |z, i|
22
+ walk(z, ZONE_KEYS, "$.zones[#{i}]")
23
+ end
24
+ Array(raw["entries"]).each_with_index do |e, i|
25
+ path = "$.entries[#{i}]"
26
+ walk(e, ENTRY_KEYS, path)
27
+ walk(e["compute"], COMPUTE_KEYS, "#{path}.compute") if e["compute"].is_a?(Hash)
28
+ walk(e["intake"], INTAKE_KEYS, "#{path}.intake") if e["intake"].is_a?(Hash)
29
+ end
30
+ Array(raw["rules"]).each_with_index do |r, i|
31
+ path = "$.rules[#{i}]"
32
+ walk(r, RULE_KEYS, path)
33
+ walk(r["refresh"], REFRESH_KEYS, "#{path}.refresh") if r["refresh"].is_a?(Hash)
34
+ walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
35
+ end
36
+ end
37
+
38
+ def self.walk(hash, allowed, path)
39
+ return unless hash.is_a?(Hash)
40
+
41
+ hash.each_key do |k|
42
+ next if allowed.include?(k)
43
+
44
+ raise BadManifest.new("unknown key '#{k}' at '#{path}'")
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end