.blick/skills/tuist-elixir-review/SKILL.md
This skill is intentionally narrow. Generic Elixir style, naming, pipe
chains, formatting, nesting depth, and String.to_atom/1-style hygiene
are already covered by mix format and credo in CI — do not flag
those. Focus on the rules below; they catch real bugs.
For each finding, cite path:line (or Module.function/arity) and
quote the relevant snippet.
Only report findings whose cited snippet is present in the PR diff. If the concern comes from unchanged context, do not emit a finding, do not mention it as a note, and do not create a "findings outside this PR's diff" section. If every possible concern is outside the diff, return no findings.
Do not infer violations from nearby lines. A Mimic finding requires the
exact token Mimic.copy( on the cited changed line. A migration
timestamp finding requires the cited changed line to contain
timestamps() without type: :timestamptz or a timestamp column without
:timestamptz.
lib/tuist/authorization.ex + AuthorizationPlugThe policy DSL is LetMe.Policy. Categories are declared via object :foo do ... end blocks. The API plug at
server/lib/tuist_web/plugs/api/authorization/authorization_plug.ex
hard-codes which categories are project-scoped:
@project_categories [:run, :bundle, :cache, :preview, :test, :build, :automation_alert]
object :foo do block (project-scoped resource) without :foo being added to @project_categories in authorization_plug.ex. The route guard will silently miss the new category. Severity: high.action that uses [:authenticated_as_project, ...] but omits :projects_match. This lets an authenticated project act on another project's resource. Severity: critical. The canonical correct form is allow([:authenticated_as_project, :projects_match]).:public_project or [:authenticated_as_user, :ops_access] allowed for :create, :update, or :delete actions. These flags are intended for read-only paths.action :read | :create | :update | :delete that doesn't cover all three subject kinds (:authenticated_as_user, :authenticated_as_project, :authenticated_as_account with a scopes_permit: check) without an inline desc(...) explaining the omission. Missing one is usually a bug; an explicit desc is the documented escape hatch.allow without a scopes_permit: check (e.g. bare [:authenticated_as_account]). Account tokens must always be scope-gated.object/action blocks unchanged by the diff.allow(...) lines within an action./ops LiveView routes. They are not API AuthorizationPlug
categories and do not belong in @project_categories.Repo.get on multi-tenant schemasTenant-owned schemas include at least: Bundle, Run, Cache,
Preview, CommandEvent, Build, Test, Project,
AutomationAlert. They all carry a project_id or account_id.
Tuist.Repo.get(Schema, id) / Repo.one(from(s in Schema, where: s.id == ^id)) without a project_id / account_id constraint for any of the schemas above, when the call is inside a controller, plug, LiveView, channel, MCP handler, or worker that already has the project/account in scope. This is a tenant leak (an attacker who guesses a UUID gets cross-tenant data). Severity: high.id and forwards it to Repo.get without also taking the project/account.# admin / cross-tenant: ... comment or a function name like *_for_all/_global/_admin).User, Account, Organization, Subscription, etc.).lib/tuist_web/plugs/webhook_plug.ex resolves a per-row HMAC secret (e.g. GitHubController.resolve_webhook_secret/1 matches a GitHubAppInstallation row whose webhook_secret HMACs the raw body, then stashes the row on conn.assigns[:github_installation]), downstream handlers reading that assign do not need a separate installation.account_id == expected_account_id check. There is no separate "expected account" — webhooks land on a global /webhooks/<provider> URL, and the row is the tenant context, selected by a per-row cryptographic capability. A redundant account_id equality check after valid_signature?/4 would compare the row's value to itself; it adds dead code, not a defense layer. If the cryptographic check fails, the request 403's before the handler ever runs.project_id / account_id (e.g. FlakyTestsMonitor.evaluate/1 → AlertEvaluationWorker → ActionExecutor), do not flag the downstream call as needing its own scoping check. Trace the input chain before flagging; only flag when the input is user-controllable (URL param, body field, header).IngestRepo is write-onlyThere are two ClickHouse repos in this codebase, and they are not interchangeable. Be precise about which one a call uses before flagging.
Tuist.IngestRepo — write-only ingest path. Application code must
not read from it; reads happen out of band.Tuist.ClickHouseRepo — the read-only ClickHouse repo (declared
with read_only: true in server/lib/tuist/clickhouse_repo.ex).Tuist.IngestRepo.all/1, Tuist.IngestRepo.get/2,
Tuist.IngestRepo.get_by/2, Tuist.IngestRepo.one/1,
Tuist.IngestRepo.exists?/1, or Tuist.IngestRepo.aggregate/3.from(... ) |> Tuist.IngestRepo.<read fn>.The fix is almost always to read through Tuist.ClickHouseRepo (for
ClickHouse-only data) or Tuist.Repo (PostgreSQL).
Tuist.ClickHouseRepo.all/1, Tuist.ClickHouseRepo.one/1,
Tuist.ClickHouseRepo.aggregate/3, or any other read through
ClickHouseRepo. That repo exists specifically for application reads.
Do not confuse it with IngestRepo.Tuist.IngestRepo.insert/2 / insert_all/2,3 — those are
the intended use.test_helper.exsserver/test/test_helper.exs is the single place where
Mimic.copy(Module) is called. Per-test-file Mimic.copy/1 calls leak
state across tests and are an explicit anti-pattern in this repo.
Mimic.copy(...) call inside any file under server/test/ other
than test_helper.exs. Suggest: move it to test_helper.exs.Mimic.expect/3, Mimic.stub/3, Mimic.reject/1 — those belong in tests.Mimic.copy/1 calls in test_helper.exs itself.import Mimic, use Mimic, setup :set_mimic_from_context, aliases,
or any other test setup line that does not contain Mimic.copy(.use Mimic, import Mimic,
stub, expect, reject) but does not contain the exact
Mimic.copy( call in the diff.In server/priv/repo/migrations/ and server/priv/ingest_repo/migrations/:
timestamps() without
type: :utc_datetime_usec and a corresponding migration column
without :timestamptz. The .credo.exs rule says: migrations use
:timestamptz, schemas (lib/) use :utc_datetime.add :inserted_at, :naive_datetime or :datetime without timezone in a migration. Should be :timestamptz.timestamps(type: :timestamptz).def change do, create table(...), blank lines, comments, or any
line that does not itself declare a timestamp type.add :started_at, :timestamptz, add :finished_at, :timestamptz,
or any other explicit :timestamptz column.data-export.md updates on schema changesserver/data-export.md documents every piece of customer data Tuist
stores, for GDPR Article 20 / CCPA exports. It must be updated when
the diff includes any of:
server/lib/tuist/**/*.ex that maps to a
customer-facing tablebundles/,
previews/, caches/)server/priv/repo/migrations/*.exs (other than
pure index / constraint changes) or server/priv/ingest_repo/migrations/*.exs
without also modifying server/data-export.md.This is a compliance gap, not just a docs nit — call it out clearly.
In marketing copy and pricing UI:
€, $, £, ¥, currency codes) wrapped inside
dgettext/2 or gettext/1. Symbols and amounts must remain identical
across languages.dgettext("marketing", "0€ and up"). The
correct form keeps the currency literal outside the translation:
"0€ " <> dgettext("marketing", "and up")..po is read-only for humansserver/priv/gettext/**/*.po. Only the tuistit
bot may edit .po files; CI will fail otherwise.mix gettext.extract --merge. Only the no---merge form is
allowed in PRs..pot (template) changes — those are produced by mix gettext.extract
and are expected when adding new translatable strings.A Repo.* / ClickHouseRepo.* / IngestRepo.* call inside Enum.map,
Enum.each, Enum.flat_map, Enum.filter, Enum.reduce, for, or
Stream.* is almost always an N+1. Each iteration is a separate round
trip; the chart-bucket loop or per-row preload that looked harmless on
toy data stalls real page loads.
Actively search the diff for these patterns before signing off — don't just react to obvious cases:
Enum.map(_, fn ... -> ... <Repo>.<one|all|get|get_by|aggregate|exists?|stream> ... end)Enum.map(_, &<Repo>.<...>(&1, ...)) (point-free form is the same trap)Enum.each(_, fn ... -> ... <Repo>.<insert|update|delete> ... end)Enum.flat_map, Enum.reduce(..., fn _, acc -> ... <Repo>... end)for x <- xs, do: <Repo>.* / for x <- xs, do: ... with a query insideEnum.map(_, &<Repo>.preload(&1, ...)) — preload/2 already accepts a listxs |> Enum.map(&fetch_thing/1) where fetch_thing/1
internally calls a Repo — follow the function one hop in.The repos to watch: Tuist.Repo, Tuist.ClickHouseRepo,
Tuist.IngestRepo, plus any aliased form (e.g. alias Tuist.Repo,
then bare Repo.* inside the loop).
Repo/ClickHouseRepo/IngestRepo read or aggregate inside any
Enum.*/for/Stream.* in the diff. Severity is high if the
loop is on a request path (controller, LiveView mount/handle_*,
channel, MCP handler) or scales with tenant data (test cases,
bundles, runs); medium for background jobs and one-shot scripts.Repo.preload/2 — preload already takes a list; one
call covers all._all equivalent
(insert_all, update_all, delete_all).When suggesting a fix, name the consolidating primitive so the author can act on it directly:
argMaxIf / countIf /
groupArray over a single GROUP BY, or arrayJoin to fan out
buckets as rows.where: r.id in ^ids + group in Elixir, or
a join.Repo.preload(list, [:assoc]) once.*_all + a list of params.server/test/,
server/priv/repo/seeds*.exs) — correctness-first, perf is fine.Repo.stream/2 inside Enum.* with an explicit comment justifying
the cursor-based stream (e.g. "stream so we don't load 10M rows").style="..." in HEEx templatesComponent styling lives in server/assets/app/css/pages/*.css (or
noora/lib/noora/**/*.css for design-system primitives), keyed off
data-part selectors that mirror the HEEx structure. Inline style=
attributes on elements or component props bypass the design tokens
(var(--noora-spacing-*), var(--noora-font-*), etc.) at review
time, leak presentation into LiveView diffs, and prevent themers /
density modes from overriding the value.
style="..." attribute on an HTML element inside any
server/lib/tuist_web/**/*.html.heex or *_live.html.heex.style= prop passed to a Noora component (<.button_group style="...">, <.text_input style="...">, etc.). These flow through
to the underlying element via {@rest}, so they're inline styles by
another name.When suggesting a fix:
data-part (or reuse one already on the element).server/assets/app/css/pages/<page>.css) or, if it belongs to a
reusable component, the Noora primitive's CSS.--noora-spacing-*, --noora-radius-*,
--noora-font-*, --noora-surface-*) over raw values.style= attributes that already existed before the diff.style="display: none" toggles whose visibility is driven
by a temporary Phoenix :if — those still belong in CSS, but the
signal-to-noise here is low.The human-authored product changelog lives in
server/priv/marketing/changelog/*.md. Generated files such as
server/CHANGELOG.md or root CHANGELOG.md must not be edited by
authors.
server/priv/marketing/changelog/*.md entry.User-facing signals include changed dashboard routes, LiveViews, controllers, templates, page CSS, settings pages, integration flows, alerts, reports, previews, build/test/cache/bundle analytics, or public API behavior that customers can observe.
When suggesting a fix, ask for a short marketing changelog entry with
frontmatter like title, category: "Product", and pull_request.
Mention an accompanying image under
server/priv/static/marketing/images/changelog/ only when the feature
has a visual dashboard/UI state worth showing.
server/priv/marketing/changelog/*.md entry.mix format + credo (PipeChainStart,
StrictModuleLayout, Nesting, UnsafeToAtom, ModuleDoc).@spec / @type — this codebase intentionally avoids
typespecs. Never suggest adding them.@doc / @moduledoc on internal helper modules.String.to_atom/1 on user input — credo's UnsafeToAtom covers it.For each finding, confirm:
path:line is real and the snippet appears in the diff.uncertain: ...) rather than asserting a finding.