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.



205
206
207
208
209
210
211
212
213
214
215
# File 'lib/archspec/model.rb', line 205

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

Instance Attribute Details

#analysis_diagnosticsObject (readonly)

Returns the value of attribute analysis_diagnostics.



203
204
205
# File 'lib/archspec/model.rb', line 203

def analysis_diagnostics
  @analysis_diagnostics
end

#componentsObject (readonly)

Returns the value of attribute components.



203
204
205
# File 'lib/archspec/model.rb', line 203

def components
  @components
end

#constantsObject (readonly)

Returns the value of attribute constants.



203
204
205
# File 'lib/archspec/model.rb', line 203

def constants
  @constants
end

#edgesObject (readonly)

Returns the value of attribute edges.



203
204
205
# File 'lib/archspec/model.rb', line 203

def edges
  @edges
end

#filesObject (readonly)

Returns the value of attribute files.



203
204
205
# File 'lib/archspec/model.rb', line 203

def files
  @files
end

#rootObject (readonly)

Returns the value of attribute root.



203
204
205
# File 'lib/archspec/model.rb', line 203

def root
  @root
end

Instance Method Details

#add_analysis_diagnostic(rule:, message:, location:) ⇒ Object



261
262
263
# File 'lib/archspec/model.rb', line 261

def add_analysis_diagnostic(rule:, message:, location:)
  analysis_diagnostics << AnalysisDiagnostic.new(rule, message, location)
end

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



226
227
228
229
230
231
232
233
234
235
236
237
238
239
# File 'lib/archspec/model.rb', line 226

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

  constant = ConstantNode.new(name: normalized, kind: kind, path: path, location: location, nesting: nesting,
                              namespace_only: namespace_only)
  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, resolved_to: nil, resolved_receiver: nil, receiver_scope: nil, resolved_method: nil) ⇒ Object



241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/archspec/model.rb', line 241

def add_edge(type:, from_path:, from_constant:, to:, location:, confidence: :high, receiver: nil,
             lexical_nesting: nil, resolved_to: nil, resolved_receiver: nil, receiver_scope: nil,
             resolved_method: 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,
    resolved_to,
    resolved_receiver,
    receiver_scope,
    resolved_method
  )
end

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



217
218
219
220
221
222
223
224
# File 'lib/archspec/model.rb', line 217

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

#analysis_census(path: nil) ⇒ Object



507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
# File 'lib/archspec/model.rb', line 507

def analysis_census(path: nil)
  scoped_edges = path ? edges.select { |edge| edge.from_path == path } : edges
  scoped_diagnostics =
    if path
      analysis_diagnostics.select { |diagnostic| diagnostic.location.path == path }
    else
      analysis_diagnostics
    end
  unresolved = scoped_edges.select { |edge| DEPENDENCY_EDGE_TYPES.include?(edge.type) }.count do |edge|
    constants_named(resolve_edge_constant(edge)).empty?
  end

  {
    unresolved_constants: unresolved,
    dynamic_features: scoped_edges.count { |edge| edge.type == :dynamic_feature },
    unknown_receivers: scoped_edges.count { |edge| edge.type == :calls_named_method && edge.receiver == :other },
    rubydex_diagnostics: scoped_diagnostics.group_by(&:rule).transform_values(&:size).sort.to_h
  }
end

#ancestor_names(name, scope: :instance, visited: Set.new) ⇒ Object

Every resolvable ancestor in Ruby lookup order, plus names whose declarations are outside the analyzed source. The scope controls whether mixins come from include/prepend or extend; superclass traversal applies to both sides.



403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
# File 'lib/archspec/model.rb', line 403

def ancestor_names(name, scope: :instance, visited: Set.new)
  normalized = normalize_constant(name)
  return [Set.new, Set.new] if visited.include?(normalized) || RESOLVED_ROOTS.include?(normalized)

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

  ancestors = Set.new
  unresolved = Set.new
  nodes.each do |node|
    mixins = scope == :class ? node.mixins[:extend] : node.mixins[:prepend] | node.mixins[:include]
    mixins.each do |ancestor|
      resolved = resolve_constant_reference(ancestor, node.name, lexical_nesting: [node.name] + node.nesting)
      ancestors.add(resolved)
      nested, missing = ancestor_names(resolved, visited: visited)
      ancestors.merge(nested)
      unresolved.merge(missing)
    end

    next unless node.superclass

    resolved = resolve_constant_reference(node.superclass, node.name, lexical_nesting: node.nesting)
    ancestors.add(resolved)
    nested, missing = ancestor_names(resolved, scope: scope, visited: visited)
    ancestors.merge(nested)
    unresolved.merge(missing)
  end

  [ancestors, unresolved]
end

#assign_components(component_specs) ⇒ Object



293
294
295
296
297
298
299
300
301
302
303
304
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
# File 'lib/archspec/model.rb', line 293

def assign_components(component_specs)
  @components = {}

  component_specs.each do |spec|
    component = Component.new(spec.name)
    files_matched_by_pattern = Set.new
    excluded_files = spec.exclude_patterns.each_with_object(Set.new) do |pattern, matches|
      each_matching_file(pattern) { |path| matches.add(path) }
    end

    spec.file_patterns.each do |pattern|
      each_matching_file(pattern) do |path|
        next if excluded_files.include?(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) && !defers_to_real_definition?(constant)
      matched_constant = spec.matches_constant?(constant.name)
      matched_ancestor = spec.matching_ancestor(constant.name, self)
      next unless matched_file || matched_constant || matched_ancestor

      component.add_file(constant.path, reason: "defines #{constant.name}") if matched_constant || matched_ancestor
      reason =
        if matched_file
          'defined in matched file'
        elsif matched_ancestor
          "descends from #{matched_ancestor}"
        else
          'matched namespace/constant selector'
        end
      component.add_constant(constant.name, path: constant.path,
                             reason: reason)
    end

    @components[component.name] = component
  end
end

#component_assignment_reasons_for_constant(name, path: nil) ⇒ Object



535
536
537
538
539
540
541
542
543
# File 'lib/archspec/model.rb', line 535

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



527
528
529
530
531
532
533
# File 'lib/archspec/model.rb', line 527

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_names_for_constant(name, path: nil) ⇒ Object



341
342
343
344
345
346
347
# File 'lib/archspec/model.rb', line 341

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



335
336
337
338
339
# File 'lib/archspec/model.rb', line 335

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



545
546
547
548
549
550
# File 'lib/archspec/model.rb', line 545

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



276
277
278
# File 'lib/archspec/model.rb', line 276

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

#constants_named(name) ⇒ Object



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

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

#dependency_edgesObject



366
367
368
# File 'lib/archspec/model.rb', line 366

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.



353
354
355
# File 'lib/archspec/model.rb', line 353

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

#effective_class_methods(name) ⇒ Object



439
440
441
# File 'lib/archspec/model.rb', line 439

def effective_class_methods(name)
  effective_methods(name, :class)
end

#effective_instance_methods(name) ⇒ Object



435
436
437
# File 'lib/archspec/model.rb', line 435

def effective_instance_methods(name)
  effective_methods(name, :instance)
end

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

Method definitions visible on a constant, including resolvable ancestry. Extended modules contribute their instance methods to the class side.



445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
# File 'lib/archspec/model.rb', line 445

def effective_method_definitions(name, scope, visited = Set.new)
  normalized = normalize_constant(name)
  key = [normalized, scope]
  cached = @effective_definition_cache[key]
  return cached if visited.empty? && cached

  root = visited.empty?
  return [[], Set.new] if visited.include?(key)

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

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

  definitions = []
  visible_names = Set.new
  unresolved = Set.new

  if scope == :instance
    append_mixin_definitions(nodes, :prepend, definitions, visible_names, unresolved, visited)
  end

  own = nodes.flat_map { |node| node.method_definitions.select { |definition| definition.scope == scope } }
  append_visible_definitions(definitions, visible_names, own)

  mixin_kind = scope == :class ? :extend : :include
  append_mixin_definitions(nodes, mixin_kind, definitions, visible_names, unresolved, visited)
  append_superclass_definitions(nodes, scope, definitions, visible_names, unresolved, visited)

  result = [definitions, unresolved]
  @effective_definition_cache[key] = result if root
  result
end

#effective_methods(name, scope) ⇒ Object



494
495
496
497
498
499
500
# File 'lib/archspec/model.rb', line 494

def effective_methods(name, scope)
  key = [normalize_constant(name), scope]
  @effective_method_cache[key] ||= begin
    definitions, unresolved = effective_method_definitions(name, scope)
    [definitions.map(&:name).to_set, unresolved]
  end
end

#incoming_dependency_edges(name) ⇒ Object



502
503
504
505
# File 'lib/archspec/model.rb', line 502

def incoming_dependency_edges(name)
  normalized = normalize_constant(name)
  dependency_edges.select { |edge| resolve_edge_constant(edge) == normalized }
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.



289
290
291
# File 'lib/archspec/model.rb', line 289

def method_definitions
  constants.flat_map(&:method_definitions)
end

#method_definitions_for_component(name) ⇒ Object



280
281
282
283
284
285
# File 'lib/archspec/model.rb', line 280

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



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

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.



393
394
395
396
397
# File 'lib/archspec/model.rb', line 393

def resolve_edge_constant(edge)
  return normalize_constant(edge.resolved_to) if edge.resolved_to

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

#resolve_method_alias(owner, name, scope, visited = Set.new) ⇒ Object



480
481
482
483
484
485
486
487
488
489
490
491
492
# File 'lib/archspec/model.rb', line 480

def resolve_method_alias(owner, name, scope, visited = Set.new)
  name = name.to_sym
  return if visited.include?(name)

  definitions, = effective_method_definitions(owner, scope)
  targets = definitions.filter_map do |definition|
    definition.alias_target if definition.name == name
  end.uniq
  return unless targets.one?

  target = targets.first
  resolve_method_alias(owner, target, scope, visited.dup.add(name)) || target
end

#set_namespace_only(name, path, value) ⇒ Object



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

def set_namespace_only(name, path, value)
  normalized = normalize_constant(name)
  @constants_by_name[normalized].each do |constant|
    constant.namespace_only = value if constant.path == path && (constant.class? || constant.module?)
  end
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.



360
361
362
363
364
# File 'lib/archspec/model.rb', line 360

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)


552
553
554
# File 'lib/archspec/model.rb', line 552

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

#target_components_for(edge) ⇒ Object



370
371
372
373
374
375
376
377
# File 'lib/archspec/model.rb', line 370

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