From 45705bb4053af6b5ef9d9a57c2d91c028b7c7b33 Mon Sep 17 00:00:00 2001 From: TristanKruse Date: Tue, 20 Oct 2026 14:47:56 +0200 Subject: [PATCH 1/3] Replace recursive cycle searches with stack-safe iterative traversals --- .../projection/cycles/johnson_cycles.rb | 54 ++++++++++++------- .../common/projection/cycles/tarjan_scc.rb | 40 ++++++++++---- .../projection/cycles/deep_graph_spec.rb | 33 ++++++++++++ .../projection/cycles/tarjan_scc_spec.rb | 28 ++++++++++ 4 files changed, 126 insertions(+), 29 deletions(-) create mode 100644 spec/common/projection/cycles/deep_graph_spec.rb diff --git a/lib/archunit/common/projection/cycles/johnson_cycles.rb b/lib/archunit/common/projection/cycles/johnson_cycles.rb index 3a2b10e..8daddee 100644 --- a/lib/archunit/common/projection/cycles/johnson_cycles.rb +++ b/lib/archunit/common/projection/cycles/johnson_cycles.rb @@ -8,6 +8,9 @@ module Projection module Cycles # Enumerates every elementary directed cycle once using Johnson's algorithm. class JohnsonCycles + Frame = Struct.new(:vertex, :neighbours, :index, :found_cycle) + private_constant :Frame + def self.call(adjacency) new(adjacency).call end @@ -55,36 +58,49 @@ def prepare_search(component, start) end def circuit(vertex) + frames = [enter_vertex(vertex)] + until frames.empty? + frame = frames.last + if frame.index < frame.neighbours.length + explore_neighbour(frame, frames) + else + finish_vertex(frames) + end + end + end + + def enter_vertex(vertex) @stack << vertex @blocked.add(vertex) - found_cycle = explore_neighbours(vertex) - found_cycle ? unblock(vertex) : record_blockers(vertex) - @stack.pop - found_cycle + Frame.new(vertex, component_neighbours(vertex), 0, false) end - def explore_neighbours(vertex) - found_cycle = false - component_neighbours(vertex).each do |neighbour| - closes_cycle = record_cycle?(neighbour) - finds_cycle = !@blocked.include?(neighbour) && circuit(neighbour) - found_cycle = true if closes_cycle || finds_cycle + def explore_neighbour(frame, frames) + neighbour = frame.neighbours.fetch(frame.index) + frame.index += 1 + if neighbour == @start + @cycles << @stack.dup + frame.found_cycle = true + elsif !@blocked.include?(neighbour) + frames << enter_vertex(neighbour) end - found_cycle end - def record_cycle?(neighbour) - return false unless neighbour == @start - - @cycles << @stack.dup - true + def finish_vertex(frames) + frame = frames.pop + frame.found_cycle ? unblock(frame.vertex) : record_blockers(frame.vertex) + @stack.pop + frames.last.found_cycle ||= frame.found_cycle unless frames.empty? end def unblock(vertex) - return unless @blocked.delete?(vertex) + pending = [vertex] + until pending.empty? + candidate = pending.pop + next unless @blocked.delete?(candidate) - dependants = @blocked_by.delete(vertex) || [] - dependants.each { |dependant| unblock(dependant) } + pending.concat((@blocked_by.delete(candidate) || []).to_a) + end end def record_blockers(vertex) diff --git a/lib/archunit/common/projection/cycles/tarjan_scc.rb b/lib/archunit/common/projection/cycles/tarjan_scc.rb index d4cf131..509ab32 100644 --- a/lib/archunit/common/projection/cycles/tarjan_scc.rb +++ b/lib/archunit/common/projection/cycles/tarjan_scc.rb @@ -6,6 +6,9 @@ module Projection module Cycles # Finds strongly connected components in a directed adjacency map. class TarjanScc + Frame = Struct.new(:vertex, :neighbours, :index) + private_constant :Frame + def self.call(adjacency, vertices: adjacency.keys) new(adjacency, vertices).call end @@ -30,13 +33,20 @@ def call private def visit(vertex) - index_vertex(vertex) - - neighbours(vertex).each do |neighbour| - inspect_neighbour(vertex, neighbour) + frames = [enter_vertex(vertex)] + until frames.empty? + frame = frames.last + if frame.index < frame.neighbours.length + inspect_neighbour(frame, frames) + else + finish_vertex(frames) + end end + end - extract_component(vertex) if @lowlinks.fetch(vertex) == @indices.fetch(vertex) + def enter_vertex(vertex) + index_vertex(vertex) + Frame.new(vertex, neighbours(vertex), 0) end def index_vertex(vertex) @@ -47,16 +57,26 @@ def index_vertex(vertex) @on_stack.add(vertex) end - def inspect_neighbour(vertex, neighbour) + def inspect_neighbour(frame, frames) + neighbour = frame.neighbours.fetch(frame.index) + frame.index += 1 unless @indices.key?(neighbour) - visit(neighbour) - @lowlinks[vertex] = [@lowlinks.fetch(vertex), @lowlinks.fetch(neighbour)].min + frames << enter_vertex(neighbour) return end - return unless @on_stack.include?(neighbour) - @lowlinks[vertex] = [@lowlinks.fetch(vertex), @indices.fetch(neighbour)].min + update_lowlink(frame.vertex, @indices.fetch(neighbour)) + end + + def finish_vertex(frames) + vertex = frames.pop.vertex + extract_component(vertex) if @lowlinks.fetch(vertex) == @indices.fetch(vertex) + update_lowlink(frames.last.vertex, @lowlinks.fetch(vertex)) unless frames.empty? + end + + def update_lowlink(vertex, index) + @lowlinks[vertex] = [@lowlinks.fetch(vertex), index].min end def extract_component(root) diff --git a/spec/common/projection/cycles/deep_graph_spec.rb b/spec/common/projection/cycles/deep_graph_spec.rb new file mode 100644 index 0000000..dd1184b --- /dev/null +++ b/spec/common/projection/cycles/deep_graph_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +RSpec.describe 'Cycle analysis on deep graphs' do + let(:size) { 10_000 } + let(:vertices) { (0...size).to_a } + let(:chain) { vertices.to_h { |vertex| [vertex, vertex + 1 < size ? [vertex + 1] : []] } } + let(:algorithms) { ArchUnit::Common::Projection::Cycles } + + it 'finds every component of a long acyclic chain without growing the Ruby call stack' do + expect(algorithms::TarjanScc.call(chain)).to match_array(vertices.map { |vertex| [vertex] }) + end + + it 'returns no cycles for a long acyclic chain' do + expect(algorithms::JohnsonCycles.call(chain)).to eq([]) + end + + it 'enumerates a long ring once, preserving its directed order' do + ring = vertices.to_h { |vertex| [vertex, [(vertex + 1) % size]] } + expect(algorithms::JohnsonCycles.call(ring)).to eq([vertices]) + end + + it 'unblocks a deep dependency chain without recursion' do + search = algorithms::JohnsonCycles.new(chain) + blocked_by = vertices.to_h { |vertex| [vertex, Set.new(chain.fetch(vertex))] } + search.instance_variable_set(:@blocked, vertices.to_set) + search.instance_variable_set(:@blocked_by, blocked_by) + + search.__send__(:unblock, 0) + + expect(search.instance_variable_get(:@blocked)).to be_empty + expect(search.instance_variable_get(:@blocked_by)).to be_empty + end +end diff --git a/spec/common/projection/cycles/tarjan_scc_spec.rb b/spec/common/projection/cycles/tarjan_scc_spec.rb index 7dada7b..e3a9888 100644 --- a/spec/common/projection/cycles/tarjan_scc_spec.rb +++ b/spec/common/projection/cycles/tarjan_scc_spec.rb @@ -26,4 +26,32 @@ expect(components).to eq([]) expect(components).to be_frozen end + + it 'matches mutual reachability on deterministic random induced subgraphs' do + random = Random.new(640) + 100.times do + adjacency = (0..7).to_h do |vertex| + [vertex, (0..7).select { random.rand < 0.25 }] + end + allowed = (0..7).select { random.rand < 0.8 }.to_set + reachable = allowed.to_h { |vertex| [vertex, reachable_from(adjacency, vertex, allowed)] } + expected = allowed.group_by do |vertex| + reachable.fetch(vertex).select { |other| reachable.fetch(other).include?(vertex) }.sort + end.values.map(&:sort) + + expect(described_class.call(adjacency, vertices: allowed)).to match_array(expected) + end + end + + def reachable_from(adjacency, start, allowed) + pending = [start] + visited = Set.new + until pending.empty? + vertex = pending.pop + next unless allowed.include?(vertex) && visited.add?(vertex) + + pending.concat(adjacency.fetch(vertex)) + end + visited + end end From 21ba9a4e04b62e23bf33c10c15d46dec8237cb4f Mon Sep 17 00:00:00 2001 From: TristanKruse Date: Tue, 20 Oct 2026 14:47:57 +0200 Subject: [PATCH 2/3] Skip linked source entries and validate canonical file boundaries --- .../extraction/enumerate_source_files.rb | 14 ++-- .../files/extraction/extract_file_info.rb | 19 +++-- spec/extraction/source_boundary_spec.rb | 77 +++++++++++++++++++ 3 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 spec/extraction/source_boundary_spec.rb diff --git a/lib/archunit/extraction/enumerate_source_files.rb b/lib/archunit/extraction/enumerate_source_files.rb index f61b633..11771dd 100644 --- a/lib/archunit/extraction/enumerate_source_files.rb +++ b/lib/archunit/extraction/enumerate_source_files.rb @@ -53,12 +53,12 @@ def resolve_project_exclude_patterns(root, value) def source_files_under(root, exclude_patterns) Find.find(root.to_s).filter_map do |entry| - if File.directory?(entry) - Find.prune if excluded_path?(entry, root, exclude_patterns) - next - end + stat = File.lstat(entry) + next if stat.symlink? - if ruby_source_file?(entry) && !excluded_path?(entry, root, exclude_patterns) + if stat.directory? + Find.prune if excluded_path?(entry, root, exclude_patterns) + elsif ruby_source_file?(entry, stat) && !excluded_path?(entry, root, exclude_patterns) relative_identifier(entry, root).freeze end end @@ -98,8 +98,8 @@ def normalize_exclude_pattern(pattern) end private_class_method :normalize_exclude_pattern - def ruby_source_file?(entry) - File.file?(entry) && File.extname(entry) == '.rb' + def ruby_source_file?(entry, stat) + stat.file? && File.extname(entry) == '.rb' end private_class_method :ruby_source_file? diff --git a/lib/archunit/files/extraction/extract_file_info.rb b/lib/archunit/files/extraction/extract_file_info.rb index 67a11cc..68e7166 100644 --- a/lib/archunit/files/extraction/extract_file_info.rb +++ b/lib/archunit/files/extraction/extract_file_info.rb @@ -63,16 +63,25 @@ def normalized_identifier(value) def source_path_within(root, identifier) path = root.join(identifier).cleanpath - relative = path.relative_path_from(root).to_s.tr('\\', '/') - if relative == '..' || relative.start_with?('../') - raise TechnicalError, 'source file must be inside the project root' - end + ensure_inside_root(root, path) raise TechnicalError, "source file does not exist: #{identifier}" unless path.file? - path + resolved = path.realpath + ensure_inside_root(root, resolved) + resolved end private_class_method :source_path_within + def ensure_inside_root(root, path) + relative = path.relative_path_from(root).to_s.tr('\\', '/') + return unless relative == '..' || relative.start_with?('../') + + raise TechnicalError, 'source file must be inside the project root' + rescue ArgumentError + raise TechnicalError, 'source file must be inside the project root' + end + private_class_method :ensure_inside_root + def directory_of(identifier) return '' unless identifier.include?('/') diff --git a/spec/extraction/source_boundary_spec.rb b/spec/extraction/source_boundary_spec.rb new file mode 100644 index 0000000..a455737 --- /dev/null +++ b/spec/extraction/source_boundary_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'pathname' +require 'tmpdir' + +RSpec.describe 'Source discovery boundaries' do + around do |example| + Dir.mktmpdir('archunit-source-boundary') do |directory| + @root = Pathname.new(directory).realpath + @root.join('Gemfile').write('') + @root.join('keep.rb').write('class Keep; end') + ArchUnit.clear_graph_cache + example.run + ensure + ArchUnit.clear_graph_cache + end + end + + it 'does not enumerate a symlinked file, including on hosts unable to create symlinks' do + link = @root.join('linked.rb') + link.write('class Linked; end') + original = File.lstat(link) + symlink_stat = original.dup + allow(symlink_stat).to receive(:symlink?).and_return(true) + allow(symlink_stat).to receive(:file?).and_return(false) + allow(File).to receive(:lstat).and_call_original + allow(File).to receive(:lstat).with(link.to_s).and_return(symlink_stat) + + expect(ArchUnit::Extraction.enumerate_source_files(@root)).to eq(['keep.rb']) + end + + it 'checks the canonical boundary before a direct FileInfo read on every platform' do + candidate = @root.join('keep.rb') + outside = @root.parent.join('not-inside-project.rb') + allow_any_instance_of(Pathname).to receive(:realpath).and_wrap_original do |method, *args| + method.receiver == candidate ? outside : method.call(*args) + end + expect(File).not_to receive(:binread) + + expect { ArchUnit::Files::Extraction.extract_file_info(@root, 'keep.rb') } + .to raise_error(ArchUnit::TechnicalError, /inside the project root/) + end + + it 'treats paths on incompatible Windows volumes as outside, not as raw pathname errors' do + allow_any_instance_of(Pathname).to receive(:relative_path_from).and_raise(ArgumentError) + expect(File).not_to receive(:binread) + + expect { ArchUnit::Files::Extraction.extract_file_info(@root, 'keep.rb') } + .to raise_error(ArchUnit::TechnicalError, /inside the project root/) + end + + %i[inside outside dangling directory].each do |kind| + it "skips #{kind} source links consistently in graphs and metrics" do + Dir.mktmpdir('archunit-external-source') do |directory| + outside = Pathname.new(directory) + outside.join('secret.rb').write("raise 'must not read or execute'\nclass Secret; end") + target = case kind + when :inside then @root.join('keep.rb') + when :outside then outside.join('secret.rb') + when :directory then outside + else outside.join('missing.rb') + end + File.symlink(target, @root.join('linked.rb')) + allow(File).to receive(:binread).and_call_original + expect(File).not_to receive(:binread).with(@root.join('linked.rb')) + + graph = ArchUnit::Extraction.extract_graph(@root) + metrics = ArchUnit::Metrics::Extraction.extract_project_info(@root) + expect(graph.map(&:source)).to eq(['keep.rb']) + expect(metrics.files.map(&:path)).to eq(['keep.rb']) + end + rescue NotImplementedError, Errno::EACCES + skip 'creating symlinks is not available for this user or platform' + end + end +end From 7669a8c82bcbae88230f35522bf5426080fd75e1 Mon Sep 17 00:00:00 2001 From: TristanKruse Date: Tue, 20 Oct 2026 14:47:57 +0200 Subject: [PATCH 3/3] Gate release metadata and document pre-v1 audit validation --- .github/PRE_RELEASE_CHECKLIST.md | 43 +++++++++++++++++++ .github/RELEASING.md | 15 ++++--- .github/workflows/ci.yml | 3 ++ .github/workflows/release.yml | 3 ++ AGENTS.md | 7 ++-- API.md | 8 ++++ CHANGELOG.md | 7 ++++ CONTRIBUTING.md | 8 +++- README.md | 9 +++- Rakefile | 7 +++- benchmark/README.md | 19 +++++++++ scripts/check_release_metadata.rb | 65 +++++++++++++++++++++++++++++ spec/ci/release_metadata_spec.rb | 69 +++++++++++++++++++++++++++++++ spec/ci/workflow_spec.rb | 15 +++++++ 14 files changed, 267 insertions(+), 11 deletions(-) create mode 100644 .github/PRE_RELEASE_CHECKLIST.md create mode 100644 scripts/check_release_metadata.rb create mode 100644 spec/ci/release_metadata_spec.rb diff --git a/.github/PRE_RELEASE_CHECKLIST.md b/.github/PRE_RELEASE_CHECKLIST.md new file mode 100644 index 0000000..19cfe74 --- /dev/null +++ b/.github/PRE_RELEASE_CHECKLIST.md @@ -0,0 +1,43 @@ +# Stable-release readiness + +All four currently open feature issues (#47, #48, #54, #55) have implementations in the pending +PR stack. They are not considered shipped until those changes reach `main` and a gem is published. + +## Before 1.0.0 + +- Merge the reviewed integration work and the rationale/exclusion PRs in dependency order. Retarget + stacked PRs to `main` after their base merges. Do not bypass the existing branch protections. +- Merge the pre-v1 audit fixes and rerun the entire matrix on `main`: supported Ruby versions, + Windows/Linux, coverage, lint, dogfooding, documentation links, installed-gem checks, and the RAG fixture. +- Keep the import-only default and the explicit `imports_and_constants` opt-in documented. Unknown + runtime behavior, ambiguous constants, and dynamic import paths are not a complete Ruby interpreter. +- Review representative passing and failing rules manually. Include a constant-only forbidden edge, + a safely inferred import, a `.because(...)` failure, and `.archignore` exclusions. +- Set the version to `1.0.0`, refresh the local gem lock entry, date the changelog, update installation + instructions, and replace prototype/pre-release wording with an accurate stable API contract. +- Run `bundle exec ruby scripts/check_release_metadata.rb --release`; development checks alone + intentionally do not prove publication readiness. +- Publish only after maintainer approval via the trusted-publisher workflow. Install the published + version in a clean project and rerun a passing/failing architecture example before announcing it. + +## Audit coverage added + +- 10,000-node chains, directed rings, and unblock cascades; deterministic mutual-reachability and + exhaustive small-graph oracles protect the iterative algorithms' correctness. +- Source symlinks inside/outside the project, broken links, directory links, canonical file boundaries, + and incompatible-volume errors. Native-link tests run on Linux; portable boundary tests also run + on Windows hosts without symlink privileges. +- Metadata consistency and strict release gating, inspired by ArchUnitPython's existing metadata + validation. Unlike automatic sibling publishing, Ruby publication remains a deliberate dispatch. + +These are targeted regression tests, not a claim that every bug has been found or a security +certification. Analysis does not execute application code, but it is not an OS-level sandbox for +an actively changing or hostile filesystem. Dense graphs can have exponentially many cycles. + +## Next useful improvements + +- Establish repeated real-world performance baselines on pinned Ruby repositories before setting + tighter thresholds. Report cold analysis, warm cache reuse, graph contents, and memory separately. +- Add an opt-in, non-blocking real-world canary once the repository set and update policy are agreed. +- Consider dependency vulnerability scanning and mutation testing as separate, measured additions; + do not confuse high line coverage with proof of semantic correctness. diff --git a/.github/RELEASING.md b/.github/RELEASING.md index e6a88b9..8f8107e 100644 --- a/.github/RELEASING.md +++ b/.github/RELEASING.md @@ -22,12 +22,17 @@ and makes that RubyGems account an owner. ## Publishing a version 1. Update `ArchUnit::VERSION` in `lib/archunit/version.rb` and add the release to `CHANGELOG.md`. -2. Ensure `main` is green and the README installation instructions match the version. -3. Run the `Release` workflow on the `main` branch and enter the version without a leading `v`. -4. The workflow verifies the version, tests and lints the project, validates documentation and the - gem, publishes it, and creates and pushes the matching `v` tag. -5. Install the version from RubyGems in an empty gem home and run `require 'archunit'` before +2. Refresh the local gem's `Gemfile.lock` entry, date the changelog heading as + `## - YYYY-MM-DD`, and update the README installation example to `~> `. +3. Ensure `main` is green and run `bundle exec ruby scripts/check_release_metadata.rb --release`. + The development check permits an unreleased heading; the publishing check deliberately does not. +4. Run the `Release` workflow on the `main` branch and enter the version without a leading `v`. +5. The workflow verifies the version and release metadata, tests and lints the project, validates + documentation and the gem, publishes it, and creates and pushes the matching `v` tag. +6. Install the version from RubyGems in an empty gem home and run `require 'archunit'` before announcing the release. The workflow refuses to release from a branch other than `main` or when its version input differs from `ArchUnit::VERSION`. + +Use the [pre-release checklist](PRE_RELEASE_CHECKLIST.md) for the stable-version readiness gates. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21f9d71..491fe42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,9 @@ jobs: ruby-version: "4.0" bundler-cache: true + - name: Check release metadata + run: bundle exec ruby scripts/check_release_metadata.rb + - name: Test with coverage run: bundle exec rspec env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b1ef93..83ccf9a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,9 @@ jobs: puts "Releasing archunit #{expected}" ' + - name: Verify release metadata + run: bundle exec ruby scripts/check_release_metadata.rb --release + - name: Test and lint run: bundle exec rake diff --git a/AGENTS.md b/AGENTS.md index cc60175..2c1f787 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -282,9 +282,10 @@ assert helper's job, at the very edge. ## Working here -- **Commit straight to `main`.** No branching ceremony, no required review, no pull requests. There - are no users yet and one maintainer. Speed is the point. (This does *not* apply to ArchUnitTS and - ArchUnitPython, which have real users and full process.) +- **Respect the current repository protections.** The gem is public and has multiple maintainers. + Keep changes focused and tested, use the agreed PR workflow, and never bypass a required review + or failing CI check. Review policy is a maintainer decision; do not assume the original prototype's + direct-to-main policy still applies. Publishing a release is a separate, explicitly authorized step. - **Test the pure parts against fixture graphs**, and add one integration test per rule through the fluent API against a sample project. The first is where the coverage comes from; the second proves the wiring. diff --git a/API.md b/API.md index a89473c..d13bff1 100644 --- a/API.md +++ b/API.md @@ -46,6 +46,10 @@ failed negated RSpec expectations. Successful assertions and raw violation forma Start with `ArchUnit.project_files(project_locator = nil)`; `ArchUnit.files` is its short alias. Without a locator, ArchUnitRuby searches upward for a `Gemfile` or gemspec. +Discovery skips symlinked source files and directories, including aliases inside the project root. +Direct `FileInfo` extraction validates canonical as well as lexical containment before reading. +The source walker reuses a single file-status result per entry for type and symlink decisions. + The detected root's `.archignore` is applied centrally to graphs and file-based metrics. It accepts blank lines, full-line `#` comments, basename globs, root-relative path globs, leading `/` anchors, and trailing `/` directory patterns. Backslashes normalize to `/`. Bare names match at any depth; @@ -87,6 +91,10 @@ rules = [ Positive dependency rules are allowlists: every selected dependency must match at least one target. Negated dependency rules are blocklists: matching dependencies become violations. +Cycle projection uses iterative strongly-connected-component and elementary-cycle traversals, so +long dependency chains do not consume Ruby's call stack. It still enumerates all elementary cycles; +dense graphs can have exponentially many results. Scope cycle rules to meaningful components. + ## Layer rules Use `ArchUnit.project_layers(project_locator = nil)` to name architectural layers and then declare diff --git a/CHANGELOG.md b/CHANGELOG.md index f9cb719..0af2301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## 0.0.2 (unreleased) +- Avoid stack overflows in strongly-connected-component detection, elementary-cycle traversal, + and blocked-vertex propagation on deep dependency graphs. +- Skip symlinked source entries and enforce canonical project boundaries for direct file metadata. +- Reuse source-entry file status instead of repeating separate directory and file probes. +- Validate version/changelog/lock consistency in CI and require prepared release metadata before + RubyGems publication. + - Apply bounded project-root `.archignore` exclusions consistently to dependency graphs and metrics, with cache invalidation, cross-platform glob handling, and configuration safety checks. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 919e004..4490a93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,8 @@ bundle install bundle exec rake ~~~ -`bundle exec rake` runs the randomized RSpec suite and RuboCop. Before submitting documentation or +`bundle exec rake` validates development release metadata, then runs the randomized RSpec suite and +RuboCop. Before submitting documentation or public API changes, also run: ~~~bash @@ -35,6 +36,11 @@ The coverage suite enforces at least 98% line and 90% branch coverage. The docum the complete site, checks the public API guide, rejects unrendered Markdown, and validates internal links. +For a stable release, follow the [pre-release checklist](.github/PRE_RELEASE_CHECKLIST.md) and +[publishing guide](.github/RELEASING.md). Development allows an unreleased changelog heading and +installation instructions for the published gem. The separate `--release` metadata check requires +a valid dated heading and an installation example targeting the version being published. + ## Design expectations - Write the fluent sentence first and read it aloud. diff --git a/README.md b/README.md index fe5394a..90046be 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,10 @@ ArchUnit.project_files('/workspace/my_app') ArchUnit.project_files('/workspace/my_app/Gemfile') ~~~ +Source discovery skips symlinked files and directories, including project-local aliases. This +avoids duplicate file identities and unintentionally parsing linked source outside the project. +Direct file-information extraction also checks the canonical source path against the project root. + ### Project-wide exclusions Add a `.archignore` at the detected project root to keep generated or intentionally out-of-scope @@ -566,7 +570,8 @@ bundle exec rake docs gem build archunit.gemspec --strict ~~~ -`bundle exec rake` runs the randomized RSpec suite and RuboCop. CI additionally enforces 98% line +`bundle exec rake` validates release metadata, then runs the randomized RSpec suite and RuboCop. +CI additionally enforces 98% line and 90% branch coverage, runs the dogfooding rules explicitly, builds the documentation, loads the public API with Ruby warnings, tests optional RSpec/Minitest integrations, builds and installs the gem artifact, runs the external RAG fixture, and checks Ruby 3.3, 3.4, and 4.0 across Ubuntu and @@ -591,6 +596,8 @@ The implementation conventions and intended dependency directions live in [`AGEN - Runtime-derived import paths cannot be resolved; safely known local literal strings are supported. - PlantUML support is a deliberately small component-diagram subset, not a complete UML parser. - `.archignore` supports exclusion globs, not negated gitignore patterns or nested ignore files. +- Cycle traversal is iterative and tested on 10,000-node chains and rings. Enumerating every cycle + can still be expensive in densely connected graphs; narrow the selected scope when necessary. - The API is still pre-release and may change before the first stable gem version. ## 💟 Community diff --git a/Rakefile b/Rakefile index d7f6a58..86c7875 100644 --- a/Rakefile +++ b/Rakefile @@ -8,6 +8,11 @@ require 'yard' RSpec::Core::RakeTask.new(:spec) RuboCop::RakeTask.new(:rubocop) +desc 'Validate version, changelog, and local-gem lock consistency' +task :release_metadata do + ruby 'scripts/check_release_metadata.rb' +end + desc 'Build the GitHub Pages documentation site' task :docs do sh 'yard doc' @@ -22,4 +27,4 @@ namespace :benchmark do end end -task default: %i[spec rubocop] +task default: %i[release_metadata spec rubocop] diff --git a/benchmark/README.md b/benchmark/README.md index dff8337..e9e8348 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -65,6 +65,25 @@ temporary; the cached graph retains immutable edges and evidence only. ## Reference baseline and target +### Pre-v1 source-walk audit (2026-10-20) + +Three alternating fresh-process pairs compared commit `d5f3e36` with the audit's status-reusing, +symlink-skipping walker on the same 1,000-file / 10-gem / 3-import corpus. Ruby 4.0.6, Windows x64: + +| Pair | Before cold | After cold | Before enumeration | After enumeration | +| --- | ---: | ---: | ---: | ---: | +| 1 | 3.772 s | 3.472 s | 0.938 s | 0.673 s | +| 2 | 7.445 s | 6.373 s | 2.126 s | 1.002 s | +| 3 | 7.810 s | 7.754 s | 2.155 s | 1.975 s | + +Every pair retained 4,001 graph edges and reused its cached graph. Warm lookups ranged from +3.6–11.0 ms. Enumeration improved in each pair, but the large run-to-run variation makes an exact +speedup claim inappropriate. This is evidence against an obvious regression, not a portable speed +guarantee. The walker now uses one explicit `lstat` result for its own type/symlink decisions; +Ruby's `Find` traversal still performs its own metadata checks. + +### Original extraction baseline + On 2026-08-14, Ruby 4.0.6 on the Windows development machine produced the following default-corpus results. The optimized values are the median of three fresh processes: diff --git a/scripts/check_release_metadata.rb b/scripts/check_release_metadata.rb new file mode 100644 index 0000000..1c8338c --- /dev/null +++ b/scripts/check_release_metadata.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'date' + +# Maintainer-side validation, not part of the installed gem or application analysis. +module ArchUnitReleaseMetadata + module_function + + def errors(root:, version:, release: false) + common_errors(root, version) + (release ? release_errors(root, version) : []) + rescue SystemCallError => e + ["Release metadata is unavailable: #{e.message}"] + end + + def common_errors(root, version) + failures = [] + failures << "CHANGELOG.md has no heading for #{version}." unless changelog_entry(root, version) + lock = File.read(File.join(root, 'Gemfile.lock')) + unless lock.match?(/^ archunit \(#{Regexp.escape(version)}\)$/) + failures << "Gemfile.lock does not lock the local archunit gem at #{version}." + end + failures + end + + def release_errors(root, version) + failures = [] + unless dated_entry?(changelog_entry(root, version), version) + failures << 'A release needs a dated CHANGELOG heading, not an unreleased or invalid date.' + end + readme = File.read(File.join(root, 'README.md')) + unless readme.match?(/gem ['"]archunit['"],\s*['"]~> #{Regexp.escape(version)}['"]/) + failures << "Update the README installation example to ~> #{version} before release." + end + failures + end + + def changelog_entry(root, version) + File.foreach(File.join(root, 'CHANGELOG.md')).find do |line| + line.match?(/^## #{Regexp.escape(version)}(?:\s|$)/) + end&.strip + end + + def dated_entry?(entry, version) + date = entry&.match(/\A## #{Regexp.escape(version)} - (\d{4}-\d{2}-\d{2})\z/)&.[](1) + return false unless date + + Date.iso8601(date) + true + rescue Date::Error + false + end +end + +if $PROGRAM_NAME == __FILE__ + require_relative '../lib/archunit/version' + + release = !ARGV.delete('--release').nil? + abort 'Usage: ruby scripts/check_release_metadata.rb [--release]' unless ARGV.empty? + errors = ArchUnitReleaseMetadata.errors( + root: File.expand_path('..', __dir__), version: ArchUnit::VERSION, release: + ) + abort "Release metadata check failed:\n- #{errors.join("\n- ")}" unless errors.empty? + mode = release ? 'release' : 'development' + puts "Release metadata is synchronized for #{ArchUnit::VERSION} (#{mode})." +end diff --git a/spec/ci/release_metadata_spec.rb b/spec/ci/release_metadata_spec.rb new file mode 100644 index 0000000..856556e --- /dev/null +++ b/spec/ci/release_metadata_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'open3' +require 'tmpdir' +require_relative '../../scripts/check_release_metadata' + +RSpec.describe ArchUnitReleaseMetadata do + around do |example| + Dir.mktmpdir('archunit-release-metadata') do |root| + @root = root + write_metadata + example.run + end + end + + def write_metadata + File.write(File.join(@root, 'CHANGELOG.md'), "# Changelog\n\n## 1.0.0 (unreleased)\n") + File.write(File.join(@root, 'Gemfile.lock'), "PATH\n specs:\n archunit (1.0.0)\n") + File.write(File.join(@root, 'README.md'), "gem 'archunit', '~> 0.0.1'\n") + end + + def errors(release: false) + described_class.errors(root: @root, version: '1.0.0', release:) + end + + it 'allows unreleased development without pretending the published version changed' do + expect(errors).to eq([]) + end + + it 'blocks publishing an unreleased changelog and stale README example' do + expect(errors(release: true)).to contain_exactly( + /dated CHANGELOG heading/, /README installation example/ + ) + end + + it 'requires an exact changelog version, not a similar prefix' do + File.write(File.join(@root, 'CHANGELOG.md'), "## 1.0.01 (unreleased)\n") + expect(errors).to contain_exactly(/no heading for 1\.0\.0/) + end + + it 'rejects a stale local-gem lock entry' do + File.write(File.join(@root, 'Gemfile.lock'), " archunit (0.0.2)\n") + expect(errors).to contain_exactly(/Gemfile.lock/) + end + + it 'accepts a prepared release with synchronized instructions and a real calendar date' do + File.write(File.join(@root, 'CHANGELOG.md'), "## 1.0.0 - 2026-10-20\n") + File.write(File.join(@root, 'README.md'), "gem 'archunit', '~> 1.0.0'\n") + expect(errors(release: true)).to eq([]) + end + + it 'rejects an impossible calendar date' do + File.write(File.join(@root, 'CHANGELOG.md'), "## 1.0.0 - 2026-02-30\n") + File.write(File.join(@root, 'README.md'), "gem 'archunit', '~> 1.0.0'\n") + expect(errors(release: true)).to contain_exactly(/dated CHANGELOG/) + end + + it 'reports missing metadata as an actionable validation failure' do + File.delete(File.join(@root, 'Gemfile.lock')) + expect(errors).to contain_exactly(/metadata is unavailable/) + end + + it 'uses a failing exit status for invalid CLI arguments without attempting a release' do + script = File.expand_path('../../scripts/check_release_metadata.rb', __dir__) + _stdout, stderr, status = Open3.capture3(RbConfig.ruby, script, '--not-a-real-option') + expect(status).not_to be_success + expect(stderr).to include('Usage:') + end +end diff --git a/spec/ci/workflow_spec.rb b/spec/ci/workflow_spec.rb index eeb3fb6..83890b9 100644 --- a/spec/ci/workflow_spec.rb +++ b/spec/ci/workflow_spec.rb @@ -96,6 +96,21 @@ expect(workflow.fetch('jobs').values).to all(include('timeout-minutes' => 10)) end + it 'checks development metadata and guards release readiness before publication' do + expect(quality_job.fetch('steps')).to include( + include('run' => 'bundle exec ruby scripts/check_release_metadata.rb') + ) + release = YAML.safe_load_file( + File.expand_path('../../.github/workflows/release.yml', __dir__), aliases: true + ) + release_steps = release.fetch('jobs').fetch('release').fetch('steps') + validation = release_steps.index do |step| + step['run'] == 'bundle exec ruby scripts/check_release_metadata.rb --release' + end + publication = release_steps.index { |step| step['uses'] == 'rubygems/release-gem@v1' } + expect(validation).to be < publication + end + it 'locks dependencies for both CI operating-system families' do lockfile = File.read(File.expand_path('../../Gemfile.lock', __dir__))