Module: Hecks::Translation::Scaffold::Differ

Included in:
Hecks::Translation::Scaffold
Defined in:
lib/hecks/translation/scaffold/differ.rb

Overview

Diff two eras' storage-shape projections into the edge's declarations — confident rules by unique signature pairing, everything ambiguous left unresolved for a human.

Instance Method Summary collapse

Instance Method Details

#appeared_paths(held_attrs, current_attrs, retyped) ⇒ Object



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/hecks/translation/scaffold/differ.rb', line 134

def appeared_paths(held_attrs, current_attrs, retyped)
  paths = {}
  current_attrs.each do |name, current|
    next if retyped.include?(container?(current) ? current["type"]["type"] : nil)

    held = held_attrs[name]
    if held.nil?
      paths[name] = current["type"]
      next
    end
    next if held == current

    held_members = members_of(held)
    members_of(current).each do |member, signature|
      paths["#{name}.#{member}"] = signature if held_members[member] != signature
    end
  end
  paths
end

#attribute_rules(held_shape, current_shape) ⇒ Object

Every changed path in one aggregate, resolved into rules by signature matching over the FULL path set — top-level names and dotted members alike. Unique signature pair: a rename (both top-level) or a move (any dotted end). Same path, same members, new type name: a retype. Anything else: unresolved, carrying its type-compatible candidates (an empty list is the arrow toward compute, or drop).



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/hecks/translation/scaffold/differ.rb', line 70

def attribute_rules(held_shape, current_shape)
  rules = []
  rules << identity_hint(held_shape, current_shape)
  rules.compact!
  held_attrs = (held_shape["attributes"] || []).to_h { |attribute| [attribute["name"], attribute] }
  current_attrs = (current_shape["attributes"] || []).to_h { |attribute| [attribute["name"], attribute] }

  # retype pass: same attribute name, same member structure, the
  # TYPE's own name changed
  (held_attrs.keys & current_attrs.keys).each do |name|
    held = held_attrs[name]
    current = current_attrs[name]
    next if held == current
    next unless container?(held) && container?(current)
    next unless held["type"]["members"] == current["type"]["members"] && held["list"] == current["list"]

    rules << { kind: :retype, from: held["type"]["type"], to: current["type"]["type"] }
  end
  retyped = rules.filter_map { |rule| rule[:from] if rule[:kind] == :retype }

  vanished = vanished_paths(held_attrs, current_attrs, retyped)
  appeared = appeared_paths(held_attrs, current_attrs, retyped)

  vanished.each do |path, signature|
    matches = appeared.select { |_, candidate| candidate == signature }.keys
    taken = rules.filter_map { |rule| rule[:to] if %i[rename move].include?(rule[:kind]) }
    matches -= taken
    if matches.size == 1 && vanished.count { |_, other| other == signature } == 1
      target = matches.first
      kind = path.include?(".") || target.include?(".") ? :move : :rename
      rules << { kind: kind, from: path, to: target }
    else
      candidates = matches.empty? ? compatible_candidates(appeared, signature) : matches
      rules << { kind: :unresolved, from: path, candidates: candidates }
    end
  end
  rules
end

#compatible_candidates(appeared, signature) ⇒ Object



167
168
169
# File 'lib/hecks/translation/scaffold/differ.rb', line 167

def compatible_candidates(appeared, signature)
  appeared.select { |_, candidate| scalar_of(candidate) == scalar_of(signature) }.keys
end

#container?(attribute) ⇒ Boolean

Returns:

  • (Boolean)


179
# File 'lib/hecks/translation/scaffold/differ.rb', line 179

def container?(attribute) = attribute["type"].is_a?(Hash)

#diff(held_bluebook, current_bluebook) ⇒ Object

Diff two bluebook IRs into the edge's declarations.



11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# File 'lib/hecks/translation/scaffold/differ.rb', line 11

def diff(held_bluebook, current_bluebook)
  held_shapes = projections(held_bluebook)
  current_shapes = projections(current_bluebook)

  matched = match_aggregates(held_shapes, current_shapes)
  aggregates = current_bluebook.aggregates.filter_map do |aggregate|
    held_name = matched[:pairs][aggregate.name]
    next unless held_name

    rules = attribute_rules(held_shapes[held_name], current_shapes[aggregate.name])
    was = held_name == aggregate.name ? nil : held_name
    next if rules.empty? && was.nil?

    ScaffoldedAggregate.new(name: aggregate.name, was: was, rules: rules)
  end

  { aggregates: aggregates, retired: matched[:retired], unclaimed: matched[:unclaimed] }
end

#identity_hint(held_shape, current_shape) ⇒ Object

THE ONE THING attribute_rules COULD NOT SEE BEFORE — the shape projection already carries "identity" (StorageShape .project_aggregate), it was simply never read here. An unresolved placeholder, not a guess: coverage_check.rb's own check_identity_unchanged! is the real gate this only hints toward, the same "tool proactively guides you" pattern the generic unresolved message already gives unfed fields.



161
162
163
164
165
# File 'lib/hecks/translation/scaffold/differ.rb', line 161

def identity_hint(held_shape, current_shape)
  return if held_shape["identity"] == current_shape["identity"]

  { kind: :unresolved, from: :identity, candidates: [] }
end

#match_aggregates(held_shapes, current_shapes) ⇒ Object

A vanished aggregate whose full shape reappears under exactly one new name was renamed. retired is only confident when NOTHING remains it could plausibly have become — a vanished aggregate beside an unmatched new one might be a rename-plus-reshape, and writing retired there would be a guess that strands data. Anything ambiguous stays unclaimed, and the coverage gate names it until a human decides.



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/hecks/translation/scaffold/differ.rb', line 41

def match_aggregates(held_shapes, current_shapes)
  pairs = {}
  current_shapes.each_key { |name| pairs[name] = held_shapes.key?(name) ? name : nil }

  vanished = held_shapes.keys - current_shapes.keys
  appeared = current_shapes.keys.select { |name| pairs[name].nil? }
  unclaimed = []
  vanished.each do |old_name|
    old_shape = held_shapes[old_name].merge("name" => nil)
    candidates = appeared.select { |name| current_shapes[name].merge("name" => nil) == old_shape }
    if candidates.size == 1
      pairs[candidates.first] = old_name
      appeared -= candidates
    else
      unclaimed << old_name
    end
  end

  retired = appeared.empty? ? unclaimed : []
  { pairs: pairs, retired: retired, unclaimed: retired.empty? ? unclaimed : [] }
end

#members_of(attribute) ⇒ Object



173
174
175
176
177
# File 'lib/hecks/translation/scaffold/differ.rb', line 173

def members_of(attribute)
  return {} unless container?(attribute)

  attribute["type"]["members"].to_h { |member| [member["name"], member["type"]] }
end

#projections(bluebook) ⇒ Object



30
31
32
# File 'lib/hecks/translation/scaffold/differ.rb', line 30

def projections(bluebook)
  Runtime::StorageShape.project(bluebook)["aggregates"].to_h { |shape| [shape["name"], shape] }
end

#scalar_of(signature) ⇒ Object



171
# File 'lib/hecks/translation/scaffold/differ.rb', line 171

def scalar_of(signature) = signature.is_a?(Hash) ? signature["members"] : signature

#vanished_paths(held_attrs, current_attrs, retyped) ⇒ Object

Held paths that need explaining: whole attributes that vanished, and members that vanished (or changed type) inside a kept attribute — mirroring exactly what EraGuard will demand coverage for.



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/hecks/translation/scaffold/differ.rb', line 113

def vanished_paths(held_attrs, current_attrs, retyped)
  paths = {}
  held_attrs.each do |name, held|
    next if retyped.include?(container?(held) ? held["type"]["type"] : nil)

    current = current_attrs[name]
    if current.nil?
      paths[name] = held["type"]
      next
    end
    next if held == current

    held_members = members_of(held)
    current_members = members_of(current)
    held_members.each do |member, signature|
      paths["#{name}.#{member}"] = signature if current_members[member] != signature
    end
  end
  paths
end