Module: Hecks::Projector

Defined in:
lib/hecks/projector.rb,
lib/hecks/projector/target.rb,
lib/hecks/projector/exporter.rb,
lib/hecks/projector/ir_projector.rb,
lib/hecks/projector/cli_projector.rb,
lib/hecks/projector/docs_projector.rb,
lib/hecks/projector/narrate_projector.rb

Overview

A named registry of "canonical IR in, external artifact out" tools — §30 of docs/HECKS_IMPLEMENTATION_PLAN.md. Before this existed, every such tool was its own thing with its own call shape: Exporter (registry-wide, consumed directly by bin/ir, bin/project_rust, and translation/audit's approval digest — untouched by this file, still exactly what those three read), and RustProjection::Projector (rust/project.rb, a whole separate Ruby program under a confusingly-identical module name) are the two that already exist. Neither is registered here — retrofitting either is real, separate work (Rust's generator is a whole second toolchain; a UL/OIDC projector doesn't exist yet at all) — but :ir is, as a genuine, working, golden-tested example of the shape every future projector (:rust, :ul, :openid, ...) is meant to follow.

The unit is ONE bluebook's IR, not a whole booted registry — matching every real projection target (Rust/UL/OIDC all project one domain at a time), and deliberately narrower than Exporter.call's own multi-domain shape. THREE KINDS OF "PROJECT", TOLD APART BY WHAT THEY NEED AS INPUT. Only the first belongs in this registry.

A PROJECTION takes a chapter's DECLARATION and answers something
that DESCRIBES the domain: its IR, its storage shape, an OIDC scope
manifest, the parser's keyword table, the reference pages. Inert,
derived, and runnable against any chapter that carries what it
declares it needs. These are what `register` holds.

AN EXPORT takes a declaration AND its BINDINGS and answers
something that IS the domain, running elsewhere — rust/project.rb's
generated crate, the WASM artifact, the SAM template
bin/project_deploy renders. It needs the `.world`/`.hecksagon` a
projection never looks at, because a running system has to know how
it is wired. That is the whole reason bin/project_deploy cannot use
this protocol: `call(bluebook:, options:)` has no channel for it.

A STATE PROJECTION takes RECORDS — a domain after dispatch — and is
a read-model question wearing the same word.
`bin/expression_projection` is the one of these: its operators are
not declared anywhere, they are what exists after
`Grammar.expression` replays expression_operators.json's ledger of
dispatches. Converting it into this registry would be a category
error, however much its name suggests otherwise.

ONE WORD, THREE OTHER MEANINGS — worth naming too, because grepping "projection" turns all of these up and none is the above:

Ports::Projection    read-model catch-up, events folded into state
bin/project          forces that catch-up by hand
RustProjection       rust/project.rb's own separate toolchain

Defined Under Namespace

Modules: CliProjector, DocsProjector, Exporter, IRProjector, NarrateProjector, Target Classes: UnknownProjector, WrongConstruct

Class Method Summary collapse

Class Method Details

.admits!(name, projector, construct) ⇒ Object

A projection names the CAPABILITIES it needs; this refuses a construct that lacks one, before the projector runs.

ONE CHECK COVERS BOTH SHAPES. An ordinary construct INCLUDES its capabilities and a class-shaped one — Command, Entity, ValueObject — EXTENDS them, and is_a? consults the singleton chain, so it answers for an extended module as readily as an included one. This started as two checks on the assumption it would not; a spec asserting the assumption failed, which is the only reason the redundant half was noticed.

Raises:



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/hecks/projector.rb', line 90

def admits!(name, projector, construct)
  needed = projector.respond_to?(:projection_requires) ? projector.projection_requires : []
  missing = needed.reject { |capability| capable?(construct, capability) }
  unless missing.empty?
    raise WrongConstruct,
          "#{name.inspect} needs #{missing.map(&:name).join(' and ')}, and was handed " \
          "#{construct.class} (#{construct.respond_to?(:hecks_name) ? construct.hecks_name : construct.inspect})."
  end

  declared = projector.respond_to?(:projection_declares) ? projector.projection_declares : []
  absent   = declared.reject { |named| construct.aggregate(named) }
  return if absent.empty?

  raise WrongConstruct,
        "#{name.inspect} needs a chapter declaring #{absent.join(' and ')}; " \
        "#{construct.name} declares no such aggregate."
end

.call(name, bluebook:, options: {}) ⇒ Object

bluebook: is kept as the keyword because it is the shipped spelling and every existing caller uses it — but what it accepts is any construct that emits IR, and admits! is what decides whether THIS target can actually take the one handed over.



72
73
74
75
76
77
78
# File 'lib/hecks/projector.rb', line 72

def call(name, bluebook:, options: {})
  projector = registry.fetch(name.to_sym) {
    raise UnknownProjector, "no projector registered for #{name.inspect} — registered: #{registered.sort.inspect}"
  }
  admits!(name, projector, bluebook)
  projector.call(bluebook: bluebook, options: options)
end

.capable?(construct, capability) ⇒ Boolean

Returns:

  • (Boolean)


108
# File 'lib/hecks/projector.rb', line 108

def capable?(construct, capability) = construct.is_a?(capability)

.emits_for(name) ⇒ Object

What KIND of artifact a registered target emits — asked of the projection rather than inferred from what it returned.



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

def emits_for(name)
  projector = registry.fetch(name.to_sym) { return :artifact }
  projector.respond_to?(:projection_emits) ? projector.projection_emits : :artifact
end

.key_for(target) ⇒ Object

A target may be addressed by the constant that implements it (Projections::OIDC) or by the bare key it registered under (:oidc). Both resolve here, so the constant form is added surface rather than a replacement — every Projector.call(:ir, ...) written before this existed keeps working untouched.



129
130
131
132
133
# File 'lib/hecks/projector.rb', line 129

def key_for(target)
  return target.projection_key if target.respond_to?(:projection_key) && target.projection_key

  target
end

.register(name, projector) ⇒ Object

projector needs only to answer call(bluebook:, options:) — a module, a class with a class method, or any object responding to call all work. Re-registering a name replaces it outright, deliberately unguarded: a spec re-registering a stub under the same name between examples is the ordinary case, not a footgun to fence against.



64
65
66
# File 'lib/hecks/projector.rb', line 64

def register(name, projector)
  registry[name.to_sym] = projector
end

.registeredObject



118
# File 'lib/hecks/projector.rb', line 118

def registered = registry.keys

.registered?(name) ⇒ Boolean

Returns:

  • (Boolean)


117
# File 'lib/hecks/projector.rb', line 117

def registered?(name) = registry.key?(name.to_sym)

.registryObject



120
121
122
# File 'lib/hecks/projector.rb', line 120

def registry
  @registry ||= {}
end

.write(artifact, out, as: :artifact) ⇒ Object

WRITING IS THE CALLER'S CHOICE, NOT THE PROJECTOR'S. A projector returns an artifact and never touches disk, which is what lets spec/projector_spec.rb compare :ir's output against a golden fixture without a tmpdir. out: is the only thing that writes.

as: comes from the projection's own emits: declaration rather than from inspecting the artifact. A Hash of path => contents and a Hash that simply happens to hold strings are the same object to Ruby; only the projection knows which it meant.



144
145
146
147
148
149
# File 'lib/hecks/projector.rb', line 144

def write(artifact, out, as: :artifact)
  return write_tree(artifact, out) if as == :files

  File.write(out, artifact.is_a?(String) ? artifact : "#{JSON.pretty_generate(artifact)}\n")
  out
end

.write_tree(files, directory) ⇒ Object

Answers the paths written, in the order given — so a caller can report what happened without re-deriving it from the tree.



153
154
155
156
157
158
159
160
161
# File 'lib/hecks/projector.rb', line 153

def write_tree(files, directory)
  require "fileutils"
  files.map do |relative, contents|
    path = File.join(directory, relative)
    FileUtils.mkdir_p(File.dirname(path))
    File.write(path, contents)
    path
  end
end