Crosswake.ComponentTierGuard (crosswake v0.2.3)

View Source

Merge-blocking structural guard for FALL-02 — "nothing UI-shaped ships importable from lib/" (Phase 155).

This module is a plain support module (NO use ExUnit.Case) callable from the proof lane and, in the future, from mix crosswake.doctor. It lives in lib/, not test/, for the same reason CatalogGuard and CompanionGuard do: deleting the test must not delete the rule. The guard travels with the code.

The names-as-strings trick (D-36) — why this file cannot self-trip

@banned_namespace below is a plain STRING, split on "." and mapped through String.to_atom/1 at compile time to build @banned_alias_parts. The banned dotted name is never written anywhere in this file as an actual module reference (SomeNamespace.Child) — only as string content. A bare module reference would parse to a {:__aliases__, _, parts} AST node, and if that node's parts matched the banned prefix, THIS file would trip its own namespace rule the moment it compiled. Stealing this trick from companion_guard.ex:29-33 (and reusing its own comment, almost verbatim) is what makes a self-referential structural guard possible at all.

The six rules

Five are absence rules over every .ex file under lib/:

  • namespace — a bare module reference whose first two segments spell the banned namespace. Any OTHER alias is clean.
  • namespace_minted — a Module.concat/1 or String.to_atom/1 call whose LITERAL argument spells the banned namespace. A runtime-computed argument is out of reach for a static AST walk — documented as a limitation, not silently claimed as covered.
  • component_use — use Phoenix.Component or use Phoenix.LiveComponent anywhere under lib/. Zero occurrences today.
  • component_dsl — an attr/2, attr/3, slot/1, or slot/2 call node at module body level.
  • template_sigil — a ~H (sigil_H) AST node. Zero occurrences today.

The sixth is the anti-vacuity twin, and it is the whole point (D-37):

  • components_exist_in_templates — the native-controls generator's templates must contain at least one real component-DSL attribute call AND at least one real ~H sigil. An empty, missing, or stripped template directory FAILS this rule.

Without the sixth rule, the other five prove only "this project ships no components" — true of a project that ships NOTHING. With it, the claim proven is the actual requirement: "components exist only as adopter-owned template text, never as an importable tier." A .eex template carrying EEx interpolation is not parseable as Elixir, so this rule uses the belt-style regex approach companion_guard.ex already establishes for the same not-really-Elixir problem — labelled honestly in this module as a regex belt, not an upgrade to AST rigor it cannot claim.

The injection seam, and why it is not a loophole

assert_no_component_tier!/1 accepts root:, lib_glob:, and template_glob:, each defaulting to the real shipped value (mirroring catalog_guard.ex:88-102's seam contract), so the zero-argument call — the one CI makes — is byte-for-byte the production gate. The seam exists so a fixture test can drive THIS raiser against a synthetic tree, never a re-composition of its predicates. Nothing is relaxed and nothing is skippable through the seam.

No bypass

There is no allowlist, no suppression attribute, and no environment escape. The opts keyword accepts exactly root:, lib_glob:, and template_glob: — nothing that weakens a violated rule. The only sanctioned exit is retirement: delete the guard and its test in a PR that also amends FALL-02, README, and the guides. See failure_message/1 for the full five-step recipe every failure message prints.

Stable failure ids

  • proof.fall_02.no_component_tier.namespace
  • proof.fall_02.no_component_tier.namespace_minted
  • proof.fall_02.no_component_tier.component_use
  • proof.fall_02.no_component_tier.component_dsl
  • proof.fall_02.no_component_tier.template_sigil
  • proof.fall_02.no_component_tier.components_exist_in_templates

AST mechanism

Code.string_to_quoted/2 + Macro.prewalk/3 only — stdlib, no new dependency, no cross-reference tooling. Mirrors Crosswake.CompanionGuard and Crosswake.Bridge.CatalogGuard.

Summary

Functions

Walks the real repo tree (or a fixture tree via the injection seam) and raises if any of the six rules is violated.

Rule component_dsl: :ok unless source contains an attr/2, attr/3, slot/1, or slot/2 call node.

Rule component_use: :ok unless source contains use Phoenix.Component or use Phoenix.LiveComponent.

Rule namespace: :ok unless source contains a bare module reference whose first segments spell the banned namespace.

Rule namespace_minted: :ok unless source contains a Module.concat/1 or String.to_atom/1 call whose literal argument spells the banned namespace. A runtime-computed argument cannot be seen by a static walk — this rule does not claim to cover that case.

Runs the five source-level rules over source_string and returns the COMPLETE SET of violations.

Rule template_sigil: :ok unless source contains a ~H sigil node.

Rule components_exist_in_templates — the anti-vacuity twin (D-37).

The six rule names this guard implements, in a stable order. Exposed so a test can assert the guard's OWN live list against an expected list, rather than re-declaring the six names independently and never noticing if one silently stops being checked.

Functions

assert_no_component_tier!(opts \\ [])

@spec assert_no_component_tier!(keyword()) :: :ok

Walks the real repo tree (or a fixture tree via the injection seam) and raises if any of the six rules is violated.

Returns :ok when the tier line holds.

Options — all defaulting to the real shipped values

  • :root — the tree lib_glob and template_glob are resolved against. Defaults to File.cwd!().
  • :lib_glob — relative to root. Defaults to "lib/**/*.ex".
  • :template_glob — relative to root. Defaults to the native-controls generator's template directory glob.

assert_no_component_tier!() with no options is the merge-blocking gate, unchanged. The seam exists so a fixture test can drive THIS function against a synthetic tree — it never relaxes anything and never accepts a "skip" flag.

check_component_dsl(source)

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

Rule component_dsl: :ok unless source contains an attr/2, attr/3, slot/1, or slot/2 call node.

check_component_use(source)

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

Rule component_use: :ok unless source contains use Phoenix.Component or use Phoenix.LiveComponent.

check_namespace(source)

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

Rule namespace: :ok unless source contains a bare module reference whose first segments spell the banned namespace.

check_namespace_minted(source)

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

Rule namespace_minted: :ok unless source contains a Module.concat/1 or String.to_atom/1 call whose literal argument spells the banned namespace. A runtime-computed argument cannot be seen by a static walk — this rule does not claim to cover that case.

check_source(source)

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

Runs the five source-level rules over source_string and returns the COMPLETE SET of violations.

A source violating three rules reports all three — a report, not a short-circuit, matching CatalogGuard.check_source/1's discipline: an assertion that stops at the first violation makes a three-violation fixture indistinguishable from a one-violation fixture, which is exactly how a structural gate degrades into a nuisance.

Returns :ok, or {:violation, [{rule_atom, node_or_detail}]}. An empty or trivially small source is clean. An UNPARSEABLE source is a violation, not a pass — the guard fails closed, matching the sibling guards' discipline.

check_template_sigil(source)

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

Rule template_sigil: :ok unless source contains a ~H sigil node.

check_templates(template_glob)

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

Rule components_exist_in_templates — the anti-vacuity twin (D-37).

:ok only when at least one file matching template_glob carries a real component-DSL attribute call (attr/slot) AND at least one file (the same one or a different one) carries a real ~H sigil. An empty, missing, or stripped template directory is a violation, never a vacuous pass.

Uses a belt-style regex, not an AST assertion, because a .eex template carrying EEx interpolation is not parseable Elixir.

rule_names()

@spec rule_names() :: [atom()]

The six rule names this guard implements, in a stable order. Exposed so a test can assert the guard's OWN live list against an expected list, rather than re-declaring the six names independently and never noticing if one silently stops being checked.