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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/API.md +290 -0
  3. data/CHANGELOG.md +45 -0
  4. data/README.md +389 -90
  5. data/assets/logo-rounded.png +0 -0
  6. data/lib/archunit/common/extraction/dependency_evidence.rb +44 -0
  7. data/lib/archunit/common/extraction/edge.rb +7 -2
  8. data/lib/archunit/common/extraction/import_kind.rb +2 -1
  9. data/lib/archunit/common/extraction/parse_diagnostic.rb +23 -0
  10. data/lib/archunit/common/fluentapi/check_options.rb +43 -2
  11. data/lib/archunit/common/fluentapi/checkable.rb +25 -1
  12. data/lib/archunit/common/logging/check_logger.rb +8 -0
  13. data/lib/archunit/common/logging/inspection.rb +59 -0
  14. data/lib/archunit/common/logging/logging_options.rb +5 -0
  15. data/lib/archunit/common/pattern.rb +18 -8
  16. data/lib/archunit/common/projection/cycles/johnson_cycles.rb +35 -19
  17. data/lib/archunit/common/projection/cycles/tarjan_scc.rb +30 -10
  18. data/lib/archunit/error/source_parse_error.rb +38 -0
  19. data/lib/archunit/extraction/arch_ignore.rb +91 -0
  20. data/lib/archunit/extraction/conservative_local_flow.rb +100 -0
  21. data/lib/archunit/extraction/constant_index.rb +110 -0
  22. data/lib/archunit/extraction/constant_visitor.rb +129 -0
  23. data/lib/archunit/extraction/discover_gemspec_load_paths.rb +96 -0
  24. data/lib/archunit/extraction/enumerate_source_files.rb +23 -8
  25. data/lib/archunit/extraction/extract_dependencies.rb +82 -14
  26. data/lib/archunit/extraction/extract_graph.rb +56 -12
  27. data/lib/archunit/extraction/extract_imports.rb +60 -124
  28. data/lib/archunit/extraction/extraction_profile.rb +105 -0
  29. data/lib/archunit/extraction/import_resolver.rb +56 -0
  30. data/lib/archunit/extraction/import_visitor.rb +104 -0
  31. data/lib/archunit/extraction/local_string_tracker.rb +80 -0
  32. data/lib/archunit/extraction/resolve_import.rb +5 -3
  33. data/lib/archunit/extraction/resolve_load_paths.rb +93 -0
  34. data/lib/archunit/extraction/resolved_import.rb +5 -2
  35. data/lib/archunit/extraction/validate_extraction_paths.rb +37 -0
  36. data/lib/archunit/files/extraction/extract_file_info.rb +14 -5
  37. data/lib/archunit/files/extraction/file_info.rb +1 -2
  38. data/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb +2 -0
  39. data/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb +2 -0
  40. data/lib/archunit/files/fluentapi/depend_on_file_condition.rb +6 -0
  41. data/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb +6 -0
  42. data/lib/archunit/files/fluentapi/file_condition_builder.rb +12 -0
  43. data/lib/archunit/files/fluentapi/file_rule_support.rb +8 -1
  44. data/lib/archunit/files/fluentapi/files.rb +6 -0
  45. data/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb +14 -0
  46. data/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb +2 -0
  47. data/lib/archunit/graph/fluentapi/graph.rb +5 -0
  48. data/lib/archunit/graph/fluentapi/project_graph_builder.rb +44 -0
  49. data/lib/archunit/graph/projection/aggregate_edges.rb +4 -2
  50. data/lib/archunit/graph/projection/graph_query_options.rb +1 -2
  51. data/lib/archunit/graph/projection/graph_report_edge.rb +12 -2
  52. data/lib/archunit/graph/rendering/json_renderer.rb +3 -1
  53. data/lib/archunit/layers/fluentapi/layer_definition_builder.rb +4 -0
  54. data/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb +4 -0
  55. data/lib/archunit/layers/fluentapi/layered_architecture.rb +5 -0
  56. data/lib/archunit/layers/fluentapi/layers.rb +5 -0
  57. data/lib/archunit/metrics/extraction/extract_distance_info.rb +1 -2
  58. data/lib/archunit/metrics/extraction/extract_project_info.rb +6 -6
  59. data/lib/archunit/metrics/extraction/metric_info.rb +2 -4
  60. data/lib/archunit/metrics/extraction/source_metrics_visitor.rb +2 -4
  61. data/lib/archunit/metrics/fluentapi/custom_metric_builder.rb +2 -0
  62. data/lib/archunit/metrics/fluentapi/custom_metric_condition.rb +4 -1
  63. data/lib/archunit/metrics/fluentapi/logged_metric.rb +23 -0
  64. data/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb +4 -1
  65. data/lib/archunit/metrics/fluentapi/metric_report_builder.rb +2 -0
  66. data/lib/archunit/metrics/fluentapi/metric_selection.rb +18 -2
  67. data/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb +2 -1
  68. data/lib/archunit/metrics/fluentapi/metrics.rb +5 -0
  69. data/lib/archunit/metrics/fluentapi/metrics_builder.rb +15 -0
  70. data/lib/archunit/slices/fluentapi/diagram_slice_condition.rb +1 -0
  71. data/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb +1 -0
  72. data/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb +2 -0
  73. data/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb +8 -0
  74. data/lib/archunit/slices/fluentapi/slice_scope_builder.rb +12 -0
  75. data/lib/archunit/slices/fluentapi/slices.rb +5 -0
  76. data/lib/archunit/testing/assert_passes.rb +4 -0
  77. data/lib/archunit/testing/result_factory.rb +16 -4
  78. data/lib/archunit/testing/rspec_adapter.rb +1 -2
  79. data/lib/archunit/testing.rb +7 -3
  80. data/lib/archunit/version.rb +1 -1
  81. data/lib/archunit.rb +9 -0
  82. 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(:allow_empty_tests, :logging, :clear_cache) do
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
- def initialize(allow_empty_tests: false, logging: nil, clear_cache: false)
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 = validate_violations(perform_check(options))
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
- 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]
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
- found_cycle = explore_neighbours(vertex)
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 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
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 record_cycle?(neighbour)
77
- return false unless neighbour == @start
78
-
79
- @cycles << @stack.dup
80
- true
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
- return unless @blocked.delete?(vertex)
97
+ pending = [vertex]
98
+ until pending.empty?
99
+ candidate = pending.pop
100
+ next unless @blocked.delete?(candidate)
85
101
 
86
- dependants = @blocked_by.delete(vertex) || []
87
- dependants.each { |dependant| unblock(dependant) }
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
- index_vertex(vertex)
34
-
35
- neighbours(vertex).each do |neighbour|
36
- inspect_neighbour(vertex, neighbour)
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
- extract_component(vertex) if @lowlinks.fetch(vertex) == @indices.fetch(vertex)
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(vertex, 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
- visit(neighbour)
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
- @lowlinks[vertex] = [@lowlinks.fetch(vertex), @indices.fetch(neighbour)].min
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