Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/PRE_RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 10 additions & 5 deletions .github/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>` 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
`## <version> - YYYY-MM-DD`, and update the README installation example to `~> <version>`.
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<version>` 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.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 7 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -22,4 +27,4 @@ namespace :benchmark do
end
end

task default: %i[spec rubocop]
task default: %i[release_metadata spec rubocop]
19 changes: 19 additions & 0 deletions benchmark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
54 changes: 35 additions & 19 deletions lib/archunit/common/projection/cycles/johnson_cycles.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
40 changes: 30 additions & 10 deletions lib/archunit/common/projection/cycles/tarjan_scc.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading
Loading