Module: Clacky::PatchLoader
- Defined in:
- lib/clacky/extension/patch_loader.rb
Overview
Runtime patch layer. Loads user/AI-authored patches from ~/.clacky/patches/
that override existing methods via Module#prepend, WITHOUT touching the
installed gem source (so gem update never loses them).
Each patch lives in its own directory:
~/.clacky/patches/<id>/
meta.yml declares target + a fingerprint of the original method source
patch.rb a prepend module that overrides the target method
Safety — fingerprint drift:
meta.yml records a SHA256 of the targeted method's source at authoring time.
Before applying, the loader recomputes the fingerprint of the method as it
exists in the CURRENTLY installed gem. If they differ, the upstream code has
changed and the patch may no longer be valid, so by default the patch is
DISABLED (moved to _disabled/) rather than applied — a stale patch must never
silently corrupt behavior.
meta.yml:
id: fix-web-search-timeout
description: bump default timeout to 30s
target: "Clacky::Tools::WebSearch#execute" # '#' = instance, '.' = class method
fingerprint: "a3f8c…"
gem_version: "0.7.0"
on_mismatch: disable # disable | warn (default disable)
Defined Under Namespace
Classes: Result
Constant Summary collapse
- DEFAULT_DIR =
File.("~/.clacky/patches")
- DISABLED_DIR =
"_disabled"
Class Method Summary collapse
- .apply_one(patch_dir, meta_path, result) ⇒ Object
- .ast_line_range(meth) ⇒ Object
- .disable!(patch_dir, id) ⇒ Object
- .find_def_at(node, lineno, name) ⇒ Object
-
.fingerprint(target) ⇒ String
Recompute the fingerprint of a target's method as currently installed.
- .handle_mismatch(patch_dir, id, meta, result) ⇒ Object
- .last_result ⇒ Object
- .load_all(dir: DEFAULT_DIR) ⇒ Object
-
.load_extension_patches(result) ⇒ Object
Apply patches contributed by ext.yml containers (contributes.patches).
- .log(level, id, msg) ⇒ Object
- .method_line_range(file, lineno, name, meth) ⇒ Object
-
.original_method(meth) ⇒ Object
Walk past any methods introduced by our own patches (files under the patches dir) so the fingerprint always reflects the original upstream definition, even after a prepend has already been applied.
- .patch_skeleton(slug, target) ⇒ Object
- .prism_line_range(file, lineno, name) ⇒ Object
- .resolve_const(name) ⇒ Object
- .resolve_method(target) ⇒ Object
-
.scaffold(id, target, description: "", dir: DEFAULT_DIR) ⇒ String
Generate a ready-to-edit patch (meta.yml + patch.rb) for a target method.
Class Method Details
.apply_one(patch_dir, meta_path, result) ⇒ Object
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 |
# File 'lib/clacky/extension/patch_loader.rb', line 252 def apply_one(patch_dir, , result) id = File.basename(patch_dir) = YAMLCompat.load_file() || {} target = ["target"].to_s recorded = ["fingerprint"].to_s if target.empty? || recorded.empty? result.skipped << [id, "meta.yml missing target or fingerprint"] log(:warn, id, result.skipped.last[1]) return end current = begin fingerprint(target) rescue StandardError => e result.skipped << [id, "cannot fingerprint #{target}: #{e.}"] log(:warn, id, result.skipped.last[1]) return end if current != recorded handle_mismatch(patch_dir, id, , result) return end patch_rb = File.join(patch_dir, "patch.rb") unless File.exist?(patch_rb) result.skipped << [id, "patch.rb not found"] log(:warn, id, result.skipped.last[1]) return end require patch_rb result.applied << id log(:info, id, "applied → #{target}") rescue StandardError, ScriptError => e result.skipped << [id, e.] log(:warn, id, e.) end |
.ast_line_range(meth) ⇒ Object
210 211 212 213 214 215 216 217 218 219 |
# File 'lib/clacky/extension/patch_loader.rb', line 210 def ast_line_range(meth) return nil unless defined?(RubyVM::AbstractSyntaxTree) node = RubyVM::AbstractSyntaxTree.of(meth) return nil unless node [node.first_lineno, node.last_lineno] rescue StandardError nil end |
.disable!(patch_dir, id) ⇒ Object
305 306 307 308 309 310 311 312 313 314 |
# File 'lib/clacky/extension/patch_loader.rb', line 305 def disable!(patch_dir, id) base = File.dirname(patch_dir) dest_root = File.join(base, DISABLED_DIR) FileUtils.mkdir_p(dest_root) dest = File.join(dest_root, id) FileUtils.rm_rf(dest) FileUtils.mv(patch_dir, dest) rescue StandardError => e log(:error, id, "failed to disable: #{e.}") end |
.find_def_at(node, lineno, name) ⇒ Object
196 197 198 199 200 201 202 203 204 205 206 207 208 |
# File 'lib/clacky/extension/patch_loader.rb', line 196 def find_def_at(node, lineno, name) return nil unless node if node.is_a?(Prism::DefNode) && node.name == name && node.location.start_line == lineno return node end node.compact_child_nodes.each do |child| found = find_def_at(child, lineno, name) return found if found end nil end |
.fingerprint(target) ⇒ String
Recompute the fingerprint of a target's method as currently installed.
164 165 166 167 168 169 170 171 172 173 174 |
# File 'lib/clacky/extension/patch_loader.rb', line 164 def fingerprint(target) meth = original_method(resolve_method(target)) file, lineno = meth.source_location raise "no source location for #{target} (defined in C or eval?)" unless file && lineno first, last = method_line_range(file, lineno, meth.name, meth) raise "cannot locate source for #{target} in #{file}:#{lineno}" unless first && last lines = File.readlines(file)[(first - 1)...last] Digest::SHA256.hexdigest(lines.join) end |
.handle_mismatch(patch_dir, id, meta, result) ⇒ Object
292 293 294 295 296 297 298 299 300 301 302 303 |
# File 'lib/clacky/extension/patch_loader.rb', line 292 def handle_mismatch(patch_dir, id, , result) reason = "fingerprint mismatch — upstream code for #{["target"]} changed" if ["on_mismatch"].to_s == "warn" result.skipped << [id, "#{reason} (kept, not applied)"] log(:warn, id, result.skipped.last[1]) return end disable!(patch_dir, id) result.disabled << [id, reason] log(:warn, id, "#{reason} — disabled") end |
.last_result ⇒ Object
105 106 107 |
# File 'lib/clacky/extension/patch_loader.rb', line 105 def last_result @last_result || load_all end |
.load_all(dir: DEFAULT_DIR) ⇒ Object
46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
# File 'lib/clacky/extension/patch_loader.rb', line 46 def load_all(dir: DEFAULT_DIR) result = Result.new(applied: [], disabled: [], skipped: []) if Dir.exist?(dir) Dir.glob(File.join(dir, "*", "meta.yml")).sort.each do || patch_dir = File.dirname() next if File.basename(File.dirname(patch_dir)) == DISABLED_DIR apply_one(patch_dir, , result) end end load_extension_patches(result) @last_result = result result end |
.load_extension_patches(result) ⇒ Object
Apply patches contributed by ext.yml containers (contributes.patches).
ext.yml-driven patches use a flat shape (target + file [+ fingerprint])
rather than the per-dir meta.yml + patch.rb layout, since the container
author already owns the upgrade lifecycle. If fingerprint is given in
ext.yml we still verify it; if absent we trust the author and require
the file directly. Errors are isolated to that single patch.
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/clacky/extension/patch_loader.rb', line 67 def load_extension_patches(result) units = Array(Clacky::ExtensionLoader.last_result&.patches) units.each do |unit| target = unit.spec["target"] file_abs = unit.spec["file_abs"] recorded = unit.spec["fingerprint"].to_s id = "#{unit.ext_id}/#{File.basename(unit.spec['file'], '.rb')}" if recorded.empty? require file_abs result.applied << id log(:info, id, "applied → #{target} (no fingerprint, trusted)") next end current = fingerprint(target) if current != recorded reason = "fingerprint mismatch — upstream code for #{target} changed" if unit.spec["on_mismatch"] == "warn" result.skipped << [id, "#{reason} (kept, not applied)"] log(:warn, id, result.skipped.last[1]) else result.skipped << [id, "#{reason} — disabled"] log(:warn, id, result.skipped.last[1]) end next end require file_abs result.applied << id log(:info, id, "applied → #{target}") rescue StandardError, ScriptError => e fallback_id = "#{unit&.ext_id}/#{File.basename(unit&.spec&.[]('file').to_s, '.rb')}" result.skipped << [fallback_id, e.] log(:warn, fallback_id, e.) end end |
.log(level, id, msg) ⇒ Object
322 323 324 |
# File 'lib/clacky/extension/patch_loader.rb', line 322 def log(level, id, msg) Clacky::Logger.public_send(level, "[PatchLoader] #{id}: #{msg}") end |
.method_line_range(file, lineno, name, meth) ⇒ Object
176 177 178 179 180 181 182 183 |
# File 'lib/clacky/extension/patch_loader.rb', line 176 def method_line_range(file, lineno, name, meth) if defined?(Prism) range = prism_line_range(file, lineno, name) return range if range end ast_line_range(meth) end |
.original_method(meth) ⇒ Object
Walk past any methods introduced by our own patches (files under the patches dir) so the fingerprint always reflects the original upstream definition, even after a prepend has already been applied.
224 225 226 227 228 229 230 231 232 233 234 235 236 |
# File 'lib/clacky/extension/patch_loader.rb', line 224 def original_method(meth) current = meth while current file, = current.source_location break if file.nil? || !file.start_with?(DEFAULT_DIR) nxt = current.super_method break if nxt.nil? current = nxt end current end |
.patch_skeleton(slug, target) ⇒ Object
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/clacky/extension/patch_loader.rb', line 137 def patch_skeleton(slug, target) const_name, sep, method_name = target.partition(/[#.]/) mod_const = "Patch_#{slug.gsub(/[^a-zA-Z0-9_]/, "_")}" prepend_target = sep == "#" ? const_name : "#{const_name}.singleton_class" <<~RUBY # frozen_string_literal: true # Patch for #{target} # Only edit the method body below. Call `super` to keep the original behavior. module #{mod_const} def #{method_name}(*args, **kwargs, &blk) # TODO: your fix here. Examples: # result = super # result super end end #{prepend_target}.prepend(#{mod_const}) RUBY end |
.prism_line_range(file, lineno, name) ⇒ Object
185 186 187 188 189 190 191 192 193 194 |
# File 'lib/clacky/extension/patch_loader.rb', line 185 def prism_line_range(file, lineno, name) result = Prism.parse_file(file) return nil unless result.success? node = find_def_at(result.value, lineno, name.to_sym) return nil unless node loc = node.location [loc.start_line, loc.end_line] end |
.resolve_const(name) ⇒ Object
316 317 318 319 320 |
# File 'lib/clacky/extension/patch_loader.rb', line 316 def resolve_const(name) name.split("::").reject(&:empty?).inject(Object) do |mod, part| mod.const_get(part) end end |
.resolve_method(target) ⇒ Object
238 239 240 241 242 243 244 245 246 247 248 249 250 |
# File 'lib/clacky/extension/patch_loader.rb', line 238 def resolve_method(target) if target.include?("#") const_name, method_name = target.split("#", 2) const = resolve_const(const_name) const.instance_method(method_name.to_sym) elsif target.include?(".") const_name, method_name = target.split(".", 2) const = resolve_const(const_name) const.method(method_name.to_sym) else raise "invalid target (need '#' or '.'): #{target}" end end |
.scaffold(id, target, description: "", dir: DEFAULT_DIR) ⇒ String
Generate a ready-to-edit patch (meta.yml + patch.rb) for a target method. Computes the current fingerprint automatically so the author never does it by hand. The patch.rb skeleton prepends a module that overrides the method and calls super by default.
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/clacky/extension/patch_loader.rb', line 115 def scaffold(id, target, description: "", dir: DEFAULT_DIR) slug = id.to_s.strip.downcase.gsub(/[^a-z0-9_-]+/, "-").gsub(/\A-+|-+\z/, "") raise ArgumentError, "invalid patch id: #{id.inspect}" if slug.empty? fp = fingerprint(target) # also validates the target resolves patch_dir = File.join(dir, slug) raise ArgumentError, "patch already exists: #{patch_dir}" if Dir.exist?(patch_dir) FileUtils.mkdir_p(patch_dir) File.write(File.join(patch_dir, "meta.yml"), <<~YAML) id: #{slug} description: #{description.to_s.empty? ? "(describe what this fixes)" : description} target: "#{target}" fingerprint: "#{fp}" gem_version: "#{Clacky::VERSION}" on_mismatch: disable YAML File.write(File.join(patch_dir, "patch.rb"), patch_skeleton(slug, target)) patch_dir end |