Crosswake. ComponentTierGuard
(crosswake v0.2.4)
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— aModule.concat/1orString.to_atom/1call 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.Componentoruse Phoenix.LiveComponentanywhere underlib/. Zero occurrences today.component_dsl— anattr/2,attr/3,slot/1, orslot/2call 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~Hsigil. 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.namespaceproof.fall_02.no_component_tier.namespace_mintedproof.fall_02.no_component_tier.component_useproof.fall_02.no_component_tier.component_dslproof.fall_02.no_component_tier.template_sigilproof.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
@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 treelib_globandtemplate_globare resolved against. Defaults toFile.cwd!().:lib_glob— relative toroot. Defaults to"lib/**/*.ex".:template_glob— relative toroot. 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.
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.
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.
Rule template_sigil: :ok unless source contains a ~H sigil node.
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.
@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.