From 833b4b956717a6f761909e22d1321062daf35d50 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Wed, 15 Apr 2026 16:04:34 -0400 Subject: [PATCH 01/31] Add new Ball API methods - remove_entry - count - files --- lib/codeball/ball.rb | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/lib/codeball/ball.rb b/lib/codeball/ball.rb index 7eb6bf1..c1482f1 100644 --- a/lib/codeball/ball.rb +++ b/lib/codeball/ball.rb @@ -6,6 +6,8 @@ module Codeball # wires Cursor -> Stream -> Ball. # class Ball + attr_reader :entries, :warnings + def self.parse(text) raise MalformedBallError, "empty input, nothing to extract" if text.nil? || text.strip.empty? @@ -16,6 +18,12 @@ def self.parse(text) ball end + def self.load_file(path) + pathname = Pathname(path) + raise "No file found" unless pathname.file? + parse(pathname.read) + end + def initialize @entries = [] @warnings = [] @@ -27,6 +35,18 @@ def add_entry(entry) @warnings << "truncated entry for #{entry.path.inspect} - missing END marker" if entry.truncated? end + def remove_entry(identifier) + to_remove = ( + case identifier + in Entry then identifier + in String then each_entry.find { it.header == identifier } + else raise ArgumentError, "#{identifier} is not a valid Entry or identifier" + end + ) + raise ArgumentError, "#{identifier} did not match an existing Entry in this Ball" unless to_remove + @entries.delete(to_remove) + end + def validate! valid = entries.select(&:valid?) if valid.empty? && warnings.any? @@ -36,6 +56,14 @@ def validate! end end + def files + each_entry.map(&:header) + end + + def entry_count + each_entry.count + end + def each_entry(&) = entries.select(&:valid?).each(&) def each_text_entry(&) = entries.select(&:valid?).select(&:text?).each(&) def each_non_text_entry(&) = entries.select(&:valid?).reject(&:text?).each(&) @@ -46,9 +74,5 @@ def warning_count = warnings.length def serialize entries.select(&:valid?).select(&:text?).map(&:serialize).join end - - private - - attr_reader :entries, :warnings end end From 8636b9f1501b769ccce796e62499a75bb70f2719 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Wed, 15 Apr 2026 16:54:05 -0400 Subject: [PATCH 02/31] DRY up Entry and Ball methods --- lib/codeball/ball.rb | 10 ++++++---- lib/codeball/entry.rb | 43 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/lib/codeball/ball.rb b/lib/codeball/ball.rb index c1482f1..0df437a 100644 --- a/lib/codeball/ball.rb +++ b/lib/codeball/ball.rb @@ -21,6 +21,7 @@ def self.parse(text) def self.load_file(path) pathname = Pathname(path) raise "No file found" unless pathname.file? + parse(pathname.read) end @@ -44,6 +45,7 @@ def remove_entry(identifier) end ) raise ArgumentError, "#{identifier} did not match an existing Entry in this Ball" unless to_remove + @entries.delete(to_remove) end @@ -65,14 +67,14 @@ def entry_count end def each_entry(&) = entries.select(&:valid?).each(&) - def each_text_entry(&) = entries.select(&:valid?).select(&:text?).each(&) - def each_non_text_entry(&) = entries.select(&:valid?).reject(&:text?).each(&) + def each_text_entry(&) = each_entry.select(&:text?).each(&) + def each_non_text_entry(&) = each_entry.reject(&:text?).each(&) def each_warning(&) = warnings.each(&) - def all_text? = entries.select(&:valid?).all?(&:text?) + def all_text? = each_entry.all?(&:text?) def warning_count = warnings.length def serialize - entries.select(&:valid?).select(&:text?).map(&:serialize).join + each_text_entry.map(&:serialize).join end end end diff --git a/lib/codeball/entry.rb b/lib/codeball/entry.rb index c3b576e..2553b1f 100644 --- a/lib/codeball/entry.rb +++ b/lib/codeball/entry.rb @@ -39,6 +39,12 @@ def initialize @magic_client = self.class.magic_client end + def name=(name) + stringified_name = name.to_s + self.header = stringified_name + self.footer = stringified_name + end + def header=(header) if @header @error = "duplicate header: already have #{@header}, received #{header}" @@ -64,25 +70,48 @@ def footer=(footer) @error = "footer #{footer} does not match header #{header}" unless footer_matches_header? end - def valid? = !!(header && body && footer && !errors? && footer_matches_header?) - def incomplete? = !valid? && !errors? + def valid? + return false unless header + return false unless footer + return false if errors? + return false unless footer_matches_header? + + true + end + + def contents = body&.to_s + def header? = !header.nil? && !header.empty? + def footer? = !footer.nil? && !footer.empty? + def contents? = !contents.nil? && !contents.empty? + def empty? = !contents? def errors? = !error.nil? - def truncated? = !!(header && (body.nil? || footer.nil?) && !errors?) + def invalid? = !valid? + def incomplete? = invalid? && !errors? + + def truncated? + missing_body_or_footer = body.nil? || footer.nil? + return false unless header? + return false unless missing_body_or_footer + return false if errors? + + true + end def path = header&.to_s - def contents = body&.to_s - def empty? = contents&.empty? || contents.nil? def byte_size = contents&.bytesize || 0 def line_count - return 0 if contents.nil? || contents.empty? + return 0 if empty? contents.count("\n") + (contents.end_with?("\n") ? 0 : 1) end def text? - contents.nil? || contents.empty? || !mime_type.include?("charset=binary") + return true if empty? + return false if mime_type.include?("charset=binary") + + true end def serialize From 4872e34eb1eab1585e1d626ba1801f68a78f3753 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Wed, 15 Apr 2026 18:11:24 -0400 Subject: [PATCH 03/31] Save tarball scripts for now --- tar2ball.rb | 23 +++++++++++++++++++++++ tarscript.rb | 14 ++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tar2ball.rb create mode 100644 tarscript.rb diff --git a/tar2ball.rb b/tar2ball.rb new file mode 100644 index 0000000..412861a --- /dev/null +++ b/tar2ball.rb @@ -0,0 +1,23 @@ +require "zlib" +require_relative "lib/codeball" +require "rubygems/package" + +gzipped_io = File.open("ball.tar.gz", "rb") +io = Zlib::GzipReader.wrap(gzipped_io) +tar_reader = Gem::Package::TarReader.new(io) +class LazyEntry +end + +ball = tar_reader + .lazy + .map { |te| + Codeball::Entry.new.tap { |ce| + ce.name = te.header.name + ce.body = te.read + } +} + .reject { it.body.empty? } + .inject(Codeball::Ball.new) { |ball, entry| ball.tap { it.add_entry entry } } + +binding.irb +puts ball.serialize diff --git a/tarscript.rb b/tarscript.rb new file mode 100644 index 0000000..a3977ab --- /dev/null +++ b/tarscript.rb @@ -0,0 +1,14 @@ +require "codeball" +require "ronin/support" +include Ronin::Support + +tar = Archive::Tar::Reader.new(gzip_open("ball.tar.gz")) +tar + .tap(&:rewind) + .map { |tarentry| + Codeball::Entry.new.tap { + it.header = tarentry.header.name + it.body = tarentry.read + } +} + .then { p it } From 4f958d41b283189767c758c290699bb651fa1cb4 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Wed, 15 Apr 2026 20:15:06 -0400 Subject: [PATCH 04/31] Add tar -xO analagous option to unpack --- lib/codeball/commands/unpack.rb | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/lib/codeball/commands/unpack.rb b/lib/codeball/commands/unpack.rb index 54a1a9c..7352779 100644 --- a/lib/codeball/commands/unpack.rb +++ b/lib/codeball/commands/unpack.rb @@ -1,12 +1,14 @@ -require "command_kit/commands/command" +require "command_kit/command" require "command_kit/colors" +require 'command_kit/open' module Codeball module Commands # Extract files from a codeball. # - class Unpack < CommandKit::Commands::Command + class Unpack < CommandKit::Command include CommandKit::Colors + include CommandKit::Open usage "[options] [FILE]" description "Extract files from a codeball" @@ -15,6 +17,7 @@ class Unpack < CommandKit::Commands::Command value: { type: String, default: "." }, desc: "Output directory" + option :stdout, short: '-O', desc: "Write file contents to stdout instead of to files. (Analagous to tar -Ox)" option :dry_run, short: "-n", desc: "Preview extraction without writing files" @@ -30,11 +33,19 @@ class Unpack < CommandKit::Commands::Command "< bundle.txt", ] - def run(file = nil) - ball = Ball.parse(read_input(file)) - dest = build_destination + def run(file = '-') + ball = read_input(file) + .then { Ball.parse(it) } ball.each_warning { |msg| warn colors.yellow("warning: #{msg}") } + + if options[:stdout] + ball.each_entry { stdout.puts it.body } + return + end + + dest = build_destination + ball.each_entry { |entry| dest.write(entry) { |outcome| print_outcome(outcome) } } print_summary(dest.summary(malformed: ball.warning_count)) @@ -47,9 +58,7 @@ def build_destination end def read_input(file) - ARGV.replace(file ? [file] : []) - input = ARGF.read - + input = open(file).read abort_on_empty(input) input end From 18f19bea83ddc6ff5da47e3f69c95eb9dacaaa43 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Wed, 15 Apr 2026 20:16:03 -0400 Subject: [PATCH 05/31] Add filter command --- lib/codeball/commands/filter.rb | 68 +++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 lib/codeball/commands/filter.rb diff --git a/lib/codeball/commands/filter.rb b/lib/codeball/commands/filter.rb new file mode 100644 index 0000000..e75da77 --- /dev/null +++ b/lib/codeball/commands/filter.rb @@ -0,0 +1,68 @@ +require "command_kit/command" +require 'command_kit/open' +require "command_kit/colors" + +module Codeball + module Commands + # List files contained in a codeball. + class Filter < CommandKit::Command + include CommandKit::Open + include CommandKit::Colors + + usage "[options] [FILE]" + description "Filter entries in a codeball" + + option :inverse, short: '-v', desc: 'Reverse direction of filtering' + + argument :patterns, required: true, repeats: true, desc: "Patterns to filter on" + argument :file, required: false, desc: "Codeball file (or stdin if omitted)" + + examples ["bundle.txt", "< bundle.txt"] + + def env + (super || {}).merge("TERM" => "1") + end + + def run(*args) + file = ( + if stdin.tty? + args => [*patterns, path] + path + else + args => [*patterns] + '-' + end + ) + io = open file + input = io.read + + abort_if_empty(input) + + ball = Ball.parse(input) + + ball.each_warning { |msg| stderr.puts colors.yellow("warning: #{msg}") } + + ball + .each_entry + .reject { match?(patterns, it) } + .each { ball.remove_entry it } + + stdout.puts ball.serialize + end + + private + + def match?(patterns, entry) + verb = options[:inverse] ? :none? : :any? + patterns.public_send(verb) { |pattern| File.fnmatch?(pattern, entry.path) } + end + + def abort_if_empty(input) + return unless input.nil? || input.strip.empty? + + print_error "no input" + exit 1 + end + end + end +end From a2d4a54b6cfb0cde4489b21e0d5b1ebdfda95b60 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 16 Apr 2026 20:14:37 -0400 Subject: [PATCH 06/31] Update .gitignore with new items - env.sh off.sh on.sh codeball.txt ball.tar.gz --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 81fa929..4e7bc34 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ Gemfile.lock *.gem .rspec_status .patches/ +env.sh +off.sh +on.sh +codeball.txt +ball.tar.gz From 477f4739eaed79618b82fb2a00bc0fe26190b0a4 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 16 Apr 2026 20:14:57 -0400 Subject: [PATCH 07/31] Make specs more idiomatic, run rubocop --- lib/codeball/commands/filter.rb | 8 ++++---- lib/codeball/commands/unpack.rb | 8 ++++---- spec/integration/list_spec.rb | 22 +++++++++++----------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/lib/codeball/commands/filter.rb b/lib/codeball/commands/filter.rb index e75da77..a7855b4 100644 --- a/lib/codeball/commands/filter.rb +++ b/lib/codeball/commands/filter.rb @@ -1,5 +1,5 @@ require "command_kit/command" -require 'command_kit/open' +require "command_kit/open" require "command_kit/colors" module Codeball @@ -12,7 +12,7 @@ class Filter < CommandKit::Command usage "[options] [FILE]" description "Filter entries in a codeball" - option :inverse, short: '-v', desc: 'Reverse direction of filtering' + option :inverse, short: "-v", desc: "Reverse direction of filtering" argument :patterns, required: true, repeats: true, desc: "Patterns to filter on" argument :file, required: false, desc: "Codeball file (or stdin if omitted)" @@ -30,9 +30,9 @@ def run(*args) path else args => [*patterns] - '-' + "-" end - ) + ) io = open file input = io.read diff --git a/lib/codeball/commands/unpack.rb b/lib/codeball/commands/unpack.rb index 7352779..012d604 100644 --- a/lib/codeball/commands/unpack.rb +++ b/lib/codeball/commands/unpack.rb @@ -1,6 +1,6 @@ require "command_kit/command" require "command_kit/colors" -require 'command_kit/open' +require "command_kit/open" module Codeball module Commands @@ -17,7 +17,7 @@ class Unpack < CommandKit::Command value: { type: String, default: "." }, desc: "Output directory" - option :stdout, short: '-O', desc: "Write file contents to stdout instead of to files. (Analagous to tar -Ox)" + option :stdout, short: "-O", desc: "Write file contents to stdout instead of to files. (Analagous to tar -Ox)" option :dry_run, short: "-n", desc: "Preview extraction without writing files" @@ -33,9 +33,9 @@ class Unpack < CommandKit::Command "< bundle.txt", ] - def run(file = '-') + def run(file = "-") ball = read_input(file) - .then { Ball.parse(it) } + .then { Ball.parse(it) } ball.each_warning { |msg| warn colors.yellow("warning: #{msg}") } diff --git a/spec/integration/list_spec.rb b/spec/integration/list_spec.rb index 893a9cf..c621441 100644 --- a/spec/integration/list_spec.rb +++ b/spec/integration/list_spec.rb @@ -32,21 +32,21 @@ it "exits 0" do expect(result.exit_code).to eq(0) end - end - describe "with empty input" do - let(:result) { run_codeball("list", stdin: "") } + context "with empty input" do + let(:result) { run_codeball("list", stdin: "") } - it "prints an error to stderr" do - expect(result.stderr).to include("no input") - end + it "prints an error to stderr" do + expect(result.stderr).to include("no input") + end - it "exits non-zero" do - expect(result.exit_code).not_to eq(0) + it "exits non-zero" do + expect(result.exit_code).not_to eq(0) + end end end - describe "with a bundle containing multiple files" do + context "with a bundle containing multiple files" do let(:bundle_text) do pack_bundle( ["alpha.rb", "a = 1\n"], @@ -66,7 +66,7 @@ end end - describe "with a truncated bundle" do + context "with a truncated bundle" do let(:full_bundle) do pack_bundle( ["complete.rb", "good = true\n"], @@ -90,7 +90,7 @@ end end - describe "with a fully malformed bundle (no valid entries)" do + context "with a fully malformed bundle (no valid entries)" do let(:result) { run_codeball("list", stdin: "this is not a bundle at all\njust garbage\n") } it "prints an error to stderr" do From 3d4eaf42fff54dac9efd17cec3c3de72a8af9873 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 16 Apr 2026 20:32:19 -0400 Subject: [PATCH 08/31] Add warning gem as dependency --- codeball.gemspec | 1 + 1 file changed, 1 insertion(+) diff --git a/codeball.gemspec b/codeball.gemspec index f11aa22..b4e560d 100644 --- a/codeball.gemspec +++ b/codeball.gemspec @@ -27,5 +27,6 @@ Gem::Specification.new do |spec| spec.add_dependency "command_kit", "~> 0.6" spec.add_dependency "zeitwerk" + spec.add_dependency "warning" spec.metadata["rubygems_mfa_required"] = "true" end From 75923d50d9a52da7b43b4460075c8442bf4f03a4 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 16 Apr 2026 20:34:07 -0400 Subject: [PATCH 09/31] Add more issues --- issues.rec | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/issues.rec b/issues.rec index 67d6dfd..6289bd1 100644 --- a/issues.rec +++ b/issues.rec @@ -59,3 +59,15 @@ Updated: Fri, 10 Apr 2026 01:03:42 -0400 Title: Add option to ignore warnings Description: Add optarg to ignore warnings, and produce exit code 0 if warnings but no errors occur Status: open + +Id: 1FB1874E-39F5-11F1-AB43-FE6CB9572C2F +Updated: Thu, 16 Apr 2026 20:33:58 -0400 +Title: Possible encoding issues on some platforms +Description: claude web reports that "Encoding issue — the em-dashes in comments are UTF-8 but Ruby's opening the file as US-ASCII. " when using codeball in a browser-based sandbox +Status: open + +Id: 672E80EA-39F5-11F1-8B16-FE6CB9572C2F +Updated: Thu, 16 Apr 2026 20:35:58 -0400 +Title: Need to add a dep on ruby-filemagic and/or handle when libmagic-dev is not installed on platform +Description: Claude tried to run, but got an error saying could not load "filemagic." It should be added to gemspec as a dependency or given an alternative if not present +Status: open From ed1e18aadb485e40bcdd17bddc689227f93fc623 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Fri, 29 May 2026 01:05:54 -0400 Subject: [PATCH 10/31] Draft of filter --- lib/codeball/commands/filter.rb | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/codeball/commands/filter.rb b/lib/codeball/commands/filter.rb index a7855b4..ca4dcac 100644 --- a/lib/codeball/commands/filter.rb +++ b/lib/codeball/commands/filter.rb @@ -4,7 +4,7 @@ module Codeball module Commands - # List files contained in a codeball. + # Filter entries in a codeball by glob pattern. class Filter < CommandKit::Command include CommandKit::Open include CommandKit::Colors @@ -14,14 +14,20 @@ class Filter < CommandKit::Command option :inverse, short: "-v", desc: "Reverse direction of filtering" + # Flags chosen so glob semantics match what users expect from shell + # globs: FNM_PATHNAME makes '*' stop at '/' and enables '**/' for + # recursive matching; FNM_EXTGLOB enables '{rb,py}' brace expansion. + FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB + argument :patterns, required: true, repeats: true, desc: "Patterns to filter on" argument :file, required: false, desc: "Codeball file (or stdin if omitted)" - examples ["bundle.txt", "< bundle.txt"] - - def env - (super || {}).merge("TERM" => "1") - end + examples [ + "'*.rb' bundle.txt", + "'*.rb' < bundle.txt", + "'lib/**/*.rb' bundle.txt", + "-v 'test/**' bundle.txt" + ] def run(*args) file = ( @@ -40,7 +46,7 @@ def run(*args) ball = Ball.parse(input) - ball.each_warning { |msg| stderr.puts colors.yellow("warning: #{msg}") } + ball.each_warning { |msg| stderr.puts colors(stderr).yellow("warning: #{msg}") } ball .each_entry @@ -54,7 +60,7 @@ def run(*args) def match?(patterns, entry) verb = options[:inverse] ? :none? : :any? - patterns.public_send(verb) { |pattern| File.fnmatch?(pattern, entry.path) } + patterns.public_send(verb) { |pattern| File.fnmatch?(pattern, entry.path, FNMATCH_FLAGS) } end def abort_if_empty(input) From 5d3e99e1eaa035c71f66a49f30623a34c99b966e Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 2 Jul 2026 03:15:12 +0000 Subject: [PATCH 11/31] Remove throwaway tarball experiment scripts tar2ball.rb and tarscript.rb were scratch experiments (left-in binding.irb, undeclared Ronin::Support). They are not required by any code and were being packaged into the gem. Delete them. --- tar2ball.rb | 23 ----------------------- tarscript.rb | 14 -------------- 2 files changed, 37 deletions(-) delete mode 100644 tar2ball.rb delete mode 100644 tarscript.rb diff --git a/tar2ball.rb b/tar2ball.rb deleted file mode 100644 index 412861a..0000000 --- a/tar2ball.rb +++ /dev/null @@ -1,23 +0,0 @@ -require "zlib" -require_relative "lib/codeball" -require "rubygems/package" - -gzipped_io = File.open("ball.tar.gz", "rb") -io = Zlib::GzipReader.wrap(gzipped_io) -tar_reader = Gem::Package::TarReader.new(io) -class LazyEntry -end - -ball = tar_reader - .lazy - .map { |te| - Codeball::Entry.new.tap { |ce| - ce.name = te.header.name - ce.body = te.read - } -} - .reject { it.body.empty? } - .inject(Codeball::Ball.new) { |ball, entry| ball.tap { it.add_entry entry } } - -binding.irb -puts ball.serialize diff --git a/tarscript.rb b/tarscript.rb deleted file mode 100644 index a3977ab..0000000 --- a/tarscript.rb +++ /dev/null @@ -1,14 +0,0 @@ -require "codeball" -require "ronin/support" -include Ronin::Support - -tar = Archive::Tar::Reader.new(gzip_open("ball.tar.gz")) -tar - .tap(&:rewind) - .map { |tarentry| - Codeball::Entry.new.tap { - it.header = tarentry.header.name - it.body = tarentry.read - } -} - .then { p it } From ee5adbfe46e7d371cc109c886f6b164fbfb47bf6 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 2 Jul 2026 03:20:28 +0000 Subject: [PATCH 12/31] Order gemspec deps; keep planning docs out of VCS and lint warning was listed after zeitwerk, tripping Gemspec/OrderedDependencies. Reorder alphabetically. Also gitignore docs/superpowers/ (local planning artifacts) and exclude docs/**/* from RuboCop so rubocop-md does not lint planning documents. --- .gitignore | 1 + .rubocop.yml | 2 ++ codeball.gemspec | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4e7bc34..0a0c8ad 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ off.sh on.sh codeball.txt ball.tar.gz +docs/superpowers/ diff --git a/.rubocop.yml b/.rubocop.yml index 0278610..3c69c0c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -19,6 +19,8 @@ plugins: AllCops: NewCops: enable TargetRubyVersion: 3.4.8 + Exclude: + - "docs/**/*" # =========================================================================== # Overrides from stock — personal style preferences diff --git a/codeball.gemspec b/codeball.gemspec index b4e560d..caec465 100644 --- a/codeball.gemspec +++ b/codeball.gemspec @@ -26,7 +26,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "command_kit", "~> 0.6" - spec.add_dependency "zeitwerk" spec.add_dependency "warning" + spec.add_dependency "zeitwerk" spec.metadata["rubygems_mfa_required"] = "true" end From 3a7213766f86ffee9bc40c52bab2a70b94f52031 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 2 Jul 2026 03:23:15 +0000 Subject: [PATCH 13/31] Fix filter FILE argument handling and add specs filter decided FILE-vs-stdin from stdin.tty?, so the documented 'filter PATTERN FILE' form silently failed in any non-interactive context (pipes, scripts, CI): the file was swallowed as a pattern and empty stdin was read. Now split_source treats the trailing arg as the ball file when it names a real file, else reads stdin, and the ball is read via File.read/stdin.read (no Kernel#open). Add integration coverage for file/stdin, glob semantics, -v, empty input, and a nonexistent trailing arg. Also clears the AbcSize/MethodLength/Security/Open offenses. --- lib/codeball/commands/filter.rb | 49 +++++++++--------- spec/integration/filter_spec.rb | 92 +++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 24 deletions(-) create mode 100644 spec/integration/filter_spec.rb diff --git a/lib/codeball/commands/filter.rb b/lib/codeball/commands/filter.rb index ca4dcac..6d2990f 100644 --- a/lib/codeball/commands/filter.rb +++ b/lib/codeball/commands/filter.rb @@ -1,12 +1,10 @@ require "command_kit/command" -require "command_kit/open" require "command_kit/colors" module Codeball module Commands # Filter entries in a codeball by glob pattern. class Filter < CommandKit::Command - include CommandKit::Open include CommandKit::Colors usage "[options] [FILE]" @@ -26,37 +24,40 @@ class Filter < CommandKit::Command "'*.rb' bundle.txt", "'*.rb' < bundle.txt", "'lib/**/*.rb' bundle.txt", - "-v 'test/**' bundle.txt" + "-v 'test/**' bundle.txt", ] def run(*args) - file = ( - if stdin.tty? - args => [*patterns, path] - path - else - args => [*patterns] - "-" - end - ) - io = open file - input = io.read - - abort_if_empty(input) + patterns, file = split_source(args) + ball = Ball.parse(read_input(file)) + report_warnings(ball) + drop_unmatched(ball, patterns) + stdout.puts ball.serialize + end - ball = Ball.parse(input) + private - ball.each_warning { |msg| stderr.puts colors(stderr).yellow("warning: #{msg}") } + # The trailing argument is the codeball file when it names a readable + # file on disk; otherwise every argument is a pattern and the ball is + # read from stdin. This behaves the same interactively and in pipes. + def split_source(args) + *leading, last = args + last && File.file?(last) ? [leading, last] : [args, nil] + end - ball - .each_entry - .reject { match?(patterns, it) } - .each { ball.remove_entry it } + def read_input(file) + input = file ? File.read(file) : stdin.read + abort_if_empty(input) + input + end - stdout.puts ball.serialize + def report_warnings(ball) + ball.each_warning { |msg| stderr.puts colors(stderr).yellow("warning: #{msg}") } end - private + def drop_unmatched(ball, patterns) + ball.each_entry.reject { match?(patterns, it) }.each { ball.remove_entry(it) } + end def match?(patterns, entry) verb = options[:inverse] ? :none? : :any? diff --git a/spec/integration/filter_spec.rb b/spec/integration/filter_spec.rb new file mode 100644 index 0000000..2e8432d --- /dev/null +++ b/spec/integration/filter_spec.rb @@ -0,0 +1,92 @@ +require_relative "../spec_helper" + +RSpec.describe "codeball filter", type: :integration do + include CLIHelper + + # A ball with entries at several depths so glob semantics are observable. + let(:bundle) do + pack_bundle( + ["lib/app.rb", "puts :app\n"], + ["lib/nested/helper.rb", "puts :helper\n"], + ["test/app_test.rb", "puts :test\n"], + ["README.md", "# readme\n"], + ) + end + let(:bundle_path) { create_file("bundle.txt", bundle) } + + # Extract the entry paths from a serialized ball (BEGIN "path" lines). + def entries_in(ball_text) + ball_text.scan(/^BEGIN "(.+)"$/).flatten + end + + describe "with a FILE argument and non-interactive stdin" do + # Regression: the FILE argument must be honored even when stdin is not a + # TTY (pipes, scripts, CI). Previously the file was ignored and treated + # as another pattern, so this errored with "no input". + let(:result) { run_codeball("filter", "lib/**/*.rb", bundle_path) } + + it "reads the ball from the file and keeps only matching entries" do + expect(entries_in(result.stdout)).to contain_exactly("lib/app.rb", "lib/nested/helper.rb") + end + + it "exits 0" do + expect(result.exit_code).to eq(0) + end + end + + describe "reading from stdin" do + let(:result) { run_codeball("filter", "lib/**/*.rb", stdin: bundle) } + + it "keeps only matching entries" do + expect(entries_in(result.stdout)).to contain_exactly("lib/app.rb", "lib/nested/helper.rb") + end + end + + describe "glob semantics" do + it "treats '*' as non-recursive (stops at '/')" do + result = run_codeball("filter", "*.md", stdin: bundle) + expect(entries_in(result.stdout)).to contain_exactly("README.md") + end + + it "ORs multiple patterns together" do + result = run_codeball("filter", "README.md", "test/**", stdin: bundle) + expect(entries_in(result.stdout)).to contain_exactly("README.md", "test/app_test.rb") + end + end + + describe "with --inverse (-v)" do + let(:result) { run_codeball("filter", "-v", "test/**", stdin: bundle) } + + it "keeps entries that do NOT match" do + expect(entries_in(result.stdout)).to contain_exactly( + "lib/app.rb", "lib/nested/helper.rb", "README.md" + ) + end + end + + describe "with empty input" do + let(:result) { run_codeball("filter", "*.rb", stdin: "") } + + it "prints an error to stderr" do + expect(result.stderr).to include("no input") + end + + it "exits non-zero" do + expect(result.exit_code).not_to eq(0) + end + end + + describe "with a nonexistent trailing argument and no stdin" do + # A trailing arg that is not a file is treated as another pattern; with + # no piped ball there is nothing to filter, so the command fails loudly. + let(:result) { run_codeball("filter", "*.rb", "no_such_ball.txt", stdin: "") } + + it "exits non-zero" do + expect(result.exit_code).not_to eq(0) + end + + it "reports no input on stderr" do + expect(result.stderr).to include("no input") + end + end +end From 390ea1d61b1bf858781b1bc90aa71578716cee51 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 2 Jul 2026 03:37:20 +0000 Subject: [PATCH 14/31] Read unpack input via File.read, add -O and not-found coverage The -O option introduced an open-based read (Security/Open) and pushed run over AbcSize. Match filter's approach: read via File.read/stdin.read (no Kernel#open) with an ENOENT guard, and extract report_warnings/dump_to_stdout/extract_to_disk. Emit entry.contents for -O. Add integration tests for the -O dump-to-stdout path and the nonexistent-file error. --- lib/codeball/commands/unpack.rb | 40 +++++++++++++++++------------ spec/integration/unpack_spec.rb | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/lib/codeball/commands/unpack.rb b/lib/codeball/commands/unpack.rb index 012d604..fdc609d 100644 --- a/lib/codeball/commands/unpack.rb +++ b/lib/codeball/commands/unpack.rb @@ -1,6 +1,5 @@ require "command_kit/command" require "command_kit/colors" -require "command_kit/open" module Codeball module Commands @@ -8,7 +7,6 @@ module Commands # class Unpack < CommandKit::Command include CommandKit::Colors - include CommandKit::Open usage "[options] [FILE]" description "Extract files from a codeball" @@ -33,37 +31,47 @@ class Unpack < CommandKit::Command "< bundle.txt", ] - def run(file = "-") - ball = read_input(file) - .then { Ball.parse(it) } + def run(file = nil) + ball = Ball.parse(read_input(file)) + report_warnings(ball) + return dump_to_stdout(ball) if options[:stdout] + extract_to_disk(ball) + end + + private + + def report_warnings(ball) ball.each_warning { |msg| warn colors.yellow("warning: #{msg}") } + end - if options[:stdout] - ball.each_entry { stdout.puts it.body } - return - end + def dump_to_stdout(ball) + ball.each_entry { |entry| stdout.puts entry.contents } + end + def extract_to_disk(ball) dest = build_destination - ball.each_entry { |entry| dest.write(entry) { |outcome| print_outcome(outcome) } } - print_summary(dest.summary(malformed: ball.warning_count)) end - private - def build_destination Destination.new(options[:output_dir], dry_run: options[:dry_run]) end def read_input(file) - input = open(file).read - abort_on_empty(input) + input = case file + when nil, "-" then stdin.read + else File.read(file) + end + abort_if_empty(input) input + rescue Errno::ENOENT + print_error "no such file: #{file}" + exit 1 end - def abort_on_empty(input) + def abort_if_empty(input) return unless input.nil? || input.strip.empty? print_error "no input" diff --git a/spec/integration/unpack_spec.rb b/spec/integration/unpack_spec.rb index 7cac80d..aeff7a9 100644 --- a/spec/integration/unpack_spec.rb +++ b/spec/integration/unpack_spec.rb @@ -3,6 +3,51 @@ RSpec.describe "codeball unpack", type: :integration do include CLIHelper + describe "with --stdout (-O)" do + let(:bundle) do + ball_text_for("a.txt", "alpha\n") + ball_text_for("b.txt", "beta\n") + end + let(:result) { run_codeball("unpack", "-O", stdin: bundle) } + + it "writes file contents to stdout" do + expect(result.stdout).to include("alpha") + expect(result.stdout).to include("beta") + end + + it "does not write any files to disk" do + result + expect(output_path("a.txt")).not_to exist + expect(output_path("b.txt")).not_to exist + end + + it "exits 0" do + expect(result.exit_code).to eq(0) + end + end + + describe "with a nonexistent FILE argument" do + let(:result) { run_codeball("unpack", "no_such_bundle.txt") } + + it "prints a helpful error naming the file" do + expect(result.stderr).to match(/no such file/i) + expect(result.stderr).to include("no_such_bundle.txt") + end + + it "exits non-zero" do + expect(result.exit_code).not_to eq(0) + end + end + + describe "with an explicit '-' file argument" do + let(:bundle) { ball_text_for("dash.txt", "via dash\n") } + let(:result) { run_codeball("unpack", "-O", "-", stdin: bundle) } + + it "treats - as stdin" do + expect(result.stdout).to include("via dash") + expect(result.exit_code).to eq(0) + end + end + describe "extracting from a file argument" do let(:bundle) { pack_bundle(["hello.txt", "hello world\n"]) } let(:bundle_path) { create_file("bundle.txt", bundle) } From 9df2d4691bb42c223a1b70f12e32f53e3495345d Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 2 Jul 2026 03:59:48 +0000 Subject: [PATCH 15/31] Require a body for Entry#valid? A DRY refactor dropped the body check from valid?, so an entry with a matching header and footer but no body was both valid? and truncated? (contradictory) -- reachable via Entry#name=. Restore the body guard to match master's behavior. Empty files are unaffected (they carry a non-nil empty Body). --- lib/codeball/entry.rb | 1 + spec/codeball/entry_spec.rb | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/lib/codeball/entry.rb b/lib/codeball/entry.rb index 2553b1f..bf4af1b 100644 --- a/lib/codeball/entry.rb +++ b/lib/codeball/entry.rb @@ -72,6 +72,7 @@ def footer=(footer) def valid? return false unless header + return false unless body return false unless footer return false if errors? return false unless footer_matches_header? diff --git a/spec/codeball/entry_spec.rb b/spec/codeball/entry_spec.rb index 1ca5c9e..4088960 100644 --- a/spec/codeball/entry_spec.rb +++ b/spec/codeball/entry_spec.rb @@ -148,6 +148,17 @@ expect(entry.error).to include("duplicate footer") end end + + context "with a matching footer but no body" do + before do + entry.header = Codeball::Header.new("hello.rb") + entry.footer = Codeball::Footer.new("hello.rb") + end + + it "is not valid" do + expect(entry.valid?).to be false + end + end end describe "#truncated?" do From 7aaec25b15a84961adaa07ba8b9e6d9e44312230 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 2 Jul 2026 04:00:29 +0000 Subject: [PATCH 16/31] Emit raw bytes for unpack -O dump_to_stdout used puts, which appends a newline to content lacking a trailing one -- breaking the advertised tar -Ox parity and corrupting binary output. Use print for a raw byte copy, and assert exact output in the spec. Also fix an Analagous->Analogous typo in the option help. --- lib/codeball/commands/unpack.rb | 4 ++-- spec/integration/unpack_spec.rb | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/codeball/commands/unpack.rb b/lib/codeball/commands/unpack.rb index fdc609d..d0e42c9 100644 --- a/lib/codeball/commands/unpack.rb +++ b/lib/codeball/commands/unpack.rb @@ -15,7 +15,7 @@ class Unpack < CommandKit::Command value: { type: String, default: "." }, desc: "Output directory" - option :stdout, short: "-O", desc: "Write file contents to stdout instead of to files. (Analagous to tar -Ox)" + option :stdout, short: "-O", desc: "Write file contents to stdout instead of to files. (Analogous to tar -Ox)" option :dry_run, short: "-n", desc: "Preview extraction without writing files" @@ -46,7 +46,7 @@ def report_warnings(ball) end def dump_to_stdout(ball) - ball.each_entry { |entry| stdout.puts entry.contents } + ball.each_entry { |entry| stdout.print entry.contents } end def extract_to_disk(ball) diff --git a/spec/integration/unpack_spec.rb b/spec/integration/unpack_spec.rb index aeff7a9..243c55f 100644 --- a/spec/integration/unpack_spec.rb +++ b/spec/integration/unpack_spec.rb @@ -4,14 +4,15 @@ include CLIHelper describe "with --stdout (-O)" do + # Include a file with NO trailing newline to prove -O emits raw bytes + # (tar -Ox parity) and does not append a newline of its own. let(:bundle) do - ball_text_for("a.txt", "alpha\n") + ball_text_for("b.txt", "beta\n") + ball_text_for("a.txt", "alpha\n") + ball_text_for("b.txt", "beta-no-newline") end let(:result) { run_codeball("unpack", "-O", stdin: bundle) } - it "writes file contents to stdout" do - expect(result.stdout).to include("alpha") - expect(result.stdout).to include("beta") + it "writes raw file contents to stdout with no added newline" do + expect(result.stdout).to eq("alpha\nbeta-no-newline") end it "does not write any files to disk" do From a6686f53dbd23629d0964c793ea08b2d29c31034 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 2 Jul 2026 16:19:10 +0000 Subject: [PATCH 17/31] Harden filter argument parsing split_source consumed a lone argument as the ball file whenever it named an existing file, leaving zero patterns so every entry was silently dropped; and it treated an unreadable file as the source, raising an uncaught Errno::EACCES. Only take the trailing arg as the file when a preceding pattern exists and the file is readable; otherwise treat it as a pattern and read stdin. Add regression specs for both. --- lib/codeball/commands/filter.rb | 11 +++++++---- spec/integration/filter_spec.rb | 31 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/lib/codeball/commands/filter.rb b/lib/codeball/commands/filter.rb index 6d2990f..ad74603 100644 --- a/lib/codeball/commands/filter.rb +++ b/lib/codeball/commands/filter.rb @@ -37,12 +37,15 @@ def run(*args) private - # The trailing argument is the codeball file when it names a readable - # file on disk; otherwise every argument is a pattern and the ball is - # read from stdin. This behaves the same interactively and in pipes. + # The trailing argument is the codeball file only when there is at + # least one preceding pattern and it names a readable file on disk; + # otherwise every argument is a pattern and the ball is read from + # stdin. Requiring a preceding pattern keeps a lone argument a pattern + # (so `filter '*.rb'` filters stdin instead of trying to open '*.rb') + # and guarantees patterns is never empty. def split_source(args) *leading, last = args - last && File.file?(last) ? [leading, last] : [args, nil] + leading.any? && File.file?(last) && File.readable?(last) ? [leading, last] : [args, nil] end def read_input(file) diff --git a/spec/integration/filter_spec.rb b/spec/integration/filter_spec.rb index 2e8432d..dfde67c 100644 --- a/spec/integration/filter_spec.rb +++ b/spec/integration/filter_spec.rb @@ -89,4 +89,35 @@ def entries_in(ball_text) expect(result.stderr).to include("no input") end end + + describe "when the sole argument also names a file on disk (finding A)" do + # pack_bundle wrote lib/app.rb into the working dir, so that name is now + # both a valid glob pattern AND an existing file. A lone argument must + # stay a pattern -- it must never be consumed as the ball file, which + # would leave zero patterns and silently drop every entry. + let(:result) { run_codeball("filter", "lib/app.rb", stdin: bundle) } + + it "treats it as a pattern and filters stdin" do + expect(entries_in(result.stdout)).to contain_exactly("lib/app.rb") + end + + it "exits 0" do + expect(result.exit_code).to eq(0) + end + end + + describe "when the trailing argument is an unreadable file (finding E)" do + before { skip "chmod has no effect when running as root" if Process.uid.zero? } + + let(:result) do + path = create_file("locked.ball", "secret\n") + File.chmod(0o000, path) + run_codeball("filter", "lib/**/*.rb", "locked.ball", stdin: bundle) + end + + it "treats the unreadable file as a pattern and filters stdin instead of crashing" do + expect(result.exit_code).to eq(0) + expect(entries_in(result.stdout)).to contain_exactly("lib/app.rb", "lib/nested/helper.rb") + end + end end From 44bfd9b1b2dfa494ad3cd4c01323ec6fb2858bb2 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 2 Jul 2026 16:23:08 +0000 Subject: [PATCH 18/31] Make Ball#entries and #warnings private again These readers were public, exposing the live internal arrays so callers could push entries/warnings directly and bypass add_entry's warning bookkeeping. Restore them to private (as on master); nothing reads them externally. Add a spec locking the encapsulation. --- lib/codeball/ball.rb | 6 ++++-- spec/codeball/ball_spec.rb | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/codeball/ball.rb b/lib/codeball/ball.rb index 0df437a..e12e0c7 100644 --- a/lib/codeball/ball.rb +++ b/lib/codeball/ball.rb @@ -6,8 +6,6 @@ module Codeball # wires Cursor -> Stream -> Ball. # class Ball - attr_reader :entries, :warnings - def self.parse(text) raise MalformedBallError, "empty input, nothing to extract" if text.nil? || text.strip.empty? @@ -76,5 +74,9 @@ def warning_count = warnings.length def serialize each_text_entry.map(&:serialize).join end + + private + + attr_reader :entries, :warnings end end diff --git a/spec/codeball/ball_spec.rb b/spec/codeball/ball_spec.rb index f29deb3..8c208ce 100644 --- a/spec/codeball/ball_spec.rb +++ b/spec/codeball/ball_spec.rb @@ -38,6 +38,18 @@ def serialize_entry(path, contents) serialize_entry("lib/greet.rb", "def greet\n 'hi'\nend\n") end + describe "encapsulation" do + it "keeps entries a private reader" do + expect(described_class.public_method_defined?(:entries)).to be false + expect(described_class.private_method_defined?(:entries)).to be true + end + + it "keeps warnings a private reader" do + expect(described_class.public_method_defined?(:warnings)).to be false + expect(described_class.private_method_defined?(:warnings)).to be true + end + end + describe ".parse" do context "with valid two-entry codeball text" do let(:ball) { described_class.parse(ball_text) } From c476fe8a57fa4f14f06e90e24689487709fdf5f9 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 2 Jul 2026 16:24:32 +0000 Subject: [PATCH 19/31] Fix Ball doc comment about filesystem access The class comment claimed Ball does not touch the filesystem, but the load_file factory reads from disk. Reword to say an instance does no I/O while the load_file factory reads the source file. --- lib/codeball/ball.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/codeball/ball.rb b/lib/codeball/ball.rb index e12e0c7..b76947d 100644 --- a/lib/codeball/ball.rb +++ b/lib/codeball/ball.rb @@ -2,8 +2,10 @@ module Codeball # A codeball -- the aggregate root. # # Ball starts empty and grows as entries are added, like a snowball. - # It does not touch the filesystem. Parse is a thin factory that - # wires Cursor -> Stream -> Ball. + # An instance holds parsed entries in memory and does no I/O itself. + # Two class factories build one from source: parse (from an in-memory + # string, wiring Cursor -> Stream -> Ball) and load_file (which reads + # the source file from disk, then parses it). # class Ball def self.parse(text) From 429af6f9a26d52cf9dbebc8981d7aad3e0ef262e Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 2 Jul 2026 16:25:44 +0000 Subject: [PATCH 20/31] Remove unused Entry#name= setter name= had no callers and set header+footer without a body, yielding an entry that was both invalid? and truncated? -- a third construction path that violated the documented Header -> Body -> Footer state machine. Delete it; entries are built via the Stream and Entry.from_file paths. --- lib/codeball/entry.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/codeball/entry.rb b/lib/codeball/entry.rb index bf4af1b..125be3c 100644 --- a/lib/codeball/entry.rb +++ b/lib/codeball/entry.rb @@ -39,12 +39,6 @@ def initialize @magic_client = self.class.magic_client end - def name=(name) - stringified_name = name.to_s - self.header = stringified_name - self.footer = stringified_name - end - def header=(header) if @header @error = "duplicate header: already have #{@header}, received #{header}" From 272d18f915566e2839484c6aa8cc24ae5ff7288d Mon Sep 17 00:00:00 2001 From: David Gillis Date: Tue, 7 Jul 2026 15:03:15 -0400 Subject: [PATCH 21/31] Bump version to 0.2.1 --- lib/codeball/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/codeball/version.rb b/lib/codeball/version.rb index 29b8708..44266d4 100644 --- a/lib/codeball/version.rb +++ b/lib/codeball/version.rb @@ -1,3 +1,3 @@ module Codeball - VERSION = "0.2.0".freeze + VERSION = "0.2.1".freeze end From b185b9294962c6ea18074aae30f012e3838a92de Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 6 Aug 2026 13:20:37 -0400 Subject: [PATCH 22/31] Add rubocop-rspec --- Gemfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile b/Gemfile index 7086925..58da6aa 100644 --- a/Gemfile +++ b/Gemfile @@ -16,5 +16,6 @@ gem "rubocop-md" gem "rubocop-minitest" gem "rubocop-performance" gem "rubocop-rake" +gem "rubocop-rspec" gem "ruby-filemagic", "~> 0.7.3" gem "warning", "~> 1.5" From f7cdd56c36d5444a1a0d323d7516c2fce1442a56 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 6 Aug 2026 13:31:55 -0400 Subject: [PATCH 23/31] Add missing rubocop-rubycw dep --- .idea/codeball.iml | 7 +++---- .rubocop.yml | 5 ++--- Gemfile | 1 + 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.idea/codeball.iml b/.idea/codeball.iml index 3c21da6..1cf406e 100644 --- a/.idea/codeball.iml +++ b/.idea/codeball.iml @@ -1,8 +1,5 @@ - - - @@ -14,7 +11,7 @@ - + @@ -53,6 +50,8 @@ + + diff --git a/.rubocop.yml b/.rubocop.yml index 3c69c0c..a662724 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -14,6 +14,7 @@ plugins: - rubocop-performance - rubocop-minitest - rubocop-md + - rubocop-rubycw - rubocop-rake AllCops: @@ -339,6 +340,4 @@ Style/TrailingCommaInHashLiteral: EnforcedStyleForMultiline: comma Style/TrailingCommaInArguments: - EnforcedStyleForMultiline: comma - - + EnforcedStyleForMultiline: comma \ No newline at end of file diff --git a/Gemfile b/Gemfile index 58da6aa..55ac278 100644 --- a/Gemfile +++ b/Gemfile @@ -17,5 +17,6 @@ gem "rubocop-minitest" gem "rubocop-performance" gem "rubocop-rake" gem "rubocop-rspec" +gem "rubocop-rubycw" gem "ruby-filemagic", "~> 0.7.3" gem "warning", "~> 1.5" From 45d2a0e50a4b4b1744c9e5403aac0e2f4d500fca Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 6 Aug 2026 13:33:12 -0400 Subject: [PATCH 24/31] noop From 41dfbee05c7d2be32aad9bf2247d6fe36bf40334 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 6 Aug 2026 13:48:45 -0400 Subject: [PATCH 25/31] Update config --- .idea/inspectionProfiles/Project_Default.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 9166713..80b1919 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -2,5 +2,6 @@ \ No newline at end of file From d0a4d30ae6ee0b2e3056cecfc2973d6efd336a0b Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 6 Aug 2026 13:48:59 -0400 Subject: [PATCH 26/31] Add name setter on entry --- lib/codeball/entry.rb | 39 ++++++++++++++++++++++++------------- spec/codeball/entry_spec.rb | 17 ++++++++++++++++ 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/lib/codeball/entry.rb b/lib/codeball/entry.rb index 125be3c..0ffcc3f 100644 --- a/lib/codeball/entry.rb +++ b/lib/codeball/entry.rb @@ -19,12 +19,10 @@ def self.from_file(path) pathname = Pathname.new(path) return nil unless pathname.exist? && pathname.readable? - entry = new - name = pathname.to_s - entry.header = Header.new(name) - entry.body = Body.new(pathname.read) - entry.footer = Footer.new(name) - entry + new do |entry| + entry.body = Body.new(pathname.read) + entry.name = pathname.to_s + end end def self.magic_client @@ -37,6 +35,12 @@ def initialize @footer = nil @error = nil @magic_client = self.class.magic_client + yield self if block_given? + end + + def name=(name) + self.header = Header.new(name) + self.footer = Footer.new(name) end def header=(header) @@ -47,14 +51,6 @@ def header=(header) @header = header end - def body=(body) - if @body - @error = "duplicate body for #{path}" - return - end - @body = body - end - def footer=(footer) if @footer @error = "duplicate footer for #{path}" @@ -64,6 +60,14 @@ def footer=(footer) @error = "footer #{footer} does not match header #{header}" unless footer_matches_header? end + def body=(body) + if @body + @error = "duplicate body for #{path}" + return + end + @body = body + end + def valid? return false unless header return false unless body @@ -75,12 +79,19 @@ def valid? end def contents = body&.to_s + def header? = !header.nil? && !header.empty? + def footer? = !footer.nil? && !footer.empty? + def contents? = !contents.nil? && !contents.empty? + def empty? = !contents? + def errors? = !error.nil? + def invalid? = !valid? + def incomplete? = invalid? && !errors? def truncated? diff --git a/spec/codeball/entry_spec.rb b/spec/codeball/entry_spec.rb index 4088960..f741ce3 100644 --- a/spec/codeball/entry_spec.rb +++ b/spec/codeball/entry_spec.rb @@ -31,6 +31,23 @@ end end + describe "#name" do + subject { described_class.new } + let(:name) { "myclass.rb" } + + it "sets the header" do + expect { + subject.name = name + }.to change { subject.header }.from(nil).to(Codeball::Header.new(name)) + end + + it "sets the footer" do + expect { + subject.name = name + }.to change { subject.footer }.from(nil).to(Codeball::Footer.new(name)) + end + end + describe "#header=" do let(:entry) { described_class.new } From f39dcbbd3e178dc045976356ea6d630a20c67eb4 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 6 Aug 2026 14:07:57 -0400 Subject: [PATCH 27/31] Use subject over let --- spec/codeball/ball_spec.rb | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/spec/codeball/ball_spec.rb b/spec/codeball/ball_spec.rb index 8c208ce..c2defdb 100644 --- a/spec/codeball/ball_spec.rb +++ b/spec/codeball/ball_spec.rb @@ -52,7 +52,7 @@ def serialize_entry(path, contents) describe ".parse" do context "with valid two-entry codeball text" do - let(:ball) { described_class.parse(ball_text) } + subject(:ball) { described_class.parse(ball_text) } it "returns a Ball" do expect(ball).to be_a(described_class) @@ -95,7 +95,7 @@ def serialize_entry(path, contents) incomplete = "#{border}\nBEGIN \"orphan.rb\"\n#{border}\norphan content\n" complete + incomplete end - let(:ball) { described_class.parse(truncated_text) } + subject(:ball) { described_class.parse(truncated_text) } it "returns a Ball" do expect(ball).to be_a(described_class) @@ -120,7 +120,7 @@ def serialize_entry(path, contents) end describe ".new" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } it "creates an empty Ball" do entries = [] @@ -134,7 +134,8 @@ def serialize_entry(path, contents) end describe "#add_entry" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } + context "with a valid entry" do before { ball.add_entry(valid_entry) } @@ -184,7 +185,7 @@ def serialize_entry(path, contents) end describe "#each_entry" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } before do ball.add_entry(valid_entry(path: "hello.rb")) @@ -205,7 +206,7 @@ def serialize_entry(path, contents) end describe "#each_text_entry" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } before do ball.add_entry(valid_entry) @@ -220,7 +221,7 @@ def serialize_entry(path, contents) end describe "#each_non_text_entry" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } before do ball.add_entry(valid_entry) @@ -235,7 +236,7 @@ def serialize_entry(path, contents) end describe "#all_text?" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } context "when all entries are text" do before { ball.add_entry(valid_entry) } @@ -258,7 +259,7 @@ def serialize_entry(path, contents) end describe "#serialize" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } describe "output format" do before { ball.add_entry(valid_entry) } @@ -285,7 +286,7 @@ def serialize_entry(path, contents) end describe "#validate!" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } context "with entries present" do before { ball.add_entry(valid_entry) } From e0bb5d8723e31e2322eaa7477f27faad9dcfc808 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 6 Aug 2026 14:08:17 -0400 Subject: [PATCH 28/31] Add builder pattern support --- lib/codeball/ball.rb | 5 ++++- spec/codeball/ball_spec.rb | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/codeball/ball.rb b/lib/codeball/ball.rb index b76947d..d33b7e4 100644 --- a/lib/codeball/ball.rb +++ b/lib/codeball/ball.rb @@ -30,7 +30,10 @@ def initialize @warnings = [] end - def add_entry(entry) + def add_entry(entry = Entry.new) + yield entry if block_given? + raise ArgumentError, "Entry cannot be nil" if entry.nil? + @entries << entry @warnings << entry.error if entry.errors? @warnings << "truncated entry for #{entry.path.inspect} - missing END marker" if entry.truncated? diff --git a/spec/codeball/ball_spec.rb b/spec/codeball/ball_spec.rb index c2defdb..c18d449 100644 --- a/spec/codeball/ball_spec.rb +++ b/spec/codeball/ball_spec.rb @@ -136,6 +136,11 @@ def serialize_entry(path, contents) describe "#add_entry" do subject(:ball) { described_class.new } + context "with a nil entry" do + it "raises an ArgumentError" do + expect { ball.add_entry(nil) }.to raise_error ArgumentError + end + end context "with a valid entry" do before { ball.add_entry(valid_entry) } @@ -182,6 +187,26 @@ def serialize_entry(path, contents) expect(entries).to be_empty end end + + context "when a block is supplied" do + let(:body) { "mybody" } + let(:name) { "myname" } + let(:entry) { spy "Entry" } + + before do + allow(Codeball::Entry).to receive(:new).and_return(entry) + end + + it "yields the entry for assignment" do + ball.add_entry do |s| + s.name = name + s.body = body + end + + expect(entry).to have_received(:name=).with(name) + expect(entry).to have_received(:body=).with(body) + end + end end describe "#each_entry" do From 0abe5b0c14216f03e15f03742782e8f75351a71a Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 6 Aug 2026 14:13:02 -0400 Subject: [PATCH 29/31] Remove this --- .rubocop.yml | 4 ++-- Gemfile | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index a662724..203f9c5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -14,7 +14,6 @@ plugins: - rubocop-performance - rubocop-minitest - rubocop-md - - rubocop-rubycw - rubocop-rake AllCops: @@ -22,6 +21,7 @@ AllCops: TargetRubyVersion: 3.4.8 Exclude: - "docs/**/*" + - "references/**/*" # =========================================================================== # Overrides from stock — personal style preferences @@ -340,4 +340,4 @@ Style/TrailingCommaInHashLiteral: EnforcedStyleForMultiline: comma Style/TrailingCommaInArguments: - EnforcedStyleForMultiline: comma \ No newline at end of file + EnforcedStyleForMultiline: comma diff --git a/Gemfile b/Gemfile index 55ac278..58da6aa 100644 --- a/Gemfile +++ b/Gemfile @@ -17,6 +17,5 @@ gem "rubocop-minitest" gem "rubocop-performance" gem "rubocop-rake" gem "rubocop-rspec" -gem "rubocop-rubycw" gem "ruby-filemagic", "~> 0.7.3" gem "warning", "~> 1.5" From 82525ef448f6279f37c0ab66f21d093fb0859e0a Mon Sep 17 00:00:00 2001 From: David Gillis Date: Thu, 6 Aug 2026 14:14:55 -0400 Subject: [PATCH 30/31] Bump version to 0.2.2 --- lib/codeball/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/codeball/version.rb b/lib/codeball/version.rb index 44266d4..637e1e5 100644 --- a/lib/codeball/version.rb +++ b/lib/codeball/version.rb @@ -1,3 +1,3 @@ module Codeball - VERSION = "0.2.1".freeze + VERSION = "0.2.2".freeze end From 122684a3f14d1afd00d84f79f29d7db0e85f1618 Mon Sep 17 00:00:00 2001 From: David Gillis Date: Fri, 7 Aug 2026 12:45:29 -0400 Subject: [PATCH 31/31] Remove --- .idea/codeball.iml | 1 - 1 file changed, 1 deletion(-) diff --git a/.idea/codeball.iml b/.idea/codeball.iml index 1cf406e..42d4d85 100644 --- a/.idea/codeball.iml +++ b/.idea/codeball.iml @@ -51,7 +51,6 @@ -