archunit 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +7 -0
- data/README.md +375 -0
- data/lib/archunit/common/assertion/empty_test_violation.rb +50 -0
- data/lib/archunit/common/assertion/violation.rb +14 -0
- data/lib/archunit/common/extraction/edge.rb +46 -0
- data/lib/archunit/common/extraction/graph.rb +73 -0
- data/lib/archunit/common/extraction/import_kind.rb +21 -0
- data/lib/archunit/common/filter.rb +46 -0
- data/lib/archunit/common/fluentapi/check_options.rb +48 -0
- data/lib/archunit/common/fluentapi/checkable.rb +70 -0
- data/lib/archunit/common/logging/check_logger.rb +124 -0
- data/lib/archunit/common/logging/logging_options.rb +57 -0
- data/lib/archunit/common/pattern.rb +99 -0
- data/lib/archunit/common/pattern_matching.rb +76 -0
- data/lib/archunit/common/projection/cycles/johnson_cycles.rb +103 -0
- data/lib/archunit/common/projection/cycles/tarjan_scc.rb +82 -0
- data/lib/archunit/common/projection/edge_projections.rb +50 -0
- data/lib/archunit/common/projection/mapped_edge.rb +26 -0
- data/lib/archunit/common/projection/project_cycles.rb +92 -0
- data/lib/archunit/common/projection/project_edges.rb +69 -0
- data/lib/archunit/common/projection/project_to_nodes.rb +59 -0
- data/lib/archunit/common/projection/projected_edge.rb +35 -0
- data/lib/archunit/common/projection/projected_node.rb +36 -0
- data/lib/archunit/common/regex_factory.rb +96 -0
- data/lib/archunit/error/technical_error.rb +6 -0
- data/lib/archunit/error/user_error.rb +6 -0
- data/lib/archunit/extraction/enumerate_source_files.rb +131 -0
- data/lib/archunit/extraction/extract_dependencies.rb +61 -0
- data/lib/archunit/extraction/extract_graph.rb +89 -0
- data/lib/archunit/extraction/extract_imports.rb +197 -0
- data/lib/archunit/extraction/locate_project.rb +80 -0
- data/lib/archunit/extraction/resolve_import.rb +76 -0
- data/lib/archunit/extraction/resolved_import.rb +46 -0
- data/lib/archunit/files/assertion/custom_file_condition.rb +89 -0
- data/lib/archunit/files/assertion/cycle_free.rb +58 -0
- data/lib/archunit/files/assertion/depend_on_external_modules.rb +116 -0
- data/lib/archunit/files/assertion/depend_on_files.rb +110 -0
- data/lib/archunit/files/assertion/matching_files.rb +87 -0
- data/lib/archunit/files/extraction/extract_file_info.rb +84 -0
- data/lib/archunit/files/extraction/file_info.rb +46 -0
- data/lib/archunit/files/fluentapi/custom_file_condition.rb +73 -0
- data/lib/archunit/files/fluentapi/cycle_free_file_condition.rb +55 -0
- data/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb +69 -0
- data/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb +36 -0
- data/lib/archunit/files/fluentapi/depend_on_file_condition.rb +80 -0
- data/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb +49 -0
- data/lib/archunit/files/fluentapi/file_condition_builder.rb +71 -0
- data/lib/archunit/files/fluentapi/file_rule_support.rb +24 -0
- data/lib/archunit/files/fluentapi/files.rb +31 -0
- data/lib/archunit/files/fluentapi/match_pattern_file_condition.rb +59 -0
- data/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb +66 -0
- data/lib/archunit/files/fluentapi/negated_match_pattern_file_condition_builder.rb +16 -0
- data/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb +21 -0
- data/lib/archunit/graph/fluentapi/graph.rb +29 -0
- data/lib/archunit/graph/fluentapi/project_graph_builder.rb +106 -0
- data/lib/archunit/graph/projection/aggregate_edges.rb +48 -0
- data/lib/archunit/graph/projection/collapse_node.rb +33 -0
- data/lib/archunit/graph/projection/create_snapshot.rb +84 -0
- data/lib/archunit/graph/projection/folder_depth_collapse.rb +18 -0
- data/lib/archunit/graph/projection/graph_query_options.rb +81 -0
- data/lib/archunit/graph/projection/graph_report_edge.rb +50 -0
- data/lib/archunit/graph/projection/graph_report_node.rb +22 -0
- data/lib/archunit/graph/projection/graph_report_snapshot.rb +43 -0
- data/lib/archunit/graph/projection/graph_report_summary.rb +20 -0
- data/lib/archunit/graph/projection/node_selection.rb +111 -0
- data/lib/archunit/graph/projection/pattern_collapse.rb +24 -0
- data/lib/archunit/graph/rendering/csv_renderer.rb +30 -0
- data/lib/archunit/graph/rendering/d2_renderer.rb +30 -0
- data/lib/archunit/graph/rendering/dot_renderer.rb +51 -0
- data/lib/archunit/graph/rendering/escaping.rb +31 -0
- data/lib/archunit/graph/rendering/export_report.rb +29 -0
- data/lib/archunit/graph/rendering/graph_renderer.rb +54 -0
- data/lib/archunit/graph/rendering/html_document.rb +46 -0
- data/lib/archunit/graph/rendering/html_renderer.rb +102 -0
- data/lib/archunit/graph/rendering/json_renderer.rb +38 -0
- data/lib/archunit/graph/rendering/mermaid_renderer.rb +33 -0
- data/lib/archunit/graph/rendering/rendering_support.rb +24 -0
- data/lib/archunit/layers/assertion/layer_definition.rb +56 -0
- data/lib/archunit/layers/assertion/layer_dependencies.rb +91 -0
- data/lib/archunit/layers/assertion/layer_dependency_violation.rb +59 -0
- data/lib/archunit/layers/fluentapi/layer_definition_builder.rb +46 -0
- data/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb +44 -0
- data/lib/archunit/layers/fluentapi/layered_architecture.rb +133 -0
- data/lib/archunit/layers/fluentapi/layered_architecture_values.rb +51 -0
- data/lib/archunit/layers/fluentapi/layers.rb +29 -0
- data/lib/archunit/metrics/assertion/custom_metric.rb +73 -0
- data/lib/archunit/metrics/assertion/metric_predicate.rb +66 -0
- data/lib/archunit/metrics/assertion/metric_threshold.rb +99 -0
- data/lib/archunit/metrics/assertion/metric_zone.rb +57 -0
- data/lib/archunit/metrics/calculation/count.rb +45 -0
- data/lib/archunit/metrics/calculation/distance.rb +84 -0
- data/lib/archunit/metrics/calculation/lcom.rb +103 -0
- data/lib/archunit/metrics/calculation/metric.rb +52 -0
- data/lib/archunit/metrics/calculation/numeric_value.rb +25 -0
- data/lib/archunit/metrics/extraction/distance_info.rb +60 -0
- data/lib/archunit/metrics/extraction/extract_distance_info.rb +50 -0
- data/lib/archunit/metrics/extraction/extract_project_info.rb +90 -0
- data/lib/archunit/metrics/extraction/metric_info.rb +159 -0
- data/lib/archunit/metrics/extraction/source_metrics_visitor.rb +278 -0
- data/lib/archunit/metrics/fluentapi/count_metrics_builder.rb +49 -0
- data/lib/archunit/metrics/fluentapi/custom_metric_builder.rb +30 -0
- data/lib/archunit/metrics/fluentapi/custom_metric_condition.rb +40 -0
- data/lib/archunit/metrics/fluentapi/distance_metrics_builder.rb +48 -0
- data/lib/archunit/metrics/fluentapi/lcom_metrics_builder.rb +39 -0
- data/lib/archunit/metrics/fluentapi/metric_measurement.rb +36 -0
- data/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb +42 -0
- data/lib/archunit/metrics/fluentapi/metric_report_builder.rb +34 -0
- data/lib/archunit/metrics/fluentapi/metric_selection.rb +54 -0
- data/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb +47 -0
- data/lib/archunit/metrics/fluentapi/metrics.rb +21 -0
- data/lib/archunit/metrics/fluentapi/metrics_builder.rb +136 -0
- data/lib/archunit/metrics/fluentapi/zone_condition.rb +40 -0
- data/lib/archunit/metrics/reporting/metrics_export_options.rb +57 -0
- data/lib/archunit/metrics/reporting/metrics_exporter.rb +130 -0
- data/lib/archunit/slices/assertion/adhere_to_diagram.rb +57 -0
- data/lib/archunit/slices/assertion/contain_dependency.rb +42 -0
- data/lib/archunit/slices/assertion/diagram_adherence_options.rb +37 -0
- data/lib/archunit/slices/assertion/slice_dependency_violation.rb +70 -0
- data/lib/archunit/slices/fluentapi/diagram_slice_condition.rb +65 -0
- data/lib/archunit/slices/fluentapi/diagram_source.rb +36 -0
- data/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb +57 -0
- data/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb +27 -0
- data/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb +51 -0
- data/lib/archunit/slices/fluentapi/slice_scope_builder.rb +83 -0
- data/lib/archunit/slices/fluentapi/slices.rb +29 -0
- data/lib/archunit/slices/projection/slice_projection.rb +76 -0
- data/lib/archunit/slices/projection/slicing_projections.rb +98 -0
- data/lib/archunit/slices/uml/export_diagram.rb +70 -0
- data/lib/archunit/slices/uml/parse_diagram.rb +53 -0
- data/lib/archunit/slices/uml/plant_uml_dependency.rb +25 -0
- data/lib/archunit/slices/uml/plant_uml_diagram.rb +44 -0
- data/lib/archunit/testing/assert_passes.rb +24 -0
- data/lib/archunit/testing/assertion_failure.rb +22 -0
- data/lib/archunit/testing/color_utils.rb +66 -0
- data/lib/archunit/testing/layer_violation_formatter.rb +26 -0
- data/lib/archunit/testing/metric_violation_formatter.rb +58 -0
- data/lib/archunit/testing/minitest_adapter.rb +33 -0
- data/lib/archunit/testing/result_factory.rb +93 -0
- data/lib/archunit/testing/rspec_adapter.rb +74 -0
- data/lib/archunit/testing/slice_violation_formatter.rb +25 -0
- data/lib/archunit/testing/test_result.rb +26 -0
- data/lib/archunit/testing/test_violation.rb +23 -0
- data/lib/archunit/testing/violation_factory.rb +138 -0
- data/lib/archunit/testing.rb +39 -0
- data/lib/archunit/version.rb +5 -0
- data/lib/archunit.rb +110 -0
- metadata +251 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../logging/logging_options'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module Common
|
|
7
|
+
module FluentApi
|
|
8
|
+
# Immutable options shared by every terminal rule check.
|
|
9
|
+
CheckOptions = Data.define(:allow_empty_tests, :logging, :clear_cache) do
|
|
10
|
+
def self.resolve(value)
|
|
11
|
+
return new if value.nil?
|
|
12
|
+
return value if value.is_a?(self)
|
|
13
|
+
|
|
14
|
+
raise ArgumentError, 'options must be a CheckOptions value or nil'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(allow_empty_tests: false, logging: nil, clear_cache: false)
|
|
18
|
+
validate_boolean(allow_empty_tests, :allow_empty_tests)
|
|
19
|
+
validate_logging(logging)
|
|
20
|
+
validate_boolean(clear_cache, :clear_cache)
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def allow_empty_tests?
|
|
25
|
+
allow_empty_tests
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def clear_cache?
|
|
29
|
+
clear_cache
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def validate_boolean(value, attribute)
|
|
35
|
+
return if [true, false].include?(value)
|
|
36
|
+
|
|
37
|
+
raise ArgumentError, "#{attribute} must be true or false"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate_logging(value)
|
|
41
|
+
return if value.nil? || value.is_a?(Logging::LoggingOptions)
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, 'logging must be a LoggingOptions value or nil'
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../assertion/violation'
|
|
4
|
+
require_relative '../assertion/empty_test_violation'
|
|
5
|
+
require_relative '../logging/check_logger'
|
|
6
|
+
require_relative 'check_options'
|
|
7
|
+
|
|
8
|
+
module ArchUnit
|
|
9
|
+
module Common
|
|
10
|
+
module FluentApi
|
|
11
|
+
# The shared execution contract implemented by every terminal rule.
|
|
12
|
+
module Checkable
|
|
13
|
+
def check(options = nil)
|
|
14
|
+
resolved_options = CheckOptions.resolve(options)
|
|
15
|
+
logger = Logging::CheckLogger.new(resolved_options.logging)
|
|
16
|
+
check_name = self.class.name || self.class.to_s
|
|
17
|
+
logger.start_check(check_name)
|
|
18
|
+
execute_check(resolved_options, logger, check_name)
|
|
19
|
+
ensure
|
|
20
|
+
logger&.close
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def perform_check(_options)
|
|
26
|
+
raise NotImplementedError, "#{self.class} must implement #perform_check"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def execute_check(options, logger, check_name)
|
|
30
|
+
logger.log_progress("executing #{check_name}")
|
|
31
|
+
violations = validate_violations(perform_check(options))
|
|
32
|
+
log_violations(logger, violations)
|
|
33
|
+
logger.end_check(check_name, violation_count: violations.length)
|
|
34
|
+
violations
|
|
35
|
+
rescue StandardError, NotImplementedError => e
|
|
36
|
+
logger.end_check(check_name, error: e)
|
|
37
|
+
raise
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def log_violations(logger, violations)
|
|
41
|
+
violations.each do |violation|
|
|
42
|
+
logger.log_violation(violation)
|
|
43
|
+
next unless violation.respond_to?(:metric_name) && violation.respond_to?(:value)
|
|
44
|
+
|
|
45
|
+
subject = violation.identifier if violation.respond_to?(:identifier)
|
|
46
|
+
logger.log_metric(name: violation.metric_name, value: violation.value, subject:)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def empty_test_violation(selected_items, filters:, negated:, options:)
|
|
51
|
+
unless selected_items.respond_to?(:empty?)
|
|
52
|
+
raise ArgumentError, 'selected_items must respond to empty?'
|
|
53
|
+
end
|
|
54
|
+
unless options.is_a?(CheckOptions)
|
|
55
|
+
raise ArgumentError, 'options must be a CheckOptions value'
|
|
56
|
+
end
|
|
57
|
+
return if !selected_items.empty? || options.allow_empty_tests?
|
|
58
|
+
|
|
59
|
+
[Assertion::EmptyTestViolation.new(filters:, is_negated: negated)]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_violations(violations)
|
|
63
|
+
return violations if violations.is_a?(Array) && violations.all?(Assertion::Violation)
|
|
64
|
+
|
|
65
|
+
raise TypeError, 'check must return an Array of Violation values'
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'time'
|
|
5
|
+
require_relative 'logging_options'
|
|
6
|
+
|
|
7
|
+
module ArchUnit
|
|
8
|
+
module Common
|
|
9
|
+
module Logging
|
|
10
|
+
# A logger owned by one check invocation; no configuration or output state is global.
|
|
11
|
+
class CheckLogger
|
|
12
|
+
LEVEL_PRIORITIES = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :log_path
|
|
15
|
+
|
|
16
|
+
def initialize(options = nil, clock: -> { Time.now.utc })
|
|
17
|
+
validate_options(options)
|
|
18
|
+
raise ArgumentError, 'clock must respond to call' unless clock.respond_to?(:call)
|
|
19
|
+
|
|
20
|
+
@options = options
|
|
21
|
+
@clock = clock
|
|
22
|
+
@file = nil
|
|
23
|
+
@log_path = nil
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def start_check(check_name)
|
|
28
|
+
write(:info, "start check: #{label(check_name, 'check_name')}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def end_check(check_name, violation_count: nil, error: nil)
|
|
32
|
+
name = label(check_name, 'check_name')
|
|
33
|
+
if error
|
|
34
|
+
write(:error, "end check: #{name} failed with #{error.class}: #{error.message}")
|
|
35
|
+
else
|
|
36
|
+
write(:info, "end check: #{name} (#{Integer(violation_count)} violations)")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def log_progress(message)
|
|
41
|
+
write(:info, "log progress: #{label(message, 'message')}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def log_violation(violation)
|
|
45
|
+
raise ArgumentError, 'violation is required' if violation.nil?
|
|
46
|
+
|
|
47
|
+
write(:warn, "log violation: #{violation_description(violation)}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def log_metric(name:, value:, subject: nil)
|
|
51
|
+
metric = label(name, 'name')
|
|
52
|
+
context = subject.nil? ? '' : " [#{subject}]"
|
|
53
|
+
write(:debug, "log metric: #{metric}=#{value}#{context}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def close
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
@file&.close
|
|
59
|
+
@file = nil
|
|
60
|
+
end
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def validate_options(value)
|
|
67
|
+
return if value.nil? || value.is_a?(LoggingOptions)
|
|
68
|
+
|
|
69
|
+
raise ArgumentError, 'options must be a LoggingOptions value or nil'
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def write(level, message)
|
|
73
|
+
return if @options.nil? || !enabled?(level)
|
|
74
|
+
|
|
75
|
+
timestamp = current_time
|
|
76
|
+
line = "[#{timestamp.iso8601(6)}] [#{level.to_s.upcase}] #{message}\n"
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
@options.io&.write(line)
|
|
79
|
+
output_file(timestamp)&.write(line)
|
|
80
|
+
end
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def enabled?(level)
|
|
85
|
+
LEVEL_PRIORITIES.fetch(level) >= LEVEL_PRIORITIES.fetch(@options.level)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def current_time
|
|
89
|
+
value = @clock.call
|
|
90
|
+
raise ArgumentError, 'clock must return a Time' unless value.is_a?(Time)
|
|
91
|
+
|
|
92
|
+
value.utc
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def output_file(timestamp)
|
|
96
|
+
return unless @options.file_output?
|
|
97
|
+
return @file if @file
|
|
98
|
+
|
|
99
|
+
FileUtils.mkdir_p(@options.output_directory)
|
|
100
|
+
@log_path = File.join(
|
|
101
|
+
@options.output_directory,
|
|
102
|
+
"archunit-#{timestamp.strftime('%Y-%m-%d_%H-%M-%S-%6N')}.log"
|
|
103
|
+
).freeze
|
|
104
|
+
@file = File.open(@log_path, @options.append ? 'ab' : 'wb')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def label(value, attribute)
|
|
108
|
+
text = value.to_s
|
|
109
|
+
return text unless text.empty?
|
|
110
|
+
|
|
111
|
+
raise ArgumentError, "#{attribute} must not be empty"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def violation_description(violation)
|
|
115
|
+
description = violation.class.name.to_s
|
|
116
|
+
description = violation.class.to_s if description.empty?
|
|
117
|
+
return description unless violation.respond_to?(:identifier)
|
|
118
|
+
|
|
119
|
+
"#{description} [#{violation.identifier}]"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
module Common
|
|
5
|
+
module Logging
|
|
6
|
+
LOG_LEVELS = %i[debug info warn error].freeze
|
|
7
|
+
private_constant :LOG_LEVELS
|
|
8
|
+
|
|
9
|
+
# Immutable, per-check logging configuration. A nil CheckOptions#logging disables logging.
|
|
10
|
+
LoggingOptions = Data.define(:level, :io, :output_directory, :append) do
|
|
11
|
+
def initialize(level: :info, io: $stderr, output_directory: nil, append: false)
|
|
12
|
+
level = normalize_level(level)
|
|
13
|
+
validate_io(io)
|
|
14
|
+
output_directory = normalize_output_directory(output_directory)
|
|
15
|
+
validate_append(append)
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def file_output?
|
|
20
|
+
!output_directory.nil?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def normalize_level(value)
|
|
26
|
+
normalized = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
27
|
+
return normalized if LOG_LEVELS.include?(normalized)
|
|
28
|
+
|
|
29
|
+
raise ArgumentError, "level must be one of: #{LOG_LEVELS.join(', ')}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def validate_io(value)
|
|
33
|
+
return if value.nil? || value.respond_to?(:write)
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, 'io must respond to write or be nil'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def normalize_output_directory(value)
|
|
39
|
+
return if value.nil?
|
|
40
|
+
|
|
41
|
+
path = value.respond_to?(:to_path) ? value.to_path : value
|
|
42
|
+
unless path.is_a?(String) && !path.empty?
|
|
43
|
+
raise ArgumentError, 'output_directory must be a non-empty path or nil'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
File.expand_path(path).freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate_append(value)
|
|
50
|
+
return if [true, false].include?(value)
|
|
51
|
+
|
|
52
|
+
raise ArgumentError, 'append must be true or false'
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
module Common
|
|
5
|
+
# Compiles user-facing glob strings into the regular expressions used by the kernel.
|
|
6
|
+
module Pattern
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def compile(pattern)
|
|
10
|
+
case pattern
|
|
11
|
+
when String
|
|
12
|
+
compile_glob(pattern.tr('\\', '/'))
|
|
13
|
+
when Regexp
|
|
14
|
+
pattern.dup.freeze
|
|
15
|
+
else
|
|
16
|
+
raise ArgumentError, 'pattern must be a String glob or Regexp'
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def valid?(pattern)
|
|
21
|
+
pattern.is_a?(String) || pattern.is_a?(Regexp)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def compile_glob(glob)
|
|
25
|
+
characters = glob.chars
|
|
26
|
+
source = +'\\A'
|
|
27
|
+
index = 0
|
|
28
|
+
|
|
29
|
+
while index < characters.length
|
|
30
|
+
fragment, index = compile_fragment(characters, index)
|
|
31
|
+
source << fragment
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Regexp.new("#{source}\\z").freeze
|
|
35
|
+
end
|
|
36
|
+
private_class_method :compile_glob
|
|
37
|
+
|
|
38
|
+
def compile_fragment(characters, index)
|
|
39
|
+
case characters[index]
|
|
40
|
+
when '*'
|
|
41
|
+
compile_star(characters, index)
|
|
42
|
+
when '?'
|
|
43
|
+
['[^/]', index + 1]
|
|
44
|
+
when '['
|
|
45
|
+
compile_character_class(characters, index)
|
|
46
|
+
else
|
|
47
|
+
[Regexp.escape(characters[index]), index + 1]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
private_class_method :compile_fragment
|
|
51
|
+
|
|
52
|
+
def compile_star(characters, index)
|
|
53
|
+
return ['[^/]*', index + 1] unless characters[index + 1] == '*'
|
|
54
|
+
|
|
55
|
+
index += 2
|
|
56
|
+
index += 1 while characters[index] == '*'
|
|
57
|
+
return ['.*', index] unless characters[index] == '/'
|
|
58
|
+
|
|
59
|
+
['(?:.*/)?', index + 1]
|
|
60
|
+
end
|
|
61
|
+
private_class_method :compile_star
|
|
62
|
+
|
|
63
|
+
def compile_character_class(characters, index)
|
|
64
|
+
closing_index = character_class_closing_index(characters, index)
|
|
65
|
+
return ['\\[', index + 1] if closing_index.nil? || closing_index == index + 1
|
|
66
|
+
|
|
67
|
+
content = characters[(index + 1)...closing_index]
|
|
68
|
+
return ['\\[', index + 1] if content == ['!']
|
|
69
|
+
|
|
70
|
+
[character_class_fragment(content), closing_index + 1]
|
|
71
|
+
end
|
|
72
|
+
private_class_method :compile_character_class
|
|
73
|
+
|
|
74
|
+
def character_class_closing_index(characters, index)
|
|
75
|
+
closing_offset = characters[(index + 1)..].index(']')
|
|
76
|
+
closing_offset && (index + closing_offset + 1)
|
|
77
|
+
end
|
|
78
|
+
private_class_method :character_class_closing_index
|
|
79
|
+
|
|
80
|
+
def character_class_fragment(content)
|
|
81
|
+
content = content.dup
|
|
82
|
+
negated = content.first == '!'
|
|
83
|
+
content.shift if negated
|
|
84
|
+
|
|
85
|
+
escaped_content = content.map { |character| escape_class_character(character) }.join
|
|
86
|
+
prefix = negated ? '^' : ''
|
|
87
|
+
"(?=[^/])[#{prefix}#{escaped_content}]"
|
|
88
|
+
end
|
|
89
|
+
private_class_method :character_class_fragment
|
|
90
|
+
|
|
91
|
+
def escape_class_character(character)
|
|
92
|
+
return "\\#{character}" if ['\\', '[', ']', '&', '^'].include?(character)
|
|
93
|
+
|
|
94
|
+
character
|
|
95
|
+
end
|
|
96
|
+
private_class_method :escape_class_character
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'filter'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module Common
|
|
7
|
+
# Generic matching operations. The Filter selects which part of a candidate is tested.
|
|
8
|
+
module PatternMatching
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def matches_pattern?(file_path, filter, class_name: nil)
|
|
12
|
+
validate_arguments(file_path, filter, class_name)
|
|
13
|
+
target = target_string(file_path, filter.target, class_name)
|
|
14
|
+
matches = regexp_matches?(filter, target)
|
|
15
|
+
|
|
16
|
+
matches && filter.exclusions.none? do |exclusion|
|
|
17
|
+
matches_pattern?(file_path, exclusion, class_name:)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def matches_all_patterns?(file_path, filters, class_name: nil)
|
|
22
|
+
filters.all? { |filter| matches_pattern?(file_path, filter, class_name:) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def matches_any_pattern?(file_path, filters, class_name: nil)
|
|
26
|
+
filters.any? { |filter| matches_pattern?(file_path, filter, class_name:) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def normalize_path(path)
|
|
30
|
+
path.tr('\\', '/')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def extract_filename(path)
|
|
34
|
+
normalize_path(path).split('/').last.to_s
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def path_without_filename(path)
|
|
38
|
+
parts = normalize_path(path).split('/')
|
|
39
|
+
parts.pop
|
|
40
|
+
parts.join('/')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate_arguments(file_path, filter, class_name)
|
|
44
|
+
raise ArgumentError, 'file_path must be a String' unless file_path.is_a?(String)
|
|
45
|
+
raise ArgumentError, 'filter must be a Filter' unless filter.is_a?(Filter)
|
|
46
|
+
return if class_name.nil? || class_name.is_a?(String)
|
|
47
|
+
|
|
48
|
+
raise ArgumentError, 'class_name must be a String'
|
|
49
|
+
end
|
|
50
|
+
private_class_method :validate_arguments
|
|
51
|
+
|
|
52
|
+
def target_string(file_path, target, class_name)
|
|
53
|
+
case target
|
|
54
|
+
when :filename
|
|
55
|
+
extract_filename(file_path)
|
|
56
|
+
when :path
|
|
57
|
+
normalize_path(file_path)
|
|
58
|
+
when :path_without_filename
|
|
59
|
+
path_without_filename(file_path)
|
|
60
|
+
when :classname
|
|
61
|
+
class_name || raise(ArgumentError, 'class_name is required for a classname filter')
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
private_class_method :target_string
|
|
65
|
+
|
|
66
|
+
def regexp_matches?(filter, target)
|
|
67
|
+
match = filter.regexp.match(target)
|
|
68
|
+
return false unless match
|
|
69
|
+
return true if filter.matching == :partial
|
|
70
|
+
|
|
71
|
+
match.begin(0).zero? && match.end(0) == target.length
|
|
72
|
+
end
|
|
73
|
+
private_class_method :regexp_matches?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'tarjan_scc'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module Common
|
|
7
|
+
module Projection
|
|
8
|
+
module Cycles
|
|
9
|
+
# Enumerates every elementary directed cycle once using Johnson's algorithm.
|
|
10
|
+
class JohnsonCycles
|
|
11
|
+
def self.call(adjacency)
|
|
12
|
+
new(adjacency).call
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(adjacency)
|
|
16
|
+
@vertices = (adjacency.keys + adjacency.values.flatten).uniq.sort
|
|
17
|
+
@adjacency = @vertices.to_h do |vertex|
|
|
18
|
+
neighbours = adjacency.fetch(vertex, []).reject { |item| item == vertex }.uniq.sort
|
|
19
|
+
[vertex, neighbours]
|
|
20
|
+
end
|
|
21
|
+
@cycles = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
lower_bound = @vertices.first
|
|
26
|
+
|
|
27
|
+
while lower_bound
|
|
28
|
+
component = next_component(lower_bound)
|
|
29
|
+
break unless component
|
|
30
|
+
|
|
31
|
+
start = component.min
|
|
32
|
+
prepare_search(component, start)
|
|
33
|
+
circuit(start)
|
|
34
|
+
lower_bound = @vertices.find { |vertex| vertex > start }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@cycles.map(&:freeze).freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def next_component(lower_bound)
|
|
43
|
+
remaining = @vertices.select { |vertex| vertex >= lower_bound }
|
|
44
|
+
TarjanScc.call(@adjacency, vertices: remaining)
|
|
45
|
+
.select { |component| component.length > 1 }
|
|
46
|
+
.min_by(&:min)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def prepare_search(component, start)
|
|
50
|
+
@component = component.to_set
|
|
51
|
+
@start = start
|
|
52
|
+
@stack = []
|
|
53
|
+
@blocked = Set.new
|
|
54
|
+
@blocked_by = Hash.new { |hash, vertex| hash[vertex] = Set.new }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def circuit(vertex)
|
|
58
|
+
@stack << vertex
|
|
59
|
+
@blocked.add(vertex)
|
|
60
|
+
found_cycle = explore_neighbours(vertex)
|
|
61
|
+
found_cycle ? unblock(vertex) : record_blockers(vertex)
|
|
62
|
+
@stack.pop
|
|
63
|
+
found_cycle
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def explore_neighbours(vertex)
|
|
67
|
+
found_cycle = false
|
|
68
|
+
component_neighbours(vertex).each do |neighbour|
|
|
69
|
+
closes_cycle = record_cycle?(neighbour)
|
|
70
|
+
finds_cycle = !@blocked.include?(neighbour) && circuit(neighbour)
|
|
71
|
+
found_cycle = true if closes_cycle || finds_cycle
|
|
72
|
+
end
|
|
73
|
+
found_cycle
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def record_cycle?(neighbour)
|
|
77
|
+
return false unless neighbour == @start
|
|
78
|
+
|
|
79
|
+
@cycles << @stack.dup
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def unblock(vertex)
|
|
84
|
+
return unless @blocked.delete?(vertex)
|
|
85
|
+
|
|
86
|
+
dependants = @blocked_by.delete(vertex) || []
|
|
87
|
+
dependants.each { |dependant| unblock(dependant) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def record_blockers(vertex)
|
|
91
|
+
component_neighbours(vertex).each do |neighbour|
|
|
92
|
+
@blocked_by[neighbour].add(vertex)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def component_neighbours(vertex)
|
|
97
|
+
@adjacency.fetch(vertex, []).select { |neighbour| @component.include?(neighbour) }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
module Common
|
|
5
|
+
module Projection
|
|
6
|
+
module Cycles
|
|
7
|
+
# Finds strongly connected components in a directed adjacency map.
|
|
8
|
+
class TarjanScc
|
|
9
|
+
def self.call(adjacency, vertices: adjacency.keys)
|
|
10
|
+
new(adjacency, vertices).call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(adjacency, vertices)
|
|
14
|
+
@adjacency = adjacency
|
|
15
|
+
@vertices = vertices.to_a
|
|
16
|
+
@allowed = @vertices.to_set
|
|
17
|
+
@next_index = 0
|
|
18
|
+
@indices = {}
|
|
19
|
+
@lowlinks = {}
|
|
20
|
+
@stack = []
|
|
21
|
+
@on_stack = Set.new
|
|
22
|
+
@components = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
@vertices.each { |vertex| visit(vertex) unless @indices.key?(vertex) }
|
|
27
|
+
@components.map(&:freeze).freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def visit(vertex)
|
|
33
|
+
index_vertex(vertex)
|
|
34
|
+
|
|
35
|
+
neighbours(vertex).each do |neighbour|
|
|
36
|
+
inspect_neighbour(vertex, neighbour)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
extract_component(vertex) if @lowlinks.fetch(vertex) == @indices.fetch(vertex)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def index_vertex(vertex)
|
|
43
|
+
@indices[vertex] = @next_index
|
|
44
|
+
@lowlinks[vertex] = @next_index
|
|
45
|
+
@next_index += 1
|
|
46
|
+
@stack << vertex
|
|
47
|
+
@on_stack.add(vertex)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def inspect_neighbour(vertex, neighbour)
|
|
51
|
+
unless @indices.key?(neighbour)
|
|
52
|
+
visit(neighbour)
|
|
53
|
+
@lowlinks[vertex] = [@lowlinks.fetch(vertex), @lowlinks.fetch(neighbour)].min
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
return unless @on_stack.include?(neighbour)
|
|
58
|
+
|
|
59
|
+
@lowlinks[vertex] = [@lowlinks.fetch(vertex), @indices.fetch(neighbour)].min
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def extract_component(root)
|
|
63
|
+
component = []
|
|
64
|
+
|
|
65
|
+
loop do
|
|
66
|
+
vertex = @stack.pop
|
|
67
|
+
@on_stack.delete(vertex)
|
|
68
|
+
component << vertex
|
|
69
|
+
break if vertex == root
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
@components << component.sort
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def neighbours(vertex)
|
|
76
|
+
@adjacency.fetch(vertex, []).select { |neighbour| @allowed.include?(neighbour) }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|