Module: Hecks::Runtime::EraGuard

Extended by:
ShapeDiff
Defined in:
lib/hecks/runtime/era_guard.rb,
lib/hecks/runtime/era_guard/shape_diff.rb

Overview

Detects when a bluebook's storage shape has drifted from what its data was written under — a rename or restructure with nothing to explain it. Refuses at boot, before a command can run into the gap and raise something that only makes sense once you already suspect a rename.

The held source is a snapshot of the bluebook text as it stood the last time its shape was accepted. It updates only when a drift is seen AND covered by a translation — never on every boot, and never silently when a rename has nothing to explain it.

Defined Under Namespace

Modules: ShapeDiff

Class Method Summary collapse

Methods included from ShapeDiff

attribute_signature, diff_type, nested_type, possibly_absent?, shape, uncovered_attributes, unsafe_additions

Class Method Details

.check!(registry, directory) ⇒ Object



27
28
29
# File 'lib/hecks/runtime/era_guard.rb', line 27

def check!(registry, directory)
  registry.bluebooks.each_value { |bluebook| check_bluebook!(registry, bluebook, directory) }
end

.check_bluebook!(registry, bluebook, directory) ⇒ Object



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/hecks/runtime/era_guard.rb', line 31

def check_bluebook!(registry, bluebook, directory)
  current_text = EraCheck.source_text_for(bluebook, directory)
  return unless current_text

  era_dir   = File.join(registry.root, "data", "eras")
  held_path = File.join(era_dir, "#{Naming.snake(bluebook.name)}.bluebook")

  FileUtils.mkdir_p(era_dir)
  unless File.exist?(held_path)
    File.write(held_path, current_text)
    return
  end

  held_bluebook = shadow_parse(File.read(held_path, encoding: "UTF-8"), held_path)
  drifted = false

  bluebook.aggregates.each do |aggregate|
    lineage = Ports::Persistence::Lineage.for(registry, bluebook.name, aggregate)
    held_aggregate = held_bluebook.aggregate(lineage&.ancestor_name || aggregate.name)
    next unless held_aggregate
    next if shape(aggregate) == shape(held_aggregate)

    drifted = true
    uncovered = uncovered_attributes(aggregate, held_aggregate, lineage)
    refuse_uncovered!(bluebook, aggregate, uncovered) unless uncovered.empty?

    unsafe = unsafe_additions(aggregate, held_aggregate, lineage)
    refuse_unsafe_addition!(bluebook, aggregate, unsafe) unless unsafe.empty?
  end

  check_vanished_aggregates!(registry, bluebook, held_bluebook)
  File.write(held_path, current_text) if drifted
end

.check_vanished_aggregates!(registry, bluebook, held_bluebook) ⇒ Object

An aggregate that existed in the held text and answers to no current name — renamed silently, with nothing declaring was: to explain where its data went — is exactly the disease this guards against, and a plain per-aggregate diff would never see it: the current aggregate simply has no held counterpart to compare to.



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/hecks/runtime/era_guard.rb', line 70

def check_vanished_aggregates!(registry, bluebook, held_bluebook)
  held_bluebook.aggregates.each do |held_aggregate|
    claimed = bluebook.aggregates.any? do |aggregate|
      aggregate.name == held_aggregate.name ||
        registry.translations.any? do |translation|
          translation.domain == bluebook.name &&
            translation.for_aggregate(aggregate.name)&.was == held_aggregate.name
        end
    end
    claimed ||= registry.translations.any? do |translation|
      translation.domain == bluebook.name && translation.retired.include?(held_aggregate.name)
    end
    next if claimed

    raise WiringError,
          "cannot boot #{bluebook.name}: #{held_aggregate.name} existed and now doesn't, " \
          "and nothing declares was: #{held_aggregate.name.inspect} to explain where its data went."
  end
end

.parse_bluebook(source, path, shadow:) ⇒ Object



179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/hecks/runtime/era_guard.rb', line 179

def parse_bluebook(source, path, shadow:)
  scratch = Registry.new
  loading = Ports::Loading.bootstrap
  run = lambda do
    Hecks.with_registry(scratch) do
      loading.load_library
      Kernel.eval(source, TOPLEVEL_BINDING, path, 1)
    end
  end
  shadow ? Hecks::Bluebook::MetaValidator.while_shadow_parsing(&run) : run.call
  scratch.bluebooks.values.first
end

.refuse_uncovered!(bluebook, aggregate, uncovered) ⇒ Object

The Layer-1 coverage refusal — one wording, whoever detects the gap (the file-store boot check here, or Postgres's mint path).

Raises:



92
93
94
95
96
97
98
# File 'lib/hecks/runtime/era_guard.rb', line 92

def refuse_uncovered!(bluebook, aggregate, uncovered)
  raise WiringError,
        "cannot boot #{bluebook.name}::#{aggregate.name}: its shape changed and " \
        "#{uncovered.map { |path| render_path(path) }.join(', ')} #{uncovered.size == 1 ? 'is' : 'are'} not " \
        "explained by any rename, move, convert, retype, or drop. Update bluebook/translations/*.bluebook, e.g. " \
        "#{suggestion(uncovered.first)}."
end

.refuse_unsafe_addition!(bluebook, aggregate, unsafe) ⇒ Object

The addition-side sibling of refuse_uncovered! above — same wording shape, different cause: nothing vanished or changed type, something new arrived that an existing record has no way to hold.

Raises:



103
104
105
106
107
108
109
110
# File 'lib/hecks/runtime/era_guard.rb', line 103

def refuse_unsafe_addition!(bluebook, aggregate, unsafe)
  raise WiringError,
        "cannot boot #{bluebook.name}::#{aggregate.name}: #{unsafe.map { |name| ":#{name}" }.join(', ')} " \
        "#{unsafe.size == 1 ? 'is new and required' : 'are new and required'}, with no default: to fill " \
        "an existing record and no translation explaining what one should read there. Give it a " \
        "default:, make it optional: true or list_of, or declare bluebook/translations/*.bluebook, e.g. " \
        "`backfill :#{unsafe.first}, default: ...`."
end

.render_path(path) ⇒ Object



112
# File 'lib/hecks/runtime/era_guard.rb', line 112

def render_path(path) = path.include?(".") ? path.inspect : ":#{path}"

.shadow_parse(source, path) ⇒ Object

Parses held source into its own IR, in a scratch registry so a past era's text never touches the one actually booting.

NORMAL PARSE FIRST, shadow only as a FALLBACK — not shadow-parsing unconditionally, which is what this used to do. A handful of DSL defaults fork on MetaValidator.shadow_parsing? for a reason that has NOTHING to do with syntax the live grammar can no longer read at all (identified_by { }, belongs_to, has_one, has_many — genuinely removed spellings, exactly what shadow- parsing exists to keep readable): reference_to's own default mint name (default_reference_name, attribute_collector.rb) changed from _id-suffixed to bare under ADR 0025, and THAT fork applies even to text using nothing but current, live syntax. Held text minted under the CURRENT grammar — every real era in this corpus today, since nothing has ever minted a second one — parses fine normally; only the reference-naming DEFAULT differed once shadow mode engaged unconditionally, so it silently reconstructed a DIFFERENT shape (and hash) than a fresh parse of the identical text — the same text hashing two different ways depending on which code path read it, breaking ensure_named!'s own from/to edge lookup with a spurious "no translation edge covers it" refusal that has nothing to do with any real translation gap.

A normal parse can only ever SUCCEED on text the live grammar fully understands — there is no way for it to silently produce a wrong-but-plausible answer for genuinely legacy text, since every removed spelling refuses loudly (Malformed) rather than degrading. So: try normal first — if the ordinary grammar reads this text without complaint, that IS the canonical, unambiguous interpretation, the same one label_of/mint_hash on the same source text always computes, whoever's asking. Only on a Malformed refusal — the one signal that actually means "this spelling doesn't exist anymore" — fall back to the legacy grammar, exactly as before this change. Any OTHER exception (a genuine syntax error, an unrelated validation refusal) propagates unchanged; swallowing it here to retry under shadow mode would risk masking a real defect in the held text behind a confusing second failure instead of the original, more specific one.

MetaValidator.while_shadow_parsing (ADR 0025, docs/dsl-work- slices.md's S0a) is what makes the fallback a LEGACY grammar rather than just a second copy of today's: it stops BluebookBuilder.build from judging this text against the grammar as it stands NOW, which is the one thing that would make a removed spelling refuse HISTORY the day it is removed from live source. The scratch registry is throwaway either way — nothing here is dispatched against or exposed to the real one — so skipping the judge/assemble round-trip changes nothing this method reads: shape, uncovered_attributes, and friends only ever ask the built IR for its own structure.



173
174
175
176
177
# File 'lib/hecks/runtime/era_guard.rb', line 173

def shadow_parse(source, path)
  parse_bluebook(source, path, shadow: false)
rescue Hecks::Bluebook::DSL::Malformed
  parse_bluebook(source, path, shadow: true)
end

.suggestion(path) ⇒ Object



114
115
116
117
118
119
120
# File 'lib/hecks/runtime/era_guard.rb', line 114

def suggestion(path)
  if path.include?(".")
    "`move #{path.inspect}, to: #{path.inspect}` or `drop #{path.inspect}`"
  else
    "`rename :#{path}, to: :new_name` or `drop :#{path}`"
  end
end