archunit 0.0.1 → 1.0.0
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 +4 -4
- data/API.md +290 -0
- data/CHANGELOG.md +45 -0
- data/README.md +389 -90
- data/assets/logo-rounded.png +0 -0
- data/lib/archunit/common/extraction/dependency_evidence.rb +44 -0
- data/lib/archunit/common/extraction/edge.rb +7 -2
- data/lib/archunit/common/extraction/import_kind.rb +2 -1
- data/lib/archunit/common/extraction/parse_diagnostic.rb +23 -0
- data/lib/archunit/common/fluentapi/check_options.rb +43 -2
- data/lib/archunit/common/fluentapi/checkable.rb +25 -1
- data/lib/archunit/common/logging/check_logger.rb +8 -0
- data/lib/archunit/common/logging/inspection.rb +59 -0
- data/lib/archunit/common/logging/logging_options.rb +5 -0
- data/lib/archunit/common/pattern.rb +18 -8
- data/lib/archunit/common/projection/cycles/johnson_cycles.rb +35 -19
- data/lib/archunit/common/projection/cycles/tarjan_scc.rb +30 -10
- data/lib/archunit/error/source_parse_error.rb +38 -0
- data/lib/archunit/extraction/arch_ignore.rb +91 -0
- data/lib/archunit/extraction/conservative_local_flow.rb +100 -0
- data/lib/archunit/extraction/constant_index.rb +110 -0
- data/lib/archunit/extraction/constant_visitor.rb +129 -0
- data/lib/archunit/extraction/discover_gemspec_load_paths.rb +96 -0
- data/lib/archunit/extraction/enumerate_source_files.rb +23 -8
- data/lib/archunit/extraction/extract_dependencies.rb +82 -14
- data/lib/archunit/extraction/extract_graph.rb +56 -12
- data/lib/archunit/extraction/extract_imports.rb +60 -124
- data/lib/archunit/extraction/extraction_profile.rb +105 -0
- data/lib/archunit/extraction/import_resolver.rb +56 -0
- data/lib/archunit/extraction/import_visitor.rb +104 -0
- data/lib/archunit/extraction/local_string_tracker.rb +80 -0
- data/lib/archunit/extraction/resolve_import.rb +5 -3
- data/lib/archunit/extraction/resolve_load_paths.rb +93 -0
- data/lib/archunit/extraction/resolved_import.rb +5 -2
- data/lib/archunit/extraction/validate_extraction_paths.rb +37 -0
- data/lib/archunit/files/extraction/extract_file_info.rb +14 -5
- data/lib/archunit/files/extraction/file_info.rb +1 -2
- data/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb +2 -0
- data/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb +2 -0
- data/lib/archunit/files/fluentapi/depend_on_file_condition.rb +6 -0
- data/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb +6 -0
- data/lib/archunit/files/fluentapi/file_condition_builder.rb +12 -0
- data/lib/archunit/files/fluentapi/file_rule_support.rb +8 -1
- data/lib/archunit/files/fluentapi/files.rb +6 -0
- data/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb +14 -0
- data/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb +2 -0
- data/lib/archunit/graph/fluentapi/graph.rb +5 -0
- data/lib/archunit/graph/fluentapi/project_graph_builder.rb +44 -0
- data/lib/archunit/graph/projection/aggregate_edges.rb +4 -2
- data/lib/archunit/graph/projection/graph_query_options.rb +1 -2
- data/lib/archunit/graph/projection/graph_report_edge.rb +12 -2
- data/lib/archunit/graph/rendering/json_renderer.rb +3 -1
- data/lib/archunit/layers/fluentapi/layer_definition_builder.rb +4 -0
- data/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb +4 -0
- data/lib/archunit/layers/fluentapi/layered_architecture.rb +5 -0
- data/lib/archunit/layers/fluentapi/layers.rb +5 -0
- data/lib/archunit/metrics/extraction/extract_distance_info.rb +1 -2
- data/lib/archunit/metrics/extraction/extract_project_info.rb +6 -6
- data/lib/archunit/metrics/extraction/metric_info.rb +2 -4
- data/lib/archunit/metrics/extraction/source_metrics_visitor.rb +2 -4
- data/lib/archunit/metrics/fluentapi/custom_metric_builder.rb +2 -0
- data/lib/archunit/metrics/fluentapi/custom_metric_condition.rb +4 -1
- data/lib/archunit/metrics/fluentapi/logged_metric.rb +23 -0
- data/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb +4 -1
- data/lib/archunit/metrics/fluentapi/metric_report_builder.rb +2 -0
- data/lib/archunit/metrics/fluentapi/metric_selection.rb +18 -2
- data/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb +2 -1
- data/lib/archunit/metrics/fluentapi/metrics.rb +5 -0
- data/lib/archunit/metrics/fluentapi/metrics_builder.rb +15 -0
- data/lib/archunit/slices/fluentapi/diagram_slice_condition.rb +1 -0
- data/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb +1 -0
- data/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb +2 -0
- data/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb +8 -0
- data/lib/archunit/slices/fluentapi/slice_scope_builder.rb +12 -0
- data/lib/archunit/slices/fluentapi/slices.rb +5 -0
- data/lib/archunit/testing/assert_passes.rb +4 -0
- data/lib/archunit/testing/result_factory.rb +16 -4
- data/lib/archunit/testing/rspec_adapter.rb +1 -2
- data/lib/archunit/testing.rb +7 -3
- data/lib/archunit/version.rb +1 -1
- data/lib/archunit.rb +9 -0
- metadata +20 -2
|
Binary file
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'import_kind'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module Common
|
|
7
|
+
module Extraction
|
|
8
|
+
# Source-level support for a file dependency, without implying runtime execution.
|
|
9
|
+
DependencyEvidence = Data.define(
|
|
10
|
+
:source, :kind, :name, :line_number, :end_line, :target_line_number
|
|
11
|
+
) do
|
|
12
|
+
# Keywords mirror this immutable source span.
|
|
13
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
14
|
+
source:, kind:, name:, line_number:, end_line: line_number, target_line_number: nil
|
|
15
|
+
)
|
|
16
|
+
validate_identity(source, kind, name)
|
|
17
|
+
validate_lines(line_number, end_line, target_line_number)
|
|
18
|
+
source = source.tr('\\', '/').freeze
|
|
19
|
+
name = name.dup.freeze
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def validate_identity(source, kind, name)
|
|
26
|
+
return if source.is_a?(String) && name.is_a?(String) && ImportKind.valid?(kind)
|
|
27
|
+
|
|
28
|
+
raise ArgumentError, 'evidence requires a source, name, and valid dependency kind'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_lines(first, last, target)
|
|
32
|
+
valid = positive_line?(first) && positive_line?(last) && last >= first
|
|
33
|
+
return if valid && (target.nil? || positive_line?(target))
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, 'evidence requires valid source line numbers'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def positive_line?(value)
|
|
39
|
+
value.is_a?(Integer) && value.positive?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'import_kind'
|
|
4
|
+
require_relative 'dependency_evidence'
|
|
4
5
|
|
|
5
6
|
module ArchUnit
|
|
6
7
|
module Common
|
|
7
8
|
module Extraction
|
|
8
|
-
Edge = Data.define(:source, :target, :external, :import_kinds) do
|
|
9
|
-
def initialize(source:, target:, external:, import_kinds: [])
|
|
9
|
+
Edge = Data.define(:source, :target, :external, :import_kinds, :evidence) do
|
|
10
|
+
def initialize(source:, target:, external:, import_kinds: [], evidence: [])
|
|
10
11
|
source = immutable_identifier(source, :source)
|
|
11
12
|
target = immutable_identifier(target, :target)
|
|
12
13
|
validate_external(external)
|
|
13
14
|
import_kinds = immutable_import_kinds(import_kinds)
|
|
15
|
+
evidence = Array(evidence).uniq.freeze
|
|
16
|
+
unless evidence.all?(DependencyEvidence)
|
|
17
|
+
raise ArgumentError, 'evidence must contain DependencyEvidence values'
|
|
18
|
+
end
|
|
14
19
|
|
|
15
20
|
super
|
|
16
21
|
end
|
|
@@ -9,8 +9,9 @@ module ArchUnit
|
|
|
9
9
|
REQUIRE_RELATIVE = :require_relative
|
|
10
10
|
AUTOLOAD = :autoload
|
|
11
11
|
LOAD = :load
|
|
12
|
+
CONSTANT_REFERENCE = :constant_reference
|
|
12
13
|
|
|
13
|
-
ALL = [REQUIRE, REQUIRE_RELATIVE, AUTOLOAD, LOAD].freeze
|
|
14
|
+
ALL = [REQUIRE, REQUIRE_RELATIVE, AUTOLOAD, LOAD, CONSTANT_REFERENCE].freeze
|
|
14
15
|
|
|
15
16
|
def self.valid?(value)
|
|
16
17
|
ALL.include?(value)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
module Common
|
|
5
|
+
module Extraction
|
|
6
|
+
# Immutable parser failure location; line and column are both one-based.
|
|
7
|
+
ParseDiagnostic = Data.define(:path, :line, :column, :message) do
|
|
8
|
+
def initialize(path:, line:, column:, message:)
|
|
9
|
+
unless [path, message].all? { |value| value.is_a?(String) && !value.empty? }
|
|
10
|
+
raise ArgumentError, 'parse diagnostic requires a non-empty path and message'
|
|
11
|
+
end
|
|
12
|
+
unless [line, column].all? { |value| value.is_a?(Integer) && value.positive? }
|
|
13
|
+
raise ArgumentError, 'parse diagnostic coordinates must be positive integers'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
path = path.tr('\\', '/').freeze
|
|
17
|
+
message = message.dup.freeze
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -6,7 +6,12 @@ module ArchUnit
|
|
|
6
6
|
module Common
|
|
7
7
|
module FluentApi
|
|
8
8
|
# Immutable options shared by every terminal rule check.
|
|
9
|
-
CheckOptions = Data.define(
|
|
9
|
+
CheckOptions = Data.define(
|
|
10
|
+
:allow_empty_tests, :logging, :clear_cache, :load_paths, :dependency_analysis
|
|
11
|
+
) do
|
|
12
|
+
# Normalizes nil or an existing options value for terminal execution.
|
|
13
|
+
# @param value [CheckOptions, nil]
|
|
14
|
+
# @return [CheckOptions]
|
|
10
15
|
def self.resolve(value)
|
|
11
16
|
return new if value.nil?
|
|
12
17
|
return value if value.is_a?(self)
|
|
@@ -14,21 +19,40 @@ module ArchUnit
|
|
|
14
19
|
raise ArgumentError, 'options must be a CheckOptions value or nil'
|
|
15
20
|
end
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
# @param allow_empty_tests [Boolean] permit a selector that finds no subjects
|
|
23
|
+
# @param logging [LoggingOptions, nil] per-check logging configuration
|
|
24
|
+
# @param clear_cache [Boolean] rebuild the dependency graph for this check
|
|
25
|
+
# @param load_paths [Array<String, Pathname>] additional project-contained source roots
|
|
26
|
+
# @param dependency_analysis [Symbol] :imports or opt-in :imports_and_constants
|
|
27
|
+
def initialize(
|
|
28
|
+
allow_empty_tests: false, logging: nil, clear_cache: false, load_paths: [],
|
|
29
|
+
dependency_analysis: :imports
|
|
30
|
+
)
|
|
18
31
|
validate_boolean(allow_empty_tests, :allow_empty_tests)
|
|
19
32
|
validate_logging(logging)
|
|
20
33
|
validate_boolean(clear_cache, :clear_cache)
|
|
34
|
+
load_paths = immutable_load_paths(load_paths)
|
|
35
|
+
unless %i[imports imports_and_constants].include?(dependency_analysis)
|
|
36
|
+
raise ArgumentError, 'dependency_analysis must be :imports or :imports_and_constants'
|
|
37
|
+
end
|
|
38
|
+
|
|
21
39
|
super
|
|
22
40
|
end
|
|
23
41
|
|
|
42
|
+
# @return [Boolean] whether an empty subject selection is allowed
|
|
24
43
|
def allow_empty_tests?
|
|
25
44
|
allow_empty_tests
|
|
26
45
|
end
|
|
27
46
|
|
|
47
|
+
# @return [Boolean] whether this check rebuilds the cached graph
|
|
28
48
|
def clear_cache?
|
|
29
49
|
clear_cache
|
|
30
50
|
end
|
|
31
51
|
|
|
52
|
+
def constant_references?
|
|
53
|
+
dependency_analysis == :imports_and_constants
|
|
54
|
+
end
|
|
55
|
+
|
|
32
56
|
private
|
|
33
57
|
|
|
34
58
|
def validate_boolean(value, attribute)
|
|
@@ -42,6 +66,23 @@ module ArchUnit
|
|
|
42
66
|
|
|
43
67
|
raise ArgumentError, 'logging must be a LoggingOptions value or nil'
|
|
44
68
|
end
|
|
69
|
+
|
|
70
|
+
def immutable_load_paths(value)
|
|
71
|
+
unless value.is_a?(Array)
|
|
72
|
+
raise ArgumentError, 'load_paths must be an Array of non-empty paths'
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
value.map { |path| immutable_load_path(path) }.uniq.freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def immutable_load_path(value)
|
|
79
|
+
value = value.to_path if value.respond_to?(:to_path)
|
|
80
|
+
unless value.is_a?(String) && !value.empty?
|
|
81
|
+
raise ArgumentError, 'load_paths must be an Array of non-empty paths'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
value.tr('\\', '/').delete_suffix('/').freeze
|
|
85
|
+
end
|
|
45
86
|
end
|
|
46
87
|
end
|
|
47
88
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative '../assertion/violation'
|
|
4
4
|
require_relative '../assertion/empty_test_violation'
|
|
5
5
|
require_relative '../logging/check_logger'
|
|
6
|
+
require_relative '../logging/inspection'
|
|
6
7
|
require_relative 'check_options'
|
|
7
8
|
|
|
8
9
|
module ArchUnit
|
|
@@ -10,6 +11,27 @@ module ArchUnit
|
|
|
10
11
|
module FluentApi
|
|
11
12
|
# The shared execution contract implemented by every terminal rule.
|
|
12
13
|
module Checkable
|
|
14
|
+
# Optional normalized explanation, kept separate from violation values.
|
|
15
|
+
# @return [String, nil]
|
|
16
|
+
attr_reader :rationale
|
|
17
|
+
|
|
18
|
+
# Explains why this completed rule exists, without changing the original.
|
|
19
|
+
# Append this after all selectors and modifiers.
|
|
20
|
+
# @param reason [String] a non-blank human-readable explanation
|
|
21
|
+
# @return [Checkable] a distinct frozen rule of the same type
|
|
22
|
+
def because(reason)
|
|
23
|
+
unless reason.is_a?(String) && !reason.strip.empty?
|
|
24
|
+
raise ArgumentError, 'reason must be a non-empty String'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
copy = dup
|
|
28
|
+
copy.instance_variable_set(:@rationale, reason.strip.freeze)
|
|
29
|
+
copy.freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Executes the rule and returns every architecture disagreement as structured data.
|
|
33
|
+
# @param options [CheckOptions, nil]
|
|
34
|
+
# @return [Array<ArchUnit::Violation>]
|
|
13
35
|
def check(options = nil)
|
|
14
36
|
resolved_options = CheckOptions.resolve(options)
|
|
15
37
|
logger = Logging::CheckLogger.new(resolved_options.logging)
|
|
@@ -28,7 +50,9 @@ module ArchUnit
|
|
|
28
50
|
|
|
29
51
|
def execute_check(options, logger, check_name)
|
|
30
52
|
logger.log_progress("executing #{check_name}")
|
|
31
|
-
violations =
|
|
53
|
+
violations = Logging::Inspection.with(logger) do
|
|
54
|
+
validate_violations(perform_check(options))
|
|
55
|
+
end
|
|
32
56
|
log_violations(logger, violations)
|
|
33
57
|
logger.end_check(check_name, violation_count: violations.length)
|
|
34
58
|
violations
|
|
@@ -53,6 +53,14 @@ module ArchUnit
|
|
|
53
53
|
write(:debug, "log metric: #{metric}=#{value}#{context}")
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
def debug?
|
|
57
|
+
!@options.nil? && enabled?(:debug)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def debug
|
|
61
|
+
write(:debug, "inspect: #{yield}") if debug?
|
|
62
|
+
end
|
|
63
|
+
|
|
56
64
|
def close
|
|
57
65
|
@mutex.synchronize do
|
|
58
66
|
@file&.close
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
module Common
|
|
5
|
+
module Logging
|
|
6
|
+
# Internal inspection context, scoped to one check and isolated by Ruby fiber.
|
|
7
|
+
module Inspection
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def with(logger)
|
|
11
|
+
previous = Thread.current[:archunit_inspection_logger]
|
|
12
|
+
Thread.current[:archunit_inspection_logger] = logger
|
|
13
|
+
yield
|
|
14
|
+
ensure
|
|
15
|
+
Thread.current[:archunit_inspection_logger] = previous
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def logger
|
|
19
|
+
Thread.current[:archunit_inspection_logger]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def debug(&)
|
|
23
|
+
logger&.debug(&)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def graph(graph, root:)
|
|
27
|
+
debug do
|
|
28
|
+
logger.debug { "project root: #{root.to_s.inspect}" }
|
|
29
|
+
graph.each { |edge| logger.debug { describe_edge(edge) } }
|
|
30
|
+
"graph edges: #{graph.size}"
|
|
31
|
+
end
|
|
32
|
+
graph
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def selection(label, items)
|
|
36
|
+
debug do
|
|
37
|
+
items.each { |item| logger.debug { "#{label}: #{yield(item).inspect}" } }
|
|
38
|
+
"#{label} count: #{items.length}"
|
|
39
|
+
end
|
|
40
|
+
items
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def projection(edges)
|
|
44
|
+
selection('projected dependency', edges) do |edge|
|
|
45
|
+
"#{edge.source_label} -> #{edge.target_label} (#{edge.cumulated_edges.length} edges)"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def describe_edge(edge)
|
|
50
|
+
return "discovered file: #{edge.source.inspect}" if edge.source == edge.target
|
|
51
|
+
|
|
52
|
+
"dependency: #{edge.source.inspect} -> #{edge.target.inspect} " \
|
|
53
|
+
"(external=#{edge.external}, kinds=#{edge.import_kinds.inspect})"
|
|
54
|
+
end
|
|
55
|
+
private_class_method :describe_edge
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -8,6 +8,10 @@ module ArchUnit
|
|
|
8
8
|
|
|
9
9
|
# Immutable, per-check logging configuration. A nil CheckOptions#logging disables logging.
|
|
10
10
|
LoggingOptions = Data.define(:level, :io, :output_directory, :append) do
|
|
11
|
+
# @param level [Symbol, String] one of debug, info, warn, or error
|
|
12
|
+
# @param io [#write, nil] stream sink, or nil to disable stream output
|
|
13
|
+
# @param output_directory [String, Pathname, nil] optional directory for timestamped logs
|
|
14
|
+
# @param append [Boolean] append to an existing selected log file when possible
|
|
11
15
|
def initialize(level: :info, io: $stderr, output_directory: nil, append: false)
|
|
12
16
|
level = normalize_level(level)
|
|
13
17
|
validate_io(io)
|
|
@@ -16,6 +20,7 @@ module ArchUnit
|
|
|
16
20
|
super
|
|
17
21
|
end
|
|
18
22
|
|
|
23
|
+
# @return [Boolean] whether file logging is configured
|
|
19
24
|
def file_output?
|
|
20
25
|
!output_directory.nil?
|
|
21
26
|
end
|
|
@@ -37,18 +37,28 @@ module ArchUnit
|
|
|
37
37
|
|
|
38
38
|
def compile_fragment(characters, index)
|
|
39
39
|
case characters[index]
|
|
40
|
-
when '*'
|
|
41
|
-
|
|
42
|
-
when '?'
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
compile_character_class(characters, index)
|
|
46
|
-
else
|
|
47
|
-
[Regexp.escape(characters[index]), index + 1]
|
|
40
|
+
when '*' then compile_star(characters, index)
|
|
41
|
+
when '/' then compile_slash(characters, index)
|
|
42
|
+
when '?' then ['[^/]', index + 1]
|
|
43
|
+
when '[' then compile_character_class(characters, index)
|
|
44
|
+
else [Regexp.escape(characters[index]), index + 1]
|
|
48
45
|
end
|
|
49
46
|
end
|
|
50
47
|
private_class_method :compile_fragment
|
|
51
48
|
|
|
49
|
+
def compile_slash(characters, index)
|
|
50
|
+
globstar_index = index + 1
|
|
51
|
+
globstar = characters[globstar_index] == '*' && characters[globstar_index + 1] == '*'
|
|
52
|
+
return ['/', globstar_index] unless globstar
|
|
53
|
+
|
|
54
|
+
tail_index = globstar_index + 2
|
|
55
|
+
tail_index += 1 while characters[tail_index] == '*'
|
|
56
|
+
return ['/', globstar_index] unless tail_index == characters.length
|
|
57
|
+
|
|
58
|
+
['(?:/.*)?', tail_index]
|
|
59
|
+
end
|
|
60
|
+
private_class_method :compile_slash
|
|
61
|
+
|
|
52
62
|
def compile_star(characters, index)
|
|
53
63
|
return ['[^/]*', index + 1] unless characters[index + 1] == '*'
|
|
54
64
|
|
|
@@ -8,6 +8,9 @@ module ArchUnit
|
|
|
8
8
|
module Cycles
|
|
9
9
|
# Enumerates every elementary directed cycle once using Johnson's algorithm.
|
|
10
10
|
class JohnsonCycles
|
|
11
|
+
Frame = Struct.new(:vertex, :neighbours, :index, :found_cycle)
|
|
12
|
+
private_constant :Frame
|
|
13
|
+
|
|
11
14
|
def self.call(adjacency)
|
|
12
15
|
new(adjacency).call
|
|
13
16
|
end
|
|
@@ -55,36 +58,49 @@ module ArchUnit
|
|
|
55
58
|
end
|
|
56
59
|
|
|
57
60
|
def circuit(vertex)
|
|
61
|
+
frames = [enter_vertex(vertex)]
|
|
62
|
+
until frames.empty?
|
|
63
|
+
frame = frames.last
|
|
64
|
+
if frame.index < frame.neighbours.length
|
|
65
|
+
explore_neighbour(frame, frames)
|
|
66
|
+
else
|
|
67
|
+
finish_vertex(frames)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def enter_vertex(vertex)
|
|
58
73
|
@stack << vertex
|
|
59
74
|
@blocked.add(vertex)
|
|
60
|
-
|
|
61
|
-
found_cycle ? unblock(vertex) : record_blockers(vertex)
|
|
62
|
-
@stack.pop
|
|
63
|
-
found_cycle
|
|
75
|
+
Frame.new(vertex, component_neighbours(vertex), 0, false)
|
|
64
76
|
end
|
|
65
77
|
|
|
66
|
-
def
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
found_cycle = true
|
|
78
|
+
def explore_neighbour(frame, frames)
|
|
79
|
+
neighbour = frame.neighbours.fetch(frame.index)
|
|
80
|
+
frame.index += 1
|
|
81
|
+
if neighbour == @start
|
|
82
|
+
@cycles << @stack.dup
|
|
83
|
+
frame.found_cycle = true
|
|
84
|
+
elsif !@blocked.include?(neighbour)
|
|
85
|
+
frames << enter_vertex(neighbour)
|
|
72
86
|
end
|
|
73
|
-
found_cycle
|
|
74
87
|
end
|
|
75
88
|
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
@
|
|
80
|
-
|
|
89
|
+
def finish_vertex(frames)
|
|
90
|
+
frame = frames.pop
|
|
91
|
+
frame.found_cycle ? unblock(frame.vertex) : record_blockers(frame.vertex)
|
|
92
|
+
@stack.pop
|
|
93
|
+
frames.last.found_cycle ||= frame.found_cycle unless frames.empty?
|
|
81
94
|
end
|
|
82
95
|
|
|
83
96
|
def unblock(vertex)
|
|
84
|
-
|
|
97
|
+
pending = [vertex]
|
|
98
|
+
until pending.empty?
|
|
99
|
+
candidate = pending.pop
|
|
100
|
+
next unless @blocked.delete?(candidate)
|
|
85
101
|
|
|
86
|
-
|
|
87
|
-
|
|
102
|
+
pending.concat((@blocked_by.delete(candidate) || []).to_a)
|
|
103
|
+
end
|
|
88
104
|
end
|
|
89
105
|
|
|
90
106
|
def record_blockers(vertex)
|
|
@@ -6,6 +6,9 @@ module ArchUnit
|
|
|
6
6
|
module Cycles
|
|
7
7
|
# Finds strongly connected components in a directed adjacency map.
|
|
8
8
|
class TarjanScc
|
|
9
|
+
Frame = Struct.new(:vertex, :neighbours, :index)
|
|
10
|
+
private_constant :Frame
|
|
11
|
+
|
|
9
12
|
def self.call(adjacency, vertices: adjacency.keys)
|
|
10
13
|
new(adjacency, vertices).call
|
|
11
14
|
end
|
|
@@ -30,13 +33,20 @@ module ArchUnit
|
|
|
30
33
|
private
|
|
31
34
|
|
|
32
35
|
def visit(vertex)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
frames = [enter_vertex(vertex)]
|
|
37
|
+
until frames.empty?
|
|
38
|
+
frame = frames.last
|
|
39
|
+
if frame.index < frame.neighbours.length
|
|
40
|
+
inspect_neighbour(frame, frames)
|
|
41
|
+
else
|
|
42
|
+
finish_vertex(frames)
|
|
43
|
+
end
|
|
37
44
|
end
|
|
45
|
+
end
|
|
38
46
|
|
|
39
|
-
|
|
47
|
+
def enter_vertex(vertex)
|
|
48
|
+
index_vertex(vertex)
|
|
49
|
+
Frame.new(vertex, neighbours(vertex), 0)
|
|
40
50
|
end
|
|
41
51
|
|
|
42
52
|
def index_vertex(vertex)
|
|
@@ -47,16 +57,26 @@ module ArchUnit
|
|
|
47
57
|
@on_stack.add(vertex)
|
|
48
58
|
end
|
|
49
59
|
|
|
50
|
-
def inspect_neighbour(
|
|
60
|
+
def inspect_neighbour(frame, frames)
|
|
61
|
+
neighbour = frame.neighbours.fetch(frame.index)
|
|
62
|
+
frame.index += 1
|
|
51
63
|
unless @indices.key?(neighbour)
|
|
52
|
-
|
|
53
|
-
@lowlinks[vertex] = [@lowlinks.fetch(vertex), @lowlinks.fetch(neighbour)].min
|
|
64
|
+
frames << enter_vertex(neighbour)
|
|
54
65
|
return
|
|
55
66
|
end
|
|
56
|
-
|
|
57
67
|
return unless @on_stack.include?(neighbour)
|
|
58
68
|
|
|
59
|
-
|
|
69
|
+
update_lowlink(frame.vertex, @indices.fetch(neighbour))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def finish_vertex(frames)
|
|
73
|
+
vertex = frames.pop.vertex
|
|
74
|
+
extract_component(vertex) if @lowlinks.fetch(vertex) == @indices.fetch(vertex)
|
|
75
|
+
update_lowlink(frames.last.vertex, @lowlinks.fetch(vertex)) unless frames.empty?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def update_lowlink(vertex, index)
|
|
79
|
+
@lowlinks[vertex] = [@lowlinks.fetch(vertex), index].min
|
|
60
80
|
end
|
|
61
81
|
|
|
62
82
|
def extract_component(root)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'technical_error'
|
|
4
|
+
require_relative '../common/extraction/parse_diagnostic'
|
|
5
|
+
|
|
6
|
+
module ArchUnit
|
|
7
|
+
# An included source could not be analyzed; never an ordinary architecture violation.
|
|
8
|
+
class SourceParseError < TechnicalError
|
|
9
|
+
# @return [Array<Common::Extraction::ParseDiagnostic>] immutable source diagnostics
|
|
10
|
+
attr_reader :diagnostics
|
|
11
|
+
|
|
12
|
+
# @return [Prism::ParseResult] the complete result, or a technical failure
|
|
13
|
+
def self.validate!(result, path:)
|
|
14
|
+
return result if result.success?
|
|
15
|
+
|
|
16
|
+
diagnostics = result.errors.map do |error|
|
|
17
|
+
Common::Extraction::ParseDiagnostic.new(
|
|
18
|
+
path: path.to_s, line: error.location.start_line,
|
|
19
|
+
column: error.location.start_column + 1, message: error.message
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
raise SourceParseError, diagnostics
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(diagnostics)
|
|
26
|
+
unless diagnostics.is_a?(Array) && !diagnostics.empty? &&
|
|
27
|
+
diagnostics.all?(Common::Extraction::ParseDiagnostic)
|
|
28
|
+
raise ArgumentError, 'source parse error requires non-empty parse diagnostics'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@diagnostics = diagnostics.dup.freeze
|
|
32
|
+
details = @diagnostics.map do |item|
|
|
33
|
+
"#{item.path}:#{item.line}:#{item.column}: #{item.message}"
|
|
34
|
+
end
|
|
35
|
+
super("Cannot analyze malformed Ruby source:\n#{details.join("\n")}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../error/user_error'
|
|
4
|
+
require_relative '../error/technical_error'
|
|
5
|
+
|
|
6
|
+
module ArchUnit
|
|
7
|
+
module Extraction
|
|
8
|
+
# Reads bounded, repository-owned exclusions without evaluating project code.
|
|
9
|
+
# Only the project-root .archignore is read. Symlinks and negation are rejected.
|
|
10
|
+
module ArchIgnore
|
|
11
|
+
MAX_BYTES = 1024 * 1024
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param root [Pathname] canonical project root
|
|
16
|
+
# @return [Array<String>] exclusion patterns using the source enumerator's glob syntax
|
|
17
|
+
def patterns(root)
|
|
18
|
+
path = root.join('.archignore')
|
|
19
|
+
stat = configuration_stat(path)
|
|
20
|
+
return [] unless stat
|
|
21
|
+
|
|
22
|
+
validate_configuration(stat)
|
|
23
|
+
parse(read_configuration(path))
|
|
24
|
+
rescue Errno::ELOOP
|
|
25
|
+
raise UserError, '.archignore must not be a symlink'
|
|
26
|
+
rescue SystemCallError, IOError => e
|
|
27
|
+
raise TechnicalError, "could not read .archignore: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def configuration_stat(path)
|
|
31
|
+
path.lstat
|
|
32
|
+
rescue Errno::ENOENT
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
private_class_method :configuration_stat
|
|
36
|
+
|
|
37
|
+
def validate_configuration(stat)
|
|
38
|
+
return if stat.file? && !stat.symlink?
|
|
39
|
+
|
|
40
|
+
raise UserError, '.archignore must be a regular file, not a symlink or directory'
|
|
41
|
+
end
|
|
42
|
+
private_class_method :validate_configuration
|
|
43
|
+
|
|
44
|
+
def read_configuration(path)
|
|
45
|
+
flags = File::RDONLY
|
|
46
|
+
# Windows defines NONBLOCK as 1, which is also WRONLY: do not use it there.
|
|
47
|
+
flags |= File::NOFOLLOW | File::NONBLOCK if File.const_defined?(:NOFOLLOW)
|
|
48
|
+
File.open(path, flags) do |file|
|
|
49
|
+
raise UserError, '.archignore must be a regular file' unless file.stat.file?
|
|
50
|
+
|
|
51
|
+
bounded_contents(file)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
private_class_method :read_configuration
|
|
55
|
+
|
|
56
|
+
def bounded_contents(file)
|
|
57
|
+
contents = file.binmode.read(MAX_BYTES + 1) || String.new
|
|
58
|
+
raise UserError, '.archignore exceeds the 1 MiB size limit' if contents.bytesize > MAX_BYTES
|
|
59
|
+
|
|
60
|
+
contents
|
|
61
|
+
end
|
|
62
|
+
private_class_method :bounded_contents
|
|
63
|
+
|
|
64
|
+
def parse(contents)
|
|
65
|
+
contents.force_encoding(Encoding::UTF_8).scrub.delete_prefix("\uFEFF")
|
|
66
|
+
.each_line.filter_map do |line|
|
|
67
|
+
pattern = line.strip.tr('\\', '/')
|
|
68
|
+
next if pattern.empty? || pattern.start_with?('#')
|
|
69
|
+
|
|
70
|
+
if pattern.start_with?('!') || pattern.include?("\0")
|
|
71
|
+
raise UserError, '.archignore does not support negation (!) or NUL characters'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
directory_pattern(pattern)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
private_class_method :parse
|
|
78
|
+
|
|
79
|
+
def directory_pattern(pattern)
|
|
80
|
+
return pattern unless pattern.end_with?('/')
|
|
81
|
+
|
|
82
|
+
pattern = pattern.delete_suffix('/')
|
|
83
|
+
raise UserError, '.archignore cannot exclude the project root with /' if pattern.empty?
|
|
84
|
+
|
|
85
|
+
prefix = pattern.include?('/') ? '' : '**/'
|
|
86
|
+
"#{prefix}#{pattern}/**"
|
|
87
|
+
end
|
|
88
|
+
private_class_method :directory_pattern
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|