diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afd4bb2..38921ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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' diff --git a/API.md b/API.md index b7f392b..df7c9df 100644 --- a/API.md +++ b/API.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index abd7557..421c385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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; diff --git a/Gemfile.lock b/Gemfile.lock index 56bfaac..f01a601 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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 diff --git a/README.md b/README.md index a3a5012..a7ad79e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/archunit.gemspec b/archunit.gemspec index 425da02..a0d00a3 100644 --- a/archunit.gemspec +++ b/archunit.gemspec @@ -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' diff --git a/exe/archunit b/exe/archunit new file mode 100755 index 0000000..1ef6567 --- /dev/null +++ b/exe/archunit @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'archunit' +require 'archunit/cli' + +exit ArchUnit::CLI.run(ARGV) diff --git a/lib/archunit/cli.rb b/lib/archunit/cli.rb new file mode 100644 index 0000000..8b852f9 --- /dev/null +++ b/lib/archunit/cli.rb @@ -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 diff --git a/lib/archunit/cli/configuration.rb b/lib/archunit/cli/configuration.rb new file mode 100644 index 0000000..fb1d9c0 --- /dev/null +++ b/lib/archunit/cli/configuration.rb @@ -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 diff --git a/lib/archunit/cli/options.rb b/lib/archunit/cli/options.rb new file mode 100644 index 0000000..e7fa853 --- /dev/null +++ b/lib/archunit/cli/options.rb @@ -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 diff --git a/lib/archunit/cli/report.rb b/lib/archunit/cli/report.rb new file mode 100644 index 0000000..194875d --- /dev/null +++ b/lib/archunit/cli/report.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'json' +require_relative '../version' +require_relative '../testing/violation_factory' +require_relative '../error/source_parse_error' + +module ArchUnit + module CLI + # Internal presentation adapter reusing the shared violation vocabulary. + # @api private + class Report + attr_reader :payload + + def initialize(options) + @payload = { + schema_version: 1, archunit_version: ArchUnit::VERSION, command: options.command, + project_root: options.project_root, config_path: options.config_path, + dependency_analysis: options.dependency_analysis, status: 'error', rules: [] + } + end + + def add_rule(rule, violations: nil) + payload[:rules] << { + name: rule.name, rationale: rule.checkable.rationale, status: rule_status(violations), + violations: (violations || []).map { |violation| violation_payload(violation) } + } + end + + def finish(status) + payload[:status] = status + payload[:summary] = { + rule_count: payload[:rules].length, + violation_count: payload[:rules].sum { |rule| rule[:violations].length } + } + self + end + + def fail_with(error, rule_name: nil) + payload[:error] = { type: error.class.name, message: error.message, rule_name: } + if error.is_a?(SourceParseError) + payload[:error][:diagnostics] = error.diagnostics.map(&:to_h) + end + finish('error') + end + + def write(out:, err:, format:) + return out.puts(JSON.pretty_generate(payload)) if format == 'json' + return err.puts("ArchUnit error: #{payload[:error][:message]}") if payload[:error] + + write_text(out) + end + + private + + def rule_status(violations) + return 'not_checked' unless violations + + violations.empty? ? 'passed' : 'failed' + end + + def violation_payload(violation) + formatted = Testing::ViolationFactory.from_violation(violation) + { type: violation.class.name, message: formatted.message, details: formatted.details } + end + + def write_text(out) + out.puts("ArchUnit #{ArchUnit::VERSION} — #{payload[:status]} " \ + "(#{payload[:dependency_analysis]})") + out.puts("Project: #{payload[:project_root]}\nConfiguration: #{payload[:config_path]}") + write_notice(out) + payload[:rules].each { |rule| write_rule(rule, out) } + end + + def write_notice(out) + case payload[:status] + when 'configured' + out.puts('Rules are configured, not evaluated.') + when 'initialized' + out.puts('Review and customize the trusted Ruby configuration before checking.') + end + end + + def write_rule(rule, out) + out.puts("[#{rule[:status]}] #{rule[:name]}") + out.puts(" Because: #{rule[:rationale]}") if rule[:rationale] + rule[:violations].each do |violation| + out.puts(" #{violation[:message]}\n #{violation[:details]}") + end + end + end + end +end diff --git a/lib/archunit/cli/runner.rb b/lib/archunit/cli/runner.rb new file mode 100644 index 0000000..186b03f --- /dev/null +++ b/lib/archunit/cli/runner.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require_relative 'options' +require_relative 'configuration' +require_relative 'report' + +module ArchUnit + module CLI + # Internal command execution; technical errors never become successful rule checks. + # @api private + class Runner + def initialize(arguments, out:, err:, cwd:) + @options = Options.new(arguments, cwd:) + @out = out + @err = err + end + + def run + @options.parse! + return information if %w[help version].include?(@options.command) + + @report = Report.new(@options) + status = execute + @report.write(out: @out, err: @err, format: @options.format) + status + rescue StandardError, SyntaxError, LoadError, NotImplementedError, SystemExit => e + # An accidental exit in trusted configuration is not proof that rules passed. + report_error(e) + end + + private + + def report_error(error) + @report ||= Report.new(@options) + @report.fail_with(error, rule_name: @current_rule&.name) + @report.write(out: @out, err: @err, format: @options.format) + 2 + end + + def information + @out.puts(@options.command == 'help' ? @options.parser.to_s : ArchUnit::VERSION) + 0 + end + + def execute + return initialize_configuration if @options.command == 'init' + + rules = Configuration.new(@options.project_root).load(@options.config_path) + return explain(rules) if @options.command == 'explain' + + check(rules) + end + + def initialize_configuration + ensure_missing_configuration + File.open(@options.config_path, File::WRONLY | File::CREAT | File::EXCL) do |file| + file.write(Configuration::STARTER) + end + @report.finish('initialized') + 0 + end + + def ensure_missing_configuration + # Windows exclusive creation may follow a dangling symlink; inspect the entry itself. + File.lstat(@options.config_path) + raise Errno::EEXIST, @options.config_path + rescue Errno::ENOENT + nil + end + + def explain(rules) + rules.each { |rule| @report.add_rule(rule) } + @report.finish('configured') + 0 + end + + def check(rules) + Extraction.clear_graph_cache + options = Common::FluentApi::CheckOptions.new( + dependency_analysis: @options.dependency_analysis.to_sym + ) + check_rules(rules, options) + failed = @report.payload[:rules].any? { |rule| rule[:status] == 'failed' } + @report.finish(failed ? 'failed' : 'passed') + failed ? 1 : 0 + end + + def check_rules(rules, options) + rules.each do |rule| + @current_rule = rule + @report.add_rule(rule, violations: rule.checkable.check(options)) + end + end + end + end +end diff --git a/lib/archunit/version.rb b/lib/archunit/version.rb index 1246ec3..0652c65 100644 --- a/lib/archunit/version.rb +++ b/lib/archunit/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module ArchUnit - VERSION = '1.0.0' + VERSION = '1.1.0' end diff --git a/scripts/check_cli.rb b/scripts/check_cli.rb new file mode 100644 index 0000000..957b241 --- /dev/null +++ b/scripts/check_cli.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'archunit' +require 'fileutils' +require 'json' +require 'open3' +require 'rbconfig' +require 'tmpdir' + +# Shared subprocess acceptance check for the checkout and the isolated installed gem. +module ArchUnitCLISmoke + module_function + + def run + executable = Gem.bin_path('archunit', 'archunit') + Dir.mktmpdir('archunit-cli-smoke') do |root| + check_setup(executable, root) + prepare_project(root) + check_imports(executable, root) + check_constants(executable, root) + check_parse_failure(executable, root) + end + puts "CLI init/check/explain passed for archunit #{ArchUnit::VERSION} from #{executable}" + end + + def check_setup(executable, root) + check(executable, root, '--version', expected: 0, json: false) + check(executable, root, '--help', expected: 0, json: false) + check(executable, root, 'init', '--format=json', expected: 0) + check(executable, root, 'init', '--format=json', expected: 2) + File.write(File.join(root, '.archunit.rb'), 'exit 0') + check(executable, root, 'check', '--format=json', expected: 2) + end + + def check_imports(executable, root) + check(executable, root, 'check', '--format=json', expected: 0) + File.write(File.join(root, 'app/api/request.rb'), "require_relative '../db/settings'\n") + failed = check(executable, root, 'check', '--format=json', expected: 1) + return if failed.dig('summary', 'violation_count') == 1 + + abort 'CLI missed the single forbidden edge' + end + + def check_constants(executable, root) + File.write(File.join(root, 'app/api/request.rb'), '::Db::DEFAULT') + check(executable, root, 'check', '--format=json', expected: 0) + check(executable, root, 'check', '--format=json', '--dependency-analysis', + 'imports_and_constants', expected: 1) + end + + def check_parse_failure(executable, root) + File.write(File.join(root, 'app/api/request.rb'), 'class Broken <') + failure = check(executable, root, 'check', '--format=json', expected: 2) + abort 'CLI lost parse diagnostics' if failure.dig('error', 'diagnostics').to_a.empty? + explanation = check(executable, root, 'explain', '--format=json', expected: 0) + abort 'CLI explanation pretends to check' unless explanation['status'] == 'configured' + end + + def prepare_project(root) + %w[api db].each { |folder| FileUtils.mkdir_p(File.join(root, 'app', folder)) } + File.write(File.join(root, 'Gemfile'), "raise 'Target Gemfile must not execute'") + File.write(File.join(root, 'app/api/request.rb'), "raise 'Target source must not execute'") + File.write(File.join(root, 'app/db/settings.rb'), 'module Db; DEFAULT ||= 42; end') + File.write(File.join(root, '.archunit.rb'), <<~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 + RUBY + end + + def check(executable, root, *arguments, expected:, json: true) + output, errors, status = Open3.capture3(RbConfig.ruby, executable, *arguments, chdir: root) + unless status.exitstatus == expected && errors.empty? + abort "CLI #{arguments.inspect}: expected #{expected}, got #{status.exitstatus}: " \ + "#{output}#{errors}" + end + return output unless json + + payload = JSON.parse(output) + abort 'CLI JSON schema changed' unless payload['schema_version'] == 1 + payload + end +end + +ArchUnitCLISmoke.run if $PROGRAM_NAME == __FILE__ diff --git a/spec/ci/workflow_spec.rb b/spec/ci/workflow_spec.rb index 1abafc8..fcaa085 100644 --- a/spec/ci/workflow_spec.rb +++ b/spec/ci/workflow_spec.rb @@ -44,6 +44,7 @@ 'gem build archunit.gemspec --strict --output pkg/archunit.gem', 'gem install pkg/archunit.gem', "require 'archunit'", 'ArchUnit.project_files', 'ruby scripts/check_constant_writes.rb', + 'ruby scripts/check_cli.rb', 'depend_on_files', 'depend_on_external_modules', "matching('json')", 'adhere_to', 'file.extension', 'ArchUnit::Checkable', 'ArchUnit.assert_passes', 'ArchUnit.format_violations', @@ -78,6 +79,10 @@ ) end + it 'exercises CLI acceptance on every supported compatibility platform' do + expect(steps).to include(include('run' => 'bundle exec ruby scripts/check_cli.rb')) + end + it 'keeps the installed-gem smoke program syntactically valid Ruby' do install_step = quality_job.fetch('steps').find do |step| step['name'] == 'Install and load the built gem' diff --git a/spec/cli/configuration_spec.rb b/spec/cli/configuration_spec.rb new file mode 100644 index 0000000..f6cfa9a --- /dev/null +++ b/spec/cli/configuration_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'archunit/cli' +require 'tmpdir' +require 'fileutils' + +RSpec.describe ArchUnit::CLI::Configuration do + around do |example| + Dir.mktmpdir('archunit-cli-configuration') do |directory| + @root = directory + example.run + end + end + + it 'provides every rule entry point rooted at the selected project' do + configuration = described_class.new(@root) + scopes = [configuration.project_files, configuration.files, configuration.project_layers, + configuration.layers, configuration.project_slices, configuration.metrics] + + expect(scopes.map(&:project_locator)).to all(eq(@root)) + end + + it 'normalizes rule names and retains lazy checkables and rationale' do + configuration = described_class.new(@root) + configuration.rule(' boundary ') { project_files.should.have_no_cycles.because('No cycles') } + + expect(configuration.rules.first.name).to eq('boundary') + expect(configuration.rules.first.checkable.rationale).to eq('No cycles') + end + + [nil, '', ' ', 7].each do |name| + it "rejects invalid rule name #{name.inspect}" do + expect do + described_class.new(@root).rule(name) { project_files.should.have_no_cycles } + end.to raise_error(ArgumentError, /non-empty name/) + end + end + + it 'requires a block and a completed rule, not a selector or arbitrary object' do + configuration = described_class.new(@root) + + expect { configuration.rule('missing') }.to raise_error(ArgumentError, /block/) + expect do + configuration.rule('selector') do + project_files + end + end.to raise_error(ArgumentError, /completed/) + end + + it 'rejects duplicate normalized names' do + configuration = described_class.new(@root) + configuration.rule('boundary') { project_files.should.have_no_cycles } + + expect do + configuration.rule(' boundary ') { project_files.should.have_no_cycles } + end.to raise_error(ArgumentError, /Duplicate rule name/) + end + + it 'loads the explicitly trusted configuration without changing the working directory' do + path = File.join(@root, '.archunit.rb') + File.write(path, "rule('library') { project_files.should.have_no_cycles }") + cwd = Dir.pwd + rules = described_class.new(@root).load(path) + + expect(rules.map(&:name)).to eq(['library']) + expect(rules).to be_frozen + expect(Dir.pwd).to eq(cwd) + end + + it 'rejects a configuration without any rules' do + path = File.join(@root, '.archunit.rb') + File.write(path, '# nothing declared') + + expect do + described_class.new(@root).load(path) + end.to raise_error(ArgumentError, /at least one rule/) + end +end diff --git a/spec/cli/options_spec.rb b/spec/cli/options_spec.rb new file mode 100644 index 0000000..5fc1313 --- /dev/null +++ b/spec/cli/options_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'archunit/cli' + +RSpec.describe ArchUnit::CLI::Options do + let(:root) { File.expand_path('../..', __dir__) } + + it 'defaults to check, import analysis and a configuration in the selected root' do + options = described_class.new([], cwd: root).parse! + + expect(options.command).to eq('check') + expect(options.project_root).to eq(root) + expect(options.config_path).to eq(File.join(root, '.archunit.rb')) + expect(options.dependency_analysis).to eq('imports') + expect(options.format).to eq('text') + end + + it 'resolves explicit project and configuration paths against the invocation directory' do + arguments = %w[explain --project .. --config ../custom.rb --format json + --dependency-analysis imports_and_constants] + original = arguments.dup + options = described_class.new(arguments, cwd: root).parse! + + expect(options.command).to eq('explain') + expect(options.project_root).to eq(File.expand_path('..', root)) + expect(options.config_path).to eq(File.expand_path('../custom.rb', root)) + expect(options.format).to eq('json') + expect(options.dependency_analysis).to eq('imports_and_constants') + expect(arguments).to eq(original) + end + + %w[--help -h --version -v].each do |flag| + it "handles #{flag} without requiring a valid project" do + options = described_class.new([flag], cwd: File.join(root, 'missing')).parse! + + expect(options.command).to eq(flag.include?('h') ? 'help' : 'version') + end + end + + [%w[unknown], %w[check leftover], %w[check --unknown], %w[check --format xml], + %w[check --dependency-analysis reflection], %w[check --config]].each do |arguments| + it "rejects invalid arguments #{arguments.join(' ')}" do + expect { described_class.new(arguments, cwd: root).parse! }.to raise_error(OptionParser::ParseError) + end + end + + it 'rejects a missing project root' do + expect do + described_class.new(%w[check --project absent], cwd: root).parse! + end.to raise_error(ArgumentError, /Project directory does not exist/) + end + + it 'recognizes JSON even when an earlier unknown flag interrupts option parsing' do + options = described_class.new(%w[check --unknown --format=json], cwd: root) + + expect(options.format).to eq('json') + expect { options.parse! }.to raise_error(OptionParser::InvalidOption) + end +end diff --git a/spec/cli/packaging_spec.rb b/spec/cli/packaging_spec.rb new file mode 100644 index 0000000..1e2a626 --- /dev/null +++ b/spec/cli/packaging_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'archunit/cli' +require 'open3' +require 'rbconfig' + +RSpec.describe 'CLI packaging and subprocess acceptance' do + let(:root) { File.expand_path('../..', __dir__) } + + it 'packages the executable and CLI implementation' do + specification = Gem::Specification.load(File.join(root, 'archunit.gemspec')) + + expect(specification.bindir).to eq('exe') + expect(specification.executables).to eq(['archunit']) + expect(specification.files).to include('exe/archunit', 'lib/archunit/cli.rb') + end + + it 'supports the full passing/failing/error flow in separate Ruby processes' do + output, errors, status = Open3.capture3( + RbConfig.ruby, File.join(root, 'scripts/check_cli.rb'), chdir: root + ) + + expect(status.exitstatus).to eq(0), "#{output}\n#{errors}" + expect(errors).to eq('') + expect(output).to include("CLI init/check/explain passed for archunit #{ArchUnit::VERSION}") + end +end diff --git a/spec/cli/runner_spec.rb b/spec/cli/runner_spec.rb new file mode 100644 index 0000000..2435236 --- /dev/null +++ b/spec/cli/runner_spec.rb @@ -0,0 +1,253 @@ +# frozen_string_literal: true + +require 'archunit/cli' +require 'json' +require 'stringio' +require 'tmpdir' +require 'fileutils' + +RSpec.describe ArchUnit::CLI do + around do |example| + Dir.mktmpdir('archunit-cli-project') do |directory| + @root = directory + @config = File.join(directory, '.archunit.rb') + FileUtils.mkdir_p(File.join(directory, 'app/api')) + FileUtils.mkdir_p(File.join(directory, 'app/db')) + File.write(File.join(directory, 'Gemfile'), "raise 'Do not evaluate target Gemfile'") + File.write(File.join(directory, 'project.gemspec'), "raise 'Do not evaluate target gemspec'") + File.write(File.join(directory, 'app/api/request.rb'), "raise 'Do not execute target source'") + File.write(File.join(directory, 'app/db/settings.rb'), 'module Db; DEFAULT ||= 42; end') + write_config + example.run + ensure + ArchUnit.clear_graph_cache + end + end + + def write_config + File.write(@config, <<~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 + RUBY + end + + def invoke(*arguments, cwd: @root) + output = StringIO.new + errors = StringIO.new + code = described_class.run(arguments, out: output, err: errors, cwd:) + [code, output.string, errors.string] + end + + def json_check(*arguments) + code, output, errors = invoke('check', '--format', 'json', *arguments) + expect(errors).to eq('') + [code, JSON.parse(output)] + end + + it 'passes in text mode without executing a target Gemfile, gemspec, or source' do + code, output, errors = invoke('check') + + expect(code).to eq(0) + expect(output).to include('[passed] API stays out of storage', 'Because: Keep storage private') + expect(errors).to eq('') + end + + it 'reports a failing import after a source edit, including rationale and versioned JSON' do + expect(json_check.first).to eq(0) + File.write(File.join(@root, 'app/api/request.rb'), "require_relative '../db/settings'") + code, payload = json_check + + expect(code).to eq(1) + expect(payload).to include('schema_version' => 1, 'status' => 'failed') + expect(payload['summary']).to eq('rule_count' => 1, 'violation_count' => 1) + rule = payload['rules'].first + expect(rule).to include('rationale' => 'Keep storage private', 'status' => 'failed') + expect(rule['violations'].first).to include( + 'type' => 'ArchUnit::Files::Assertion::FileDependencyViolation', + 'message' => 'File dependency violation', + 'details' => include('app/api/request.rb', 'app/db/settings.rb') + ) + end + + it 'renders shared violation wording in text output' do + File.write(File.join(@root, 'app/api/request.rb'), "require_relative '../db/settings'") + code, output, errors = invoke + + expect(code).to eq(1) + expect(output).to include('File dependency violation', 'depends on forbidden file') + expect(errors).to eq('') + end + + it 'preserves import-only defaults and explicitly opts into conditional constant references' do + File.write(File.join(@root, 'app/api/request.rb'), '::Db::DEFAULT') + + expect(json_check.first).to eq(0) + code, payload = json_check('--dependency-analysis', 'imports_and_constants') + expect(code).to eq(1) + expect(payload['dependency_analysis']).to eq('imports_and_constants') + end + + it 'does not silently pass a selector matching no files' do + File.write(@config, "rule('typo') { project_files.in_path('absent/**').should.have_no_cycles }") + code, payload = json_check + + expect(code).to eq(1) + expect(payload.dig('rules', 0, 'violations', 0, 'type')).to eq('ArchUnit::Common::Assertion::EmptyTestViolation') + end + + it 'reports structured parse diagnostics with a technical-error status, not a rule violation' do + File.write(File.join(@root, 'app/db/settings.rb'), 'class Broken <') + code, payload = json_check + + expect(code).to eq(2) + expect(payload).to include('status' => 'error') + expect(payload['error']).to include('type' => 'ArchUnit::SourceParseError', + 'rule_name' => 'API stays out of storage') + expect(payload.dig('error', 'diagnostics').first).to include( + 'path' => include('settings.rb'), 'line' => 1, 'column' => be_positive, + 'message' => be_a(String) + ) + end + + it 'checks every rule and preserves completed results if a later rule raises' do + File.open(@config, 'a') do |file| + file.puts "rule('missing') { project_files.in_path('missing/**').should.have_no_cycles }" + end + code, payload = json_check + expect(code).to eq(1) + expect(payload['rules'].map { |rule| rule['status'] }).to eq(%w[passed failed]) + + File.open(@config, 'a') do |file| + file.puts "rule('broken') { project_files.should.adhere_to(" \ + "->(_file) { raise 'custom failure' }, 'custom') }" + end + code, payload = json_check + expect(code).to eq(2) + expect(payload.dig('error', 'rule_name')).to eq('broken') + expect(payload['rules'].length).to eq(2) + end + + it 'honors project exclusions' do + File.write(File.join(@root, 'app/api/request.rb'), '::Db::DEFAULT') + File.write(File.join(@root, '.archignore'), "app/db/\n") + + expect(json_check('--dependency-analysis', 'imports_and_constants').first).to eq(0) + end + + it 'explains configured names and rationale without parsing malformed target source' do + File.write(File.join(@root, 'app/api/request.rb'), 'class Broken <') + expect(Prism).not_to receive(:parse) + code, output, errors = invoke('explain', '--format=json') + payload = JSON.parse(output) + + expect(code).to eq(0) + expect(payload['status']).to eq('configured') + expect(payload.dig('rules', 0, 'status')).to eq('not_checked') + expect(payload.dig('rules', 0, 'rationale')).to eq('Keep storage private') + expect(errors).to eq('') + end + + it 'clearly labels text explanations as not evaluated' do + code, output, = invoke('explain') + + expect(code).to eq(0) + expect(output).to include('not evaluated', '[not_checked] API stays out of storage') + end + + it 'uses an explicit config and root even when invoked from elsewhere' do + custom = File.join(@root, 'custom.rb') + FileUtils.mv(@config, custom) + code, output, errors = invoke('check', '--project', @root, '--config', custom, + cwd: File.dirname(@root)) + + expect(code).to eq(0) + expect(output).to include(@root, custom) + expect(errors).to eq('') + end + + it 'handles help and version without loading configuration' do + File.write(@config, "raise 'Do not load configuration for informational commands'") + + expect(invoke('--help')[0..1]).to match([0, include('Usage:', '--dependency-analysis')]) + expect(invoke('--version')[0..1]).to eq([0, "#{ArchUnit::VERSION}\n"]) + end + + it 'reports missing configuration as an error on stderr in text mode' do + FileUtils.rm_f(@config) + code, output, errors = invoke('check') + + expect(code).to eq(2) + expect(output).to eq('') + expect(errors).to include('ArchUnit error:', '.archunit.rb') + end + + ['# no rules', 'exit 0', 'rule(', "raise 'bad configuration'", + "require 'archunit_missing_cli_dependency'", + "rule('incomplete') { project_files }", "rule('name')", "rule('') { nil }"].each do |source| + it "does not pass invalid configuration #{source.inspect}" do + File.write(@config, source) + code, payload = json_check + + expect(code).to eq(2) + expect(payload['status']).to eq('error') + expect(payload['error']['message']).not_to be_empty + end + end + + it 'rejects unknown flags as JSON even before the output-format flag' do + code, output, errors = invoke('check', '--unknown', '--format=json') + + expect(code).to eq(2) + expect(JSON.parse(output).dig('error', 'type')).to eq('OptionParser::InvalidOption') + expect(errors).to eq('') + end + + it 'initializes an explicit destination with a usable cycle-check starter' do + destination = File.join(@root, 'new-config.rb') + code, output, errors = invoke('init', '--config', destination) + expect(code).to eq(0) + expect(output).to include('initialized', 'trusted Ruby configuration') + expect(errors).to eq('') + expect(File.read(destination)).to include('have_no_cycles', 'lib/**/*.rb') + + FileUtils.mkdir_p(File.join(@root, 'lib')) + File.write(File.join(@root, 'lib/clean.rb'), 'class Clean; end') + expect(invoke('check', '--config', destination).first).to eq(0) + end + + it 'never overwrites an existing configuration or directory' do + original = File.binread(@config) + + expect(invoke('init').first).to eq(2) + expect(File.binread(@config)).to eq(original) + expect(invoke('init', '--config', File.join(@root, 'app')).first).to eq(2) + expect(File.directory?(File.join(@root, 'app'))).to be(true) + end + + it 'refuses an existing filesystem entry before attempting to open a configuration' do + link = File.join(@root, 'simulated-linked-config.rb') + allow(File).to receive(:lstat).with(link).and_return(double('existing filesystem entry')) + expect(File).not_to receive(:open) + + code, output, errors = invoke('init', '--config', link, '--format=json') + expect(code).to eq(2) + expect(JSON.parse(output).dig('error', 'type')).to eq('Errno::EEXIST') + expect(errors).to eq('') + end + + it 'never follows or replaces a dangling configuration symlink during init' do + link = File.join(@root, 'linked-config.rb') + target = File.join(@root, 'missing-config.rb') + begin + File.symlink(target, link) + rescue NotImplementedError, Errno::EPERM, Errno::EACCES + skip 'creating symlinks is not available for this user or platform' + end + + expect(invoke('init', '--config', link).first).to eq(2) + expect(File.symlink?(link)).to be(true) + expect(File.exist?(target)).to be(false) + end +end