archspec 1.1.0.rc2 → 1.2.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.
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ # The built-in Rails producer. <tt>archspec reflect</tt> boots the
5
+ # application in a separate process and records what Rails itself resolved:
6
+ # association targets and generated methods, custom validator classes, and
7
+ # the effects conditional concern callbacks had on each consumer. Normal
8
+ # analysis never requires Rails.
9
+ module RailsReflector
10
+ extend self
11
+
12
+ ASSOCIATIONS = %i[belongs_to has_one has_many has_and_belongs_to_many].freeze
13
+ # Macros that declare associations under derived names.
14
+ GENERATED_ASSOCIATIONS = {
15
+ has_one_attached: [[:has_one, '%s_attachment'], [:has_one, '%s_blob']],
16
+ has_many_attached: [[:has_many, '%s_attachments'], [:has_many, '%s_blobs']],
17
+ has_rich_text: [[:has_one, 'rich_text_%s']]
18
+ }.freeze
19
+ VALIDATIONS = %i[validates validates!].freeze
20
+ Site = Data.define(:macro, :name, :owner, :location)
21
+ # Module#name, immune to classes that redefine +name+ with other meanings.
22
+ MODULE_NAME = Module.instance_method(:name)
23
+
24
+ def boot(root)
25
+ environment = File.join(root, 'config/environment.rb')
26
+ raise Error, "no #{environment} found; the rails producer requires a Rails application" unless File.file?(environment)
27
+
28
+ ENV['RAILS_ENV'] ||= 'development'
29
+ require environment
30
+ eager_load_application
31
+ end
32
+
33
+ # Records every Rails fact for the loaded application.
34
+ def capture(facts, models: active_record_models, validated: validated_classes)
35
+ sites = macro_sites(facts.graph, models | validated)
36
+ associations(facts, models, sites)
37
+ validators(facts, validated, sites)
38
+ concerns(facts)
39
+ facts
40
+ end
41
+
42
+ def associations(facts, models, sites = macro_sites(facts.graph, models))
43
+ graph = facts.graph
44
+ reflections = models.flat_map(&:reflect_on_all_associations)
45
+ .uniq { |reflection| [reflection.active_record.name, reflection.name] }
46
+ reflections.sort_by { |reflection| [reflection.active_record.name.to_s, reflection.name.to_s] }.each do |reflection|
47
+ model = reflection.active_record
48
+ next if model.name.nil? || graph.constants_named(model.name).empty?
49
+
50
+ ancestors = graph.ancestor_names(model.name).first | Set[model.name]
51
+ candidates = sites.fetch([reflection.macro, reflection.name.to_s], []).select { |site| ancestors.include?(site.owner) }
52
+ source = source_node(graph, model, candidates)
53
+ next unless source
54
+
55
+ if candidates.size != 1
56
+ facts.gap(source: source, location: source.location,
57
+ message: "association #{model.name}.#{reflection.name}: no unique literal declaration")
58
+ next
59
+ end
60
+ site = candidates.first
61
+ generated = model.generated_association_methods.instance_methods(false).map(&:to_s)
62
+ names = [reflection.name.to_s, "#{reflection.name}="] & generated
63
+ facts.methods(owner: source, location: site.location, names: names.sort) unless names.empty?
64
+ if reflection.polymorphic?
65
+ facts.gap(source: source, location: site.location, message: "polymorphic association #{model.name}.#{reflection.name}")
66
+ next
67
+ end
68
+ begin
69
+ facts.reference(source: source, target: reflection.klass, location: site.location)
70
+ rescue NameError, ArgumentError, ActiveRecord::ActiveRecordError, Error => error
71
+ facts.gap(source: source, location: site.location,
72
+ message: "unresolved association #{model.name}.#{reflection.name}: #{error.message.lines.first.strip}")
73
+ end
74
+ end
75
+ end
76
+
77
+ # A validation such as <tt>validates :email, email: true</tt> names its
78
+ # validator class only by convention. The loaded model knows the class
79
+ # Rails resolved; the literal +validates+ call supplies the location.
80
+ def validators(facts, classes, sites = macro_sites(facts.graph, classes))
81
+ graph = facts.graph
82
+ classes.to_h { |klass| [MODULE_NAME.bind_call(klass), klass] }.sort.each do |class_name, klass|
83
+ next if class_name.nil? || graph.constants_named(class_name).empty?
84
+
85
+ ancestors = graph.ancestor_names(class_name).first | Set[class_name]
86
+ klass.validators.map(&:class).uniq.map { |validator| MODULE_NAME.bind_call(validator) }.compact.sort.each do |name|
87
+ next if name.start_with?('ActiveModel::', 'ActiveRecord::')
88
+
89
+ candidates = VALIDATIONS.flat_map { |macro| sites.fetch([macro, :validator], []) }.select do |site|
90
+ ancestors.include?(site.owner) && validator_key?(site.name, name)
91
+ end
92
+ if candidates.empty?
93
+ next if referenced?(graph, ancestors, name)
94
+
95
+ source = source_node(graph, klass, [])
96
+ if source
97
+ facts.gap(source: source, location: source.location,
98
+ message: "validator #{name} on #{class_name}: no literal declaration")
99
+ end
100
+ next
101
+ end
102
+ candidates.each do |site|
103
+ source = site_source(graph, klass, site)
104
+ facts.reference(source: source, target: name, location: site.location) if source
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ # Conditional concern callbacks are gaps in static analysis. Once the
111
+ # application is loaded, each consumer shows which candidate mixins and
112
+ # methods it actually received. An effect is recorded only when exactly one
113
+ # candidate explains it; the gap is resolved only when every consumer could
114
+ # be inspected and every observed effect was attributed.
115
+ def concerns(facts)
116
+ graph = facts.graph
117
+ callbacks = graph.conditional_callbacks
118
+ return if callbacks.empty?
119
+
120
+ unsettled = callbacks.map(&:concern).uniq.reject { |concern| runtime_constant(concern) }.to_set
121
+ consumers(graph, callbacks).each do |node, applicable|
122
+ consumer = runtime_constant(node.name)
123
+ if consumer
124
+ unsettled.merge(observe_mixins(facts, node, consumer, applicable))
125
+ observe_methods(facts, node, consumer, applicable)
126
+ else
127
+ unsettled.merge(applicable.map(&:concern))
128
+ end
129
+ end
130
+ callbacks.each do |callback|
131
+ next if unsettled.include?(callback.concern)
132
+
133
+ source = graph.constants_named(callback.concern).find { |constant| constant.path == callback.path }
134
+ facts.resolve(source: source, location: callback.location, message: "conditional #{callback.kind} callback",
135
+ consumers: graph.consumers_of(callback.concern))
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ # Loads the application's own code, including engines inside the project,
142
+ # but not installed gems: their models are not analyzed, and loading them
143
+ # can require configuration the chosen environment does not have.
144
+ def eager_load_application
145
+ loader = Rails.autoloaders.main if defined?(Rails.autoloaders)
146
+ return Rails.application.eager_load! unless loader.respond_to?(:dirs) && loader.respond_to?(:eager_load_dir)
147
+
148
+ root = "#{Rails.root}/"
149
+ gems = (Gem.path + [defined?(Bundler) ? Bundler.bundle_path.to_s : nil]).compact.map { |path| "#{File.expand_path(path)}/" }
150
+ loader.dirs.select { |dir| dir.start_with?(root) && gems.none? { |gem_dir| dir.start_with?(gem_dir) } }
151
+ .each { |dir| loader.eager_load_dir(dir) }
152
+ end
153
+
154
+ def active_record_models
155
+ defined?(ActiveRecord::Base) ? ActiveRecord::Base.descendants.reject(&:abstract_class?).select(&:name) : []
156
+ end
157
+
158
+ def validated_classes
159
+ return [] unless defined?(ActiveModel::Validations)
160
+
161
+ ObjectSpace.each_object(Class).select { |klass| klass.include?(ActiveModel::Validations) && MODULE_NAME.bind_call(klass) }
162
+ end
163
+
164
+ # Returns the concerns whose observed effects could not be attributed to a
165
+ # single candidate declaration.
166
+ def observe_mixins(facts, node, consumer, callbacks)
167
+ graph = facts.graph
168
+ instance, = graph.ancestor_names(node.name)
169
+ singleton, = graph.ancestor_names(node.name, scope: :class)
170
+ candidates = callbacks.flat_map { |callback| callback.mixins.map { |mixin| [callback, mixin] } }
171
+ candidates.group_by { |_, mixin| [mixin.kind, mixin.target] }.each_with_object(Set.new) do |((kind, target), sites), ambiguous|
172
+ module_object = runtime_constant(target)
173
+ next unless module_object.is_a?(Module)
174
+
175
+ observed = kind == :extend ? consumer.singleton_class.include?(module_object) : consumer.include?(module_object)
176
+ next unless observed && !(kind == :extend ? singleton : instance).include?(target)
177
+ next ambiguous.merge(sites.map { |callback, _| callback.concern }) unless sites.one?
178
+
179
+ facts.mixin(owner: node, target: target, kind: kind, location: sites.first.last.location)
180
+ end
181
+ end
182
+
183
+ def observe_methods(facts, node, consumer, callbacks)
184
+ callbacks.flat_map(&:methods).each do |definition|
185
+ method = begin
186
+ definition.scope == :class ? consumer.method(definition.name) : consumer.instance_method(definition.name)
187
+ rescue NameError
188
+ nil
189
+ end
190
+ path, line = method&.source_location
191
+ next unless path && File.expand_path(path) == definition.location.path && line == definition.location.line
192
+
193
+ facts.method_definition(owner: node, definition: definition, installation: definition.location)
194
+ end
195
+ end
196
+
197
+ # Each analyzed consumer with the conditional callbacks it runs: included
198
+ # callbacks for an include, prepended callbacks for a prepend.
199
+ def consumers(graph, callbacks)
200
+ concerns = callbacks.map(&:concern).to_set
201
+ graph.constants.each_with_object({}) do |node, found|
202
+ next if node.namespace_only || concerns.include?(node.name) || found.key?(node.name)
203
+
204
+ applicable = callbacks.select do |callback|
205
+ node.mixins.fetch(callback.kind == :prepended ? :prepend : :include).include?("::#{callback.concern}")
206
+ end
207
+ found[node.name] = [node, applicable] unless applicable.empty?
208
+ end.values
209
+ end
210
+
211
+ def runtime_constant(name)
212
+ Object.const_get(name)
213
+ rescue NameError, ArgumentError
214
+ nil
215
+ end
216
+
217
+ def validator_key?(key, validator)
218
+ class_name = "#{key.to_s.camelize}Validator"
219
+ validator == class_name || validator.end_with?("::#{class_name}")
220
+ end
221
+
222
+ def referenced?(graph, ancestors, name)
223
+ graph.dependency_edges.any? { |edge| ancestors.include?(edge.from_constant) && graph.resolve_edge_constant(edge) == name }
224
+ end
225
+
226
+ def site_source(graph, klass, site)
227
+ owner = graph.constants_named(site.owner).find { |node| node.class? && node.path == site.location.path }
228
+ owner || source_node(graph, klass, [site])
229
+ end
230
+
231
+ def source_node(graph, model, candidates)
232
+ nodes = graph.constants_named(model.name)
233
+ direct = nodes.select { |node| candidates.any? { |site| site.owner == model.name && site.location.path == node.path } }
234
+ return direct.first if direct.one?
235
+
236
+ location = Object.const_source_location(model.name)
237
+ nodes.find { |node| location && node.path == File.expand_path(location.first) } || nodes.find { |node| !node.namespace_only }
238
+ end
239
+
240
+ # Literal association and validation declarations, found by parsing only
241
+ # the files that define the given classes and their analyzed ancestors.
242
+ def macro_sites(graph, classes)
243
+ names = classes.filter_map { |klass| MODULE_NAME.bind_call(klass) }.flat_map { |name| graph.ancestor_names(name).first.to_a << name }.to_set
244
+ paths = names.flat_map { |name| graph.constants_named(name).map(&:path) }.uniq.sort
245
+ owners = graph.edges.select { |edge| edge.type == :calls_named_method && paths.include?(edge.from_path) }
246
+ .to_h { |edge| [[edge.from_path, edge.location], edge.from_constant] }
247
+ sites = []
248
+ paths.each { |path| collect_sites(owners, path, Prism.parse_file(path).value, sites) }
249
+ sites.group_by { |site| [site.macro, site.name.is_a?(String) ? site.name : :validator] }
250
+ end
251
+
252
+ def collect_sites(owners, path, node, sites)
253
+ return unless node
254
+ return if node.is_a?(Prism::DefNode)
255
+
256
+ if node.is_a?(Prism::CallNode) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
257
+ location = SourceLocation.from_prism(path, node.location)
258
+ arguments = node.arguments&.arguments || []
259
+ argument = arguments.first
260
+ literal = argument.unescaped if argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode)
261
+ if ASSOCIATIONS.include?(node.name)
262
+ sites << Site.new(node.name, literal, owners[[path, location]], location) if literal
263
+ elsif GENERATED_ASSOCIATIONS.key?(node.name)
264
+ GENERATED_ASSOCIATIONS.fetch(node.name).each do |macro, pattern|
265
+ sites << Site.new(macro, format(pattern, literal), owners[[path, location]], location) if literal
266
+ end
267
+ elsif VALIDATIONS.include?(node.name)
268
+ validation_keys(arguments).each do |key|
269
+ sites << Site.new(node.name, key.to_sym, owners[[path, location]], location)
270
+ end
271
+ end
272
+ end
273
+ node.compact_child_nodes.each { |child| collect_sites(owners, path, child, sites) }
274
+ end
275
+
276
+ def validation_keys(arguments)
277
+ options = arguments.select { |argument| argument.is_a?(Prism::KeywordHashNode) || argument.is_a?(Prism::HashNode) }
278
+ options.flat_map(&:elements).filter_map do |element|
279
+ key = element.key if element.is_a?(Prism::AssocNode)
280
+ key.unescaped if key.is_a?(Prism::SymbolNode) || key.is_a?(Prism::StringNode)
281
+ end
282
+ end
283
+ end
284
+
285
+ producer :rails do
286
+ sources 'app/models/**/*.rb', '**/app/models/**/*.rb'
287
+ env 'RAILS_ENV'
288
+ boot { |root| RailsReflector.boot(root) }
289
+ capture { |facts| RailsReflector.capture(facts) }
290
+ end
291
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Executed only by `archspec reflect`, once per producer, in its own process.
4
+ require_relative '../archspec'
5
+
6
+ begin
7
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
8
+ definition, root = ArchSpec::CLI.load_definition(ENV.fetch('ARCHSPEC_REFLECTION_CONFIG'))
9
+ producer = ArchSpec::Producer.load(ENV.fetch('ARCHSPEC_PRODUCER'), definition)
10
+ document = producer.run(definition, root)
11
+ output = File.join(File.expand_path(definition.facts_path, root), "#{producer.name}.yml")
12
+ ArchSpec::Facts.write(output, document)
13
+ labels = { 'references' => 'reference', 'methods' => 'method', 'mixins' => 'mixin', 'gaps' => 'gap',
14
+ 'resolves' => 'resolved gap' }
15
+ counts = labels.filter_map do |key, label|
16
+ size = key == 'methods' ? document[key].sum { |entry| entry['names'].size } : document[key].size
17
+ "#{size} #{label}#{'s' unless size == 1}" if size.positive?
18
+ end
19
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
20
+ summary = counts.empty? ? 'no facts' : counts.join(', ')
21
+ puts format('Updated %<path>s: %<summary>s (%<seconds>.1fs)',
22
+ path: Pathname(output).relative_path_from(Pathname(root)), summary: summary, seconds: elapsed)
23
+ document['gaps'].each { |gap| puts " gap: #{gap['path']}:#{gap['line']} #{gap['message']}" }
24
+ rescue ArchSpec::Error => error
25
+ warn error.message
26
+ exit 1
27
+ end
@@ -131,7 +131,9 @@ module ArchSpec
131
131
  next unless syntax.constant_name_at(path, location)
132
132
 
133
133
  target, resolved = reference_target(reference, path)
134
- constant.add_mixin(kind, resolved || target)
134
+ # A resolved name is absolute; resolving it again from the includer's
135
+ # scope could pick a nested constant with the same name.
136
+ constant.add_mixin(kind, resolved ? "::#{resolved}" : target)
135
137
  add_ancestry_edge(graph, :"#{kind}s", name, definition, reference, target, resolved)
136
138
  end
137
139
  end
@@ -35,14 +35,14 @@ module ArchSpec
35
35
  next if consumers.empty?
36
36
 
37
37
  target = graph.resolve_edge_constant(edge)
38
- next if within_namespace?(target, edge.from_constant)
39
-
40
- includer = consumers.find { |name| within_namespace?(target, name) }
41
- next unless includer
38
+ owner = [edge.from_constant, *consumers]
39
+ .select { |namespace| within_namespace?(target, namespace) }
40
+ .max_by(&:length)
41
+ next unless consumers.include?(owner)
42
42
 
43
43
  Diagnostic.new(
44
44
  rule: id,
45
- message: "#{edge.from_constant} must not reference its includer #{includer}",
45
+ message: "#{edge.from_constant} must not reference its includer #{owner}",
46
46
  location: edge.location,
47
47
  evidence: "#{edge.from_constant} #{edge.verb} #{target}"
48
48
  )
@@ -42,9 +42,10 @@ module ArchSpec
42
42
 
43
43
  def evaluate(graph)
44
44
  relevant_edges(graph).flat_map do |edge|
45
- graph.target_components_for(edge).filter_map do |target|
46
- next if target == source || targets.include?(target)
45
+ owners = graph.target_components_for(edge)
46
+ next [] if owners.any? { |target| target == source || targets.include?(target) }
47
47
 
48
+ owners.map do |target|
48
49
  Diagnostic.new(
49
50
  rule: id,
50
51
  message: "#{source} may not depend on #{target}",
@@ -107,9 +108,8 @@ module ArchSpec
107
108
  graph.dependency_edges.flat_map do |edge|
108
109
  next [] unless graph.target_components_for(edge).include?(source)
109
110
 
110
- offenders = graph.source_components_for(edge).reject do |component|
111
- component == source || consumers.include?(component)
112
- end
111
+ offenders = graph.source_components_for(edge)
112
+ next [] if offenders.any? { |component| component == source || consumers.include?(component) }
113
113
 
114
114
  offenders.map do |offender|
115
115
  Diagnostic.new(
@@ -74,7 +74,8 @@ module ArchSpec
74
74
  def own_method_call?(graph, edge)
75
75
  return false unless edge.receiver == :none && edge.from_constant
76
76
 
77
- methods, = graph.effective_instance_methods(edge.from_constant)
77
+ owner = edge.resolved_receiver || edge.from_constant
78
+ methods, = graph.effective_methods(owner, edge.receiver_scope || :instance)
78
79
  methods.include?(edge.to.to_sym)
79
80
  end
80
81
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'comment'
4
+
5
+ module ArchSpec
6
+ module Sources
7
+ # Shared interface for source adapters. Normalizes the file path, parses
8
+ # eagerly, and converts parser errors to ArchSpec source locations.
9
+ # Subclasses provide supported extensions, a Prism program, and comments.
10
+ class Base
11
+ def self.extensions
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def self.rubydex_indexable?
16
+ false
17
+ end
18
+
19
+ attr_reader :path
20
+
21
+ def initialize(path)
22
+ @path = File.expand_path(path)
23
+ @result = parse_file
24
+ end
25
+
26
+ def comments
27
+ raise NotImplementedError
28
+ end
29
+
30
+ def prism
31
+ raise NotImplementedError
32
+ end
33
+
34
+ def parse_errors
35
+ result.errors.map do |error|
36
+ ParseError.new(error.message, SourceLocation.from_prism(path, error.location))
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :result
43
+
44
+ def parse_file
45
+ raise NotImplementedError
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ module Sources
5
+ # Parser-independent comment text and position for suppression handling.
6
+ # Lines are one-based and columns are zero-based byte offsets within a line.
7
+ Comment = Data.define(:text, :line, :column) do
8
+ def self.from_prism(comment)
9
+ location = comment.location
10
+ new(comment.slice, location.start_line, location.start_column)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'herb'
4
+ require 'prism'
5
+ require_relative 'base'
6
+
7
+ module ArchSpec
8
+ module Sources
9
+ # Parses ERB templates with Herb, exposing a complete Prism program and
10
+ # suppression comments at their original template positions. ERB sources
11
+ # contribute syntax facts without being indexed by Rubydex.
12
+ class Erb < Base
13
+ attr_reader :comments, :prism
14
+
15
+ def self.extensions
16
+ %w[.erb]
17
+ end
18
+
19
+ private
20
+
21
+ def parse_file
22
+ source = File.read(path)
23
+ document = Herb.parse(source, prism_program: true).value
24
+ # Herb omits the program payload for a completely empty file.
25
+ parsed = source.empty? ? Prism.parse('', filepath: path) : Prism.load(source, document.prism_node)
26
+ @prism = parsed.value
27
+ collect_comments!(source, document, parsed)
28
+ parsed
29
+ end
30
+
31
+ def collect_comments!(source, document, parsed)
32
+ bytes = source.b
33
+ @comments = []
34
+ # Work around a Herb bug: Prism comment ranges extend past ERB closing
35
+ # tags to the end of the physical line. This Collects tag boundaries and
36
+ # clamps comments; Intention is to remove once it's fixed upstream.
37
+ ruby_ranges = []
38
+ collect_erb_comments(document, bytes, ruby_ranges)
39
+ ruby_ranges.sort_by!(&:from)
40
+ parsed.comments.each do |comment|
41
+ location = comment.location
42
+ range = ruby_ranges.bsearch { |candidate| candidate.to > location.start_offset }
43
+ finish = location.end_offset
44
+ finish = [finish, range.to].min if range && range.from <= location.start_offset
45
+ @comments << Comment.new(source.byteslice(location.start_offset...finish), location.start_line, location.start_column)
46
+ end
47
+ @comments.uniq! { |comment| [comment.line, comment.column] }
48
+ end
49
+
50
+ def collect_erb_comments(node, bytes, ruby_ranges)
51
+ if node.is_a?(Herb::AST::ERBCommentNode)
52
+ process_erb_comment(node.content, bytes)
53
+ elsif node.respond_to?(:tag_opening) && node.tag_opening&.value&.start_with?('<%') &&
54
+ node.respond_to?(:content) && node.content
55
+ ruby_ranges << node.content.range
56
+ # Work around a Herb bug: single-line comment-only Ruby tags are absent
57
+ # from the Prism comments. This recovers them from the AST, excluding escapes.
58
+ # Intention is to remove this once the bug is fixed upstream in Herb.
59
+ if node.is_a?(Herb::AST::ERBContentNode) && %w[<% <%-].include?(node.tag_opening.value) &&
60
+ node.content.value.match?(/\A[^\S\r\n]*#[^\r\n]*\z/)
61
+ token = node.content
62
+ offset = token.range.from + token.value.b.index('#')
63
+ @comments << Comment.new(token.value.lstrip, token.location.start.line, byte_column(bytes, offset))
64
+ end
65
+ end
66
+
67
+ node.compact_child_nodes.each do |child|
68
+ collect_erb_comments(child, bytes, ruby_ranges)
69
+ end
70
+ end
71
+
72
+ def process_erb_comment(token, bytes)
73
+ start = token.location.start
74
+ column = byte_column(bytes, token.range.from)
75
+ token.value.each_line.with_index do |text, index|
76
+ @comments << Comment.new(text, start.line + index, index.zero? ? column : 0)
77
+ end
78
+ end
79
+
80
+ def byte_column(bytes, offset)
81
+ line_start = (bytes.rindex("\n", offset - 1) || -1) + 1
82
+ offset - line_start
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+ require_relative 'base'
5
+
6
+ module ArchSpec
7
+ module Sources
8
+ # Parses Ruby and Rake files with Prism and exposes their program and
9
+ # comments. These sources also support semantic indexing with Rubydex.
10
+ class Ruby < Base
11
+ def self.extensions
12
+ %w[.rb .rake]
13
+ end
14
+
15
+ def self.rubydex_indexable?
16
+ true
17
+ end
18
+
19
+ def comments
20
+ result.comments.map { |comment| Comment.from_prism(comment) }
21
+ end
22
+
23
+ def prism
24
+ result.value
25
+ end
26
+
27
+ private
28
+
29
+ def parse_file
30
+ Prism.parse_file(path)
31
+ end
32
+ end
33
+ end
34
+ end
data/lib/archspec/todo.rb CHANGED
@@ -8,8 +8,12 @@ module ArchSpec
8
8
  # in an existing app and burn the list down over time. Matched by
9
9
  # ArchSpec::Diagnostic#fingerprint, so entries survive edits that shift lines.
10
10
  class Todo
11
+ # The keys an entry keeps. Anything else in the file, such as the line an
12
+ # older format recorded, is dropped so reports have one fixed shape.
13
+ FIELDS = %w[id rule path message evidence].freeze
14
+
11
15
  def self.empty(root: nil)
12
- new(Set.new, root: root)
16
+ new({}, root: root)
13
17
  end
14
18
 
15
19
  def self.load(path, root:)
@@ -20,16 +24,17 @@ module ArchSpec
20
24
  raise Error, "invalid todo file #{path}: expected a violations list"
21
25
  end
22
26
 
23
- ids = Array(document['violations']).map.with_index do |entry, index|
27
+ entries = Array(document['violations']).each_with_index.to_h do |entry, index|
24
28
  id = entry.is_a?(Hash) ? entry['id'] : entry
25
29
  unless id.is_a?(String) && !id.empty?
26
30
  raise Error, "invalid todo file #{path}: violation #{index + 1} has no id"
27
31
  end
28
32
 
29
- id
33
+ fields = entry.is_a?(Hash) ? entry : {}
34
+ [id, FIELDS.to_h { |key| [key, fields[key]] }.merge('id' => id)]
30
35
  end
31
36
 
32
- new(ids.to_set, root: root)
37
+ new(entries, root: root)
33
38
  rescue Error
34
39
  raise
35
40
  rescue Psych::Exception, SystemCallError => e
@@ -37,34 +42,40 @@ module ArchSpec
37
42
  end
38
43
 
39
44
  def self.write(path, diagnostics, root:)
40
- payload = {
41
- 'violations' => diagnostics.map do |diagnostic|
42
- {
43
- 'id' => diagnostic.fingerprint(root: root),
44
- 'rule' => diagnostic.rule,
45
- 'path' => diagnostic.location.relative_path(root),
46
- 'line' => diagnostic.location.line,
47
- 'message' => diagnostic.message
48
- }
49
- end
50
- }
45
+ entries = diagnostics.map do |diagnostic|
46
+ {
47
+ 'id' => diagnostic.fingerprint(root: root),
48
+ 'rule' => diagnostic.rule,
49
+ 'path' => diagnostic.location.relative_path(root),
50
+ 'message' => diagnostic.message,
51
+ 'evidence' => diagnostic.evidence
52
+ }
53
+ end
54
+ entries.uniq! { |entry| entry['id'] }
55
+ entries.sort_by! { |entry| entry.values_at('path', 'rule', 'message', 'evidence') }
51
56
 
52
- File.write(path, payload.to_yaml)
57
+ File.write(path, { 'violations' => entries }.to_yaml)
58
+ entries.size
53
59
  rescue SystemCallError => e
54
60
  raise Error, "could not write todo file #{path}: #{e.message}"
55
61
  end
56
62
 
57
- def initialize(ids, root:)
58
- @ids = ids
63
+ def initialize(entries, root:)
64
+ @entries = entries
59
65
  @root = root
60
66
  end
61
67
 
62
68
  def include?(diagnostic)
63
- ids.include?(diagnostic.fingerprint(root: root))
69
+ entries.key?(diagnostic.fingerprint(root: root))
70
+ end
71
+
72
+ def unmatched_by(diagnostics)
73
+ matched = diagnostics.map { |diagnostic| diagnostic.fingerprint(root: root) }.to_set
74
+ entries.reject { |id, _entry| matched.include?(id) }.values
64
75
  end
65
76
 
66
77
  private
67
78
 
68
- attr_reader :ids, :root
79
+ attr_reader :entries, :root
69
80
  end
70
81
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ArchSpec
4
- VERSION = '1.1.0.rc2'
4
+ VERSION = '1.2.0'
5
5
  end