Module: Clacky::PatchLoader
- Defined in:
- lib/clacky/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
- .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
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/clacky/patch_loader.rb', line 207 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
165 166 167 168 169 170 171 172 173 174 |
# File 'lib/clacky/patch_loader.rb', line 165 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
260 261 262 263 264 265 266 267 268 269 |
# File 'lib/clacky/patch_loader.rb', line 260 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
151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/clacky/patch_loader.rb', line 151 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.
119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/clacky/patch_loader.rb', line 119 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
247 248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/clacky/patch_loader.rb', line 247 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
60 61 62 |
# File 'lib/clacky/patch_loader.rb', line 60 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 |
# File 'lib/clacky/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 @last_result = result result end |
.log(level, id, msg) ⇒ Object
277 278 279 |
# File 'lib/clacky/patch_loader.rb', line 277 def log(level, id, msg) Clacky::Logger.public_send(level, "[PatchLoader] #{id}: #{msg}") end |
.method_line_range(file, lineno, name, meth) ⇒ Object
131 132 133 134 135 136 137 138 |
# File 'lib/clacky/patch_loader.rb', line 131 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.
179 180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'lib/clacky/patch_loader.rb', line 179 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
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/clacky/patch_loader.rb', line 92 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
140 141 142 143 144 145 146 147 148 149 |
# File 'lib/clacky/patch_loader.rb', line 140 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
271 272 273 274 275 |
# File 'lib/clacky/patch_loader.rb', line 271 def resolve_const(name) name.split("::").reject(&:empty?).inject(Object) do |mod, part| mod.const_get(part) end end |
.resolve_method(target) ⇒ Object
193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/clacky/patch_loader.rb', line 193 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.
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/clacky/patch_loader.rb', line 70 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 |