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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ jobs:
- name: Exercise dependency-analysis reproducers
run: bundle exec ruby scripts/check_dependency_analysis.rb

- name: Exercise command-line acceptance checks
run: bundle exec ruby scripts/check_cli.rb

quality:
name: Quality and package — Ruby 4.0 on Ubuntu
runs-on: ubuntu-latest
Expand Down Expand Up @@ -137,6 +140,7 @@ jobs:
gem install pkg/archunit.gem --no-document
ruby scripts/check_dependency_analysis.rb
ruby scripts/check_constant_writes.rb
ruby scripts/check_cli.rb
ruby -e "
require 'json'
require 'stringio'
Expand Down
43 changes: 43 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,49 @@ the same file pair merge into one edge. Import-only edge evidence stays empty fo
`ArchUnit::Extraction.extract_imports` retains import call locations in both modes. The analysis
mode participates in graph-cache identity, and `clear_cache: true` rebuilds the symbol index.

## CLI contract (unreleased 1.1 milestone)

The new executable is development-only until a 1.1 release; it is not included in 1.0.0. It wraps
Checkable rules without changing RSpec/Minitest or the library's default analysis mode.

~~~sh
archunit init --project .
archunit check --project . --format json
archunit check --dependency-analysis imports_and_constants
archunit explain --config ./trusted-architecture.rb
~~~

`init` checks the filesystem entry itself and uses exclusive creation to reject existing paths,
including dangling symlinks on Windows. This is not a sandbox against a hostile process concurrently
replacing filesystem entries. The default configuration is
`PROJECT/.archunit.rb`; an explicit `--config` path is relative to the invocation directory. The
project defaults to that working directory. Named declarations are
`rule('unique name') { completed_checkable }`; the configuration helpers `project_files` / `files`,
`project_layers` / `layers`, `project_slices`, and `metrics` use the selected root. Rules must be
non-empty and uniquely named. Every `check` command starts with a fresh graph cache, then shares
that cache across its rules.

Configuration is trusted executable Ruby, not an isolation boundary. `explain` loads the same Ruby
configuration but never calls its rules' `check` methods. It reports `configured` and per-rule
`not_checked`, not an architecture pass. It lists rule names/rationale and settings, not a full
semantic explanation or focused-path report. Avoid stdout writes in configuration when requesting
machine-readable output. No target source, Gemfile, or gemspec is automatically executed by the CLI;
the caller's own Bundler/configuration code retains its normal Ruby behavior.

Exit codes: **0** for a successful informational/init/explain command or all checked rules passing;
**1** for architecture violations; **2** for argument/configuration/analysis errors. Empty selectors
remain violations. Text errors go to stderr; JSON diagnostics go to stdout without color. The
format option applies to init/check/explain and errors; help/version remain plain text.

`--format json` produces one document with `schema_version: 1`, `archunit_version`, `command`,
`project_root`, `config_path`, `dependency_analysis`, `status`, `rules`, and `summary`. Rule entries
carry `name`, nullable `rationale`, `status`, and `violations`; each violation carries its `type`,
shared formatter `message`, and `details`. Summary counts describe completed/listed rules and their
violations; an analysis error may stop later rules, so a partial summary is never a pass. An error
adds `error.type`, `error.message`, and nullable `error.rule_name`; a `SourceParseError` also adds
`error.diagnostics` with path, one-based line/column, and message. Messages/details are for people,
not stable violation fingerprints. The CLI implementation classes are not a new fluent API.

## Errors and support

Invalid API input raises `ArchUnit::UserError` or `ArgumentError`; library or environment failures
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 1.1.0 (unreleased)

- Add an opt-in `archunit` executable with non-destructive configuration initialization, named
Checkable rules, check/explain commands, text or versioned JSON diagnostics, and explicit
success/violation/error exit codes. This is development work, not a published 1.1.0 release.

## 1.0.0 - 2026-10-26

First stable API baseline. The previously unreleased 0.0.2 development work is included here;
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
archunit (1.0.0)
archunit (1.1.0)
csv (>= 3.3, < 4.0)
json (>= 2.7, < 3.0)
prism (>= 1.0, < 2.0)
Expand Down Expand Up @@ -112,7 +112,7 @@ DEPENDENCIES
yard (~> 0.9.45)

CHECKSUMS
archunit (1.0.0)
archunit (1.1.0)
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
Expand Down
46 changes: 44 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,48 @@ bundle install

Or install it directly with `gem install archunit --version '~> 1.0.0'`.

### Command line (unreleased 1.1 development feature)

The development checkout adds an `archunit` executable; the published 1.0.0 gem does **not** include
it. Use these commands from this checkout with its development bundle, or after a future 1.1 release:

~~~sh
bundle exec archunit init
# Review and customize .archunit.rb before running checks.
bundle exec archunit check
bundle exec archunit check --format json --dependency-analysis imports_and_constants
bundle exec archunit explain
~~~

The starter checks cycles in `lib/**/*.rb`. Replace or extend it with named rules, for example:

~~~ruby
# .archunit.rb -- trusted executable Ruby
rule 'API stays out of storage' do
project_files.in_path('app/api/**').should_not.depend_on_files.in_path('app/db/**')
.because('Keep storage private')
end
~~~

Configuration helpers `project_files` / `files`, `project_layers` / `layers`, `project_slices`, and
`metrics` default to `--project DIRECTORY` (the working directory otherwise). `--config FILE` selects
a configuration relative to the invocation directory; the default is `PROJECT/.archunit.rb`.
`init` refuses to overwrite an existing destination, including directories and dangling symlinks.

`check` returns exit **0** when every rule passes, **1** for architecture violations (including empty
selections), and **2** for argument, configuration, or analysis errors. Text uses the existing
violation wording. `--format json` returns a schema-versioned document with per-rule names, rationales,
violations, summary counts, and structured parse diagnostics for malformed source. `explain` lists
configured rules and settings without checking them: `configured` / `not_checked` is not a passing
architecture result. It is not yet a semantic dependency trace or focused-path report.

Only run configurations you trust: `.archunit.rb` is arbitrary executable Ruby, **not a sandbox**,
and `explain` also loads it. For an untrusted target repository, provide your own trusted
configuration via `--config`. The CLI does not automatically load target application source,
evaluate a target Gemfile/gemspec, install its dependencies, or boot Rails. Invoking Bundler yourself
still has Bundler's normal Gemfile behavior. RSpec/Minitest remain fully supported; the CLI composes
the same Checkable rules and is not a replacement for their integrations.

[ClickGems download analytics](https://clickgems.clickhouse.com/dashboard/archunit) show trends by
date, gem version, Ruby version, system, and country. Download totals count package fetches rather
than unique users, so repeated installs and automated clients may contribute to the total. The
Expand Down Expand Up @@ -641,8 +683,8 @@ The supported static-analysis boundaries below remain part of the contract.
- `.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.
- Method-call/protocol rules, a standalone CLI, presets, and adoption baselines are roadmap work,
not claims made by the current 1.0 API.
- Method-call/protocol rules, presets, and adoption baselines remain roadmap work. The initial CLI
is unreleased 1.1 development work, not a claim about the published 1.0 API.

## 💟 Community

Expand Down
4 changes: 3 additions & 1 deletion archunit.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ Gem::Specification.new do |spec|
spec.metadata['rubygems_mfa_required'] = 'true'

packaged_files = [
'lib/**/*', 'assets/logo-rounded.png', 'README.md', 'API.md', 'CHANGELOG.md', 'LICENSE'
'lib/**/*', 'exe/*', 'assets/logo-rounded.png', 'README.md', 'API.md', 'CHANGELOG.md', 'LICENSE'
]
spec.files = Dir[*packaged_files].select { |path| File.file?(path) }
spec.require_paths = ['lib']
spec.bindir = 'exe'
spec.executables = ['archunit']

spec.add_dependency 'csv', '>= 3.3', '< 4.0'
spec.add_dependency 'json', '>= 2.7', '< 3.0'
Expand Down
7 changes: 7 additions & 0 deletions exe/archunit
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'archunit'
require 'archunit/cli'

exit ArchUnit::CLI.run(ARGV)
15 changes: 15 additions & 0 deletions lib/archunit/cli.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

require_relative 'cli/runner'

module ArchUnit
# Command-line composition; load separately from the test-library entry point.
# Implementation classes are private to the executable, not the fluent public API.
module CLI
# Runs a command without terminating the embedding process.
# @return [Integer] 0 for success, 1 for violations, 2 for command/analysis errors
def self.run(arguments = ARGV, out: $stdout, err: $stderr, cwd: Dir.pwd)
Runner.new(arguments, out:, err:, cwd:).run
end
end
end
86 changes: 86 additions & 0 deletions lib/archunit/cli/configuration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# frozen_string_literal: true

require_relative '../common/fluentapi/checkable'
require_relative '../files/fluentapi/files'
require_relative '../layers/fluentapi/layers'
require_relative '../slices/fluentapi/slices'
require_relative '../metrics/fluentapi/metrics'

module ArchUnit
module CLI
# Trusted executable Ruby configuration. This context is deliberately not a sandbox.
# @api private
class Configuration
Rule = Data.define(:name, :checkable)
STARTER = <<~RUBY
# frozen_string_literal: true

# Trusted Ruby configuration: review this file before running archunit.
# Customize the scope and add named rules for your project's boundaries.
rule 'Library files have no dependency cycles' do
project_files.in_path('lib/**/*.rb').should.have_no_cycles
end
RUBY

attr_reader :rules

def initialize(project_root)
@project_root = project_root
@rules = []
end

def load(path)
instance_eval(File.read(path), path, 1)
raise ArgumentError, 'Configuration must declare at least one rule' if rules.empty?

rules.freeze
end

def rule(name, &block)
name = validate_name(name, block)
checkable = instance_eval(&block)
validate_checkable(checkable, name)
rules << Rule.new(name: name.freeze, checkable:)
end

def project_files
Files::FluentApi.project_files(@project_root)
end
alias files project_files

def project_layers
Layers::FluentApi.project_layers(@project_root)
end
alias layers project_layers

def project_slices
Slices::FluentApi.project_slices(@project_root)
end

def metrics
Metrics::FluentApi.metrics(@project_root)
end

private

def validate_name(name, block)
unless name.is_a?(String) && !name.strip.empty? && block
raise ArgumentError, 'rule requires a non-empty name and a block returning a Checkable'
end

name = name.strip
if rules.any? { |item| item.name == name }
raise ArgumentError, "Duplicate rule name: #{name}"
end

name
end

def validate_checkable(checkable, name)
return if checkable.is_a?(Common::FluentApi::Checkable)

raise ArgumentError, "Rule '#{name}' must return a completed Checkable rule"
end
end
end
end
82 changes: 82 additions & 0 deletions lib/archunit/cli/options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# frozen_string_literal: true

require 'optparse'

module ArchUnit
module CLI
# Internal command parsing; does not discover or execute project code.
# @api private
class Options
attr_reader :command, :project_root, :config_path, :format, :dependency_analysis, :parser

def initialize(arguments, cwd:)
@arguments = arguments.dup
@cwd = cwd
@command = @arguments.first&.start_with?('-') ? 'check' : (@arguments.shift || 'check')
@project_root = File.expand_path(cwd)
@format = json_requested? ? 'json' : 'text'
@dependency_analysis = 'imports'
@parser = build_parser
end

def parse!
parser.parse!(@arguments)
validate_arguments
return self if %w[help version].include?(command)

unless File.directory?(project_root)
raise ArgumentError, "Project directory does not exist: #{project_root}"
end

@config_path ||= File.join(project_root, '.archunit.rb')
self
end

private

def validate_arguments
unless @arguments.empty?
raise OptionParser::InvalidArgument, "unexpected arguments: #{@arguments.join(' ')}"
end
return if %w[init check explain help version].include?(command)

raise OptionParser::InvalidArgument, "unknown command: #{command}"
end

def json_requested?
@arguments.include?('--format=json') ||
@arguments.each_cons(2).any? { |flag, value| flag == '--format' && value == 'json' }
end

def build_parser
OptionParser.new do |options|
options.banner = 'Usage: archunit [init|check|explain] [options]'
project_options(options)
output_options(options)
end
end

def project_options(options)
options.on('--project DIRECTORY', 'Project root (default: working directory)') do |value|
@project_root = File.expand_path(value, @cwd)
end
options.on('--config FILE',
'Trusted Ruby config (default: PROJECT/.archunit.rb)') do |value|
@config_path = File.expand_path(value, @cwd)
end
end

def output_options(options)
options.on('--format FORMAT', %w[text json], 'Output format: text or json') do |value|
@format = value
end
options.on('--dependency-analysis MODE', %w[imports imports_and_constants],
'Static analysis mode (default: imports)') do |value|
@dependency_analysis = value
end
options.on('-h', '--help', 'Show help without loading config') { @command = 'help' }
options.on('-v', '--version', 'Show the installed version') { @command = 'version' }
end
end
end
end
Loading
Loading