jazari 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1e44727af4c1cfd7ca851c84618d9015f39aec562c3133301772f6d97b21076
4
- data.tar.gz: 8c87563ee6acfbe91b8544274c981dbb98fb24ac05e77cb170f7e33033067f2d
3
+ metadata.gz: b3e1d3f594584f56c5ea72c8aacd01ce1aa9fc6ddd12b2b1f9c160072dfd9c63
4
+ data.tar.gz: f5d622adb6252f7465adb0aeab696c9876598bef6a368bf1845dd8a2c896b252
5
5
  SHA512:
6
- metadata.gz: dec279a4cedbcf0377a512298300487316e04a6ac3dfa9059e37415edcfba07ba0015b11d1d8d2b08d6bf4fd33ec557d87a507a216a095d66a52bdc5a4441f6e
7
- data.tar.gz: 148962f5b5f9762f35cc04ba285485c4e76e5de41ef086ea0dafcf4861cd2e9b1110c299ee39ddd45abe491e13a473c0477b66bac914306c7cc9540b6142e382
6
+ metadata.gz: dfc06b9155a1f706488fa36ee9661c92fb419a27c76cfe6f1e912be31a365ceb6249e24805a92f7e1b9d025511e0559332d6f391f604dbe24e569f46ab886cc1
7
+ data.tar.gz: f5f37bdc359b9626221055c908a5a0ab59f713ab2238ff5df99a82da9c787e403966ef591e65d25fab45ebd2d9dadb78e2a1fd8af860627c4d8661b589356668
data/CHANGELOG.md CHANGED
@@ -8,6 +8,53 @@ codes, the resolved-value shape, how revisions are computed, and the schema the
8
8
  generator emits — changes to any of those are breaking even when the method
9
9
  signatures do not move.
10
10
 
11
+ ## [0.4.0] - 2026-08-11
12
+
13
+ ### Added
14
+
15
+ - **`Jazari::RecipeFiles` — recipes as YAML or JSON, without files silently
16
+ overwriting operators.**
17
+
18
+ Nothing ever forbade files: `RecipeRegistry.seed!` takes plain hashes, so
19
+ `seed!(YAML.load_file(…))` already worked. What was missing was everything
20
+ that makes it *safe*.
21
+
22
+ - **`load(path)`** — a file or a directory of `.yml` / `.yaml` / `.json`.
23
+ Accepts one recipe, a list, or a list under a `recipes:` key. It validates
24
+ at LOAD, which is the whole reason it exists rather than "just call
25
+ `YAML.load_file`": an unknown key is a typo, and a typo that loads silently
26
+ becomes a recipe resolving to something nobody wrote. Unknown keys, missing
27
+ topics, bad `run_policy`, duplicate ids and malformed YAML all raise, naming
28
+ the file.
29
+ - **`dump(dir)`** — writes what is actually stored back out, one file per
30
+ recipe. Without this the loop never closes: an operator's runtime fix could
31
+ not be reviewed or committed, so runtime editing would quietly become the
32
+ thing you avoid rather than the thing the design is built around.
33
+ - **`drift(entries)`** — which stored recipes disagree with their file, and in
34
+ which fields. **Reported, never applied.**
35
+
36
+ **Files seed; they do not sync.** `seed!` stays create-if-missing, so a file
37
+ never overwrites a row an operator edited. That is the same rule the runbook
38
+ layer already follows — a customisation diverges rather than rebasing, because
39
+ silently overwriting a deliberate edit with a change nobody saw is the worst
40
+ available outcome. Applying files on every deploy would do exactly that, one
41
+ layer up. The cost is drift, so drift is made visible instead of resolved.
42
+
43
+ One place the loader is deliberately **stricter than the API**: a malformed
44
+ checklist id is an error rather than a fixup. `Checklist.normalize` replaces an
45
+ unusable id with a generated one, which is right when an id is absent and
46
+ opaque — but in a file someone wrote it, MCP addresses the step by it, and
47
+ documentation quotes it. Swapping it for a random token would create exactly
48
+ the file-versus-row disagreement this loader exists to prevent.
49
+
50
+ ### Fixed
51
+
52
+ - **The boundary check read heredoc bodies as code.** It stripped `"…"` and
53
+ `'…'` on the stated principle that a capitalised word inside a string is data,
54
+ but a fixture written as `<<~YML` had its own prose reported as constant
55
+ references. Heredoc bodies are now skipped. Verified still catching a planted
56
+ breach.
57
+
11
58
  ## [0.3.0] - 2026-08-11
12
59
 
13
60
  ### Added
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "json"
5
+
6
+ module Jazari
7
+ # Recipes as FILES — the version-controlled, reviewable form of the same data.
8
+ #
9
+ # This does not make files a second source of truth. `seed!` has always been
10
+ # create-if-missing, so a file is a SEED, not a sync: the row wins once it
11
+ # exists, because an operator editing a procedure at runtime is the whole
12
+ # reason recipes are data rather than code.
13
+ #
14
+ # That is deliberate and it is the same rule the runbook layer already follows —
15
+ # a customised runbook diverges permanently rather than rebasing, because
16
+ # silently overwriting a deliberate edit with a change nobody saw is the worst
17
+ # available outcome. Applying files on every deploy would do exactly that, one
18
+ # layer up.
19
+ #
20
+ # The cost of that choice is drift: a file and a row can disagree and nothing
21
+ # says so. So drift is REPORTED (`drift`) rather than resolved, and there is a
22
+ # way back out (`dump`) — edit at runtime, export, review the diff in a pull
23
+ # request, commit. The loop closes without anyone's work being overwritten.
24
+ module RecipeFiles
25
+ EXTENSIONS = %w[.yml .yaml .json].freeze
26
+
27
+ # Keys a recipe file may carry. Anything else is a typo, and a typo that
28
+ # loads silently becomes a recipe that resolves to something nobody wrote.
29
+ KEYS = %i[id version topic description checklist run_policy].freeze
30
+
31
+ module_function
32
+
33
+ # Reads one file or every recipe file in a directory. Returns plain hashes,
34
+ # ready for `RecipeRegistry.seed!` — which is why this is a loader and not a
35
+ # registry: producing the data and storing it are separate concerns.
36
+ def load(path)
37
+ entries = Array(paths_for(path)).flat_map { |file| parse(file) }
38
+ entries.each { |entry| validate!(entry) }
39
+ ids = entries.map { |entry| entry[:id] }
40
+ duplicated = ids.tally.select { |_, count| count > 1 }.keys
41
+ raise InvalidRunbook, "duplicate recipe ids: #{duplicated.join(', ')}" if duplicated.any?
42
+
43
+ entries
44
+ end
45
+
46
+ # Writes what is actually stored back out as YAML, one file per recipe.
47
+ # This is the half that makes runtime editing safe to allow: whatever an
48
+ # operator changed can be exported, diffed and committed.
49
+ def dump(directory, recipes: RecipeRecord.order(:recipe_id))
50
+ dir = File.expand_path(directory.to_s)
51
+ Dir.mkdir(dir) unless Dir.exist?(dir)
52
+ recipes.map do |record|
53
+ file = File.join(dir, "#{record.recipe_id}.yml")
54
+ File.write(file, YAML.dump(stringify(to_entry(record))))
55
+ file
56
+ end
57
+ end
58
+
59
+ # Which stored recipes disagree with their file definition, and how.
60
+ #
61
+ # Reported, never applied. A host decides what a difference means: on one
62
+ # fleet a file is the reviewed truth and a divergent row is an incident; on
63
+ # another the row is an operator's fix and the file is simply stale.
64
+ def drift(entries)
65
+ Array(entries).filter_map do |entry|
66
+ attributes = normalize(entry)
67
+ record = RecipeRecord.find_by(recipe_id: attributes[:id].to_s)
68
+ next { id: attributes[:id], state: :missing } if record.nil?
69
+
70
+ differing = KEYS.reject { |key| same?(key, attributes, record) }
71
+ next if differing.empty?
72
+
73
+ { id: attributes[:id], state: :differs, fields: differing }
74
+ end
75
+ end
76
+
77
+ # -- internals ---------------------------------------------------------
78
+
79
+ def paths_for(path)
80
+ expanded = File.expand_path(path.to_s)
81
+ return [ expanded ] if File.file?(expanded)
82
+ raise InvalidRunbook, "no such recipe path: #{path}" unless File.directory?(expanded)
83
+
84
+ Dir.children(expanded).sort
85
+ .select { |name| EXTENSIONS.include?(File.extname(name)) }
86
+ .map { |name| File.join(expanded, name) }
87
+ end
88
+
89
+ def parse(file)
90
+ raw = File.read(file)
91
+ data = if File.extname(file) == ".json"
92
+ JSON.parse(raw)
93
+ else
94
+ # safe_load: a recipe file is operational content, never a place to
95
+ # instantiate arbitrary objects.
96
+ YAML.safe_load(raw, permitted_classes: [], aliases: false)
97
+ end
98
+ # A file is either one recipe, a list of them, or a list under a `recipes:`
99
+ # key. `Array(hash)` would explode a single recipe into key/value pairs, so
100
+ # the Hash cases are named rather than coerced.
101
+ entries = if data.is_a?(Hash)
102
+ data.key?("recipes") ? Array(data["recipes"]) : [ data ]
103
+ else
104
+ Array(data)
105
+ end
106
+ entries.map { |entry| normalize(entry) }
107
+ rescue JSON::ParserError, Psych::SyntaxError => error
108
+ raise InvalidRunbook, "#{File.basename(file)}: #{error.message}"
109
+ end
110
+
111
+ def normalize(entry)
112
+ entry.to_h.transform_keys { |key| key.to_s.to_sym }
113
+ end
114
+
115
+ def validate!(entry)
116
+ unknown = entry.keys - KEYS
117
+ raise InvalidRunbook, "unknown recipe keys: #{unknown.join(', ')}" if unknown.any?
118
+ raise InvalidRunbook, "recipe id is required" if entry[:id].to_s.empty?
119
+ raise InvalidRunbook, "recipe #{entry[:id]} has no topic" if entry[:topic].to_s.empty?
120
+
121
+ policy = entry.fetch(:run_policy, RunPolicy::UNRESTRICTED).to_s
122
+ unless RunPolicy::ALL.include?(policy)
123
+ raise InvalidRunbook, "recipe #{entry[:id]} has unknown run_policy #{policy}"
124
+ end
125
+
126
+ # Reuse the one checklist validator rather than writing a second, laxer
127
+ # one here — a file must not be able to store an item the API would reject.
128
+ items = entry.fetch(:checklist, [])
129
+ Checklist.normalize(items)
130
+
131
+ # STRICTER than the API on one point, deliberately. `normalize` REPLACES an
132
+ # unusable id with a generated one, which is right when an id is absent and
133
+ # opaque. In a file it is neither: someone wrote it, MCP addresses the step
134
+ # by it, and documentation quotes it. Silently swapping it for a random
135
+ # token would put the file and the row into exactly the disagreement this
136
+ # loader exists to prevent — so a malformed id is an error, not a fixup.
137
+ Array(items).each do |item|
138
+ id = (item[:id] || item["id"]).to_s
139
+ next if id.empty? || id.match?(Checklist::ID_FORMAT)
140
+
141
+ raise InvalidRunbook, "recipe #{entry[:id]}: checklist id #{id.inspect} is not a valid token"
142
+ end
143
+
144
+ entry
145
+ end
146
+
147
+ def to_entry(record)
148
+ { id: record.recipe_id, version: record.version, topic: record.topic,
149
+ description: record.description, run_policy: record.run_policy,
150
+ checklist: Checklist.normalize(record.checklist) }
151
+ end
152
+
153
+ def stringify(value)
154
+ case value
155
+ when Hash then value.to_h { |key, inner| [ key.to_s, stringify(inner) ] }
156
+ when Array then value.map { |inner| stringify(inner) }
157
+ when Symbol then value.to_s
158
+ else value
159
+ end
160
+ end
161
+
162
+ def same?(key, attributes, record)
163
+ stored = case key
164
+ when :id then record.recipe_id
165
+ when :checklist then Checklist.normalize(record.checklist)
166
+ else record.public_send(key)
167
+ end
168
+ expected = key == :checklist ? Checklist.normalize(attributes.fetch(key, [])) : attributes[key]
169
+ return true if expected.nil? && key != :id
170
+
171
+ stringify(stored) == stringify(expected)
172
+ end
173
+ end
174
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Jazari
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/jazari.rb CHANGED
@@ -8,6 +8,7 @@ require "jazari/targets"
8
8
  require "jazari/anchors"
9
9
  require "jazari/resolved_runbook"
10
10
  require "jazari/recipe_registry"
11
+ require "jazari/recipe_files"
11
12
  require "jazari/runs"
12
13
  require "jazari/operations"
13
14
  # The MCP layer is OPTIONAL. Descriptors are cheap and a host may want them to
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jazari
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq
@@ -67,6 +67,7 @@ files:
67
67
  - lib/jazari/operations.rb
68
68
  - lib/jazari/railtie.rb
69
69
  - lib/jazari/recipe.rb
70
+ - lib/jazari/recipe_files.rb
70
71
  - lib/jazari/recipe_registry.rb
71
72
  - lib/jazari/resolved_runbook.rb
72
73
  - lib/jazari/runs.rb