From 8be556b8b7b928536099e3b178fcb441ae56c954 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Wed, 29 Jul 2026 09:46:35 -0700 Subject: [PATCH 1/3] Adopt rubyfmt for code formatting Reformats all Ruby files with rubyfmt v0.14.1 and adds a required "Rubyfmt" CI check that runs `rubyfmt --check` against the codebase, mirroring the convention already used in rubyatscale/pack_stats. --- .github/workflows/ci.yml | 11 ++ AGENTS.md | 6 + Rakefile | 3 +- bin/console | 1 + code_manifest.gemspec | 8 +- lib/code_manifest.rb | 14 +- lib/code_manifest/manifest.rb | 18 ++- lib/code_manifest/version.rb | 2 +- spec/lib/code_manifest/manifest_spec.rb | 162 ++++++++++++------------ spec/lib/code_manifest/rule_spec.rb | 64 +++++----- spec/lib/code_manifest_spec.rb | 18 +-- spec/spec_helper.rb | 23 ++-- 12 files changed, 180 insertions(+), 150 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d004da6..fcf6ec3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,14 @@ jobs: ruby-version: ${{ matrix.ruby }} - name: Run tests run: bundle exec rspec + rubyfmt: + name: "Rubyfmt" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@5126516654c75f76bca1de45dd82a3006d8890f9 + - name: Install rubyfmt + run: | + curl -sL "https://github.com/fables-tales/rubyfmt/releases/download/v0.14.1/rubyfmt-v0.14.1-Linux-x86_64.tar.gz" | tar xz + sudo install -m 755 tmp/releases/v0.14.1-Linux/rubyfmt /usr/local/bin/rubyfmt + - name: Check formatting + run: rubyfmt --check lib/ spec/ Rakefile code_manifest.gemspec Gemfile bin/console diff --git a/AGENTS.md b/AGENTS.md index bd878e5..f379d69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,12 @@ bundle exec rspec # Run a single spec file bundle exec rspec spec/path/to/spec.rb + +# Format code with rubyfmt (requires the rubyfmt binary on PATH, e.g. `brew install rubyfmt`) +rubyfmt -i lib/ spec/ Rakefile code_manifest.gemspec Gemfile bin/console + +# Check formatting without writing changes (this is what CI runs) +rubyfmt --check lib/ spec/ Rakefile code_manifest.gemspec Gemfile bin/console ``` ## Architecture diff --git a/Rakefile b/Rakefile index cd510a0..edbfafa 100644 --- a/Rakefile +++ b/Rakefile @@ -1,4 +1,5 @@ # frozen_string_literal: true require "bundler/gem_tasks" -task default: %i[] + +task(default: %i[]) diff --git a/bin/console b/bin/console index ac0c3c7..c579f64 100755 --- a/bin/console +++ b/bin/console @@ -12,4 +12,5 @@ require "code_manifest" # Pry.start require "irb" + IRB.start(__FILE__) diff --git a/code_manifest.gemspec b/code_manifest.gemspec index 30c0df6..b7e670f 100644 --- a/code_manifest.gemspec +++ b/code_manifest.gemspec @@ -5,8 +5,8 @@ require_relative "lib/code_manifest/version" Gem::Specification.new do |spec| spec.name = "code_manifest" spec.version = CodeManifest::VERSION - spec.authors = ['Gusto Engineers'] - spec.email = ['dev@gusto.com'] + spec.authors = ["Gusto Engineers"] + spec.email = ["dev@gusto.com"] spec.summary = "A code manifest" spec.description = "A code manifest" @@ -19,6 +19,6 @@ Gem::Specification.new do |spec| spec.files = Dir["README.md", "lib/**/*"] - spec.add_development_dependency 'rspec' - spec.add_development_dependency 'debug' + spec.add_development_dependency("rspec") + spec.add_development_dependency("debug") end diff --git a/lib/code_manifest.rb b/lib/code_manifest.rb index 8d3dc53..1c42ff4 100644 --- a/lib/code_manifest.rb +++ b/lib/code_manifest.rb @@ -1,14 +1,16 @@ # frozen_string_literal: true -require 'pathname' -require 'yaml' -require_relative 'code_manifest/version' -require_relative 'code_manifest/manifest' +require "pathname" +require "yaml" + +require_relative "code_manifest/version" +require_relative "code_manifest/manifest" module CodeManifest - class Error < StandardError; end + class Error < StandardError + end - MANIFEST_FILE = '.code_manifest.yml' + MANIFEST_FILE = ".code_manifest.yml" KEY_PATTERN = /[a-z_0-9]+/.freeze class << self diff --git a/lib/code_manifest/manifest.rb b/lib/code_manifest/manifest.rb index 387c894..46fd8e7 100644 --- a/lib/code_manifest/manifest.rb +++ b/lib/code_manifest/manifest.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true -require 'digest/md5' -require_relative 'rule' +require "digest/md5" + +require_relative "rule" module CodeManifest class Manifest @@ -13,6 +14,7 @@ def initialize(patterns) @rules ||= Array(patterns).map do |pattern| Rule.new(pattern) end + @cache = {} end @@ -31,9 +33,9 @@ def digest end def matches(paths) - Array(paths).select do |path| - cached_match = - if @cache.key?(path) + Array(paths) + .select do |path| + cached_match = if @cache.key?(path) @cache.fetch(path) else @cache[path] = [ @@ -41,8 +43,10 @@ def matches(paths) exclusion_rules.any? { |rule| rule.match?(path) } ] end - cached_match.first && !cached_match.last - end.sort! + + cached_match.first && !cached_match.last + end + .sort! end def matches_all?(paths) diff --git a/lib/code_manifest/version.rb b/lib/code_manifest/version.rb index ec94c4c..1f105da 100644 --- a/lib/code_manifest/version.rb +++ b/lib/code_manifest/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module CodeManifest - VERSION = '1.9.0' + VERSION = "1.9.0" end diff --git a/spec/lib/code_manifest/manifest_spec.rb b/spec/lib/code_manifest/manifest_spec.rb index 478b5d3..b780d8e 100644 --- a/spec/lib/code_manifest/manifest_spec.rb +++ b/spec/lib/code_manifest/manifest_spec.rb @@ -1,143 +1,147 @@ # frozen_string_literal: true -require 'pathname' -require 'fileutils' +require "pathname" +require "fileutils" RSpec.describe CodeManifest::Manifest do let(:root) { CodeManifest.root } - describe '#files' do - let(:patterns) { ['/foo/foo.md', 'bar/*', '!bar/exclude'] } + describe "#files" do + let(:patterns) { ["/foo/foo.md", "bar/*", "!bar/exclude"] } let(:manifest) { described_class.new(patterns) } around do |example| - root.join('bar').mkpath - FileUtils.touch(root.join('bar/include')) - FileUtils.touch(root.join('bar/exclude')) + root.join("bar").mkpath + FileUtils.touch(root.join("bar/include")) + FileUtils.touch(root.join("bar/exclude")) example.run end - it 'returns only included files' do - expect(manifest.files).to match_array(['bar/include', 'foo/foo.md']) + it "returns only included files" do + expect(manifest.files).to(match_array(["bar/include", "foo/foo.md"])) end - context 'when there are duplicate patterns' do - let(:patterns) { ['/foo/foo.md', 'bar/*', '!bar/exclude', 'bar/*'] } + context("when there are duplicate patterns") do + let(:patterns) { ["/foo/foo.md", "bar/*", "!bar/exclude", "bar/*"] } - it 'dedups files' do - expect(manifest.files).to match_array(['bar/include', 'foo/foo.md']) + it "dedups files" do + expect(manifest.files).to(match_array(["bar/include", "foo/foo.md"])) end end - context 'with different type of globs' do - let(:patterns) { ['dir_bar/**/*'] } + context("with different type of globs") do + let(:patterns) { ["dir_bar/**/*"] } let(:manifest) { described_class.new(patterns) } around do |example| - FileUtils.mkdir(root.join('dir_bar')) - FileUtils.touch(root.join('dir_bar/foo')) + FileUtils.mkdir(root.join("dir_bar")) + FileUtils.touch(root.join("dir_bar/foo")) example.run end - it 'excludes directories' do - dir = root.join('dir_bar/dir') + it "excludes directories" do + dir = root.join("dir_bar/dir") dir.mkpath - expect(manifest.files).not_to include(dir.to_s) + expect(manifest.files).not_to(include(dir.to_s)) end - context 'when pattern is for specific files' do - let(:patterns) { ['dir_bar/foo'] } + context("when pattern is for specific files") do + let(:patterns) { ["dir_bar/foo"] } - it 'returns matched files' do - FileUtils.touch(root.join('dir_bar/bar')) + it "returns matched files" do + FileUtils.touch(root.join("dir_bar/bar")) - expect(manifest.files).to match_array(['dir_bar/foo']) + expect(manifest.files).to(match_array(["dir_bar/foo"])) end end - context 'when dotfiles are involved' do - let(:patterns) { ['dir_bar/**/*'] } + context("when dotfiles are involved") do + let(:patterns) { ["dir_bar/**/*"] } - it 'supports dotfiles' do - FileUtils.touch(root.join('dir_bar/.bar')) + it "supports dotfiles" do + FileUtils.touch(root.join("dir_bar/.bar")) expected = [ - 'dir_bar/.bar', - 'dir_bar/foo' + "dir_bar/.bar", + "dir_bar/foo" ] - expect(manifest.files).to match_array(expected) + expect(manifest.files).to(match_array(expected)) end end - context 'when using union patterns' do - let(:patterns) { ['foo.{x,y}'] } + context("when using union patterns") do + let(:patterns) { ["foo.{x,y}"] } - it 'supports dotfiles' do - FileUtils.touch(root.join('foo.x')) - FileUtils.touch(root.join('foo.y')) - FileUtils.touch(root.join('foo.z')) + it "supports dotfiles" do + FileUtils.touch(root.join("foo.x")) + FileUtils.touch(root.join("foo.y")) + FileUtils.touch(root.join("foo.z")) expected = [ - 'foo.x', - 'foo.y' + "foo.x", + "foo.y" ] - expect(manifest.files).to match_array(expected) + expect(manifest.files).to(match_array(expected)) end end end end - describe '#digest' do - let(:patterns) { ['/foo', 'bar/*', '!bar/exclude'] } + describe "#digest" do + let(:patterns) { ["/foo", "bar/*", "!bar/exclude"] } let(:manifest) { described_class.new(patterns) } around do |example| root.mkpath - FileUtils.touch(root.join('foo')) - root.join('bar').mkpath - FileUtils.touch(root.join('bar/include')) - FileUtils.touch(root.join('bar/exclude')) + FileUtils.touch(root.join("foo")) + root.join("bar").mkpath + FileUtils.touch(root.join("bar/include")) + FileUtils.touch(root.join("bar/exclude")) example.run root.rmtree end - it 'returns only included files' do - expect(manifest.digest).to eq('74be16979710d4c4e7c6647856088456') + it "returns only included files" do + expect(manifest.digest).to(eq("74be16979710d4c4e7c6647856088456")) end end - describe '#matches' do - let(:patterns) { ['/foo', 'bar/*', '!bar/exclude'] } + describe "#matches" do + let(:patterns) { ["/foo", "bar/*", "!bar/exclude"] } let(:manifest) { described_class.new(patterns) } let(:paths) do [ - 'bar/exclude', - 'bar/include', - 'foo', - 'baz/baz.md' + "bar/exclude", + "bar/include", + "foo", + "baz/baz.md" ] end - it 'returns matched paths' do - expect(manifest.matches(paths)).to match_array([ - 'bar/include', - 'foo' - ]) + it "returns matched paths" do + expect(manifest.matches(paths)).to( + match_array( + [ + "bar/include", + "foo" + ] + ) + ) end - context 'caching concerns' do - let(:patterns) { ['/foo', '!bar/exclude'] } - let(:paths) { ['foo'] } + context("caching concerns") do + let(:patterns) { ["/foo", "!bar/exclude"] } + let(:paths) { ["foo"] } - it 'caches rule lookups' do + it "caches rule lookups" do include_rule = manifest.rules[0] exclude_rule = manifest.rules[1] - expect(include_rule).to receive(:match?).with('foo').once.and_return(true) - expect(exclude_rule).to receive(:match?).with('foo').once.and_return(false) + expect(include_rule).to(receive(:match?).with("foo").once.and_return(true)) + expect(exclude_rule).to(receive(:match?).with("foo").once.and_return(false)) manifest.matches(paths) manifest.matches(paths) @@ -145,32 +149,32 @@ end end - describe '#matches_all?' do - let(:patterns) { ['/foo', 'bar/*', '!bar/exclude'] } + describe "#matches_all?" do + let(:patterns) { ["/foo", "bar/*", "!bar/exclude"] } let(:manifest) { described_class.new(patterns) } let(:paths) do [ - 'foo', - 'bar/include2', - 'bar/include1', + "foo", + "bar/include2", + "bar/include1" ] end - it 'returns true if all paths are matched' do - expect(manifest.matches_all?(paths)).to be(true) + it "returns true if all paths are matched" do + expect(manifest.matches_all?(paths)).to(be(true)) end - context 'when not all paths are matched' do + context("when not all paths are matched") do let(:paths) do [ - 'bar/include', - 'bar/exclude', - 'foo' + "bar/include", + "bar/exclude", + "foo" ] end - it 'returns false' do - expect(manifest.matches_all?(paths)).to be(false) + it "returns false" do + expect(manifest.matches_all?(paths)).to(be(false)) end end end diff --git a/spec/lib/code_manifest/rule_spec.rb b/spec/lib/code_manifest/rule_spec.rb index 997b3ed..b4d1302 100644 --- a/spec/lib/code_manifest/rule_spec.rb +++ b/spec/lib/code_manifest/rule_spec.rb @@ -1,63 +1,63 @@ # frozen_string_literal: true -require 'fileutils' -require 'pathname' -require 'set' -require 'tmpdir' +require "fileutils" +require "pathname" +require "set" +require "tmpdir" RSpec.describe CodeManifest::Rule do let(:root) { CodeManifest.root } - let(:pattern) { '**/*' } + let(:pattern) { "**/*" } let(:rule) { described_class.new(pattern) } - describe '#exclude' do - context 'when exclude pattern' do - let(:pattern) { '!foo' } + describe "#exclude" do + context("when exclude pattern") do + let(:pattern) { "!foo" } - it 'returns true' do - expect(rule.exclude).to eq(true) + it "returns true" do + expect(rule.exclude).to(eq(true)) end end - context 'when not exclude pattern' do - it 'returns false' do - expect(rule.exclude).to eq(false) + context("when not exclude pattern") do + it "returns false" do + expect(rule.exclude).to(eq(false)) end end end - describe '#glob' do - context 'when pattern is rooted' do - let(:pattern) { '/foo' } + describe "#glob" do + context("when pattern is rooted") do + let(:pattern) { "/foo" } - it 'returns rooted glob' do - expect(rule.glob).to eq('foo') + it "returns rooted glob" do + expect(rule.glob).to(eq("foo")) end end - context 'when pattern is not rooted' do - let(:pattern) { 'foo' } + context("when pattern is not rooted") do + let(:pattern) { "foo" } - it 'returns non-rooted glob' do - expect(rule.glob).to eq('**/foo') + it "returns non-rooted glob" do + expect(rule.glob).to(eq("**/foo")) end end end - describe '#match?' do - context 'when matched' do - it 'returns true' do - expect(rule.match?('foo')).to eq(true) - expect(rule.match?('.foo')).to eq(true) - expect(rule.match?('nested/foo/bar')).to eq(true) + describe "#match?" do + context("when matched") do + it "returns true" do + expect(rule.match?("foo")).to(eq(true)) + expect(rule.match?(".foo")).to(eq(true)) + expect(rule.match?("nested/foo/bar")).to(eq(true)) end end - context 'when not matched' do - let(:pattern) { '/bar' } + context("when not matched") do + let(:pattern) { "/bar" } - it 'returns false' do - expect(rule.match?('foo')).to eq(false) + it "returns false" do + expect(rule.match?("foo")).to(eq(false)) end end end diff --git a/spec/lib/code_manifest_spec.rb b/spec/lib/code_manifest_spec.rb index 8644761..2fa3f89 100644 --- a/spec/lib/code_manifest_spec.rb +++ b/spec/lib/code_manifest_spec.rb @@ -1,18 +1,18 @@ # frozen_string_literal: true -require 'fileutils' +require "fileutils" RSpec.describe CodeManifest do - describe '.[]' do - it 'reads manifest out' do - expect(described_class['foo']).to be_a(CodeManifest::Manifest) - expect(described_class['foo'].files).to include('foo/bar.txt', 'foo/foo.md') - expect(described_class['foo'].digest).to eq('30c55dba4f38d996651d236f4263cf6a') + describe ".[]" do + it "reads manifest out" do + expect(described_class["foo"]).to(be_a(CodeManifest::Manifest)) + expect(described_class["foo"].files).to(include("foo/bar.txt", "foo/foo.md")) + expect(described_class["foo"].digest).to(eq("30c55dba4f38d996651d236f4263cf6a")) end - context 'when manifest does not exist' do - it 'returns nil' do - expect(described_class['fooo']).to be_nil + context("when manifest does not exist") do + it "returns nil" do + expect(described_class["fooo"]).to(be_nil) end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4ab8119..897ad3f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -16,15 +16,15 @@ # # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration -require 'code_manifest' -require 'fileutils' -require 'debug/prelude' +require "code_manifest" +require "fileutils" +require "debug/prelude" RSpec.configure do |config| # rspec-expectations config goes here. You can use an alternate # assertion/expectation library such as wrong or the stdlib/minitest # assertions if you prefer. - config.expect_with :rspec do |expectations| + config.expect_with(:rspec) do |expectations| # This option will default to `true` in RSpec 4. It makes the `description` # and `failure_message` of custom matchers include text for helper methods # defined using `chain`, e.g.: @@ -37,7 +37,7 @@ # rspec-mocks config goes here. You can use an alternate test double # library (such as bogus or mocha) by changing the `mock_with` option here. - config.mock_with :rspec do |mocks| + config.mock_with(:rspec) do |mocks| # Prevents you from mocking or stubbing a method that does not exist on # a real object. This is generally recommended, and will default to # `true` in RSpec 4. @@ -56,7 +56,7 @@ # is tagged with `:focus`, all examples get run. RSpec also provides # aliases for `it`, `describe`, and `context` that include `:focus` # metadata: `fit`, `fdescribe` and `fcontext`, respectively. - config.filter_run_when_matching :focus + config.filter_run_when_matching(:focus) # Allows RSpec to persist some state between runs in order to support # the `--only-failures` and `--next-failure` CLI options. We recommend @@ -99,17 +99,18 @@ # Setting this allows you to use `--seed` to deterministically reproduce # test failures related to randomization by passing the same `--seed` value # as the one that triggered the failure. - Kernel.srand config.seed + Kernel.srand(config.seed) config.around do |example| - FileUtils.mkdir_p('tmp') - FileUtils.cp_r('spec/fixtures/project', 'tmp') - Dir.chdir('tmp/project') do + FileUtils.mkdir_p("tmp") + FileUtils.cp_r("spec/fixtures/project", "tmp") + Dir.chdir("tmp/project") do # Reset the root. CodeManifest.root(reset: true) example.run end - FileUtils.rm_rf('tmp/project') + + FileUtils.rm_rf("tmp/project") end end From 96d659671175284db822431092fdd0b0373f0954 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Wed, 29 Jul 2026 09:53:08 -0700 Subject: [PATCH 2/3] Avoid duplicate CI runs on branches with open PRs Restrict the push trigger to main so pushes to a branch with an open PR only trigger the pull_request run, not both. --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcf6ec3..3827a6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,9 @@ name: CI -on: [push, pull_request] +on: + push: + branches: [main] + pull_request: jobs: build: From 91d99d2716b6a02f996c1081dfdad0eac55d0dc7 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Wed, 29 Jul 2026 09:55:05 -0700 Subject: [PATCH 3/3] Add explicit least-privilege permissions to CI workflow Addresses the GitHub Advanced Security finding "actions/missing-workflow-permissions": without an explicit permissions block, jobs inherit the repo/org default GITHUB_TOKEN permissions. Neither job needs more than read access to the repo. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3827a6e..77ed657 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest