Crosswake. CompanionGuard
(crosswake v0.2.0)
View Source
Merge-blocking AST guards for Phase 130 companion extraction.
This module is a plain support module (NO use ExUnit.Case) callable from
proof tests and — post-publish — from each companion package's own test suite.
The guard travels with the code (D-17).
Purpose
Provides two categories of AST-level enforcement:
EXTRACT-03 static-reference guard — detects any static alias or reference to an extracted companion module in
lib/. A static reference re-couples the poncho and violates the extraction boundary.EXTRACT-04 ensure_loaded? placement guard — verifies every
Code.ensure_loaded?call inlib/appears inside a function body (def/defp/defmacro), never at module-eval time. A module-eval call bakes the probing into compile time, creating stale-beam footguns.
AST mechanism
Uses Code.string_to_quoted/2 + Macro.prewalk/3 over Path.wildcard("lib/**/*.ex")
— stdlib only, no mix xref, no boundary lib (D-11/D-12).
Summary
Functions
Walks all lib/**/*.ex files in the current working directory and raises if
any file contains a Code.ensure_loaded? call outside a function body.
Walks all lib/**/*.ex files in the current working directory and raises if
any file contains a static reference to an extracted companion module.
Checks whether all Code.ensure_loaded? calls in source_string appear
inside function bodies (def/defp/defmacro), never at module-eval time.
Checks whether source_string (Elixir source code) contains a static
alias or reference to any extracted companion module.
Returns the frozen MapSet of extracted companion modules.
Functions
@spec assert_ensure_loaded_in_function_bodies!() :: :ok
Walks all lib/**/*.ex files in the current working directory and raises if
any file contains a Code.ensure_loaded? call outside a function body.
Uses Path.wildcard/1 from File.cwd!(). The assertion failure message
is formatted with stable_id slugs in brand voice for merge-blocking posture.
The compile-vs-runtime footgun: a module-eval Code.ensure_loaded? bakes the
engine presence check into the .beam file at compile time. If the engine is
later loaded or unloaded without recompiling, the .beam carries a stale result
— silently confusing engine-present/absent test state. EXTRACT-04 forbids it.
@spec assert_no_static_refs!() :: :ok
Walks all lib/**/*.ex files in the current working directory and raises if
any file contains a static reference to an extracted companion module.
Uses Path.wildcard/1 from File.cwd!(). The assertion failure message
is formatted with stable_id slugs in brand voice for merge-blocking posture.
Checks whether all Code.ensure_loaded? calls in source_string appear
inside function bodies (def/defp/defmacro), never at module-eval time.
Returns :ok if placement is correct, or {:violation, list} with the
offending AST nodes.
Implementation
Uses the AST prune-then-walk pattern (D-16):
- Collect the
do:body subtrees of everydef/defp/defmacroin the parsed AST. - Walk the FULL AST for
Code.ensure_loaded?call nodes (the{{:., _, [{:__aliases__, _, [:Code]}, :ensure_loaded?]}, _, _}shape). - For each such node, verify it is reachable inside at least one collected
function body. Any
ensure_loaded?node outside a body is a violation.
Additionally applies a cheap textual belt: Regex.scan/2 over the raw source
string for non-commented Code.ensure_loaded? occurrences as an escalation
signal for macro/unquote-injected edge cases. The AST walk is authoritative;
the belt only provides supporting context.
Checks whether source_string (Elixir source code) contains a static
alias or reference to any extracted companion module.
Returns :ok if no violations found, or {:violation, list} with the
offending AST nodes.
Implementation
Parses the source with Code.string_to_quoted/2, then walks the AST with
Macro.prewalk/3 collecting any {:__aliases__, _meta, parts} node whose
parts list exactly matches one of the banned alias part-lists derived from
@extracted_companion_names.
String literals (e.g. moduledoc examples mentioning a companion name) are NOT
{:__aliases__} AST nodes and cannot false-positive (D-12). No special-casing
needed for doc strings.
The banned-alias list is derived from @extracted_companion_names (strings split
to atom lists), so the matcher stays in sync — NOT a blanket Crosswake.Companions.*
ban (D-14).
@spec extracted_companions() :: MapSet.t()
Returns the frozen MapSet of extracted companion modules.
One entry per extraction phase. To add a module, add its name string to
@extracted_companion_names AND in the same PR that removes the source from
lib/ — the reviewer sees the intentional shape change (D-13).