Class: ArchSpec::Graph

Inherits:
Object
  • Object
show all
Defined in:
lib/archspec/model.rb

Constant Summary collapse

DEPENDENCY_EDGE_TYPES =
%i[
  references_constant
  inherits_from
  includes
  prepends
  extends
].freeze
RESOLVED_ROOTS =
%w[Object BasicObject].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root) ⇒ Graph

Returns a new instance of Graph.



156
157
158
159
160
161
162
163
# File 'lib/archspec/model.rb', line 156

def initialize(root)
  @root = File.expand_path(root)
  @files = {}
  @constants = []
  @constants_by_name = Hash.new { |hash, key| hash[key] = [] }
  @edges = []
  @components = {}
end

Instance Attribute Details

#componentsObject (readonly)

Returns the value of attribute components.



154
155
156
# File 'lib/archspec/model.rb', line 154

def components
  @components
end

#constantsObject (readonly)

Returns the value of attribute constants.



154
155
156
# File 'lib/archspec/model.rb', line 154

def constants
  @constants
end

#edgesObject (readonly)

Returns the value of attribute edges.



154
155
156
# File 'lib/archspec/model.rb', line 154

def edges
  @edges
end

#filesObject (readonly)

Returns the value of attribute files.



154
155
156
# File 'lib/archspec/model.rb', line 154

def files
  @files
end

#rootObject (readonly)

Returns the value of attribute root.



154
155
156
# File 'lib/archspec/model.rb', line 154

def root
  @root
end

Instance Method Details

#add_constant(name:, kind:, path:, location:, nesting: []) ⇒ Object



174
175
176
177
178
179
180
181
182
183
# File 'lib/archspec/model.rb', line 174

def add_constant(name:, kind:, path:, location:, nesting: [])
  normalized = normalize_constant(name)
  existing = @constants_by_name[normalized].find { |constant| constant.path == path && constant.kind == kind }
  return existing if existing

  constant = ConstantNode.new(name: normalized, kind: kind, path: path, location: location, nesting: nesting)
  constants << constant
  @constants_by_name[normalized] << constant
  constant
end

#add_edge(type:, from_path:, from_constant:, to:, location:, confidence: :high, receiver: nil, lexical_nesting: nil) ⇒ Object



185
186
187
188
189
# File 'lib/archspec/model.rb', line 185

def add_edge(type:, from_path:, from_constant:, to:, location:, confidence: :high, receiver: nil,
             lexical_nesting: nil)
  nesting = lexical_nesting&.map { |name| normalize_constant(name) }&.freeze
  edges << Edge.new(type, from_path, from_constant, to.to_s, location, confidence, receiver, nesting)
end

#add_file(path:, parse_errors:, suppressions: []) ⇒ Object



165
166
167
168
169
170
171
172
# File 'lib/archspec/model.rb', line 165

def add_file(path:, parse_errors:, suppressions: [])
  files[path] = SourceFile.new(
    root: root,
    path: path,
    parse_errors: parse_errors,
    suppressions: suppressions
  )
end

#assign_components(component_specs) ⇒ Object



212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/archspec/model.rb', line 212

def assign_components(component_specs)
  @components = {}

  component_specs.each do |spec|
    component = Component.new(spec.name)
    files_matched_by_pattern = Set.new

    spec.file_patterns.each do |pattern|
      each_matching_file(pattern) do |path|
        files_matched_by_pattern.add(path)
        component.add_file(path, reason: "matched file pattern #{pattern}")
      end
    end

    constants.each do |constant|
      matched_file = files_matched_by_pattern.include?(constant.path)
      matched_constant = spec.matches_constant?(constant.name)
      next unless matched_file || matched_constant

      component.add_file(constant.path, reason: "defines #{constant.name}") if matched_constant
      component.add_constant(constant.name, path: constant.path,
                             reason: matched_file ? 'defined in matched file' : 'matched namespace/constant selector')
    end

    @components[component.name] = component
  end
end

#component_assignment_reasons_for_constant(name, path: nil) ⇒ Object



372
373
374
375
376
377
378
379
380
# File 'lib/archspec/model.rb', line 372

def component_assignment_reasons_for_constant(name, path: nil)
  normalized = normalize_constant(name)

  components.values.each_with_object({}) do |component, reasons|
    next unless component.includes_constant?(normalized, path: path)

    reasons[component.name] = component.constant_reasons[normalized].to_a.sort
  end
end

#component_assignment_reasons_for_path(path) ⇒ Object



364
365
366
367
368
369
370
# File 'lib/archspec/model.rb', line 364

def component_assignment_reasons_for_path(path)
  components.values.each_with_object({}) do |component, reasons|
    next unless component.files.include?(path)

    reasons[component.name] = component.file_reasons[path].to_a.sort
  end
end

#component_dependency_pairs(only: nil) ⇒ Object



345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# File 'lib/archspec/model.rb', line 345

def component_dependency_pairs(only: nil)
  allowed_sources = Array(only).compact.map(&:to_sym).to_set
  pairs = Set.new

  dependency_edges.each do |edge|
    source_components = source_components_for(edge)
    source_components &= allowed_sources unless allowed_sources.empty?
    next if source_components.empty?

    target_components_for(edge).each do |target|
      source_components.each do |source|
        pairs.add([source, target]) unless source == target
      end
    end
  end

  pairs
end

#component_names_for_constant(name, path: nil) ⇒ Object



246
247
248
249
250
251
252
# File 'lib/archspec/model.rb', line 246

def component_names_for_constant(name, path: nil)
  normalized = normalize_constant(name)

  components.values.each_with_object(Set.new) do |component, names|
    names.add(component.name) if component.includes_constant?(normalized, path: path)
  end
end

#component_names_for_path(path) ⇒ Object



240
241
242
243
244
# File 'lib/archspec/model.rb', line 240

def component_names_for_path(path)
  components.values.each_with_object(Set.new) do |component, names|
    names.add(component.name) if component.files.include?(path)
  end
end

#constants_for_component(name) ⇒ Object



382
383
384
385
386
387
# File 'lib/archspec/model.rb', line 382

def constants_for_component(name)
  component = components[name.to_sym]
  return [] unless component

  constants.select { |constant| component.includes_constant?(constant.name, path: constant.path) }
end

#constants_for_path(path) ⇒ Object



195
196
197
# File 'lib/archspec/model.rb', line 195

def constants_for_path(path)
  constants.select { |constant| constant.path == path }
end

#constants_named(name) ⇒ Object



191
192
193
# File 'lib/archspec/model.rb', line 191

def constants_named(name)
  @constants_by_name[normalize_constant(name)]
end

#dependency_edgesObject



271
272
273
# File 'lib/archspec/model.rb', line 271

def dependency_edges
  edges.select { |edge| DEPENDENCY_EDGE_TYPES.include?(edge.type) }
end

#edge_source_name(edge) ⇒ Object

The edge's source as prose: its constant when known, otherwise the root-relative path of the file. Diagnostics use this so evidence never embeds an absolute path, which would make todo fingerprints machine-specific.



258
259
260
# File 'lib/archspec/model.rb', line 258

def edge_source_name(edge)
  edge.from_constant || files[edge.from_path]&.relative_path || edge.from_path
end

#effective_instance_methods(name, visited = Set.new) ⇒ Object

Instance methods a constant responds to, walking resolvable superclasses and include/prepend mixins. Returns [methods, unresolved ancestor names]; a non-empty second element means the answer is incomplete.



305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/archspec/model.rb', line 305

def effective_instance_methods(name, visited = Set.new)
  normalized = normalize_constant(name)
  return [Set.new, Set.new] if visited.include?(normalized)

  visited.add(normalized)
  return [Set.new, Set.new] if RESOLVED_ROOTS.include?(normalized)

  nodes = constants_named(normalized)
  return [Set.new, Set[normalized]] if nodes.empty?

  methods = Set.new
  unresolved = Set.new

  nodes.each do |node|
    methods.merge(node.instance_methods)

    mixins = node.mixins[:include].to_a + node.mixins[:prepend].to_a

    mixins.each do |ancestor|
      resolved_name = resolve_constant_reference(
        ancestor,
        node.name,
        lexical_nesting: [node.name] + node.nesting
      )
      ancestor_methods, ancestor_unresolved = effective_instance_methods(resolved_name, visited)
      methods.merge(ancestor_methods)
      unresolved.merge(ancestor_unresolved)
    end

    if node.superclass
      resolved_name = resolve_constant_reference(node.superclass, node.name, lexical_nesting: node.nesting)
      ancestor_methods, ancestor_unresolved = effective_instance_methods(resolved_name, visited)
      methods.merge(ancestor_methods)
      unresolved.merge(ancestor_unresolved)
    end
  end

  [methods, unresolved]
end

#method_definitionsObject

Every method definition in the graph, across all constants. Used by project-wide naming rules that are not scoped to one component.



208
209
210
# File 'lib/archspec/model.rb', line 208

def method_definitions
  constants.flat_map(&:method_definitions)
end

#method_definitions_for_component(name) ⇒ Object



199
200
201
202
203
204
# File 'lib/archspec/model.rb', line 199

def method_definitions_for_component(name)
  component = components[name.to_sym]
  return [] unless component

  constants_for_component(name).flat_map(&:method_definitions)
end

#resolve_constant_reference(name, from_constant = nil, lexical_nesting: nil) ⇒ Object



284
285
286
287
288
289
290
291
292
293
# File 'lib/archspec/model.rb', line 284

def resolve_constant_reference(name, from_constant = nil, lexical_nesting: nil)
  absolute = name.to_s.start_with?('::')
  normalized = normalize_constant(name)
  return normalized if absolute

  scopes = lexical_nesting || inferred_nesting(from_constant)
  candidates = scopes.map { |scope| "#{normalize_constant(scope)}::#{normalized}" }
  candidates << normalized
  candidates.find { |candidate| constants_named(candidate).any? } || normalized
end

#resolve_edge_constant(edge) ⇒ Object

Resolves an edge with the lexical nesting captured where the reference appeared. This matters for compact class declarations and superclass expressions, where inferring scope from the finished class name is wrong.



298
299
300
# File 'lib/archspec/model.rb', line 298

def resolve_edge_constant(edge)
  resolve_constant_reference(edge.to, edge.from_constant, lexical_nesting: edge.lexical_nesting)
end

#source_components_for(edge) ⇒ Object

Components that own the source of an edge. Constant and namespace selectors stay precise when a file also defines unrelated constants; top-level edges fall back to the file assignment.



265
266
267
268
269
# File 'lib/archspec/model.rb', line 265

def source_components_for(edge)
  return component_names_for_path(edge.from_path) unless edge.from_constant

  component_names_for_constant(edge.from_constant, path: edge.from_path)
end

#suppressed?(diagnostic) ⇒ Boolean

Returns:

  • (Boolean)


389
390
391
# File 'lib/archspec/model.rb', line 389

def suppressed?(diagnostic)
  files[diagnostic.location.path]&.suppressions&.any? { |suppression| suppression.matches?(diagnostic) }
end

#target_components_for(edge) ⇒ Object



275
276
277
278
279
280
281
282
# File 'lib/archspec/model.rb', line 275

def target_components_for(edge)
  return Set.new unless DEPENDENCY_EDGE_TYPES.include?(edge.type)

  resolved = resolve_edge_constant(edge)
  constants_named(resolved).each_with_object(Set.new) do |constant, names|
    names.merge(component_names_for_constant(resolved, path: constant.path))
  end
end