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:

  1. 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.

  2. EXTRACT-04 ensure_loaded? placement guard — verifies every Code.ensure_loaded? call in lib/ 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

assert_ensure_loaded_in_function_bodies!()

@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.

assert_no_static_refs!()

@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.

check_ensure_loaded_placement(source_string)

@spec check_ensure_loaded_placement(String.t()) :: :ok | {:violation, list()}

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):

  1. Collect the do: body subtrees of every def/defp/defmacro in the parsed AST.
  2. Walk the FULL AST for Code.ensure_loaded? call nodes (the {{:., _, [{:__aliases__, _, [:Code]}, :ensure_loaded?]}, _, _} shape).
  3. 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.

check_source(source_string)

@spec check_source(String.t()) :: :ok | {:violation, list()}

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).

extracted_companions()

@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).