Module: Hecks::Codemod

Defined in:
lib/hecks/codemod.rb

Overview

SHARED MACHINERY for a codemod that migrates real .bluebook source once a DSL builder change makes some previously-required declaration optional/redundant — pulled out of bin/codemod_implicit_append_fields (the first one built), which needed three real, hard-won fixes before it could be trusted: a process-lifetime AST cache with no invalidation, a batch-revert granularity that let one unsafe candidate sink every other safe one sharing its boot, and (in the SPINE the codemod migrates FOR, not here) an append-at-end insertion that only round-tripped correctly for whichever field happened to be last. None of those are guessable in advance; they only surface by actually running a real edit against real self-hosted code. This module is that lesson, kept — the NEXT codemod plugs in two rule- specific procs (find_candidates, apply_candidate) and inherits the boot/safety-net machinery rather than rediscovering it.

A CODEMOD IS NOT PATTERN-MATCHING ALONE. Deciding "is this line safe to delete" means knowing what the RUNTIME would resolve it to — so every codemod built on this module follows the same three steps:

1. Boot the real domain (or the self-hosted meta-domain) and read
 its IR to find CANDIDATES — provided by the caller's own
 `find_candidates`, since the actual redundancy rule is specific
 to whichever spine change this migration serves.
2. Locate and remove each candidate's own source text — the
 caller's own `apply_candidate`.
3. Re-boot from the edited text and diff the full IR export
 against the pre-edit export. Byte-identical -> keep. Anything
 else (including a RAISED exception — the self-hosted
 meta-domain DISPATCHES itself into being, S14, so a bad edit
 can surface as a runtime refusal, not just a differing export)
 -> revert and report SKIPPED, never silently guessed past.

DESIGN-TIME CHECKLIST for the SPINE change a future codemod migrates corpus text for — both items below are real bugs THIS module's own first use found, not hypothetical:

- Does the resolved value get inserted into an ORDER-SENSITIVE
list (the exported IR is array-order-sensitive throughout)? If
so, the spine's own insertion must preserve the ORIGINAL
position, not just append — an append-at-end insertion only
round-trips correctly for a value that already happened to be
last.
- Does resolution depend on ANOTHER construct already being
declared (the aggregate/entity a `sets`/`append:` field resolves
against)? If a creator command can be declared BEFORE the
construct it creates (real, live: `command "Handler"` before
`entity "Handler"`, one file down), one-pass resolution
genuinely cannot see it yet — not a codemod bug, a structural
limit worth naming rather than working around.

Defined Under Namespace

Classes: Runner

Constant Summary collapse

ROOT =
File.expand_path("../..", __dir__)
EXAMPLE_ROOTS =
Dir.glob(File.join(ROOT, "examples", "*")).select { |p| File.directory?(p) }.sort
META_FILES =
(Dir.glob(File.join(ROOT, "lib/hecks/grammar/*.bluebook")) +
Dir.glob(File.join(ROOT, "lib/hecks/framework/bluebook/*.bluebook")) +
Dir.glob(File.join(ROOT, "lib/hecks/language/bluebook/**/*.bluebook"))).sort
PERSISTENCE_PORT =

THE SAME LIGHTWEIGHT PATH spec/spec_helper.rb's own boot_in_memory uses — Hecks.with_registry satisfies Hecks.bluebook's own collect's "loaded outside a boot" check without Hecks.boot's full era-check/adapter-wiring path, which needs a LIVE Postgres connection for compliance and would make every codemod depend on a database it has no reason to touch — a codemod only ever reads a chapter's own declared IR, never a stored record.

File.join(ROOT, "lib/hecks/ports/persistence.port")
EXTRACTION_PORT =
File.join(ROOT, "lib/hecks/ports/extraction.port")
MEMORY_ADAPTER =
File.join(ROOT, "lib/hecks/adapters/driven/memory.adapter")
PRISM_ADAPTER =
File.join(ROOT, "lib/hecks/adapters/driven/prism.adapter")

Class Method Summary collapse

Class Method Details

.boot_metaObject

forget_all, not a single forget — the meta-domain is NINE files (MetaValidator::GRAMMAR_FILES) merged into one registry, and a caller here (the codemod runner) doesn't generally know in advance which ONE it just edited.



111
112
113
114
115
# File 'lib/hecks/codemod.rb', line 111

def self.boot_meta
  Hecks::Adapters::Prism.forget_all
  Hecks::Bluebook::MetaValidator.instance_variable_set(:@grammar_registry, nil)
  export_json(Hecks::Bluebook::MetaValidator.grammar_registry)
end

.each_command(registry) ⇒ Object

Walks every aggregate (and every nested entity, recursively) across every chapter in a booted registry, yielding [owning construct, command] pairs — construct is whichever Aggregate/Entity actually OWNS the command, the same distinction AggregateBuilder#command vs EntityBuilder#command already draws. Generic enough for any rule that needs to walk real commands, not specific to the attribute-redundancy rule.



130
131
132
133
134
135
136
137
138
139
140
# File 'lib/hecks/codemod.rb', line 130

def self.each_command(registry)
  registry.bluebooks.each_value do |chapter|
    chapter.aggregates.each do |aggregate|
      walk = lambda do |construct|
        construct.commands.each { |command| yield construct, command }
        construct.entities.each(&walk) if construct.respond_to?(:entities)
      end
      walk.call(aggregate)
    end
  end
end

.element_construct_for(construct, list_field) ⇒ Object

A LIST attribute's own element construct — the value object or entity list_of(...) names, resolved by hecks_name the same way AttributeCollector#resolve_identity_field! already does. Shared because "what does this list actually hold" is a question any append-shaped rule needs answered, not just this one.



151
152
153
154
155
156
157
158
# File 'lib/hecks/codemod.rb', line 151

def self.element_construct_for(construct, list_field)
  list_attr = owner_attribute(construct, list_field)
  return nil unless list_attr&.list?

  pool = construct.respond_to?(:value_objects) ? construct.value_objects.dup : []
  pool.concat(construct.entities) if construct.respond_to?(:entities)
  pool.find { |c| c.hecks_name.to_s == list_attr.type.to_s }
end

.export_json(registry) ⇒ Object



72
# File 'lib/hecks/codemod.rb', line 72

def self.export_json(registry) = Hecks::Projector::Exporter.json(registry)

.load_bluebook(path) ⇒ Object

Hecks::Adapters::Prism caches a file's parsed AST for the life of the PROCESS, keyed by path — fine for every existing caller (a file loads once per process: one bin/ir run, one rspec worker), but a codemod legitimately reloads the SAME path after editing it, and a stale cached tree reports a given/ensures block at its OLD line number, which no longer matches the freshly re-executed file's own block.source_location — surfacing as "did not survive extraction" on a perfectly valid file. Prism.forget is the real invalidation API this module's own first use motivated (found here, fixed at the source rather than left as a private TREES.clear poke from outside).



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/hecks/codemod.rb', line 85

def self.load_bluebook(path)
  paths = if path.is_a?(Array)
            path
          elsif File.directory?(path)
            Dir.glob(File.join(path, "*.bluebook"))
          else
            [path]
          end
  paths.each { |file| Hecks::Adapters::Prism.forget(file) }

  registry = Hecks::Runtime::Registry.new
  Hecks.with_registry(registry) do
    Kernel.load(PERSISTENCE_PORT)
    Kernel.load(EXTRACTION_PORT)
    Kernel.load(MEMORY_ADAPTER)
    Kernel.load(PRISM_ADAPTER)
    Hecks::Bluebook::MetaValidator.defer { paths.each { |file| Kernel.load(file) } }
    Hecks::Bluebook::MetaValidator.judge_deferred!(registry)
  end
  registry
end

.meta_registryObject



117
118
119
120
121
# File 'lib/hecks/codemod.rb', line 117

def self.meta_registry
  Hecks::Adapters::Prism.forget_all
  Hecks::Bluebook::MetaValidator.instance_variable_set(:@grammar_registry, nil)
  Hecks::Bluebook::MetaValidator.grammar_registry
end

.owner_attribute(construct, name) ⇒ Object



142
143
144
# File 'lib/hecks/codemod.rb', line 142

def self.owner_attribute(construct, name)
  construct.attributes.find { |attr| attr.name.to_s == name.to_s }
end

.safelyObject

Either a raised exception OR a differing export counts as unsafe — see the module header on why the meta-domain specifically can raise. Returns [value_or_nil, error_message_or_nil].



163
164
165
166
167
# File 'lib/hecks/codemod.rb', line 163

def self.safely
  [yield, nil]
rescue StandardError => e
  [nil, "#{e.class}: #{e.message}"]
end