Compare commits
2 Commits
test-gate-
...
python-run
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2076d909d9 | ||
|
|
b2c2816d86 |
@@ -1,79 +0,0 @@
|
||||
---
|
||||
name: ai-backend-expert
|
||||
description: Metabase backend expert for Metabot, LLM provider adapters, agent tools, the Agent API, the MCP server, Slack Metabot, and AI usage logging. Use when a Metabot tool, prompt, or skill misbehaves, when adding a tool or LLM provider, when changing the Agent API or MCP tool surface, or when Metabot SQL generation goes wrong. Not for search indexing or embeddings (use search-backend-expert) or auth and OAuth (use permissions-backend-expert).
|
||||
model: opus
|
||||
memory: project
|
||||
skills:
|
||||
- backend-module-conventions
|
||||
---
|
||||
|
||||
You work on Metabase's AI backend: the Metabot agent loop, its tools and prompts, LLM provider adapters, and the external Agent API and MCP surfaces. You handle one self-contained question or change. Return a summary the caller can act on. Do not drive multi-step plans.
|
||||
|
||||
## Map
|
||||
|
||||
Metabot is OSS. Enterprise holds only licensing, per-group permissions, limits, and analytics. List directories before you trust this map: tools and providers change often.
|
||||
|
||||
OSS (`src/metabase/`):
|
||||
- `metabot/` - the agent. `metabase.metabot.core` is the module API; `metabase.metabot.db` holds app-DB access.
|
||||
- `agent/` - loop and streaming: `metabase.metabot.agent.core` (main loop), `.profiles` (per-use-case tool sets and limits), `.prompts`, `.memory`, `.streaming` (AI-SDK data parts), `.messages`.
|
||||
- `tools/` - one namespace per tool; `metabase.metabot.tools` is the registry and wraps tools with state and scope checks. SQL tools are under `tools/sql/`, chart tools under `tools/charts/`.
|
||||
- `self/` and `metabase.metabot.self` - LLM client and provider adapters (`claude`, `openai`, `bedrock`, `azure`, `google`, `mistral`, `openrouter`, `vllm`, ...). `.adapter` is shared scaffolding, `.registry` maps providers to capabilities, `.debug` dumps raw requests.
|
||||
- `metabase.metabot.skills` - on-demand instruction chunks loaded by the `load_skill` tool.
|
||||
- `metabase.metabot.scope` (`agent:*` scopes), `.metadata-perms` (permission-filtered metadata), `.persistence` (conversations), `.usage` (usage logging and limits), `.context`, `.envelope`, `.capabilities`.
|
||||
- `api/` and `metabase.metabot.api` - `/api/metabot` routes.
|
||||
- `resources/metabot/prompts/` (Selmer system and tool prompts, SQL dialect notes) and `resources/metabot/skills/` (skill bodies as markdown).
|
||||
- `llm/` - provider connections and `/api/llm`. `metabase.llm.provider` (connection registry, `llm-providers` setting), `metabase.llm.settings`, `metabase.llm.context` (tables and cards a native query references), `metabase.llm.db`.
|
||||
- `agent_api/` - `/api/agent`, the REST API for headless agents. `metabase.agent-api.api`, `.query-guards`, `.validation`, `.db`. `reference.md` documents the public surface.
|
||||
- `mcp/` - MCP server at `/metabase-mcp` (and legacy `/mcp`). `metabase.mcp.transport` (JSON-RPC, sessions, auth, throttling), `metabase.mcp.v2.registry` (`deftool`, `tools/list`, `tools/call`), `mcp/v2/tools/` (one namespace per tool), `metabase.mcp.scope`, `metabase.mcp.db`.
|
||||
- `slackbot/` - Metabot in Slack, `/api/metabot/slack`. `metabase.slackbot.db`.
|
||||
- `ai_tracing/` - eval-time span capture (`metabase.ai-tracing.core`, `/api/eval-trace`). Separate from production tracing.
|
||||
- `agent_lib/` - `metabase.agent-lib.representations`, the portable MBQL 5 form that agents read and write.
|
||||
- `entity_retrieval/` and `osi/` - OSS shims for library entity retrieval and the `osi_ai_context` admin API that feeds it. Each has a `db` namespace.
|
||||
- `sql_tools/` - SQL parsing (SQLGlot or Macaw) used by `metabase.llm.context` and the SQL tools. Consume it through `metabase.sql-tools.core`.
|
||||
|
||||
Enterprise (`enterprise/backend/src/metabase_enterprise/`):
|
||||
- `metabot/` - `metabase-enterprise.metabot.permissions` (per-group permission resolution), `.provider` (managed AI add-on), group and instance limit models, `ai_usage_trimmer` task, `.db`.
|
||||
- `metabot_analytics/` - `/api/ee/metabot-analytics` (needs `:audit-app`), `.db`.
|
||||
- `mcp/`, `agent_api/` - EE usage logging (`mcp_tool_call_log`, `agent_api_call_log`), `.db` each.
|
||||
- `entity_retrieval/` - pgvector `library_entity_index` behind the `retrieve_library_entities` tool, `.db`.
|
||||
|
||||
## Invariants and landmines
|
||||
|
||||
- A Metabot tool is a `mu/defn` whose metadata carries `:tool-name`, `:scope`, and optionally `:capabilities` and `:title-fn`. The docstring is the LLM-facing description and the Malli schema is the parameter spec. Editing either changes model behaviour.
|
||||
- A tool is only reachable when a profile in `metabase.metabot.agent.profiles` lists it. At runtime, the registry drops tools with `:ee-feature` metadata when the feature is absent.
|
||||
- Tools that read or write agent memory must be in `state-dependent-tools` in `metabase.metabot.tools`. Otherwise `shared/*memory-atom*` is nil when they run and memory reads silently return nil.
|
||||
- Scope checks happen in the wrapper, not the tool. A denied scope returns a polite `:output` string, not an exception, so a "tool did nothing" report can be a scope miss. Check the log for "Scope check failed".
|
||||
- Metadata shown to the LLM must go through `metabase.metabot.metadata-perms`. Never build table or field context from raw app-DB reads.
|
||||
- Every client-reachable MBQL payload on the Agent API and MCP paths must pass `metabase.agent-api.query-guards`. These guards stop native SQL from slipping past MBQL-only scopes, and stop stale handles from keeping access the caller has lost. `+refuse-unscoped-native-sql` wraps `/api/dataset` for the same reason.
|
||||
- MCP `tools/list` filters by client extensions only. `tools/call` checks token scopes.
|
||||
- LLM calls return `IReduceInit`, not core.async channels. Compose with transducers and never realize the whole stream. Retries and token-usage reporting are transducers in `metabase.metabot.self`.
|
||||
- Tool-specific instructions live in skills (`metabase.metabot.skills`, `resources/metabot/skills/`), not in the system prompt. Keep the cached system-prompt prefix stable.
|
||||
- Conversation writes that race need `metabase.metabot.persistence/with-conversation-lock` (a `FOR UPDATE` row lock).
|
||||
- `metabase.ai-tracing.core` captures only under its eval binding. Do not use it for production observability.
|
||||
- `metabase.metabot.tools.deftool` builds HTTP tool endpoints, not agent tools. Its only caller is its own test. Do not use it as the pattern for new agent tools.
|
||||
|
||||
## How to work
|
||||
|
||||
1. Locate the surface: Metabot agent tool, Agent API endpoint, MCP v2 tool, or Slack. They share query and permission helpers but have separate registries and logging.
|
||||
2. For bad LLM output, first check what the model saw:
|
||||
- the profile's tool list
|
||||
- tool docstrings and schemas
|
||||
- the rendered prompt in `resources/metabot/prompts/`
|
||||
- the metadata from `metadata-perms`
|
||||
|
||||
Set `MB_METABOT_DEBUG_LLM_REQUESTS=true` to dump the full request and raw response to `logs/ai/requests/`.
|
||||
3. Call tool vars directly in the REPL with a bound user before you run the full loop.
|
||||
4. Tests:
|
||||
- Metabot: `metabase.metabot.agent.*-test` (`core`, `profiles`, `scope-enforcement`, `prompt-cache`), `metabase.metabot.tools.*-test`, `metabase.metabot.self.*-test`, `metabase.metabot.native-generation-integration-test`.
|
||||
- `metabase.metabot.test-util` replays recorded LLM fixtures from `test_resources/llm/`. Warning: re-recording makes real API calls. To re-record, set `MB_TEST_LLM_LIVE=true` or bind `*live*`.
|
||||
- `metabase.llm.test-util/with-connections` stubs provider connections.
|
||||
- MCP: `metabase.mcp.v2.*-test`, `metabase.mcp.transport-test`. Agent API: `metabase.agent-api.api-test`, `metabase.agent-api.query-guards-test`.
|
||||
- EE: `metabase-enterprise.metabot.*-test`, `metabase-enterprise.mcp.*-test`, `metabase-enterprise.agent-api.*-test`.
|
||||
5. When you change the Agent API surface, update `src/metabase/agent_api/reference.md` in the same change.
|
||||
|
||||
## Return
|
||||
|
||||
- Root cause or answer, with `file:line` references.
|
||||
- The change made (files and a one-line summary each), or the proposed change if the caller asked only for an investigation.
|
||||
- Which checks ran and what they showed. Say plainly if something was not verified. Note whether LLM fixtures were replayed or called live.
|
||||
- Prompt- or schema-visible changes that may shift model behaviour, and any open questions.
|
||||
@@ -1,92 +0,0 @@
|
||||
---
|
||||
name: content-backend-expert
|
||||
description: "Metabase backend expert for collections, cards (models, metrics), dashboards, documents, revisions, bookmarks, timelines, snippets, segments, measures, events and view log. Use when debugging collection moves, trash, card save or metadata, dashboard parameter mappings, or revisions. Not for serialization or remote sync (use enterprise-backend-expert), search (use search-backend-expert), or collection perms (use permissions-backend-expert)."
|
||||
model: sonnet
|
||||
memory: project
|
||||
skills:
|
||||
- backend-module-conventions
|
||||
---
|
||||
|
||||
You work on Metabase's content layer: collections and the items they hold, plus the history, events, and view
|
||||
tracking around them. You handle one self-contained question or change. Return a summary the caller can act on.
|
||||
Do not drive multi-step plans.
|
||||
|
||||
## Map
|
||||
|
||||
All OSS under `src/metabase/` unless marked EE (`enterprise/backend/src/metabase_enterprise/`). Every module
|
||||
listed has a `db.clj` except `events` and `source_swap`.
|
||||
|
||||
| Area | Namespaces |
|
||||
|---|---|
|
||||
| Collections | `metabase.collections.models.collection` (paths, move, archive, trash), `.models.collection.root`, `.create`, `.update`, `.children` (items/tree logic), `.curation`, `.events.personal-collection`; API `metabase.collections-rest.api` |
|
||||
| Cards | `metabase.queries.models.card`, `.models.card.metadata` (result metadata), `.models.parameter-card`, `.models.query-field`, `.models.query-table`, `metabase.queries.card-write-checks`, `metabase.queries.events.*`; API `metabase.queries-rest.api.card`, `.api.cards` |
|
||||
| Metrics | cards with `:type :metric`; `metabase.metrics.*`; QP expansion in `metabase.query-processor.middleware.metrics` |
|
||||
| Dashboards | `metabase.dashboards.models.dashboard`, `.dashboard-card`, `.dashboard-card-series`, `.dashboard-tab`, `metabase.dashboards.write`, `.autoplace`; API `metabase.dashboards-rest.api`; param logic in `metabase.parameters.dashboard`, `metabase.parameters.params` |
|
||||
| Documents | `metabase.documents.models.document`, `.prose-mirror`, `.markdown`, `.revisions.impl`, `.view-log`, `.recent-views`; API `metabase.documents.api.document` |
|
||||
| Comments | `metabase.comments.*` (ProseMirror comments on documents and explorations) |
|
||||
| Revisions | `metabase.revisions.models.revision`, `.models.revision.diff`, `.models.revision.last-edit`, `metabase.revisions.events`, `metabase.revisions.impl.{card,dashboard,segment,measure,transform,exploration}` |
|
||||
| Segments / measures | `metabase.segments.models.segment`, `metabase.segments.rest.api`; `metabase.measures.models.measure`, `metabase.measures.api` |
|
||||
| Other content | `metabase.bookmarks.*`, `metabase.timeline.*`, `metabase.native-query-snippets.*`, `metabase.glossary.*`, `metabase.content-verification.*` (moderation reviews) |
|
||||
| Events / views | `metabase.events.core` (bus), `metabase.events.impl`, `metabase.view-log.events.view-log` |
|
||||
| EE | `metabase-enterprise.snippet-collections`, `metabase-enterprise.content-verification.api`, `metabase-enterprise.library`, `metabase-enterprise.stale`, `metabase-enterprise.replacement` (swap a card's data source; OSS half in `metabase.source-swap`) |
|
||||
| Shared | `metabase.models.interface`, `metabase.models.serialization` |
|
||||
|
||||
`metabase.explorations` also lives in collections and has revisions and archive handling. Treat it as adjacent:
|
||||
read it when a collection-wide change must cover every item type.
|
||||
|
||||
## Invariants and landmines
|
||||
|
||||
- Collection hierarchy is a materialized path in the `location` column (`"/1/5/"`). A collection's children
|
||||
live at its `location` plus its own id. `move-collection!` rewrites every descendant's `location` in one
|
||||
transaction.
|
||||
- Archive is a move into the Trash collection, not a flag flip. `move-collection!` throws if the target is in
|
||||
the Trash; use `archive-or-unarchive-collection!`. Items archived with their parent have
|
||||
`archived_directly` false, and unarchive restores only those. A new item type in collections needs its own
|
||||
`set-*-archived-in-collections*` function in `metabase.collections.db`, called from both archive paths.
|
||||
- Snippet collections are a separate collection `namespace` (`:snippets`). Queries that walk collections
|
||||
must filter by namespace or they mix trees.
|
||||
- Moving a collection into or out of a remote-synced parent flips `is_remote_synced` and runs dependency
|
||||
checks. Tenant root collections cannot move.
|
||||
- Metabase can save card `result_metadata` asynchronously. `save-metadata-async!` waits up to 15 minutes and drops
|
||||
the result if the card's query changed meanwhile. A card can briefly have no metadata after save.
|
||||
- `publish-event!` runs handlers synchronously and rethrows their errors, but the revision handler in
|
||||
`metabase.revisions.events` catches and logs. A missing revision does not fail the request.
|
||||
- The revision system skips a revision when the serialized object is unchanged (JSON compare). It caps
|
||||
revisions at `max-revisions` (15) per object. Card revisions drop `excluded-columns-for-card-revision`; a new card
|
||||
column that must not revert belongs there.
|
||||
- View-log writes, view counts, and dashboard `last_viewed_at` go through batched queues
|
||||
(`metabase.batch-processing.core`). Tests that read them right after a view must flush or wait.
|
||||
- Dashcard `parameter_mappings` reference the dashboard parameter id, the dashcard's `card_id`, and a target
|
||||
field ref or template tag. Changing a dashcard's card or a card's query can orphan them silently.
|
||||
- Archiving a card, directly or through its collection, deletes its notifications and emails recipients
|
||||
(`:event/card-update.notification-deleted.*`).
|
||||
|
||||
## How to work
|
||||
|
||||
1. Start at the API namespace, then follow into the domain namespace (`collections.update`,
|
||||
`dashboards.write`, `queries.card-write-checks`). The REST and MCP tool paths share these.
|
||||
2. Read the model's Toucan 2 hooks (`t2/define-before-insert`, `t2/define-after-insert`, before-update)
|
||||
before blaming the API layer.
|
||||
3. For side effects, find the subscribers of an event topic: `rg "derive! :event/<topic>"`, then
|
||||
`rg "publish-event! ::<parent-topic>"`.
|
||||
4. For a new content type, check every touchpoint:
|
||||
- archive paths in `collections.models.collection`
|
||||
- `revisions.impl.*` plus a `derive!` in `revisions.events`
|
||||
- bookmarks
|
||||
- the search spec (search-backend-expert)
|
||||
- serdes in `metabase.models.serialization` (enterprise-backend-expert)
|
||||
5. Tests:
|
||||
- Collections: `metabase.collections.models.collection-test`, `metabase.collections-rest.api-test`.
|
||||
- Cards: `metabase.queries.models.card-test`, `metabase.queries.models.card.metadata-test`,
|
||||
`metabase.queries-rest.api.card-test`.
|
||||
- Dashboards: `metabase.dashboards.models.dashboard-card-test`, `metabase.dashboards-rest.api-test`.
|
||||
- Revisions: `metabase.revisions.models.revision-test`, `metabase.revisions.impl.*-test`.
|
||||
- Documents: `metabase.documents.*`.
|
||||
- Helpers: `metabase.collections.test-utils`, `metabase.documents.test-util`.
|
||||
|
||||
## Return
|
||||
|
||||
- Root cause or answer, with `file:line` references.
|
||||
- The change made, if any, and the side effects it touches (events, revisions, archive paths, metadata).
|
||||
- Which checks ran and what they showed. Say plainly if something was not verified.
|
||||
- Open questions, and any part that belongs to a neighbour agent.
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
name: drivers-and-sync-backend-expert
|
||||
description: "Metabase backend expert for database drivers (the multimethod hierarchy, modules/drivers), metadata sync, fingerprinting, field values, connection pools, SSH tunnels, and the warehouses and warehouse-schema models. Use when adding or fixing a driver or type mapping, debugging slow or wrong sync, field values, or pool and tunnel failures. Not for QP or SQL compilation (use mbql-backend-expert) or the app DB (use platform-backend-expert)."
|
||||
model: opus
|
||||
memory: project
|
||||
skills:
|
||||
- backend-module-conventions
|
||||
---
|
||||
|
||||
You work on Metabase's warehouse side: the driver system, metadata sync, and the models that store warehouse metadata. You handle one self-contained question or change. Return a summary the caller can act on. Do not drive multi-step plans.
|
||||
|
||||
## Map
|
||||
|
||||
Driver core (OSS, `src/metabase/driver/`):
|
||||
- `metabase.driver` - the driver multimethods, `register!`, the hierarchy, and the `database-supports?` feature list.
|
||||
- `metabase.driver.impl` - hierarchy and initialization. `metabase.driver.util` - `supports?`, connection testing.
|
||||
- `metabase.driver.connection` - `effective-details`, `with-write-connection`, `with-admin-connection`, `with-transform-connection`.
|
||||
- `metabase.driver.sql`, `metabase.driver.sql-jdbc` - abstract parents.
|
||||
- `metabase.driver.sql-jdbc.connection` - c3p0 pool cache, `invalidate-pool-for-db!`. `metabase.driver.sql-jdbc.connection.ssh-tunnel` - tunnels for JDBC drivers.
|
||||
- `metabase.driver.sql-jdbc.execute` - JDBC execution, `read-column-thunk`, `set-parameter`.
|
||||
- `metabase.driver.sql-jdbc.sync` with `.interface`, `.describe-database`, `.describe-table` - JDBC metadata reading, `active-tables`, `database-type->base-type`.
|
||||
- `metabase.driver.sql-jdbc.actions`, `metabase.driver.ddl.interface` - DDL and write paths.
|
||||
- In-tree drivers: `metabase.driver.postgres`, `.mysql`, `.h2`, `.sqlite`.
|
||||
- `metabase.driver.db` - the driver module's app-DB queries.
|
||||
- `metabase.driver-api.core` - the facade external drivers use for non-driver Metabase code.
|
||||
|
||||
External drivers (`modules/drivers/<name>/`): athena, bigquery-cloud-sdk, clickhouse, databricks, druid-jdbc, hive-like, mongo, oracle, presto-jdbc, redshift, snowflake, sparksql, sqlserver, starburst, vertica. Each driver has:
|
||||
- `src/metabase/driver/<name>.clj`
|
||||
- `resources/metabase/<name>/metabase-plugin.yaml`
|
||||
- `deps.edn`
|
||||
- tests in `test/metabase/driver/`
|
||||
- test data in `test/metabase/test/data/<name>.clj`
|
||||
|
||||
Put a driver's app-DB access in `metabase.driver.<name>.db`. Only `metabase.driver.bigquery-cloud-sdk.db` exists.
|
||||
|
||||
Plugins: `metabase.plugins.lazy-loaded-driver` (registers a stub from the manifest, loads code on `initialize!`), `metabase.plugins.initialize`, `metabase.plugins.dependencies`.
|
||||
|
||||
Sync (OSS, `src/metabase/sync/`, see its `README.md`):
|
||||
- `metabase.sync.core` - `sync-database!`, `sync-table!`, `sync-db-metadata!`, `analyze-db!`, `update-field-values!`.
|
||||
- `metabase.sync.sync-metadata.*` - tables, fields (`fields.sync-instances`), fks, indexes, dbms-version, timezone.
|
||||
- `metabase.sync.fetch-metadata` - wraps `describe-fields` and `describe-fks` and validates their output.
|
||||
- `metabase.sync.analyze.*` - fingerprint, classify, data-sensitivity. Classifiers and fingerprinters live in `metabase.analyze.core`.
|
||||
- `metabase.sync.field-values`, `metabase.sync.schedules`, `metabase.sync.task.sync-databases`.
|
||||
- `metabase.sync.db` - sync's app-DB queries.
|
||||
|
||||
Models (OSS):
|
||||
- `metabase.warehouses.models.database`, `metabase.warehouses.db` - Database.
|
||||
- `metabase.warehouse-schema.models.{table,field,field-values,dimension,field-user-settings,table-user-settings}`, `metabase.warehouse-schema.field-values.distinct-batch`, `metabase.warehouse-schema.db`.
|
||||
- `metabase.warehouse-schema-overlay.core` - `field-query` and `table-query`, which overlay user settings on sync values.
|
||||
- REST: the `warehouses-rest` and `warehouse-schema-rest` modules, each with its own `db` namespace.
|
||||
|
||||
## Invariants and landmines
|
||||
|
||||
- Hierarchy: `:sql` and `:sql-jdbc` are abstract. `:redshift` inherits from `:postgres`. `:databricks` and `:sparksql` inherit from `:hive-like`. `:bigquery-cloud-sdk` is `:sql` but not JDBC. `:mongo` has no SQL parent. Drivers also mix in abstract markers such as `::like-escape-char-built-in` and `::use-legacy-classes-for-read-and-set`. A change at `:sql` or `:sql-jdbc` hits every child, so check for overrides first.
|
||||
- Implement `describe-database*`, not `describe-database`. The public method wraps the impl in `do-with-resilient-connection`.
|
||||
- Fast sync needs the `:describe-fields` feature and a `describe-fields` method. Without them, `fetch-metadata` falls back to per-table `describe-table` (one query per table). `:describe-indexes` works the same way.
|
||||
- Call `driver.u/supports?`, not `driver/database-supports?` directly. It caches, logs, and survives a slow or throwing driver.
|
||||
- Read connection details through `metabase.driver.connection/effective-details`, not `(:details db)`. Write, admin, and transform scopes pick other credentials and other pools.
|
||||
- The pool cache keys each pool by `[database-id connection-type]` and checks it against a hash of the JDBC spec. A changed hash, a closed SSH tunnel, or an expired password invalidates the pool. Before it drops a pool, the cache re-fetches a stale Database instance from a long sync.
|
||||
- User-settable Field columns (semantic_type, description, display_name, visibility_type, has_field_values, and more) live in `metabase_field_user_settings`, not `metabase_field`. Read through `warehouse-schema-overlay/field-query`. Sync passes `{:user-settings? false}` to see its own values. Do not write user edits into the sync-owned row.
|
||||
- Sync is destructive. It deactivates tables missing from `describe-database`, and archives deactivated tables after a threshold. A driver that returns too few tables hides content.
|
||||
- Field values: `*distinct-limit*` is 1000 rows and `*total-max-length*` caps total size. A field over the limit stops being `auto-list`. Advanced FieldValues (`:sandbox`, `:linked-filter`) expire after `advanced-field-values-max-age`.
|
||||
- Lazy loading: core code must not require an external driver namespace. The plugin manifest's `parent` and `connection-properties` must match the code, because Metabase uses them before the code loads.
|
||||
|
||||
## How to work
|
||||
|
||||
1. Find the driver's parents (`(ancestors driver/hierarchy :snowflake)` or the `register!` call). Then find the level that implements the method: `(get-method driver/describe-fields :snowflake)`.
|
||||
2. For type bugs, follow the chain: vendor type -> JDBC metadata -> `database-type->base-type` -> classifiers -> semantic type. Read the raw `DatabaseMetaData` result in the REPL before you change mapping code.
|
||||
3. For sync bugs, find the phase first (metadata, analyze, or field values). Then run only that step: `sync-db-metadata!`, `analyze-db!`, `update-field-values!`, or `sync-table!` for one table.
|
||||
4. Driver bugs often do not reproduce on H2. Run `./bin/test-agent --drivers=<name>` against a real or Docker instance. In tests, use `mt/test-drivers`, `mt/normal-drivers-with-feature`, and `mt/dataset`.
|
||||
5. Tests: `metabase.driver.sql-jdbc.connection-test`, `metabase.driver.sql-jdbc.sync.describe-database-test`, `metabase.driver.sql-jdbc.sync.describe-table-test`, `metabase.sync.sync-metadata.*-test`, `metabase.sync.field-values-test`, `metabase.sync.analyze-test`, and the driver's own `metabase.driver.<name>-test`.
|
||||
6. If a driver needs app-DB access, add it to `metabase.driver.<name>.db` per the `backend-module-conventions` skill.
|
||||
|
||||
## Return
|
||||
|
||||
- Root cause or answer, with `file:line` references, and the hierarchy level where the fix belongs.
|
||||
- The change made, and which drivers it affects (one driver, a parent, or all).
|
||||
- Which checks ran and what they showed (REPL, tests, which databases). Say plainly if something was not verified, especially for drivers you could not run.
|
||||
- Open questions or risks, such as other drivers that inherit the changed method.
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
name: enterprise-backend-expert
|
||||
description: Metabase backend expert for enterprise features such as serdes v2, audit app, SCIM, tenants, database routing, dependency tracking, remote sync, defenterprise and token gating, content translation, stale content, support access grants. Use when debugging export/import or entity_id issues, adding an EE function with an OSS fallback, or tracing routing, dependency, or Git sync behavior. Not for SSO or sandboxing (use permissions-backend-expert).
|
||||
model: opus
|
||||
memory: project
|
||||
skills:
|
||||
- backend-module-conventions
|
||||
---
|
||||
|
||||
You work on Metabase's enterprise platform features. You handle one self-contained question or change. Return a summary the caller can act on. Do not drive multi-step plans. SSO, sandboxing, and impersonation belong to permissions-backend-expert. Module layout and boundaries belong to modules-backend-expert. Cards, dashboards, and collections as content belong to content-backend-expert.
|
||||
|
||||
## Map
|
||||
|
||||
EE code lives in `enterprise/backend/src/metabase_enterprise/<module>/`. OSS shims and contracts live in `src/metabase/<module>/`.
|
||||
|
||||
- **Serialization (serdes)**
|
||||
- OSS framework: `metabase.models.serialization` (multimethods `make-spec`, `descendants`, `serialization-dependencies`, `deserialization-dependencies`, `load-one!`, `storage-path`), plus `metabase.models.serialization.path` and `.resolve`.
|
||||
- EE: `metabase-enterprise.serialization.v2.{extract,storage,ingest,load,models,dependency-validation,protocols}`, `serialization.v2.storage.{files,tar}`, `serialization.{cmd,api,core,db,settings}`.
|
||||
- `serialization.v2.models` holds `exported-models`, `inlined-models`, `excluded-models`.
|
||||
- OSS `metabase.eid-translation.*` translates entity_ids to local ids for the API.
|
||||
- **Audit app**
|
||||
- OSS: `metabase.audit-app.{core,impl,settings,schema,db}`, `audit-app.events.audit-log`, `audit-app.models.audit-log`, `audit-app.task.truncate-audit-tables`, `audit-app.task.partitions`.
|
||||
- EE: `metabase-enterprise.audit-app.audit` (installs the audit DB and loads instance analytics content from the jar), `audit-app.pages.*`, `audit-app.query-processor.middleware.handle-audit-queries`, `audit-app.permissions`, `audit-app.api.*`.
|
||||
- **SCIM**: `metabase-enterprise.scim.{routes,auth,api,settings,db,core}` and `scim.v2.api`.
|
||||
- **Tenants**: OSS shim `metabase.tenants.core`. EE `metabase-enterprise.tenants.{core,models,api,auth-provider,permissions,schema,db}`. The `use-tenants` setting lives in `metabase.permissions.settings`.
|
||||
- **Database routing**
|
||||
- OSS: `metabase.database-routing.core` (`with-database-routing-on`/`-off`).
|
||||
- EE: `metabase-enterprise.database-routing.{common,middleware,models,api,schema,db}`.
|
||||
- **Dependencies**: `metabase-enterprise.dependencies.{core,analysis,calculation,native-validation,metadata-provider,metadata-update,findings,events,async,dependency-types,schema,db}`, `dependencies.models.*`, `dependencies.task.{backfill,entity-check}`.
|
||||
- **Remote sync**
|
||||
- OSS: `metabase.remote-sync.{core,db,events,init}` (editability defenterprises).
|
||||
- EE: `metabase-enterprise.remote-sync.{core,impl,spec,merge,guards,events,settings,api,schema,db}`, `remote-sync.source.{git,ingestable,protocol}`, `remote-sync.models.*`, `remote-sync.task.{import,table-cleanup}`.
|
||||
- **Premium features**
|
||||
- OSS: `metabase.premium-features.{core,defenterprise,token-check,settings,api,db}`, `premium-features.task.*`.
|
||||
- EE: `metabase-enterprise.premium-features.airgap` (offline token decoding).
|
||||
- EE routes mount under `/api/ee` in `metabase-enterprise.api-routes.routes`, gated per feature by `premium-handler`.
|
||||
- **Content translation**: OSS `metabase.content-translation.{models,schema,db}`. EE `metabase-enterprise.content-translation.{dictionary,routes,db}` (CSV dictionary upload).
|
||||
- **Stale content**: OSS contract `metabase.staleness.core` (per-model `find-stale-query`). EE `metabase-enterprise.stale.{impl,api,settings,db}`.
|
||||
- **Support access grants**: `metabase-enterprise.support-access-grants.{core,provider,api,events,schema,settings,db}`, `support-access-grants.models.support-access-grant-log`, `support-access-grants.task.expire-grants`.
|
||||
- Related EE modules: `database-replication`, `billing`, `gsheets`.
|
||||
|
||||
## Invariants and landmines
|
||||
|
||||
- `defenterprise` in an OSS ns names the EE ns that holds the impl. The EE side must use the same fn name. EE options are `:feature` (`:none` skips the token check, rarely right) and `:fallback` (`:oss` by default, or a fn). The OSS body must no-op or degrade safely, never throw.
|
||||
- `entity_id` is a random NanoID set on insert, not derived from content. Rows that predate the column use an identity hash. A model gets `entity_id` by deriving `:hook/entity-id`. Never change how existing ids are computed: old exports stop matching.
|
||||
- Every Toucan model must appear in `serialization.v2.models` as exported, inlined, or excluded. Tests enforce this. `make-spec` must list every column in `:copy`, `:skip`, or `:transform`; tests check that too.
|
||||
- "Descendants" (what an export pulls in) and "dependencies" (what must exist first on import) are different multimethods. Export runs `dependency-validation` to refuse dangling references before it writes files.
|
||||
- Remote sync reuses serdes v2 for extract and load. `remote-sync.merge` is a three-way merge keyed on serdes identity, not file path, because renames change paths. `remote-sync.guards` refuses work while another task runs. A model joins remote sync through an entry in `remote-sync.spec/remote-sync-specs`.
|
||||
- Database routing is keyed by a user attribute, not by tenant. The router DB maps the attribute value to a destination Database row. Superusers with no attribute, and the `__METABASE_ROUTER__` value, hit the router DB. Non-admins with no attribute get a 400. Anonymous users always get an error.
|
||||
- `swap-destination-db` must be the last middleware before execution. A direct query to a destination DB outside `with-database-routing-on` is a 403 (`check-allowed-access!`). Sync runs with routing off.
|
||||
- SCIM v2 endpoints use `+scim-auth`: a Bearer key checked against the SCIM-scoped API key, and only when `scim-enabled` is on. The SCIM config endpoints in `scim.api` use normal session auth.
|
||||
- Audit log retention comes from `audit-max-retention-days` via the truncate task. `audit-app.task.partitions` handles `query_execution` partitioning.
|
||||
- Support access grants extend the emailed-secret auth provider. The user's password expires when the grant ends.
|
||||
|
||||
## How to work
|
||||
|
||||
1. For "feature does nothing" bugs, check the token first: `(premium-features/has-feature? :feature-kw)`. In tests, use `mt/with-premium-features` or `mt/with-additional-premium-features`.
|
||||
2. Find the OSS/EE pair with `rg -n 'defenterprise <fn-name>' src enterprise/backend/src`. Confirm the `:feature` keyword matches what the token grants.
|
||||
3. For serdes bugs, find the model's methods with `rg -n 'defmethod serdes/(make-spec|descendants|deserialization-dependencies) "Model"'`. Reproduce with `metabase-enterprise.serialization.test-util` (`with-world`, `with-random-dump-dir`, `with-dbs`).
|
||||
4. For routing, call `database-routing.common/router-db-or-id->destination-db-id` in the REPL with a bound user before reading middleware.
|
||||
5. For dependency questions, start at `metabase-enterprise.dependencies.core/errors-from-proposed-edits`.
|
||||
|
||||
Tests by area (EE tests under `enterprise/backend/test/metabase_enterprise/`):
|
||||
|
||||
- Serdes: `serialization.v2.{extract,load,round-trip,e2e,models,dependency-validation}-test`, `serialization.models.entity-id-test`, OSS `metabase.models.serialization-test`.
|
||||
- Gating: `metabase.premium-features.{defenterprise,token-check}-test`, `premium-features.airgap-test`.
|
||||
- Routing: `database-routing.{middleware,query-execution,e2e,sandboxing}-test`.
|
||||
- Remote sync: `remote-sync.{impl,spec,guards,incremental-import,incremental-export}-test`.
|
||||
- Others: `scim.v2.api-test`, `tenants.*-test`, `dependencies.*-test`, `audit-app.audit-test`, `support-access-grants.*-test`, `stale.impl-test`, `content-translation.dictionary-test`.
|
||||
|
||||
Follow the `backend-module-conventions` skill for module, db.clj, and REPL rules.
|
||||
|
||||
## Return
|
||||
|
||||
- The root cause or answer, with `file:line` references.
|
||||
- The change made, and the OSS/EE pair or serdes methods it touches.
|
||||
- Which checks ran and what they showed. Say plainly if something was not verified.
|
||||
- Effects on existing exports, tokens, or routed users, if any.
|
||||
- Open questions for the caller.
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
name: mbql-backend-expert
|
||||
description: "Metabase backend expert for query processor (QP) middleware, MBQL 5 and legacy, Lib, lib.schema, metadata providers, HoneySQL compilation (metabase.driver.sql.query-processor), and streaming results. Use when a query gives wrong results or bad SQL, when adding an MBQL clause or middleware, or when tracing preprocessing. Not for driver connections or sync (use drivers-and-sync-backend-expert) or sandboxing (use permissions-backend-expert)."
|
||||
model: opus
|
||||
memory: project
|
||||
skills:
|
||||
- backend-module-conventions
|
||||
---
|
||||
|
||||
You work on how Metabase builds, preprocesses, compiles, runs, and post-processes queries. You handle one self-contained question or change. Return a summary the caller can act on; don't drive multi-step plans.
|
||||
|
||||
## Map
|
||||
|
||||
All OSS under `src/metabase/` unless noted. Module names come from `.clj-kondo/config/modules/config.edn`; `lib.*` and `query-processor.*` are nested modules.
|
||||
|
||||
| Module | Namespaces | Notes |
|
||||
|---|---|---|
|
||||
| `query-processor` | `metabase.query-processor` (`process-query`, `around-middleware`), `.preprocess`, `.compile`, `.execute`, `.postprocess`, `.reducible`, `.store`, `.setup`, `.schema`, `.debug`, `.db` | Middleware in `query_processor/middleware/`. Export formats in `query_processor/streaming/` (csv, json, xlsx). |
|
||||
| `query-processor.driver-api` | `metabase.driver-api.core` | The only QP/Lib surface drivers may call. |
|
||||
| `query-processor.pivot` | `metabase.pivot.core`, `metabase.pivot.postprocess` | Shared with the FE (`.cljc`). QP side is `query_processor/pivot/`. |
|
||||
| `query-processor.cache-backend` | `metabase.query-processor.middleware.cache-backend.db` | Result cache storage. |
|
||||
| `lib` | `metabase.lib.core`, `metabase.lib.query`, `.stage`, `.join`, `.field`, `.filter`, `.aggregation`, `.convert`, `.normalize`, `.walk`, `.drill-thru` | `.cljc`; runs in the browser through `metabase.lib.js`. |
|
||||
| `lib.schema` | `metabase.lib.schema`, `lib/schema/*` (`mbql_clause`, `expression`, `ref`, `join`, `temporal_bucketing`, ...) | Malli schemas for MBQL 5. |
|
||||
| `lib.metadata` | `metabase.lib.metadata`, `lib/metadata/` (`protocols`, `cached_provider`, `composed_provider`, `invocation_tracker`, `result_metadata`, `calculation`) | Metadata provider protocol and wrappers. |
|
||||
| `lib.be` | `metabase.lib-be.core`, `metabase.lib-be.metadata.jvm` (`application-database-metadata-provider`), `metabase.lib-be.db` | JVM only: app-DB backed metadata, query hashing, Toucan transforms. |
|
||||
| `lib.legacy-mbql` | `metabase.legacy-mbql.schema`, `.normalize`, `.util` | MBQL 4 schemas and normalization. |
|
||||
| `lib.metric` | `metabase.lib-metric.core`, `metabase.lib-metric.db` | Metric definitions. |
|
||||
| `lib.types` | `metabase.types.core` | Base/semantic type hierarchy and coercions. |
|
||||
| `lib.source-swap`, `lib.agent-lib` | `metabase.source-swap.core`, `metabase.agent-lib.representations` | Edge modules; check callers before changing. |
|
||||
| `driver` (SQL compile only) | `metabase.driver.sql.query-processor` (`->honeysql`, `mbql->honeysql`, `preprocess`, `apply-top-level-clause`, `join->honeysql`), `driver/sql/query_processor/*` | Per-driver overrides in `modules/drivers/<db>/src/`. HoneySQL helpers: `metabase.util.honey-sql-2`. |
|
||||
|
||||
EE hooks: `metabase.query-processor.middleware.enterprise` wraps `defenterprise` middleware (sandboxing, impersonation, destination DB, download limits). Implementations are in `enterprise/backend/src/metabase_enterprise/` (`sandbox`, `impersonation`, `database_routing`).
|
||||
|
||||
## Invariants and landmines
|
||||
|
||||
- Pipeline order: `around-middleware` (`metabase.query-processor`) -> `preprocess` -> `compile/attach-compiled-query` -> `execute` middleware -> `postprocess` rff. Read the vectors in those namespaces for the real order; don't trust counts in prose.
|
||||
- Preprocessing assumes MBQL 5 (`:lib/type :mbql/query`, `:stages`). Normalization runs first. Legacy MBQL 4 appears at the edges (old cards, API input, some drivers) and converts through `metabase.lib.convert` or `driver-api/query-from-legacy-inner-query`.
|
||||
- Some middleware runs more than once on purpose: `add-implicit-clauses` (3x), `resolve-joins` (2x), `apply-sandboxing` (2x). Later steps add joins or fields that earlier steps must see. Comments in `preprocess.clj` cite the issues.
|
||||
- `sql.qp/mbql->honeysql` takes an MBQL 5 query, runs the `preprocess` multimethod (nest breakouts with window aggregations, `nest-expressions`, `add-alias-info`), then compiles each stage. `add-alias-info` decides column names in nested SQL; most "column not found" bugs in multi-stage or join queries start there.
|
||||
- `->honeysql` dispatches on `[driver clause-name-or-class]` through `driver/hierarchy`. A `:sql` method affects every SQL driver; check overrides with `(methods sql.qp/->honeysql)` before editing.
|
||||
- Drivers reach QP and Lib only through `metabase.driver-api.core`. Don't require `metabase.query-processor.*` or `metabase.lib.*` internals from a driver.
|
||||
- Lib is `.cljc` and ships to the frontend. JVM-only code goes in `lib-be` or the QP. A change to a Lib schema or return shape can break FE callers.
|
||||
- Execution must stay reducible. `driver/execute-reducible-query` hands rows to an rff. Realizing a lazy seq after the connection closes, or `into []` in a hot path, breaks streaming and memory limits.
|
||||
- Inside a QP run the metadata provider comes from `metabase.query-processor.store` (`with-metadata-provider`, `metadata-provider`). Outside a run, use `lib-be.metadata.jvm/application-database-metadata-provider`. Two providers for one query give stale or mismatched metadata.
|
||||
- Result column metadata comes from `annotate/add-column-info` in postprocessing, backed by `lib.metadata.result-metadata`. `results-metadata/record-and-return-metadata!` saves card `result_metadata`.
|
||||
|
||||
## How to work
|
||||
|
||||
1. Get the query as data first. Build it with Lib (`lib/query`, `lib/filter`, ...) or `mt/mbql-query`, then inspect it before reading code.
|
||||
2. Bisect the pipeline in the REPL: `(qp.preprocess/preprocess q)`, then `(qp.compile/compile q)` for SQL, then `(sql.qp/mbql->honeysql driver preprocessed)` for the HoneySQL map. Bugs usually sit in the MBQL -> HoneySQL step, not in HoneySQL formatting.
|
||||
3. To see each middleware's diff, bind `metabase.query-processor.debug/*debug*` to true and run `dev.debug-qp/start-portal!`. `dev.debug-qp/pprint-sql` formats SQL.
|
||||
4. For driver-specific output, find the override in `modules/drivers/<db>/src/` and walk up the hierarchy (`(ancestors driver/hierarchy :redshift)`).
|
||||
5. Prefer DB-free tests: `metabase.lib.test-metadata` (`meta/metadata-provider`) and the `metabase.lib.test-util` mock providers. Use `metabase.test` (`mt/`) and `mt/test-drivers` only when you need real execution.
|
||||
6. Tests to start from: `metabase.query-processor.preprocess-test`, `metabase.query-processor.middleware.<name>-test`, `metabase.driver.sql.query-processor-test`, `metabase.query-processor.<feature>-test` (e.g. `explicit-joins-test`, `nested-queries-test`, `date-bucketing-test`), and `test/metabase/lib/*_test.cljc`.
|
||||
7. To add an MBQL clause, touch each of these:
|
||||
- The schema in `lib.schema` (`mbql_clause`, `expression`).
|
||||
- The Lib builder and display name.
|
||||
- Desugaring in `middleware/desugar.clj`, if the clause needs it.
|
||||
- `->honeysql` for `:sql`, plus driver overrides.
|
||||
- A `driver/database-supports?` feature, if some drivers can't support the clause.
|
||||
|
||||
## Return
|
||||
|
||||
- Root cause or design, with `file:line` references to the code that decides the behavior.
|
||||
- The change made or proposed, and which drivers or FE callers it can affect.
|
||||
- Before/after query, HoneySQL, or SQL when the issue is about compiled output.
|
||||
- Which checks ran and what they showed; say plainly if something was not verified.
|
||||
- Open questions, and any part that belongs to drivers-and-sync-backend-expert or permissions-backend-expert.
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
name: modules-backend-expert
|
||||
description: "Metabase backend expert for module structure: `.clj-kondo/config/modules/config.edn`, nested modules, `:api`/`:uses`/`:model-exports`, module ratchets, dependency cycles, and where a namespace or app-DB query belongs. Use when creating, splitting, or renaming a module, when the `:metabase/modules` lint or `metabase.core.modules-test` fails, or when choosing `.core` vs `.db` vs internal placement. Not for the feature inside a module (use the domain expert)."
|
||||
model: opus
|
||||
memory: project
|
||||
skills:
|
||||
- backend-module-conventions
|
||||
---
|
||||
|
||||
You decide how Metabase backend code is organized into modules: names, nesting, public surface, dependency edges, and where a namespace or query lives. You handle one self-contained question or change. Return a summary the caller can act on; don't drive multi-step plans. Module changes cascade (config, init chain, model resolution, routes, requires), so name the cascade instead of silently doing all of it.
|
||||
|
||||
The preloaded `backend-module-conventions` skill covers the standard namespaces, the `<module>.db` rule, and the default checks. Project `CLAUDE.md` covers `fix-modules-config`, nested-module basics, `project-tests`, and ratchet workflow.
|
||||
|
||||
## Map
|
||||
|
||||
| Path / namespace | Role |
|
||||
|---|---|
|
||||
| `.clj-kondo/config/modules/config.edn` | Module declarations. Header documents `:team`, `:api`, `:uses`, `:friends`, `:model-exports`, `:model-imports`, `:bypass`. |
|
||||
| `.clj-kondo/src/hooks/common/modules.clj` (`hooks.common.modules`) | The one resolver: `resolve-module`, `parent-module`, `module-ns-prefix`, `namability-error`, `usage-error`. Read this when nesting behaves unexpectedly. |
|
||||
| `.clj-kondo/src/hooks/metabase/toucan/db_ns.clj` | `:metabase/t2-query-namespace` lint (app-DB calls only in `<module>.db`). |
|
||||
| `.clj-kondo/config/modules/ratchets.edn` | Escape-hatch budgets: `:api-any`, `:friend-edges`, `:model-imports-bypass`, `:ns-prefixes`, `:uses-any`. |
|
||||
| `.clj-kondo/ratchets.edn` | Inline-ignore budgets, incl. `:metabase/modules` and `:metabase/t2-query-namespace`. |
|
||||
| `dev.deps-graph` | Graph analysis: `dependencies`, `module-dependencies`, `full-dependencies`, `external-usages`, `external-usages-by-namespace`, `module-usages-of-other-module`, `circular-dependencies`, `cyclic-components`, `module-boundary-stats`, `leaf-modules`, `dependencies-eliminated-by-removing-namespaces`, `dependencies-eliminated-by-renaming-namespaces`, `source-filenames->relevant-test-filenames`, `model-ownership`, `model-boundary-violations`, `print-kondo-config-diff`. |
|
||||
| `dev.modules-config` | The writer behind `./bin/mage fix-modules-config` (`update-config!` warm, `fix-config!` cold). |
|
||||
| `dev.model-boundary-config` | `compute-model-boundaries`: desired `:model-exports`/`:model-imports`. |
|
||||
| `dev.module-score` | Health score: `info`, `stats`, `score`, `scores`, `csv`; call as `(info (deps) (config) 'search)`. |
|
||||
| `dev.module-explorer` | `open!` builds the HTML explorer with namespace edges (needs a dev REPL). |
|
||||
| `dev/src/dev/modularization_help.clj` | `potemkin-ns!` prints an `import-vars` vector. Its ns is declared as `src.dev.modularization-help`. |
|
||||
| `dev.kondo-ratchet` | Backs `./bin/mage kondo-ratchets` and `kondo-ratchets-shrink`. |
|
||||
| `mage/src/mage/modules.clj` | CLI fns behind the `affected-modules`, `modules-tree`, and `fix-modules-config` bb tasks (`cli-print-affected-modules`, `cli-print-module-tree`, `cli-fix-config`). |
|
||||
| `metabase.core.init`, `metabase-enterprise.core.init` | Init chain roots. |
|
||||
| `metabase.models.resolution` | `:model/X` keyword -> model namespace. |
|
||||
| `metabase.api-routes.routes` | REST route aggregation. |
|
||||
| `.github/team.json` | Valid `:team` names. |
|
||||
|
||||
Tests:
|
||||
- `metabase.core.modules-test` (`dev/test/metabase/core/modules_test.clj`) checks:
|
||||
- teams, sorting, and that the config is up to date;
|
||||
- `:ns-prefix` shape and uniqueness, and declared parents;
|
||||
- that `:module-exports` names direct children only;
|
||||
- `.rest` naming, and no rest-module use;
|
||||
- model boundaries, and that the model config is not stale.
|
||||
- `metabase.core.modules-nesting-test` (`test/metabase/core/modules_nesting_test.clj`): resolver unit tests. Read these as the spec for nesting and export rules.
|
||||
- `metabase.core.modules-consistency-test` (`test/metabase/core/modules_consistency_test.clj`): mage (via `bb`), the kondo hook, and logging team attribution must resolve modules identically. Logging keeps its own resolver copy (`metabase.util.log/ns->team*`).
|
||||
- `dev.modules-config-test` (`dev/test/dev/modules_config_test.clj`): the config writer.
|
||||
|
||||
## Invariants and landmines
|
||||
|
||||
- **The config is descriptive, not a gate.** `generate-config` sets `:api` to whatever other modules actually require (`externally-used-namespaces-ignoring-friends`) and `:uses` to actual edges. `fix-modules-config` makes almost any new require pass. The design decision is the config diff it writes. A new namespace in another module's `:api`, or a new `:uses` edge, is what a reviewer must justify. Always show that diff.
|
||||
- **Human-owned keys.** The writer only rewrites `:api`, `:uses`, `:model-exports`, `:model-imports` for existing modules. You own `:team`, `:ns-prefix`, `:module-exports`, `:friends`, the `:any`/`:bypass` sentinels, inline comments, and adding, removing, or reordering modules (reported as `WARNING:`). `print-kondo-config-diff` gives a read-only preview.
|
||||
- **Nesting rules** (`hooks.common.modules`):
|
||||
- Parent of `a.b.c` is `a.b`. Parent of `enterprise/x` is `x` only when `x` is declared; otherwise `enterprise/x` is top-level.
|
||||
- Every nested module needs its parent declared. `:team` is inherited from the nearest ancestor that has one.
|
||||
- Ownership is longest-prefix on the effective `:ns-prefix` (default `metabase.<name>` / `metabase-enterprise.<name>`). Effective prefixes must be unique.
|
||||
- A child may use any ancestor namespace. A parent, sibling, or cousin must use the target's `:api` and list it in `:uses`.
|
||||
- A nested module is private to the nearest ancestor that does not export it. `:module-exports` widens by one level and may only name direct children. `.rest` children and `enterprise/x` companions are exported implicitly.
|
||||
- **REST modules** are `<module>.rest` children with `:ns-prefix "metabase.<module>-rest"` (e.g. `actions.rest`). Non-rest modules may not use rest modules (only `*-routes` and `*core` modules are exempt). Move shared logic down into the base module.
|
||||
- **Escape hatches are ratcheted.** `:friends` exists once (`lib` lets `query-processor` in, budget 1). `:api :any`, `:uses :any`, `:model-imports :bypass` (`core.cmd`, `enterprise/serialization`), and custom `:ns-prefix` are all budgeted. Adding one raises a budget the PR must defend. Prefer fixing the code.
|
||||
- **Model boundaries.** `:model-exports` may list only models the module owns (`dev.deps-graph/model-ownership`). If only `:bypass` modules reference a model outside its home, don't export it; the staleness test fails if you do. Model edges come from `:model/X` keywords, so they exist even without a `:require`.
|
||||
- **`.db` follows the owning module, not the directory.** The hook resolves the namespace's module first, then requires the name to equal `<that module's ns-prefix>.db`. If you declare a nested module, its queries need its own `.db`; the parent's `.db` does not count for it. Exceptions: `metabase.driver.<driver>.db`, the `app-db-namespaces` group, and test files.
|
||||
- **Legacy `.db` in `:api`**: `bookmarks`, `models` (`metabase.models.db`), `sso.auth-identity`, `upload`, and `query-processor.cache-backend` list a `db` namespace in `:api`. Don't add more. Cross-module data goes through the owner's `.core`.
|
||||
- **Cycles are not test-gated.** No test fails on a module cycle. Track them with `module-boundary-stats`: `:scc-namespace-sizes` is the metric to watch, because splitting a module can grow `:scc-module-sizes` without removing anything. Don't add a new edge into an existing strongly connected component.
|
||||
- **`.core` is a facade.** Mostly `potemkin/import-vars`, some `metabase.util.namespaces/import-fns`. Never require your own `.core` from inside the module (self-cycle). `^:dynamic` vars don't re-export usefully; export a `with-*` helper instead.
|
||||
- **`.init` is eager.** Everything it requires loads at launch. Require only settings, tasks, event handlers, and multimethod registrations that must exist at startup.
|
||||
- **Grab-bag modules** (`models`, `api`, `task`, `events`, `util`, `core.cmd`) are infrastructure. New feature code goes in a feature module named after the user-facing feature, OSS and EE sharing the name (`upload` + `enterprise/upload`).
|
||||
|
||||
## How to work
|
||||
|
||||
1. **Read before proposing.** Find the module's entry in `config.edn` and run `./bin/mage modules-tree` (`--prefixes` shows custom prefixes, `--nested-only` trims). Run `dev.module-explorer/open!` when a picture helps.
|
||||
2. **Trace the edge.** `(dev.deps-graph/module-usages-of-other-module 'caller 'target)` shows the exact requiring namespaces. `external-usages-by-namespace` shows what leaks from a module.
|
||||
3. **Placement questions.**
|
||||
- App-DB query -> the calling module's `.db`. If it reads another module's model, prefer a function in the owner's `.core` (backed by the owner's `.db`) over a new `:model-imports` entry.
|
||||
- Logic other modules need -> internal namespace, re-exported from `.core`.
|
||||
- HTTP -> `.api` or a `.rest` child.
|
||||
- Settings, tasks, events -> namespaces required by `.init`.
|
||||
4. **Breaking a dependency.** Try these in order:
|
||||
1. Publish an event (`metabase.events.core/publish-event!`) and subscribe in the other module's `.events.*`.
|
||||
2. Read a setting with `(setting/get :kw)` instead of requiring its module.
|
||||
3. Extract the shared piece to a lower module, or a nested child both can reach.
|
||||
4. Split the module.
|
||||
5. Last resort: `requiring-resolve` inside a fn body, against an `:api` namespace only, with a comment saying why.
|
||||
|
||||
Use `dependencies-eliminated-by-removing-namespaces` to price a move before making it.
|
||||
5. **Never cheat the linter.** No `#_{:clj-kondo/ignore [:metabase/modules]}` or `[:metabase/t2-query-namespace]`, no top-level `(require ...)` outside the `ns` form. Push back even when asked; offer the refactor.
|
||||
6. **New or renamed module.** Add the `config.edn` entry by hand with `:team`, plus `:ns-prefix` only if names don't match. Wire `.init` into the core init, models into `metabase.models.resolution`, routes into `metabase.api-routes.routes`. Then run `./bin/mage fix-modules-config` and read its diff and warnings. For a rename, sweep docstrings and comments that name the old module.
|
||||
7. **Health review.** Run `(dev.module-score/info (dev.module-score/deps) (dev.module-score/config) 'search)` and read `:undeclared-deps`, `:unexpected-api-namespaces`, and `:circular-deps`. Then recommend concrete moves, not "reduce surface area".
|
||||
8. **Verify.**
|
||||
- `./bin/mage project-tests modules` runs `metabase.core.modules-test` and `dev.modules-config-test`.
|
||||
- Add `metabase.core.modules-nesting-test` and `metabase.core.modules-consistency-test` when you touch resolution logic in `hooks.common.modules`, mage, or logging.
|
||||
- Run `./bin/mage kondo-ratchets` when a budgeted hatch moved.
|
||||
- Run `./bin/mage affected-modules master` to report downstream blast radius and whether driver tests can be skipped.
|
||||
|
||||
## Return
|
||||
|
||||
- The decision (placement, `:uses`/`:api` change, nesting, or refactor) and why the alternatives lose.
|
||||
- The exact `config.edn` diff, plus each new `:api` namespace, `:uses` edge, or ratchet increase with its justification.
|
||||
- The cascade: other files that must change (init, resolution, routes, requires, docs) and whether you changed them.
|
||||
- Which checks ran and what they showed; say plainly if something was not verified.
|
||||
- Open questions for the caller, such as a `:team` you could not determine.
|
||||
@@ -1,75 +0,0 @@
|
||||
---
|
||||
name: notifications-backend-expert
|
||||
description: Metabase backend expert for alerts, dashboard subscriptions, the notification and pulse modules, channels (email, Slack, HTTP), and static-viz rendering to HTML, PNG, and PDF. Use when an alert or subscription fails to send, renders wrong, or fires at the wrong time. Also use when adding a channel, template, or system-event email. Not for Quartz internals (use platform-backend-expert) or card and dashboard models (use content-backend-expert).
|
||||
model: sonnet
|
||||
memory: project
|
||||
skills:
|
||||
- backend-module-conventions
|
||||
---
|
||||
|
||||
You work on how Metabase sends content out: alerts, dashboard subscriptions, system-event emails, and the channels and renderers behind them. You handle one self-contained question or change. Return a summary the caller can act on; don't drive multi-step plans.
|
||||
|
||||
## Map
|
||||
|
||||
All OSS under `src/metabase/` unless noted.
|
||||
|
||||
- `notification` - the send pipeline. Start with `src/metabase/notification/README.md`.
|
||||
- `metabase.notification.models` - Notification, NotificationSubscription, NotificationHandler, NotificationRecipient, NotificationCard.
|
||||
- `metabase.notification.send` - `send-notification!`, retry, the dispatcher queues and thread pools.
|
||||
- `metabase.notification.payload.core` - `payload` and `skip-reason` multimethods; impls in `payload.impl.card`, `payload.impl.dashboard`, `payload.impl.system-event`.
|
||||
- `metabase.notification.payload.execute`, `metabase.notification.payload.temp-storage` - query execution and spill-to-disk for large results.
|
||||
- `metabase.notification.task.send`, `metabase.notification.task.send-trigger` - Quartz job and per-subscription cron triggers.
|
||||
- `metabase.notification.events.notification` - turns `:metabase/event` topics into system-event notifications.
|
||||
- `metabase.notification.seed` - default system notifications, synced on startup.
|
||||
- `metabase.notification.db`, `metabase.notification.settings`, `metabase.notification.api.*`.
|
||||
- `pulse` - dashboard subscriptions (stored as pulses) and the deprecated `/api/alert` and `/api/pulse`.
|
||||
- `metabase.pulse.models.pulse`, `pulse-channel`, `pulse-card`, `pulse-channel-recipient`.
|
||||
- `metabase.pulse.send` - converts a pulse to notification info and calls `notification/send-notification!`.
|
||||
- `metabase.pulse.task.send-pulses`, `send-pulses-trigger`, `email-remove-legacy-pulse`.
|
||||
- `metabase.pulse.broken-subscriptions` - subscriptions whose parameters no longer exist on the dashboard.
|
||||
- `metabase.pulse.update-alerts` - alert changes when a card changes. `metabase.pulse.db`.
|
||||
- `channel` - delivery and rendering.
|
||||
- `metabase.channel.core` - `can-connect?`, `render-notification` (dispatches on `[channel-type payload-type]`), `send!`.
|
||||
- `metabase.channel.impl.email`, `impl.slack`, `impl.http` - the implementations, loaded by `metabase.channel.init`.
|
||||
- `metabase.channel.email` (SMTP, throttler), `channel.email.messages`, `channel.email.result-attachment`. The `.hbs` templates live in `src/metabase/channel/email/`.
|
||||
- `metabase.channel.slack` - Slack Web API client, upload flow, channel and user cache.
|
||||
- `metabase.channel.template.handlebars`, `channel.template.handlebars-helper`, `channel.urls`, `channel.settings`, `channel.db`.
|
||||
- `metabase.channel.render.*` - `card`, `body`, `table`, `png`, `image-bundle`, `style`, `maps`, `markdown`, `preview`, `pdf.*`, and `render.js.*` (`graal`, `renderer`, `svg`, `color`).
|
||||
- `metabase.formatter` - value formatting for rendered output.
|
||||
- `slackbot` - the Metabot Slack app (`/api/metabot/slack`). It reuses the Slack token from `channel.settings`. Handle Slack API and app-config issues here. Defer agent or LLM behaviour to ai-backend-expert.
|
||||
- EE: `metabase-enterprise.dashboard-subscription-filters.parameter` (blends subscription and dashboard filter values), `metabase-enterprise.email.api` (SMTP override settings).
|
||||
- Migration: `metabase.app-db.custom-migrations.pulse-to-notification` moves alerts out of pulses.
|
||||
|
||||
## Invariants and landmines
|
||||
|
||||
- Two storage models, one pipeline. Alerts live in `notification_*` tables. Dashboard subscriptions live in `pulse*` tables, and `pulse.send` builds notification info at send time. There is no NotificationDashboard model. First find which path a bug is on.
|
||||
- Sends are async by default (`*default-options*` has `:notification/sync? false`). Tests that assert on output need `notification.tu/with-send-notification-sync`.
|
||||
- The card and dashboard dispatcher is a dedup priority queue keyed by notification id. If a notification is already queued, a new send replaces its payload and does not add a second send. Deadlines come from the subscription cron. System events use a separate plain blocking queue.
|
||||
- Retry is per handler with backoff (6 retries in prod, 1 in dev). Slack `:slack/invalid-token` and `:slack/channel-not-found` are never retried.
|
||||
- HTTP channels render only `:notification/card`. Email and Slack render card, dashboard, and system-event payloads. Check `(methods channel/render-notification)` before you assume a combination works.
|
||||
- Cron triggers use the report timezone and fall back to the system timezone, not the user's timezone. A change to the report timezone re-times all triggers (`update-send-notification-triggers-timezone!`).
|
||||
- `InitNotificationTriggers` runs on every startup and recreates triggers for all subscriptions.
|
||||
- Large results spill to disk past `cells-to-disk-threshold`. Files larger than `notification-temp-file-size-max-bytes` are truncated. `do-after-notification-sent` deletes the temp files.
|
||||
- Charts render through GraalJS from a pool of up to 3 contexts. The pool shrinks to zero when idle, so the first render after an idle period is slow.
|
||||
- Slack file shares need the bot in the channel. The client tries `conversations.join`, which fails for private channels. Upload is three steps: `files.getUploadURLExternal`, the upload, then `files.completeUploadExternal`.
|
||||
- Email sends go through a throttler (`email-max-recipients-per-second`) and `email-max-recipients-per-message`. Email HTML needs inline styles. See `render.style` and `preview/style-tag-from-inline-styles`.
|
||||
- `metabase.notification.condition` is unused. Alert conditions are `send_condition` on NotificationCard (`has_result`, `goal_above`, `goal_below`), evaluated in `payload.impl.card` `skip-reason`.
|
||||
- `notification.seed` replaces a seeded notification when its compared keys change. If you edit a seeded system notification by hand, the next startup can undo the edit.
|
||||
|
||||
## How to work
|
||||
|
||||
1. Classify the issue: alert (notification tables), subscription (pulse tables), or system event (seeded notification plus event topic). Then find the failing stage: trigger, payload, `skip-reason`, render, or send.
|
||||
2. Check delivery separately from rendering. To capture messages without sending them, use `metabase.notification.test-util/with-captured-channel-send!` or `metabase.pulse.test-util/with-captured-channel-send-messages!`.
|
||||
3. To build fixtures, use `notification.tu/with-card-notification`, `with-temp-notification`, `with-system-event-notification!`, and `with-mock-inbox-email!`. Use `with-javascript-visualization-stub` when the chart output does not matter.
|
||||
4. To render in the REPL, use `dev.render-png` (`render-card-to-png`, `render-pulse-card`, `render-dashboard-to-pngs`, `open-html`) or `metabase.channel.render.preview/render-dashboard-to-html`.
|
||||
5. To check scheduling, use `notification.tu/send-notification-triggers` and `notification-triggers`, then compare with the subscription's `cron_schedule`. Check `task_history` rows with run types `:alert` and `:subscription`.
|
||||
6. Relevant tests: `metabase.notification.send-test`, `metabase.notification.task.send-test`, `metabase.notification.payload.*-test`, `metabase.channel.impl.{email,slack,http}-test`, `metabase.channel.render.*-test`, `metabase.pulse.dashboard-subscription-test`, `metabase.pulse.send-test`, `metabase.pulse.pulse-integration-test`.
|
||||
7. To add a channel: add `channel.impl.<name>` with `can-connect?`, `render-notification` for each payload type it supports, and `send!`. Require it from `metabase.channel.init`. Map it in `pulse.send/get-notification-handler` if subscriptions should use it.
|
||||
|
||||
## Return
|
||||
|
||||
- Root cause or answer, with file:line references.
|
||||
- Which path it affects (alert, subscription, system event) and which stage.
|
||||
- The change made, if any, and any behaviour change for existing sends or seeded notifications.
|
||||
- Which checks ran and what they showed. Say plainly if something was not verified, for example a real SMTP or Slack send.
|
||||
- Open questions, and anything deferred to platform-backend-expert, content-backend-expert, or ai-backend-expert.
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
name: permissions-backend-expert
|
||||
description: "Metabase backend expert for data and collection permissions, query permission checks, sandboxing, connection impersonation, login/session/auth-identity, SSO, MFA, embedding and public-sharing security, and API keys. Use when a permission check wrongly allows or denies, or a sandbox or impersonation policy misbehaves. Also when a login or SSO flow fails or embed token validation is in doubt. Not for SCIM or tenants (use enterprise-backend-expert)."
|
||||
model: opus
|
||||
memory: project
|
||||
skills:
|
||||
- backend-module-conventions
|
||||
---
|
||||
|
||||
You work on Metabase access control: who may see or run what, and how a request proves who it is. You handle one self-contained question or change. Return a summary the caller can act on; don't drive multi-step plans. Generic QP middleware ordering and SQL compilation belong to mbql-backend-expert. SCIM and tenants belong to enterprise-backend-expert.
|
||||
|
||||
## Map
|
||||
|
||||
OSS is under `src/metabase/`, EE under `enterprise/backend/src/metabase_enterprise/`. The `db` column says whether the module has a `<module>.db` namespace.
|
||||
|
||||
| Area | Namespaces | db |
|
||||
| --- | --- | --- |
|
||||
| Data perms model | `metabase.permissions.core`, `.models.data-permissions` (coalescing, request-scoped caches), `.models.data-permissions.sql` (visible-query helpers), `.schema` (perm types and value order), `.models.permissions` (legacy path strings, used for collections and cards), `.models.permissions-group`, `.models.permissions-group-membership`, `.published-tables` (OSS stub), `.data-access-token` | yes |
|
||||
| Collection perms | `metabase.permissions.models.collection.graph`, `.models.collection-permission-graph-revision` | |
|
||||
| Perms graph API | `metabase.permissions-rest.api`, `.api.permission-graph`, `.data-permissions.graph` | yes |
|
||||
| Query perms | `metabase.query-permissions.impl` (required-perms computation), `metabase.query-processor.middleware.permissions` (QP check) | yes |
|
||||
| Advanced perms (EE) | `metabase-enterprise.advanced-permissions.models.permissions.block-permissions`, `.application-permissions`, `.group-manager`, `.query-processor.middleware.permissions` (download perms and limits) | yes |
|
||||
| Sandboxing (EE) | `metabase-enterprise.sandbox.query-processor.middleware.sandboxing`, `.models.sandbox`, `.api.gtap`, `.api.util` | yes |
|
||||
| Impersonation (EE) | `metabase-enterprise.impersonation.driver` (`set-role-if-supported!`), `.middleware`, `.util`, `.models` | yes |
|
||||
| QP hooks | `metabase.query-processor.middleware.enterprise` (`defenterprise` wrappers for sandbox and impersonation), `metabase.query-processor.preprocess` (order) | |
|
||||
| Auth identity | `metabase.auth-identity.provider` (`validate`/`authenticate`/`login!` multimethods), `.providers.password`, `.providers.emailed-secret`, `.session`; EE `metabase-enterprise.auth-identity.provider` | yes |
|
||||
| Sessions, request | `metabase.session.models.session`, `.challenge` (MFA step token), `.api`; `metabase.request.session` (`with-current-user`, `as-admin`), `.current`, `.cookies`; `metabase.login-history` | session, request, login-history |
|
||||
| SSO | OSS `metabase.sso` (`.google`, `.ldap`, `.oidc.*`, `.providers.*`, `.integrations.slack-connect`); EE `metabase-enterprise.sso.integrations.{saml,jwt,oidc,ldap,google}`, `.providers.{saml,jwt,oidc}`, `.models.relay-state` | both |
|
||||
| MFA | OSS `metabase.mfa` (settings only); EE `metabase-enterprise.mfa.gate`, `.totp`, `.enrollment`, `.verification`, `.recovery-codes`, `.throttling` | EE |
|
||||
| Embedding, public | `metabase.embedding.jwt` (`unsign`), `.validation`, `.settings`; `metabase.embedding-rest.api.{embed,preview-embed,common}`; `metabase.public-sharing`, `metabase.public-sharing-rest.api` | yes |
|
||||
| API keys, OAuth | `metabase.api-keys.models.api-key`; `metabase.api-scope.core` (scope matching); `metabase.oauth-server` (MCP OAuth tokens) | yes |
|
||||
| Warehouse auth | `metabase.auth-provider.core` (`fetch-auth` for DB connections; EE in `metabase-enterprise.auth-provider.impl`) | |
|
||||
| Diagnostics (EE) | `metabase-enterprise.permission-debug.impl` and `.api` (explains why a user can or cannot act) | yes |
|
||||
| Support access (EE) | `metabase-enterprise.support-access-grants` | yes |
|
||||
|
||||
## Invariants and landmines
|
||||
|
||||
- Group perms merge most-permissive-wins via `coalesce`. Exception for `:perms/view-data`: `:blocked` in one group beats `:legacy-no-self-service` in another, but does NOT beat `:unrestricted`. "Block overrides everything" is wrong.
|
||||
- Value order lives in `metabase.permissions.schema/data-permissions`. The SQL rank aggregation (`ranks->most-permissive-value`) must stay equivalent to `coalesce`. Change both or neither.
|
||||
- Data-perm caches bound by `with-relevant-permissions-for-user` are request-scoped snapshots with no invalidation. Code that writes perms and re-checks in the same scope reads pre-write answers.
|
||||
- Permission checks do not parse native queries for tables. Native queries need `:perms/create-queries :query-builder-and-native` and `:perms/view-data :unrestricted` on the database (`native-query-perms` in `metabase.query-permissions.impl`).
|
||||
- `apply-sandboxing` runs twice in `metabase.query-processor.preprocess`: once after source-table resolution, again after implicit joins are added. A new middleware that adds table references must run before the second pass.
|
||||
- Sandboxed queries carry `:query-permissions/sandboxed-table`. Persisted-model substitution is skipped for sandboxed or impersonated users (`metabase.query-processor.middleware.persistence`). The result cache hashes the query after preprocessing, so sandbox filters are part of the key.
|
||||
- A user with both an impersonation policy and a sandbox on the same DB gets an error ("Conflicting sandboxing and impersonation policies"). Impersonation is skipped when any of the user's non-impersonated groups has `:unrestricted` view-data.
|
||||
- `set-role-if-supported!` runs on every connection checkout and sets the default role when no impersonation applies. Pooled connections rely on this to drop a previous user's role. Don't bypass it.
|
||||
- Static embeds and public links run queries inside `request/as-admin`. Security comes from `embedding.jwt/unsign` against `embedding-secret-key` plus locked and disabled param checks, not from user perms.
|
||||
- `auth-identity.provider/login!` is the only path that mints interactive sessions, and the MFA gate hooks it. API keys and MCP OAuth tokens skip it. Don't assume MFA covers them.
|
||||
- Cached result blobs shared across users must pass `data-access-compatible?`. Its EE contributors use `:feature :none` so they fail closed when a feature flag drops.
|
||||
- Admins bypass most checks. Test denial with a non-admin user (`:rasta`, `:lucky`).
|
||||
|
||||
## How to work
|
||||
|
||||
1. Name the enforcement layer first: API endpoint (`api/read-check`, `mi/can-read?`), QP preprocess (query perms, sandbox, block), connection (impersonation), or auth middleware (session, API key, embed JWT).
|
||||
2. For "why can or can't user X", call `metabase-enterprise.permission-debug.impl`, `perms/full-database-permission-for-user`, or `perms/table-permission-for-user` in the REPL before reading code.
|
||||
3. For sandbox bugs, run `qp.preprocess/preprocess` as the user and inspect both passes. Check joins and source cards.
|
||||
4. Always add a negative test: the denied case must throw or return nothing.
|
||||
5. Test helpers: `metabase.permissions.test-util` (`with-no-data-perms-for-all-users!`, `with-perm-for-group-and-table!`, `with-restored-data-perms!`), `metabase-enterprise.sandbox.test-util` (`with-gtaps!`, `with-user-attributes!`), `with-impersonations!` in `metabase-enterprise.impersonation.util-test`, `mt/with-all-users-data-perms-graph!`.
|
||||
6. Main test namespaces: `metabase.permissions.models.data-permissions-test`, `metabase.query-permissions.impl-test`, `metabase.query-processor.middleware.permissions-test`, `metabase-enterprise.sandbox.query-processor.middleware.sandboxing-test`, `metabase-enterprise.sandbox.api.*-test`, `metabase-enterprise.impersonation.*-test`.
|
||||
|
||||
## Return
|
||||
|
||||
- Root cause or answer, with file:line for each enforcement point involved.
|
||||
- The change made, and whether it widens or narrows access for any user class (admin, sandboxed, impersonated, embed, public, API key).
|
||||
- Denial and grant cases covered by tests.
|
||||
- Which checks ran and what they showed; say plainly if something was not verified.
|
||||
- Open questions, especially any path where a check might be bypassed.
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
name: platform-backend-expert
|
||||
description: "Metabase backend expert for the app DB (connection, Liquibase/custom migrations, value-guard, cluster lock), HTTP server and middleware, defendpoint/OpenAPI, settings, Quartz tasks, caching, Toucan 2 model infra, and metabase.util. Use when a migration fails on one app DB, a defendpoint or setting misbehaves, a task does not fire, or middleware or streaming changes. Not for module boundaries (use modules-backend-expert)."
|
||||
model: opus
|
||||
memory: project
|
||||
skills:
|
||||
- backend-module-conventions
|
||||
---
|
||||
|
||||
You work on Metabase's platform layer: the app DB, the HTTP server, the API framework, settings, scheduling, caching, Toucan 2 model infra, and shared utilities. You handle one self-contained question or change. Return a summary the caller can act on; don't drive multi-step plans.
|
||||
|
||||
You own the app-DB layer itself, including `metabase.app-db.value-guard`, `metabase.app-db.honeysql-guard`, and the `db_ns` kondo hook (`.clj-kondo/src/hooks/metabase/toucan/db_ns.clj`). The rules they enforce (`<module>.db`, `[:auto/param v]`) live in the preloaded `backend-module-conventions` skill. Change the mechanism here; cite the skill for the rules.
|
||||
|
||||
## Map
|
||||
|
||||
All OSS, under `src/metabase/` unless noted.
|
||||
|
||||
| Area | Namespaces and paths |
|
||||
|---|---|
|
||||
| App DB | `metabase.app-db.core` (facade), `.connection`, `.connection-pool-setup`, `.data-source`, `.env`, `.setup`, `.query`, `.query-cancelation`, `.cluster-lock`, `.encryption`, `.h2`, `.update-h2`, `.quartz`, `.transient-error`, `.sql-errors`, `.db` |
|
||||
| Query guards | `metabase.app-db.value-guard`, `metabase.app-db.honeysql-guard`; lint `hooks.metabase.toucan.db-ns` (test: `.clj-kondo/test/hooks/metabase/toucan/db_ns_test.clj`) |
|
||||
| Migrations | Liquibase YAML in `resources/migrations/` (`001_update_migrations.yaml`, `056`-`059` files, then one file per change in per-release dirs `060/` onward); `metabase.app-db.liquibase` (+ `.liquibase.h2`, `.liquibase.mysql`); `metabase.app-db.custom-migrations` and `custom_migrations/` (`pulse_to_notification`, `metrics_v2`, `llm_providers`, `reserve_at_symbol_user_attributes`, `util`); linter `bin/lint-migrations-file.sh` |
|
||||
| CLI | `metabase.cmd.*`: `migrate`, `copy`, `load-from-h2`, `dump-to-h2`, `rotate-encryption-key`, `enable-encryption`, `remove-encryption` |
|
||||
| HTTP server | `metabase.server.core`, `.instance`, `.handler` (middleware stack), `.routes`, `.streaming-response`, `.streaming-response.thread-pool`, `.settings`, `.db`; 18 middlewares in `metabase.server.middleware.*` (`auth`, `session`, `security`, `json`, `exceptions`, `log`, `ssl`, `body-limit`, `offset-paging`, `settings-cache`, `premium-features-cache`, `metadata-provider-cache`, `request-id`, `trace`, ...) |
|
||||
| API framework | `metabase.api.macros` (`defendpoint`), `.macros.defendpoint.open-api`, `.macros.defendpoint.closed-schemas`, `.macros.scope`, `metabase.api.open-api`, `metabase.api.common`, `metabase.api.response`; routes in `metabase.api-routes.*` |
|
||||
| Settings | `metabase.settings.models.setting` (`defsetting`), `.setting.cache`, `.setting.multi-setting`, `metabase.settings.core`, `metabase.settings.db` |
|
||||
| Tasks | `metabase.task.core`, `metabase.task.impl`, `metabase.task.job-factory`; guide `src/metabase/task/QUARTZ.md`; history in `metabase.task-history.*` (`models.task-history`, `models.task-run`, `task.task-run-heartbeat`, `db`) |
|
||||
| Cache | Config: `metabase.cache.core`, `.models.cache-config`, `.models.query-cache`, `.db`. QP layer: `metabase.query-processor.middleware.cache`, `.cache.impl`, `.cache-backend.db`, `.cache-backend.interface`. EE: `metabase-enterprise.cache.strategies`, `.config`, `.db`, `task/` |
|
||||
| Model infra | `metabase.models.interface` (Toucan 2 transforms, hooks), `metabase.models.dispatch`, `metabase.models.resolution`, `metabase.models.json-migration` |
|
||||
| Util | `metabase.util.honey-sql-2`, `.malli` (+ `malli/`), `.date-2`, `.log`, `.i18n`, `.encryption`, `.json`, `.retry`, `.queue`, `.cron` |
|
||||
| Config | `metabase.config.core` (env and config reads) |
|
||||
|
||||
`metabase.models.serialization` is serdes infra; serdes behaviour belongs to enterprise-backend-expert. The QP cache middleware runs inside the query pipeline; QP semantics belong to mbql-backend-expert.
|
||||
|
||||
## Invariants and landmines
|
||||
|
||||
- **Shipped migrations are immutable.** Liquibase checksums changesets. Fix a shipped changeset or custom migration by adding a new one. Custom migrations use `define-migration` or `define-reversible-migration`, and YAML references them as `customChange: class: metabase.app_db.custom_migrations.<Name>`.
|
||||
- **Custom migrations run inside `t2/with-transaction`.** Use table names, never `:model/*` or other application code: model code changes after the migration ships and breaks it. Some older migrations use `:model/*`; don't copy them.
|
||||
- **Three app DBs: H2, Postgres, MySQL/MariaDB.** Locking, DDL, JSON, and case rules differ. `metabase.app-db.liquibase.h2` and `.mysql` exist because of this.
|
||||
- **The guards run in the Toucan pipeline.** `honeysql-guard` runs before `value-guard`. It rejects a `{:raw ...}` map or a bare subquery inline in a value slot, marked or not. `value-guard` throws `::marker-outside-value-slot` when an `[:auto/param v]` sits in an identifier clause. A marker in `:order-by` or `:group-by` compiles to `ORDER BY ?` without an error. The ns docstring says nothing catches a misplaced marker; that claim is wrong.
|
||||
- **Middleware order is inverted.** `metabase.server.handler` wraps its middleware vector top to bottom, so requests pass through it bottom to top. Read the comment there before you insert a middleware.
|
||||
- **`defendpoint` checks closed schemas when it evaluates the endpoint.** A request-reachable open `[:map ...]`, `:map-of :keyword`, or `:any` fails at load time (`metabase.api.macros.defendpoint.closed-schemas`). The decoder drops undeclared keys, so an undeclared key reads as `nil`.
|
||||
- **Settings cache coherence is eventual.** Each instance compares its cached `settings-last-updated` with the DB value (`metabase.settings.models.setting.cache`). Another instance sees a write only after it refreshes.
|
||||
- **Quartz state persists in the app DB.** `schedule-task!` reschedules when the job exists. On a multi-trigger job with no key match, it replaces an arbitrary trigger (`metabase.task.impl/reschedule-task!`). Startup tasks run on every instance and on each restart, so they must be idempotent (`QUARTZ.md`).
|
||||
- **Streaming responses share one fixed pool.** Size comes from `MB_ASYNC_QUERY_THREAD_POOL_SIZE`, else `MB_JETTY_MAXTHREADS`, else 50. Blocking work in a streaming body starves every other export.
|
||||
- **Cluster locks need a consistent order.** Take several locks by passing `:locks` in the opts map of `do-with-cluster-lock`, not with nested `with-cluster-lock` forms.
|
||||
- **Encryption key rotation is a CLI command** (`metabase.cmd.rotate-encryption-key`). It calls `mdb/encrypt-db`, which re-encrypts the raw table and column lists `encrypted-string-columns` and `encrypted-bytes-columns` in `metabase.app-db.encryption`. When you add an encrypted column, add it to those lists too.
|
||||
|
||||
## How to work
|
||||
|
||||
1. Migrations: follow the "Add app-DB migrations as one file per change" section of the `backend-module-conventions` skill (file layout, IDs, backports, scoped preconditions). Copy the shape of a recent file in the newest dir. Run `bin/lint-migrations-file.sh`. Test with the `test-migrations` macro (`metabase.app-db.schema-migrations-test.impl`); `metabase.app-db.schema-migrations-test` and `metabase.app-db.custom-migrations-test` have examples.
|
||||
2. Guards and lint: run `metabase.app-db.value-guard-test`, `metabase.app-db.query-test`, and `hooks.metabase.toucan.db-ns-test` (kondo hook tests are on the dev classpath, so `./bin/test-agent :only '[hooks.metabase.toucan.db-ns-test]'` works; `:only '[".clj-kondo/test"]'` runs all of them). A hook change alters lint output for every module, so run `./bin/mage kondo-ratchets` after it.
|
||||
3. Middleware and server: read the stack in `metabase.server.handler`, then the one middleware. Tests live in `test/metabase/server/` (`handler-test`, `streaming-response-test`, `middleware/`).
|
||||
4. API framework: run `metabase.api.macros-test` and `metabase.api.open-api-test`. A change to `defendpoint` touches every endpoint, so also load a few `*.api` namespaces.
|
||||
5. Settings: run `metabase.settings.models.setting-test`. Check the env-var override and visibility, not only the DB path.
|
||||
6. Tasks: inspect live state with `metabase.task.impl/scheduler-info` and `job-info` in the REPL before you change triggers.
|
||||
7. For cache, locks, settings, and Quartz, check single-instance and multi-instance behaviour. They differ.
|
||||
|
||||
## Return
|
||||
|
||||
- The answer or root cause, with `file:line` references.
|
||||
- The change made (files and a short description), or the proposed change if you were asked only to investigate.
|
||||
- The app-DB backends and instance modes you covered, and the ones you did not.
|
||||
- Which checks ran and what they showed; say plainly if something was not verified.
|
||||
- Open questions or follow-ups for a neighbour agent (modules-backend-expert for boundaries, mbql-backend-expert for QP, enterprise-backend-expert for serdes).
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
name: search-backend-expert
|
||||
description: Metabase backend expert for search engines (in-place, appdb, semantic), search specs, ingestion and scoring, X-rays, model indexes, and recent views. Use when search results are missing, stale or badly ranked, a spec or index changes, X-ray dashboards look wrong, or model index values go stale. Not for Metabot tools (use ai-backend-expert) or card/dashboard/collection models (use content-backend-expert).
|
||||
model: sonnet
|
||||
memory: project
|
||||
skills:
|
||||
- backend-module-conventions
|
||||
---
|
||||
|
||||
You work on Metabase search, X-rays, indexed entities, and the activity feed. You handle one self-contained question or change. Return a summary the caller can act on; don't drive multi-step plans.
|
||||
|
||||
## Map
|
||||
|
||||
OSS search (`src/metabase/search/`):
|
||||
|
||||
| Namespace | Role |
|
||||
|---|---|
|
||||
| `metabase.search.core` | Public API: `search`, `update!`, `define-spec`, `supports-index?`. |
|
||||
| `metabase.search.engine` | Multimethods every engine implements (`results`, `update!`, `delete!`, `init!`, `reindex!`, `diagnose`), engine selection, `active-engines`. |
|
||||
| `metabase.search.hierarchy` | Engine keyword hierarchy (`:search.engine/fulltext` derives from `:search.engine/appdb`). |
|
||||
| `metabase.search.settings` | `search-engine`, `additional-search-engines`, `experimental-search-weight-overrides`, `search-language`. |
|
||||
| `metabase.search.spec` | `define-spec` DSL, `index-version-hash`. Specs live next to each model (e.g. `metabase.queries.models.card`). |
|
||||
| `metabase.search.ingestion` | Spec -> documents, the async `DelayQueue`, `update!` fan-out to active engines. |
|
||||
| `metabase.search.models` | `:hook/search-index` after-insert/after-update hooks. |
|
||||
| `metabase.search.config`, `metabase.search.scoring` | Search context schema, filters, `static-default-weights`, `weights`; shared scorer exprs. |
|
||||
| `metabase.search.impl`, `metabase.search.filter`, `metabase.search.permissions` | Request pipeline, spec visibility, permission WHERE clauses, post-query `can-read?` check. |
|
||||
| `metabase.search.debug`, `metabase.search.index-health` | "Why is X not in results" diagnosis; index health gauges. |
|
||||
| `metabase.search.appdb.*` | Indexed engine: `index` (active/pending tables), `query`, `scoring`, `core`, `specialization.{api,h2,postgres}`. |
|
||||
| `metabase.search.in-place.*` | Non-indexed engine: `engine`, `legacy` (SQL), `scoring` (in-memory), `filter`. |
|
||||
| `metabase.search.semantic.core` | OSS `defenterprise` stubs for the semantic engine. |
|
||||
| `metabase.search.task.search-index` | Quartz init/reindex jobs and the queue listener. |
|
||||
| `metabase.search.db` | App-DB queries for the module. |
|
||||
| `metabase.search.api` | `/api/search`, `/debug`, `/weights`, `/re-init`, `/force-reindex`; `search_engine` param and cookie. |
|
||||
|
||||
Enterprise:
|
||||
- `metabase-enterprise.search.scoring` - adds `:official-collection` and `:verified` scorers when those features are on.
|
||||
- `metabase-enterprise.semantic-search.*`:
|
||||
- `core` - engine impl and fallback; `pgvector-api` - query and upsert; `index`, `index-metadata` - vector tables.
|
||||
- `gate` - decouples ingestion from embedding; `indexer` polls it; `dlq` isolates and retries failing docs; `repair` finds lost deletes.
|
||||
- `embedding` - embedding provider calls; `embedders` - name -> vector lookup over the index; `scoring`, `appdb-scoring` - blended ranking.
|
||||
- `db.datasource`, `db.migration` - the pgvector store; `models.token-tracking` - usage metering.
|
||||
- `task.*` - indexer, index-repair, index-cleanup, usage-trimmer, metric-collector. `db` - app-DB queries.
|
||||
- `metabase.entity-retrieval.*` / `metabase-enterprise.entity-retrieval.*` - a separate pgvector index of library entities that Metabot uses. It reuses the semantic-search datasource and embedding health checks. It is a neighbour, not part of search.
|
||||
|
||||
X-rays (`src/metabase/xrays/`): `metabase.xrays.automagic-dashboards.{core,interesting,dashboard-templates,populate,comparison,combination,filters,names}`, `metabase.xrays.related`, `metabase.xrays.domain-entities.*`, `metabase.xrays.transforms.*`, `metabase.xrays.api.automagic-dashboards`, `metabase.xrays.db`. Templates are YAML in `resources/automagic_dashboards/{table,field,metric,question,comparison}`.
|
||||
|
||||
Indexed entities: `metabase.indexed-entities.models.model-index`, `metabase.indexed-entities.task.index-values`, `metabase.indexed-entities.api`, `metabase.indexed-entities.db`.
|
||||
|
||||
Activity and views: `metabase.activity-feed.{api,models.recent-views,events.recent-views,db}`, `metabase.view-log.{models.view-log,events.view-log,db}`.
|
||||
|
||||
## Invariants and landmines
|
||||
|
||||
- The engine decides the code path. Default precedence is semantic, then appdb, then in-place. Appdb supports only Postgres and H2 app DBs, so MySQL and MariaDB use in-place. Check `(metabase.search.engine/active-engines)` before you debug.
|
||||
- `define-spec` derives the model from `:hook/search-index`. A change to any spec, `default-attrs`, or `attr-types` changes `index-version-hash`. Appdb then builds a new pending table and swaps it in. `metabase.search.spec-test` checks that exactly the right models derive the hook.
|
||||
- Ingestion is async through a `DelayQueue` in `metabase.search.ingestion`. Wrap tests in `with-sync-search-indexing`, or results look missing. Do not use search for consistency-critical reads.
|
||||
- `metabase.view-log.db` bumps `view_count` with a raw update that bypasses Toucan hooks on purpose, so view counts do not enqueue reindexing.
|
||||
- Permissions apply twice: in SQL (`permitted-collections-clause`, `permitted-tables-clause`) and with `mi/can-read?`/`can-write?` in `metabase.search.impl`. Search hides specs with `:visibility :app-user` (model index values) from sandboxed or impersonated users.
|
||||
- Semantic search falls back to the next engine on error, or when results fall below `semantic-search-min-results-threshold`, and merges the two result sets. Offsets give odd pages in that mode. The pgvector store comes from `MB_PGVECTOR_DB_URL` or the Postgres app DB.
|
||||
- Semantic `supported?` requires a usable embedder. Without one, search does not select the engine. Maintenance of an existing index runs anyway.
|
||||
- Weights are `static-default-weights` merged with per-context weights and `experimental-search-weight-overrides`. Change weights in `metabase.search.config`, not in scorer exprs.
|
||||
- Model index refresh runs the full model query and stops at `max-indexed-values` (25000). Past that it marks the index `"overflow"`.
|
||||
- X-ray output depends on field semantic types and fingerprints from sync/analyze. Fix template YAML before you change the engine.
|
||||
|
||||
## How to work
|
||||
|
||||
1. Name the engine and the app DB type first. Use `/api/search/debug` or `metabase.search.debug` to see which stage drops an entity (`:not-searchable`, `:missing-from-index`, `:filtered`, `:not-permitted`, `:ranked-out`).
|
||||
2. For ranking, read the per-scorer `:scores` in results and compare them with `metabase.search.config/weights`. Recheck exact-name matches after any weight change.
|
||||
3. For missing or stale results, check the spec `:where` (`search.ingestion/indexable-row?`), then the queue, then the active index table.
|
||||
4. Test helpers: `metabase.search.test-util` (`with-temp-index-table`, `with-sync-search-indexing`, `with-appdb-search-and-legacy-search`, `with-legacy-search`). Semantic: `metabase-enterprise.semantic-search.test-util` (`with-test-db!`, `with-mock-embeddings`). Semantic tests need a pgvector Postgres (see `semantic_search/docker-compose.yml`).
|
||||
5. Test locations: `test/metabase/search/` (`api_test`, `spec_test`, `ingestion_test`, `appdb/`, `in_place/`), `enterprise/backend/test/metabase_enterprise/semantic_search/`, `test/metabase/xrays/`, `test/metabase/indexed_entities/`, `test/metabase/activity_feed/`.
|
||||
6. Run appdb tests on both H2 and Postgres when you change SQL in `appdb.specialization` or `appdb.query`.
|
||||
|
||||
## Return
|
||||
|
||||
- Root cause or design answer with `file:line` references.
|
||||
- The change made or proposed, and which engines and app DBs it affects.
|
||||
- Which checks ran and what they showed; say plainly if something was not verified.
|
||||
- Open questions, especially reindex or migration impact from spec changes.
|
||||
@@ -1,91 +0,0 @@
|
||||
---
|
||||
name: transforms-backend-expert
|
||||
description: "Metabase backend expert for write-back and materialization: actions and EE action_v2 data editing, CSV uploads, transforms (query, incremental, Python, testing, inspector) and model persistence. Use when a transform, job or DAG run fails or misorders, or an upload infers types or appends badly, or when an action or row edit misbehaves or persisted models refresh wrong. Not for driver DDL methods or sync (use drivers-and-sync-backend-expert)."
|
||||
model: sonnet
|
||||
memory: project
|
||||
skills:
|
||||
- backend-module-conventions
|
||||
---
|
||||
|
||||
You work on the Metabase backend paths that write to the customer's warehouse: actions, uploads, transforms and model persistence. You handle one self-contained question or change. Return a summary the caller can act on; don't drive multi-step plans.
|
||||
|
||||
## Map
|
||||
|
||||
OSS (`src/metabase/`):
|
||||
|
||||
- `metabase.actions` - query, HTTP and implicit (row create/update/delete) actions.
|
||||
- `actions.actions` - `perform-action!`, `perform-action-v2!`, enable checks.
|
||||
- `actions.execution` - `execute-action!`, `execute-dashcard!`.
|
||||
- `actions.http-action`, `actions.hierarchy` (private action keyword hierarchy), `actions.scope`, `actions.settings`.
|
||||
- HTTP endpoints: `metabase.actions-rest.api`.
|
||||
- `metabase.driver.sql-jdbc.actions` - JDBC implementations of `perform-action!*`, `with-jdbc-transaction`, SQL error parsing.
|
||||
- `metabase.upload` - `upload.impl` (create, append, replace, delete), `upload.parsing`, `upload.types` (type relaxation DAG), `upload.api`, `upload.db`.
|
||||
- `metabase.transforms-base` - execution without run tracking.
|
||||
- `transforms-base.core/execute!` and `transforms-base.interface` (multimethods on source type).
|
||||
- `transforms-base.query` - query transforms.
|
||||
- `transforms-base.ordering` - dependency order and cycle detection.
|
||||
- `transforms-base.util` - targets, incremental checkpoints, temp tables, target sync.
|
||||
- `metabase.transforms` - tracked runs (`transform_run` rows).
|
||||
- `transforms.interface/execute!`, `transforms.execute`.
|
||||
- `transforms.jobs` - coordinator: lanes, heartbeat, cascade failures.
|
||||
- `transforms.dag` - manual DAG reprocess runs; `transforms.coordinated-run` shares the lifecycle.
|
||||
- `transforms.freshness`, `transforms.canceling`, `transforms.timeout`, `transforms.schedule`, `transforms.feature-gating`.
|
||||
- `transforms.models.*` - transform, transform-run, transform-job, job-run, dag-run, tags, run cancelation.
|
||||
- `metabase.transforms-rest.api.*` - transform, job, tag and DAG-run endpoints.
|
||||
- `metabase.transforms-inspector` - pure `.cljc` lens helpers shared with the frontend.
|
||||
- `metabase.model-persistence` - `models.persisted-info` (states, substitution flag), `task.persist-refresh` (Quartz refresh and prune jobs), `model-persistence.api`, `model-persistence.db`.
|
||||
- Driver side: `metabase.driver/run-transform!` (`[:sql :table]` and `[:sql :table-incremental]` in `metabase.driver.sql`), `metabase.driver.ddl.interface/refresh!` with methods in `metabase.driver.postgres.ddl` and `metabase.driver.mysql.ddl`.
|
||||
|
||||
Enterprise (`enterprise/backend/src/metabase_enterprise/`):
|
||||
|
||||
- `action-v2` - table data editing, form execution, coercion, validation, undo (`action-v2.models.undo`).
|
||||
- `upload-management` - API to list and delete upload tables.
|
||||
- `transforms` - EE companion: metering, table-dependency caching, `/api/ee/transforms` (mounts the inspector routes).
|
||||
- `transforms-python` - Python transforms.
|
||||
- `python-runner` - HTTP client for the external runner.
|
||||
- `s3` - presigned URLs for data exchange.
|
||||
- `execute`, `base`, `models.python-library`.
|
||||
- Settings for runner URL, S3 and timeouts.
|
||||
- `transforms-inspector` - lens discovery and computation (`lens.*`, `context`, `query-analysis`).
|
||||
- `transform-testing` - test runs for a transform against temp tables: `runner`, `compile`, `validator`, `executor`, `expectations.*`.
|
||||
|
||||
Every module above has its own `db.clj`.
|
||||
|
||||
## Invariants and landmines
|
||||
|
||||
- Every warehouse write path wraps its work in `metabase.driver.connection/with-write-connection`, so the write honors `:write-data-details`. A new write path that skips it writes through the read connection.
|
||||
- Query transforms run through `driver/run-transform!`. The `[:sql :table]` method picks a strategy from driver features: create-or-replace, atomic rename swap, create-drop-rename, or drop-then-create with no atomicity. Behavior differs per database, so name the driver when you reason about it.
|
||||
- A full incremental run (no watermark yet, or after a checkpoint reset) runs as a `:table` transform. It drops and recreates the target instead of appending. Merge targets upsert by unique key through a temp table (delete matches, then insert).
|
||||
- Transforms refuse to run on databases with DB routing enabled (`transforms-base.util/throw-if-db-routing-enabled!`).
|
||||
- Availability: query transforms work on OSS and self-hosted without a license; hosted needs `:transforms-basic`. Python needs `:transforms-basic` and `:transforms-python`. Both also need the `transforms-enabled` setting. Tests for incremental and merge targets live under `enterprise/backend/test/metabase_enterprise/transforms/`.
|
||||
- The job coordinator:
|
||||
- runs Python transforms in a single-slot `:py` lane;
|
||||
- never runs two transforms that write the same target table at once;
|
||||
- records dependents of a failed transform as cascade failures, not root causes.
|
||||
- Metabase reaps a job run whose coordinator misses heartbeats for 5 minutes. `TimeoutTransforms` times out lost transform runs every 10 minutes.
|
||||
- Python transforms do not run in a local subprocess. Metabase calls an external runner over HTTP (`python-runner-url`) and exchanges data through S3 presigned URLs. `transforms_python/s3_test.clj` skips with only a log warning when the runner is not reachable, so a green run can mean nothing ran.
|
||||
- Uploads parse the whole CSV into memory (`parsed-rows` is a vector), and sql-jdbc `insert-into!` inserts all rows in one transaction. The size cap is `max-upload-size-bytes` (50 MB). Keep the frontend and docs in sync with it.
|
||||
- Upload type inference walks the DAG in `upload.types` to the first common ancestor; fully blank columns become text. Drivers with `:upload-with-auto-pk` get a `_mb_row_id` primary key, and the upload drops CSV columns with that name. Appends match columns by normalized name, then by display name when names collide.
|
||||
- Model persistence refresh is drop then create, not create-then-swap. Postgres does both in one transaction. MySQL does not, so a failed MySQL refresh leaves no table until the next refresh. The refresher binds `*allow-persisted-substitution*` to false so the model rebuilds from its source query.
|
||||
- Actions need the driver feature (`:actions`, `:actions/custom`, `:actions/data-editing`) and the per-database setting (`database-enable-actions` or `database-enable-table-editing`). Query actions substitute parameters through the QP native parameter path, not string building. Bulk implicit actions run in one JDBC transaction via `with-jdbc-transaction`.
|
||||
- Driver action methods dispatch on concrete action keywords through `driver/hierarchy`. Group keywords such as `:table.row/common` exist only in `metabase.actions.hierarchy`.
|
||||
|
||||
## How to work
|
||||
|
||||
1. Name the path first:
|
||||
- action (query, HTTP, implicit, data editing);
|
||||
- upload (create, append, replace);
|
||||
- transform (query, incremental, merge, Python, test run, job, DAG run);
|
||||
- persisted model refresh.
|
||||
2. For transforms, separate base execution (`transforms-base`, no run rows) from tracked execution (`transforms.execute`, `transforms.jobs`). Bugs in run status, cancelation or failure notifications live in the tracked layer.
|
||||
3. When the failure depends on the warehouse, read the driver's `run-transform!`, `insert-into!`, `create-table!` or `ddl.i/refresh!` method and its feature flags before you change shared code. Hand driver DDL changes to drivers-and-sync-backend-expert.
|
||||
4. Reproduce at the REPL with the test macros: `metabase.transforms.test-util` (`with-transform-cleanup!`, `with-transforms-api-users!`), `metabase.actions.test-util` (`with-actions-test-data`, `with-actions-enabled`, `with-actions`), `metabase.model-persistence.test-util/with-persistence-enabled!`.
|
||||
5. Test namespaces: `test/metabase/{actions,actions_rest,upload,transforms,transforms_base,transforms_rest,model_persistence}`, `test/metabase/driver/sql_jdbc/actions_test.clj`, and `enterprise/backend/test/metabase_enterprise/{action_v2,transforms,transforms_python,transform_testing,transforms_inspector,upload_management}`. Warehouse-dependent behavior needs a run with `--drivers=` for the drivers involved.
|
||||
|
||||
## Return
|
||||
|
||||
- The path involved and the root cause, with `file:line`.
|
||||
- The change made, or the proposed change if you were asked only to investigate.
|
||||
- Which drivers the behavior depends on and which ones you checked.
|
||||
- Which checks ran and what they showed; say plainly if something was not verified (for example, Python tests skipped because no runner was up).
|
||||
- Open questions or follow-ups for a neighbouring agent.
|
||||
@@ -1,82 +0,0 @@
|
||||
Review this conversation and generate a structured improvement report.
|
||||
|
||||
**Precondition:** If the conversation contains no substantive task work prior to this invocation (no edits, no non-trivial tool calls, no user task beyond invoking `/agent-improve`), respond with a single line stating there is nothing to analyze and exit without generating `OUTPUT_DIR` or writing `SUGGESTIONS.md`. Do not run `bin/mage -bot-timestamp` in that case.
|
||||
|
||||
Generate `TIMESTAMP` by running `./bin/mage -bot-timestamp` (do **NOT** use `date` directly), then set `OUTPUT_DIR=.bot/agent-improve/<TIMESTAMP>` and save the report to `<OUTPUT_DIR>/SUGGESTIONS.md`. All artifacts for the run live directly under `<OUTPUT_DIR>/` so parallel `/agent-improve` invocations in the same checkout never collide.
|
||||
|
||||
## What to analyze
|
||||
|
||||
Read back through the full conversation history from this session. Look at every tool call, every error, every retry, every place you or the user got stuck or went in circles. Categorize what you find.
|
||||
|
||||
## Categories to evaluate
|
||||
|
||||
### 1. Tool issues
|
||||
- Tools that failed, returned unexpected results, or required workarounds
|
||||
- Tools you tried to use but couldn't (missing permissions, wrong arguments, etc.)
|
||||
- Tools you used inefficiently (e.g., multiple calls where one would suffice)
|
||||
|
||||
### 2. Prompt gaps
|
||||
- Steps that were missing from the initial prompt that had to be figured out on the fly
|
||||
- Instructions that were ambiguous or led down the wrong path
|
||||
- Steps that were in the wrong order or had missing prerequisites
|
||||
- Situations where clarification was needed that should have been covered in the prompt
|
||||
|
||||
### 3. Knowledge gaps
|
||||
- Facts about the codebase, architecture, or conventions discovered through trial and error
|
||||
- Incorrect assumptions about how the code works
|
||||
- Database schemas, API endpoints, config options, or other reference info that would have saved time if documented upfront
|
||||
|
||||
### 4. Memory-worthy patterns
|
||||
- Stable lessons learned that would benefit future sessions (not one-off fixes)
|
||||
- Common failure modes and their solutions
|
||||
- Patterns about how this codebase works that aren't obvious from reading the code
|
||||
|
||||
### 5. Workflow inefficiencies
|
||||
- Places where work was repeated unnecessarily
|
||||
- Dead ends — what signal should have indicated earlier to change approach?
|
||||
- Test/verify cycles that could have been shorter
|
||||
|
||||
## Report format
|
||||
|
||||
Write `<OUTPUT_DIR>/SUGGESTIONS.md` with this structure:
|
||||
|
||||
```markdown
|
||||
# Session Improvement Report
|
||||
|
||||
**Session:** <brief description of what was being done>
|
||||
**Date:** <today's date>
|
||||
**Outcome:** <succeeded / partially succeeded / failed>
|
||||
**Transcript:** <absolute path to this conversation's transcript JSONL file — derive the project dir by replacing `/` with `-` in the absolute working directory and prefixing `~/.claude/projects/`, then `ls -lt <that dir>/*.jsonl | head -1`>
|
||||
|
||||
## Summary
|
||||
|
||||
<2-3 sentence summary of the session and the main improvement themes>
|
||||
|
||||
## Suggestions
|
||||
|
||||
### <Category name>
|
||||
|
||||
#### <Short title for the suggestion>
|
||||
|
||||
**What happened:** <Describe the specific problem encountered>
|
||||
**Where to fix:** <Exact file path and section/line range>
|
||||
**Proposed change:** <The actual text to add, remove, or modify — be specific enough that someone can apply it directly>
|
||||
**Impact:** <What this would have saved — time, retries, user interruptions, etc.>
|
||||
|
||||
(Repeat for each suggestion, grouped by category)
|
||||
|
||||
## Quick wins
|
||||
|
||||
<Bulleted list of the 3-5 highest-impact, lowest-effort changes>
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Be specific** — include exact file paths, section names, line ranges, and actual proposed text changes. Vague suggestions like "improve error handling" are useless.
|
||||
- **Be actionable** — every suggestion must propose a concrete fix that can be applied. If you can't propose a specific change, it's not ready to be a suggestion.
|
||||
- **Be honest** — if the mistake was due to reasoning (not a prompt gap), say so. Not everything is the prompt's fault.
|
||||
- **Prioritize by impact** — order suggestions within each category by how much time/frustration they would save in future sessions.
|
||||
- **Include prompt text diffs** — for prompt gap suggestions, show the exact text you'd add to the relevant prompt or agent file as a before/after or insertion.
|
||||
- **Cover the orchestrator too** — if the issue was in how the session was set up (database choice, prompt generation, environment config), suggest changes to the relevant `.claude/commands/*.md` file as well.
|
||||
- **Don't suggest things that are already documented** — if the answer was in the prompt and was just missed, that's a different problem than a missing instruction. Note which it was.
|
||||
- **Print the absolute path** — after writing the file, print the full absolute path to the suggestions file so the user can easily find it.
|
||||
@@ -1,17 +0,0 @@
|
||||
Tear down and remove an autobot session (worktree, Docker containers, and tmux).
|
||||
|
||||
The user provided: `$ARGUMENTS`
|
||||
|
||||
Run this from the **current working directory** (don't `cd` anywhere) — `./bin/mage` works from any worktree, and the no-argument case relies on being inside the worktree you want to remove:
|
||||
|
||||
```
|
||||
./bin/mage -autobot-kill $ARGUMENTS
|
||||
```
|
||||
|
||||
The mage command handles both cases:
|
||||
- **With an argument**: uses it as the session name. If no session matches, mage prints the available sessions and exits non-zero.
|
||||
- **Without an argument**: auto-detects the current worktree's session from its path. If the caller is in the main repo (not a worktree), mage prints a usage error and exits non-zero.
|
||||
|
||||
Do NOT preemptively run `-autobot-list` and ask the user to pick. Just run `-autobot-kill` with whatever the user gave you (or nothing) and show the output.
|
||||
|
||||
Show the user the full stdout+stderr of the command.
|
||||
@@ -1,5 +0,0 @@
|
||||
List all autobot sessions with status information.
|
||||
|
||||
Run: `./bin/mage -autobot-list`
|
||||
|
||||
Present the output to the user.
|
||||
@@ -1,26 +0,0 @@
|
||||
Print the most recent `result.md` for an autobot session, so the user can see how a bot is doing without attaching to its tmux session.
|
||||
|
||||
The user provided: `$ARGUMENTS`
|
||||
|
||||
Parse as: `<branch> <bot>`
|
||||
|
||||
- First word: branch name (e.g., `uxw-291-skip-ldap-for-non-ldap`)
|
||||
- Second word: bot name (e.g., `qabot`, `fixbot`, `uxbot`, `reprobot`, `cibot`)
|
||||
|
||||
If either is missing, show usage and stop:
|
||||
```
|
||||
Usage: /autobot-result <branch> <bot>
|
||||
Example: /autobot-result uxw-291-skip-ldap-for-non-ldap qabot
|
||||
```
|
||||
|
||||
Run:
|
||||
```
|
||||
./bin/mage -autobot-result <BRANCH> <BOT>
|
||||
```
|
||||
|
||||
The command will:
|
||||
- Find the worktree for the branch (via `workmux list`)
|
||||
- Find the most recent `result.md` file under `<worktree>/.bot/<bot>/*/result.md`
|
||||
- Print its contents along with the absolute path
|
||||
|
||||
Show the user the full output of the command.
|
||||
@@ -1,17 +0,0 @@
|
||||
Stop an autobot session (kill tmux and dev environment, but keep the worktree).
|
||||
|
||||
The user provided: `$ARGUMENTS`
|
||||
|
||||
Run this from the **current working directory** (don't `cd` anywhere) — `./bin/mage` works from any worktree, and the no-argument case relies on being inside the worktree you want to stop:
|
||||
|
||||
```
|
||||
./bin/mage -autobot-stop $ARGUMENTS
|
||||
```
|
||||
|
||||
The mage command handles both cases:
|
||||
- **With an argument**: uses it as the session name. If no session matches, mage prints the available sessions and exits non-zero.
|
||||
- **Without an argument**: auto-detects the current worktree's session from its path. If the caller is in the main repo (not a worktree), mage prints a usage error and exits non-zero.
|
||||
|
||||
Do NOT preemptively run `-autobot-list` and ask the user to pick. Just run `-autobot-stop` with whatever the user gave you (or nothing) and show the output.
|
||||
|
||||
Show the user the full stdout+stderr of the command.
|
||||
@@ -1,100 +0,0 @@
|
||||
Launch a bot command in an isolated autobot session with its own worktree, backend, and frontend.
|
||||
|
||||
The user provided: `$ARGUMENTS`
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Parse arguments
|
||||
|
||||
Parse as: `<branch-name-or-pr-env-url> [from <base-branch>] <inner-command> [inner-args...]`
|
||||
|
||||
**Arg-order sanity check:** If the first token starts with `/` (e.g. the user wrote `/autobot /qabot my-branch`), stop immediately and tell the user the correct order is `/autobot <branch> [from <base>] /<command> [args]`. Do not try to guess.
|
||||
|
||||
The first token can be either a branch name OR a PR preview environment URL:
|
||||
|
||||
- **Branch name** (e.g., `master`, `my-feature-branch`) — launches a local dev environment in a worktree (normal mode).
|
||||
- **PR preview env URL** matching the exact pattern `https://pr<NUMBER>.coredev.metabase.com` (with optional trailing `/`) — launches in **remote mode**. No local backend/frontend is booted; the bot talks to the deployed preview environment instead. The URL must match the anchored regex `^https://pr(\d+)\.coredev\.metabase\.com/?$`; reject anything else (e.g., extra subdomains, different hosts, or paths) — do not pass it to `-autobot-go`.
|
||||
|
||||
If the first token matches the PR-env URL pattern:
|
||||
1. Extract the PR number from the anchored regex above (the digits after `pr`).
|
||||
2. Resolve the branch name by running `gh pr view <PR_NUMBER> --json headRefName` and reading `headRefName`.
|
||||
3. Use that branch for the worktree (so the bot has access to the PR's source code).
|
||||
4. Set `PR_ENV_URL` to the matched URL.
|
||||
5. Pass `--pr-env-url <URL>` to `-autobot-go` in step 4.
|
||||
|
||||
**Note:** PR preview environments are reachable only from the Metabase Tailscale network. If `gh pr view` or the preview URL itself hangs or times out, tell the user to check their Tailscale connection.
|
||||
|
||||
If the next two words after the branch/URL are `from <base>`: use `<base>` as the base branch. The `from <base>` clause is the signal that autobot should **create the branch** from `<base>` if it doesn't exist. If the branch already exists, passing `from <base>` is an error (the base would be silently ignored otherwise). Pass `--base <base>` to `-autobot-go` in step 4.
|
||||
|
||||
Without `from`, the branch must already exist locally or on origin.
|
||||
|
||||
Next word starting with `/`: inner command (e.g., `/qabot`, `/fixbot`).
|
||||
Remaining words: arguments to pass to the inner command.
|
||||
|
||||
Examples:
|
||||
- `/autobot master /qabot` → branch=master (must exist), command="/qabot"
|
||||
- `/autobot my-new-branch from origin/master /qabot` → **create** branch=my-new-branch from origin/master, command="/qabot"
|
||||
- `/autobot my-new-branch from existing-branch /qabot` → **create** branch=my-new-branch from existing-branch, command="/qabot"
|
||||
- `/autobot my-branch /fixbot MB-12345` → branch=my-branch (must exist), command="/fixbot MB-12345"
|
||||
- `/autobot https://pr383713.coredev.metabase.com /qabot` → **PR-env mode**: PR=383713, branch=(resolved from PR), command="/qabot", pr-env-url=https://pr383713.coredev.metabase.com
|
||||
|
||||
Extract the bot name from the inner command by stripping the leading `/` (e.g., `/qabot` → `qabot`).
|
||||
|
||||
### 2. Preflight checks
|
||||
|
||||
**Where inner-bot files live:** Inner-bot slash commands are at `.claude/commands/<bot-name>*.md` (e.g. `.claude/commands/qabot.md`, `.claude/commands/qabot-discover.md`). They are NOT under `.claude/skills/`. Read them directly with `Read`; do not `find`/`grep` `.claude/skills/` looking for them.
|
||||
|
||||
#### Autobot infrastructure checks
|
||||
|
||||
Verify these are available (stop if any fail):
|
||||
- `workmux --version` — workmux is installed (`cargo install workmux`)
|
||||
|
||||
#### Inner bot precheck
|
||||
|
||||
If a `/<bot-name>-precheck` skill exists (e.g., `/qabot-precheck` for `/qabot`), run it before launching. If the precheck reports failures, stop and show the results — do not launch the session.
|
||||
|
||||
If no precheck skill exists for the inner bot, skip this step.
|
||||
|
||||
### 3. Discover context
|
||||
|
||||
Generate a timestamp in `YYYYMMDD-HHMMSS` format (use the current wall-clock time if known; otherwise run `./bin/mage -bot-timestamp` — do NOT use `date` directly). Set:
|
||||
|
||||
- `TIMESTAMP=<YYYYMMDD-HHMMSS>`
|
||||
- `OUTPUT_DIR=.bot/<BOT_NAME>/<TIMESTAMP>`
|
||||
|
||||
Run `mkdir -p <OUTPUT_DIR>` before invoking discover so subsequent file copies / writes never fail on a missing parent directory. The discover skill must be the only producer of artifacts inside `<OUTPUT_DIR>` — do not pre-copy `linear-context.txt` from a previous run; let discover regenerate it.
|
||||
|
||||
Run the `/<bot-name>-discover` skill, passing the inner-args plus `--output-dir <OUTPUT_DIR>`. For example, if the command is `/fixbot MB-12345`, run `/fixbot-discover MB-12345 --output-dir <OUTPUT_DIR>`.
|
||||
|
||||
This will:
|
||||
- Gather external context (Linear issues, PR descriptions, etc.)
|
||||
- Determine the correct app database
|
||||
- Write results to `<OUTPUT_DIR>/config.env` (per-run, not in any shared location)
|
||||
|
||||
After the discover skill completes, read `<OUTPUT_DIR>/config.env` and extract the `APP_DB` value. If the file doesn't exist or APP_DB is missing, default to `postgres`.
|
||||
|
||||
### 4. Launch the autobot session
|
||||
|
||||
Run (append `--pr-env-url <URL>` if in PR-env mode, `--base <base>` only if the user supplied `from <base>`):
|
||||
```
|
||||
./bin/mage -autobot-go <BRANCH_NAME> --bot <BOT_NAME> --app-db <APP_DB> --command "<INNER_COMMAND> <INNER_ARGS>" [--base <BASE_BRANCH>] [--pr-env-url <PR_ENV_URL>]
|
||||
```
|
||||
|
||||
Only pass `--base` when the user explicitly wrote `from <base>`. Omitting it means "branch must already exist."
|
||||
|
||||
This will:
|
||||
- Create a worktree based on the branch (or reuse an existing one)
|
||||
- Set up the dev environment with the correct database
|
||||
- Start backend + frontend dev servers in tmux panes
|
||||
- Launch Claude with the inner command as its prompt
|
||||
|
||||
If a session already exists for this bot+branch, it will tell you to stop it first.
|
||||
|
||||
### 5. Report
|
||||
|
||||
Tell the user:
|
||||
- The session has been launched
|
||||
- How to attach: `tmux attach -t <session-name>`
|
||||
- How to stop: `/autobot-stop <session-name>` (or `/autobot-stop` from inside the session)
|
||||
- How to list all sessions: `/autobot-list`
|
||||
- How to remove: `/autobot-kill <session-name>`
|
||||
1
.claude/commands/fix-flakey-test.md
Normal file
1
.claude/commands/fix-flakey-test.md
Normal file
@@ -0,0 +1 @@
|
||||
Fix flakey test $ARGUMENTS
|
||||
@@ -1,60 +0,0 @@
|
||||
Discover context for fixbot. This is called by `/fixbot` (or `/autobot` before launching the session) to determine the app database and gather issue context.
|
||||
|
||||
The user provided: `$ARGUMENTS`
|
||||
|
||||
`$ARGUMENTS` must include a `--output-dir <PATH>` flag — the caller (`/fixbot` or `/autobot`) generates the per-run path. Discover never picks a directory itself, so nothing is ever written to a shared `.bot/fixbot/...` location.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Resolve the issue ID
|
||||
|
||||
The positional argument can be one of three formats:
|
||||
- **Linear issue ID** (e.g., `MB-12345`, `UXW-3155`) — use directly
|
||||
- **GitHub issue number** (e.g., `12345`) — resolve to Linear first
|
||||
- **GitHub issue URL** (e.g., `https://github.com/metabase/metabase/issues/12345`) — extract the number, then resolve to Linear
|
||||
|
||||
**If the input is a GitHub issue number or URL:**
|
||||
1. Fetch the GitHub issue with `gh issue view <NUMBER> --repo metabase/metabase --json body,comments,title`
|
||||
2. Search the issue body and comments for a Linear issue link (pattern: `https://linear.app/metabase/issue/[A-Z]+-[0-9]+`). Extract the Linear issue ID from the URL.
|
||||
3. If no Linear link is found, search Linear directly: run `./bin/mage -bot-fetch-issue` with a search term derived from the GitHub issue title. If that doesn't find a match, tell the user you couldn't find a corresponding Linear issue and stop.
|
||||
|
||||
**Validation:** Confirm the issue ID looks like `[A-Z]+-[0-9]+`. If not, tell the user the expected format and stop.
|
||||
|
||||
Set `ISSUE_ID=<LINEAR_ID>`.
|
||||
|
||||
### 2. Determine run directory
|
||||
|
||||
Parse `$ARGUMENTS` for `--output-dir <PATH>`. If absent, stop and tell the caller to pass `--output-dir`. Set `OUTPUT_DIR=<PATH>` and `TIMESTAMP=` the trailing timestamp portion of the path. All artifacts in subsequent steps go directly under `<OUTPUT_DIR>/`.
|
||||
|
||||
### 3. Fetch the issue from Linear
|
||||
|
||||
Run:
|
||||
```
|
||||
./bin/mage -bot-fetch-issue <ISSUE_ID>
|
||||
```
|
||||
Read the output to extract issue details and branch name.
|
||||
|
||||
Save the full output to `<OUTPUT_DIR>/linear-context.txt` using the `Write` tool.
|
||||
|
||||
### 4. Determine branch name
|
||||
|
||||
Extract the branch name from the Linear issue output. If none specified, use the issue ID lowercased (e.g., `mb-12345`).
|
||||
|
||||
### 5. Determine app database
|
||||
|
||||
From the issue description/comments:
|
||||
- If the issue mentions **MySQL** problems, MySQL-specific SQL syntax, or MySQL error messages → `mysql`
|
||||
- If the issue mentions **MariaDB** specifically → `mariadb`
|
||||
- Otherwise → `postgres` (the default)
|
||||
|
||||
### 6. Write result
|
||||
|
||||
Write the structured result to `<OUTPUT_DIR>/config.env` using the `Write` tool:
|
||||
|
||||
```
|
||||
APP_DB=<postgres|mysql|mariadb>
|
||||
BRANCH_NAME=<branch-name>
|
||||
ISSUE_ID=<ISSUE_ID>
|
||||
TIMESTAMP=<TIMESTAMP>
|
||||
OUTPUT_DIR=<OUTPUT_DIR>
|
||||
```
|
||||
@@ -1,40 +0,0 @@
|
||||
You are the orchestrator for the fixbot workflow. Fixbot fixes a Linear issue, running directly in this project against the locally running server. For an isolated worktree version, use `/autobot <branch> /fixbot <args>` instead.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Generate per-run directory
|
||||
|
||||
Generate a timestamp in `YYYYMMDD-HHMMSS` format. If you know the current wall-clock time, construct it directly. Otherwise run `./bin/mage -bot-timestamp`. Do NOT use `date` directly.
|
||||
|
||||
Set:
|
||||
- `TIMESTAMP=<YYYYMMDD-HHMMSS>`
|
||||
- `OUTPUT_DIR=.bot/fixbot/<TIMESTAMP>`
|
||||
|
||||
Run `mkdir -p <OUTPUT_DIR>` immediately so any subsequent `cp`/`Write` calls into it succeed. Every file this run writes — including discover artifacts — lives under `<OUTPUT_DIR>/`. There must be **no shared paths across runs**, so multiple `/fixbot` invocations in the same repo do not collide. Do not copy artifacts (e.g. `linear-context.txt`) from a previous run's directory; let discover regenerate them.
|
||||
|
||||
### 2. Gather context
|
||||
|
||||
If `<OUTPUT_DIR>/config.env` does NOT exist, run `/fixbot-discover $ARGUMENTS --output-dir <OUTPUT_DIR>` first. (Discover writes its artifacts directly into `<OUTPUT_DIR>/`, never in a shared location.)
|
||||
|
||||
Then read:
|
||||
- `<OUTPUT_DIR>/config.env` — extract `ISSUE_ID`, `BRANCH_NAME`, `APP_DB`
|
||||
- `<OUTPUT_DIR>/linear-context.txt` — the full Linear issue content (LINEAR_CONTEXT)
|
||||
|
||||
### 3. Generate agent prompt
|
||||
|
||||
Reference the discover-dir file directly via `--set-from-file` — no need to copy it or shell-escape it:
|
||||
|
||||
```
|
||||
./bin/mage -bot-generate-prompt \
|
||||
--template dev/bot/fixbot-agent.md \
|
||||
--output <OUTPUT_DIR>/prompt.md \
|
||||
--set "ISSUE_ID=<ISSUE_ID>" \
|
||||
--set "BRANCH_NAME=<branch-name>" \
|
||||
--set "APP_DB=<postgres|mysql|mariadb>" \
|
||||
--set "OUTPUT_DIR=<OUTPUT_DIR>" \
|
||||
--set-from-file "LINEAR_CONTEXT=<OUTPUT_DIR>/linear-context.txt"
|
||||
```
|
||||
|
||||
### 4. Execute
|
||||
|
||||
Read the generated `<OUTPUT_DIR>/prompt.md` and follow its instructions (Phases 1–4) in sequence. Execute all phases in a single turn — do not stop between phases unless a STOP condition is triggered.
|
||||
@@ -1,58 +0,0 @@
|
||||
Discover context for qabot. This is called by `/qabot` (or `/autobot` before launching the session) to determine the app database and gather issue context.
|
||||
|
||||
The user provided: `$ARGUMENTS`
|
||||
|
||||
`$ARGUMENTS` must include a `--output-dir <PATH>` flag — the caller (`/qabot` or `/autobot`) generates the per-run path. Discover never picks a directory itself, so nothing is ever written to a shared `.bot/qabot/...` location.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Determine run directory
|
||||
|
||||
Parse `$ARGUMENTS` for `--output-dir <PATH>`. If absent, stop and tell the caller to pass `--output-dir`. Set `OUTPUT_DIR=<PATH>` and `TIMESTAMP=` the trailing timestamp portion of the path. All artifacts in subsequent steps go directly under `<OUTPUT_DIR>/`.
|
||||
|
||||
### 2. Detect Linear issue
|
||||
|
||||
Parse the remaining arguments as: `[linear-issue-id]`
|
||||
|
||||
- If a Linear issue ID is provided (e.g., `MB-12345`), validate it looks like `[A-Z]+-[0-9]+`.
|
||||
- If not provided, try to detect from the current branch name:
|
||||
- Pattern: `*/<prefix>-NNNNN-*` (case-insensitive) where `<prefix>` is any 2-4 letter team prefix such as `MB`, `BOT`, `UXW`, `EMB`, `QB`, `DEV`, `GHY`, `GDGT` — Metabase uses multiple Linear projects, not just `MB`. Extract `<PREFIX>-NNNNN` in uppercase.
|
||||
- Also try the GitHub PR for this branch: `gh pr view --json title,url,body` and look for Linear links in the body
|
||||
- If still not found, set LINEAR_ISSUE_ID to empty. Do NOT ask the user — this is a non-interactive discovery step.
|
||||
|
||||
### 3. Fetch context (if issue found)
|
||||
|
||||
If a Linear issue ID was resolved, fetch the issue details:
|
||||
```
|
||||
./bin/mage -bot-fetch-issue <ISSUE_ID>
|
||||
```
|
||||
Save the output to `<OUTPUT_DIR>/linear-context.txt` using the `Write` tool.
|
||||
|
||||
### 4. Fetch PR description
|
||||
|
||||
Try to fetch the PR description for the current branch:
|
||||
```
|
||||
gh pr view --json title,body
|
||||
```
|
||||
If a PR exists, save the output to `<OUTPUT_DIR>/pr-context.txt` using the `Write` tool.
|
||||
|
||||
### 5. Verify branch divergence
|
||||
|
||||
Check if the branch has commits beyond master:
|
||||
```
|
||||
git log --oneline origin/master..HEAD
|
||||
```
|
||||
If the log is empty (no commits beyond master), add `BRANCH_WARNING=no-local-commits` to config.env in step 6.
|
||||
|
||||
### 6. Write result
|
||||
|
||||
QABot always uses postgres — it tests the branch's changes, not database-specific bugs.
|
||||
|
||||
Write the structured result to `<OUTPUT_DIR>/config.env` using the `Write` tool:
|
||||
|
||||
```
|
||||
APP_DB=postgres
|
||||
LINEAR_ISSUE_ID=<resolved-id-or-empty>
|
||||
TIMESTAMP=<TIMESTAMP>
|
||||
OUTPUT_DIR=<OUTPUT_DIR>
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
Regenerate the QA report from existing review files.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Find the latest qabot output**: Look in `.bot/qabot/` for the most recent timestamp directory that contains `initial-review-results.md` and/or `ux-review.md`.
|
||||
|
||||
2. **Gather environment info**:
|
||||
- `git branch --show-current` — branch name
|
||||
- `git rev-parse --short HEAD` — commit hash
|
||||
- Current date
|
||||
|
||||
3. **Regenerate the report**: Follow the Phase 5 instructions from the qabot agent prompt:
|
||||
- Read `initial-review-results.md` and `ux-review.md`
|
||||
- Generate `report.md` with findings grouped by severity (SECURITY > SEVERE > GOOD TO FIX > TRIVIAL)
|
||||
- Non-TRIVIAL: full detail with inline screenshots and evidence
|
||||
- TRIVIAL: 1-sentence description with file reference
|
||||
|
||||
4. **Generate PDF**:
|
||||
```bash
|
||||
cd <report-directory> && pandoc report.md -o report.pdf --pdf-engine=weasyprint
|
||||
```
|
||||
|
||||
5. **Present results**: Show absolute paths to both report.md and report.pdf.
|
||||
@@ -1,43 +0,0 @@
|
||||
You are the orchestrator for the qabot workflow. QABot performs pre-merge QA analysis on the current branch, running directly in this project against the locally running server. For an isolated worktree version, use `/autobot <branch> /qabot` instead.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Generate per-run directory
|
||||
|
||||
Generate a timestamp in `YYYYMMDD-HHMMSS` format. If you know the current wall-clock time, construct it directly. Otherwise run `./bin/mage -bot-timestamp`. Do NOT use `date` directly.
|
||||
|
||||
Set:
|
||||
- `TIMESTAMP=<YYYYMMDD-HHMMSS>`
|
||||
- `OUTPUT_DIR=.bot/qabot/<TIMESTAMP>`
|
||||
|
||||
Every file this run writes — including discover artifacts — lives under `<OUTPUT_DIR>/`. There must be **no shared paths across runs**, so multiple `/qabot` invocations in the same repo do not collide.
|
||||
|
||||
### 2. Gather context
|
||||
|
||||
If `<OUTPUT_DIR>/config.env` does NOT exist, run `/qabot-discover $ARGUMENTS --output-dir <OUTPUT_DIR>` first. (Discover writes its artifacts directly into `<OUTPUT_DIR>/`, never in a shared location.)
|
||||
|
||||
Then read:
|
||||
- `<OUTPUT_DIR>/config.env` — extract `LINEAR_ISSUE_ID`, `TIMESTAMP`
|
||||
- `<OUTPUT_DIR>/linear-context.txt` — Linear issue content (may not exist if no issue found)
|
||||
- `<OUTPUT_DIR>/pr-context.txt` — PR title and body (may not exist if no PR)
|
||||
|
||||
### 3. Generate agent prompt
|
||||
|
||||
Reference the discover-dir files directly via `--set-from-file` — no need to copy them or shell-escape them:
|
||||
|
||||
```
|
||||
./bin/mage -bot-generate-prompt \
|
||||
--template dev/bot/qabot-agent.md \
|
||||
--output <OUTPUT_DIR>/prompt.md \
|
||||
--set "TIMESTAMP=<TIMESTAMP>" \
|
||||
--set "OUTPUT_DIR=<OUTPUT_DIR>" \
|
||||
--set "LINEAR_ISSUE_ID=<resolved-id-or-empty>" \
|
||||
--set-from-file "LINEAR_CONTEXT=<OUTPUT_DIR>/linear-context.txt" \
|
||||
--set-from-file "PR_CONTEXT=<OUTPUT_DIR>/pr-context.txt"
|
||||
```
|
||||
|
||||
`--set-from-file KEY=PATH` reads the file and inlines its contents as the template variable value. If the file doesn't exist (e.g., the discover step didn't find a Linear issue or PR), the variable becomes an empty string — that's expected and fine.
|
||||
|
||||
### 4. Execute
|
||||
|
||||
Read the generated `<OUTPUT_DIR>/prompt.md` and follow its instructions (Phases 1–6) in sequence. Execute all phases in a single turn — do not stop between phases unless a STOP condition is triggered.
|
||||
@@ -1,56 +0,0 @@
|
||||
Discover context for reprobot. This is called by `/reprobot` (or `/autobot` before launching the session) to determine the app database and gather issue context.
|
||||
|
||||
The user provided: `$ARGUMENTS`
|
||||
|
||||
`$ARGUMENTS` must include a `--output-dir <PATH>` flag — the caller (`/reprobot` or `/autobot`) generates the per-run path. Discover never picks a directory itself, so nothing is ever written to a shared `.bot/reprobot/...` location.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Resolve the issue ID
|
||||
|
||||
The positional argument can be one of three formats:
|
||||
- **Linear issue ID** (e.g., `MB-12345`, `UXW-3155`) — use directly
|
||||
- **GitHub issue number** (e.g., `12345`) — resolve to Linear first
|
||||
- **GitHub issue URL** (e.g., `https://github.com/metabase/metabase/issues/12345`) — extract the number, then resolve to Linear
|
||||
|
||||
**If the input is a GitHub issue number or URL:**
|
||||
1. Fetch the GitHub issue with `gh issue view <NUMBER> --repo metabase/metabase --json body,comments,title`
|
||||
2. Search the issue body and comments for a Linear issue link (pattern: `https://linear.app/metabase/issue/[A-Z]+-[0-9]+`). Extract the Linear issue ID.
|
||||
3. If no Linear link found, search Linear directly: run `./bin/mage -bot-fetch-issue` with a search term derived from the GitHub issue title.
|
||||
|
||||
**Validation:** Confirm the issue ID looks like `[A-Z]+-[0-9]+`. If not, tell the user the expected format and stop.
|
||||
|
||||
Set `ISSUE_ID=<LINEAR_ID>`.
|
||||
|
||||
### 2. Determine run directory
|
||||
|
||||
Parse `$ARGUMENTS` for `--output-dir <PATH>`. If absent, stop and tell the caller to pass `--output-dir`. Set `OUTPUT_DIR=<PATH>` and `TIMESTAMP=` the trailing timestamp portion of the path. All artifacts in subsequent steps go directly under `<OUTPUT_DIR>/`.
|
||||
|
||||
### 3. Fetch the issue from Linear
|
||||
|
||||
Run:
|
||||
```
|
||||
./bin/mage -bot-fetch-issue <ISSUE_ID>
|
||||
```
|
||||
Read the output to extract issue details.
|
||||
|
||||
Save the full output to `<OUTPUT_DIR>/linear-context.txt` using the `Write` tool.
|
||||
|
||||
### 4. Determine app database
|
||||
|
||||
From the issue description/comments:
|
||||
- If the issue mentions **MySQL** problems, MySQL-specific SQL syntax, or MySQL error messages → `mysql`
|
||||
- If the issue mentions **MariaDB** specifically → `mariadb`
|
||||
- Otherwise → `postgres` (the default)
|
||||
|
||||
### 5. Write result
|
||||
|
||||
Write the structured result to `<OUTPUT_DIR>/config.env` using the `Write` tool:
|
||||
|
||||
```
|
||||
APP_DB=<postgres|mysql|mariadb>
|
||||
ISSUE_ID=<ISSUE_ID>
|
||||
TIMESTAMP=<TIMESTAMP>
|
||||
OUTPUT_DIR=<OUTPUT_DIR>
|
||||
```
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
You are the orchestrator for the reprobot workflow. ReproBot attempts to reproduce a reported bug against the locally running server, classifies the result, and optionally writes a failing test. For an isolated worktree version, use `/autobot <branch> /reprobot <args>` instead.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Generate per-run directory
|
||||
|
||||
Generate a timestamp in `YYYYMMDD-HHMMSS` format. If you know the current wall-clock time, construct it directly. Otherwise run `./bin/mage -bot-timestamp`. Do NOT use `date` directly.
|
||||
|
||||
Set:
|
||||
- `TIMESTAMP=<YYYYMMDD-HHMMSS>`
|
||||
- `OUTPUT_DIR=.bot/reprobot/<TIMESTAMP>`
|
||||
|
||||
Run `mkdir -p <OUTPUT_DIR>` immediately so subsequent `cp`/`Write` calls into it succeed. Every file this run writes — including discover artifacts — lives under `<OUTPUT_DIR>/`. There must be **no shared paths across runs**, so multiple `/reprobot` invocations in the same repo do not collide. Do not copy artifacts from a previous run's directory; let discover regenerate them.
|
||||
|
||||
### 2. Gather context
|
||||
|
||||
If `<OUTPUT_DIR>/config.env` does NOT exist, run `/reprobot-discover $ARGUMENTS --output-dir <OUTPUT_DIR>` first. (Discover writes its artifacts directly into `<OUTPUT_DIR>/`, never in a shared location.)
|
||||
|
||||
Then read:
|
||||
- `<OUTPUT_DIR>/config.env` — extract `ISSUE_ID`, `APP_DB`
|
||||
- `<OUTPUT_DIR>/linear-context.txt` — the full Linear issue content
|
||||
|
||||
### 3. Generate agent prompt
|
||||
|
||||
Run:
|
||||
```
|
||||
./bin/mage -bot-generate-prompt \
|
||||
--template dev/bot/reprobot-agent.md \
|
||||
--output <OUTPUT_DIR>/prompt.md \
|
||||
--set "ISSUE_ID=<ISSUE_ID>" \
|
||||
--set "OUTPUT_DIR=<OUTPUT_DIR>"
|
||||
```
|
||||
|
||||
### 4. Execute
|
||||
|
||||
Read the generated `<OUTPUT_DIR>/prompt.md` and follow its instructions (Phases 0–4) in sequence. Execute all phases in a single turn — do not stop between phases unless a STOP condition is triggered.
|
||||
@@ -1,60 +0,0 @@
|
||||
Aggregate per-task UXBot reports into an overall UX report, looking for patterns across tasks.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **List the session directories**: Every UXBot run lives in `.bot/uxbot/<TIMESTAMP>/` and (if it ran a task) contains a `task-report.md`. Run:
|
||||
```
|
||||
ls -1d .bot/uxbot/*/ 2>/dev/null
|
||||
```
|
||||
Each directory's name is a `YYYYMMDD-HHMMSS` timestamp.
|
||||
|
||||
2. **Tell the user what you're using BEFORE generating the report.** Print a short message that:
|
||||
- Tells the absolute path of the root directory you are reading from
|
||||
- Lists the number of tasks you are agggregating.
|
||||
- States the timeframe — the earliest and latest timestamps among the directories.
|
||||
- Tells the user: "If there are sessions you don't want included in this aggregate, delete those directories under `.bot/uxbot/` and re-run `/uxbot-aggregate`. I am proceeding with all of them now."
|
||||
|
||||
Do NOT wait for confirmation. Continue immediately.
|
||||
|
||||
3. **Read the per-task reports**: For each session directory, read every `task-report*.md` it contains (a session may have several if the user gave multiple tasks). Skip directories that have no `task-report*.md` (those sessions were started but never completed a task). Also collect the screenshot paths each report references so you can re-embed them.
|
||||
|
||||
**Embed screenshots inline, do not link to them.** When you cite a finding from a per-task report, use Markdown image syntax (``) so the screenshot renders directly in the aggregate PDF. The path must be relative to `.bot/uxbot/` (e.g. `20260504-092131/output/01-foo.png`), since the PDF is generated from that directory. After every image leave a blank line so the caption flows correctly. Verify in the rendered PDF that you see actual images, not URLs.
|
||||
|
||||
4. **Aggregate — look for patterns, do not re-specify findings.** The per-task reports already follow a consistent format (task, approach, steps, struggles, resolution, screenshots, time spent). Your job is the layer above that:
|
||||
- **Cross-task themes**: Friction points that appear in more than one task (e.g., "modal-data-loss came up in 3 of 5 user-management tasks"). Quote or cite the per-task reports.
|
||||
- **Severity ranking across the whole session set**, not within one task.
|
||||
- **Areas of Metabase that worked well repeatedly** — patterns worth preserving.
|
||||
- **Suggestions for improvement** at the product level, grounded in the recurring evidence.
|
||||
- **Outliers**: Single-task findings that are severe enough to call out even though they only happened once.
|
||||
|
||||
Do NOT re-list every step from every task — the per-task reports are linked from this aggregate and the reader can drill in. Keep this report at the pattern/synthesis level.
|
||||
|
||||
5. **Header**: Include this header at the top of the aggregate:
|
||||
```
|
||||
**Date:** YYYY-MM-DD
|
||||
**Branch:** <branch> (commit <hash>)
|
||||
**Database:** <type>
|
||||
**Sessions covered:** <count> (from <earliest timestamp> to <latest timestamp>)
|
||||
```
|
||||
|
||||
Get branch / commit / db type with:
|
||||
- `git -C $(pwd) branch --show-current`
|
||||
- `git -C $(pwd) rev-parse --short HEAD`
|
||||
- `grep MB_DB_TYPE mise.local.toml` (or check `./bin/mage -bot-server-info` if not in mise.local.toml)
|
||||
|
||||
6. **Link each per-task report from the aggregate.** Include a "Per-task reports" section near the top with one bullet per session: timestamp, task title (pull from each report's first heading), and a relative link to its `task-report.pdf` (preferred) or `task-report.md`.
|
||||
|
||||
7. **Write the aggregate** to `.bot/uxbot/aggregate-<timestamp>-<slug>.md` where:
|
||||
- `<timestamp>` is the current `YYYYMMDD-HHMMSS`.
|
||||
- `<slug>` is a short kebab-case description of the dominant theme across the sessions (e.g., `admin-permissions`, `dashboard-authoring`, `mixed-session`). Keep it under 40 characters.
|
||||
|
||||
8. **Generate PDF**:
|
||||
```
|
||||
./bin/mage -bot-md-to-pdf .bot/uxbot/aggregate-<timestamp>-<slug>.md
|
||||
```
|
||||
|
||||
9. **Present a brief summary to the user with absolute paths** to both the markdown and PDF files (use `pwd` to construct the absolute path).
|
||||
|
||||
## Tone
|
||||
|
||||
You are synthesizing across many sessions. Stay objective. Quote per-task reports when possible — recurring user friction is more credible when readers can see it appeared independently across multiple sessions.
|
||||
@@ -1,16 +0,0 @@
|
||||
Discover context for uxbot. This is called by `/autobot` before launching the session to determine the app database.
|
||||
|
||||
The user provided: `$ARGUMENTS`
|
||||
|
||||
## Steps
|
||||
|
||||
UXBot always uses postgres. No external context gathering needed.
|
||||
|
||||
Generate a timestamp in `YYYYMMDD-HHMMSS` format. If you know the current wall-clock time, construct it directly. Otherwise run `./bin/mage -bot-timestamp` — it prints exactly one line in the required format with no extra output. Do NOT use `date` directly.
|
||||
|
||||
Write the structured result to `.bot/uxbot/discover/result.env` using the `Write` tool:
|
||||
|
||||
```
|
||||
APP_DB=postgres
|
||||
TIMESTAMP=<YYYYMMDD-HHMMSS>
|
||||
```
|
||||
@@ -1,48 +0,0 @@
|
||||
You are the orchestrator for the uxbot workflow. UXBot acts as a regular Metabase user trying to accomplish tasks, running directly against the locally running server. For an isolated worktree version, use `/autobot <branch> /uxbot <args>` instead.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Parse arguments
|
||||
|
||||
The user provided: `$ARGUMENTS`
|
||||
|
||||
Parse as: `[task description...]`
|
||||
|
||||
Everything provided is the initial task description (optional). If no task was provided, the agent will wait for instructions.
|
||||
|
||||
If the user provided a task description, set `INITIAL_TASK` to:
|
||||
```
|
||||
## Your First Task
|
||||
|
||||
<task description>
|
||||
```
|
||||
|
||||
If no task was provided, set `INITIAL_TASK` to:
|
||||
```
|
||||
No initial task specified. Wait for the user to give you a task.
|
||||
```
|
||||
|
||||
### 2. Generate agent prompt
|
||||
|
||||
**Always generate a fresh `<TIMESTAMP>` directory for THIS invocation** — even
|
||||
if a prior `/uxbot` run already created a `.bot/uxbot/<earlier-timestamp>/`
|
||||
directory in this conversation. Each `/uxbot` call is its own session with its
|
||||
own output directory and its own `task-report.md`. Do NOT reuse a prior
|
||||
directory.
|
||||
|
||||
Use the current local time as `<TIMESTAMP>` (format `YYYYMMDD-HHMMSS`, e.g.
|
||||
`20260504-103045`). You can compute it with `date +%Y%m%d-%H%M%S`.
|
||||
|
||||
Run:
|
||||
```
|
||||
./bin/mage -bot-generate-prompt \
|
||||
--template dev/bot/uxbot-agent.md \
|
||||
--output .bot/uxbot/<TIMESTAMP>/prompt.md \
|
||||
--set "INITIAL_TASK=<initial task text>"
|
||||
```
|
||||
|
||||
**Shell escaping:** If the task description contains quotes or special characters, write it to a temp file using the `Write` tool first: write to `.bot/uxbot/tmp/task.txt`, then use `--set "INITIAL_TASK=$(cat .bot/uxbot/tmp/task.txt)"`.
|
||||
|
||||
### 3. Execute
|
||||
|
||||
Read the generated `.bot/uxbot/<TIMESTAMP>/prompt.md` and follow its instructions. Act as a regular user navigating the browser. Execute tasks as they come — the first task (if any) is embedded in the prompt.
|
||||
@@ -27,28 +27,9 @@ This guide covers Clojure and ClojureScript coding conventions for Metabase. See
|
||||
- Format using Markdown conventions
|
||||
- Reference other vars with `[[other-var]]` not backticks
|
||||
|
||||
A docstring states the *contract*: what it returns (including when it returns nil), what its
|
||||
params mean, what it throws versus swallows, and any precondition or invariant the caller must
|
||||
respect. A params block for non-obvious or optional args is good documentation — length is not
|
||||
the problem.
|
||||
|
||||
These seven things are, and each is a reason to cut or relocate:
|
||||
|
||||
| Anti-pattern | Why | Where it goes instead |
|
||||
| --- | --- | --- |
|
||||
| **Facts you don't own** — how a *callee*, another namespace, or another repo works internally | Drifts silently; nothing fails when it goes stale | Cut. Name the callee and let its own docstring say it |
|
||||
| **Said twice** — ns docstring restating its vars', a caller restating its callee's, sibling fns repeating boilerplate | Two copies means one is already wrong | Cut the copy. One fact, one owner |
|
||||
| **Design rationale** — why this approach, why it lives in this module, what the alternative was | The caller can't act on it | Commit message, or an inline comment on the specific line it constrains |
|
||||
| **Development history** — "matching master", "the X branch", "currently", "for now", "used to" | Stale by construction | Cut |
|
||||
| **Instructions to the reader** — "callers should trust this", "don't bother checking", "you should use X" | State the contract; the caller decides | Cut, keeping any actual guarantee as a plain statement |
|
||||
| **Invented precision** — score bands, thresholds, or tables no code enforces | Reads as spec, isn't | Cut, or make the code enforce it |
|
||||
| **A note on the wrong var** — describing how overrides behave, on the base/default impl | Nobody reading the override sees it | Move to the overrides it describes |
|
||||
|
||||
**Comments:**
|
||||
|
||||
- `TODO` format: `;; TODO (Name YYYY-MM-DD) -- description`
|
||||
- Prefer an inline comment over docstring prose for anything that explains *the body*. Put it on
|
||||
the line it explains, not at the top of the function.
|
||||
|
||||
## Code Organization
|
||||
|
||||
@@ -63,22 +44,6 @@ These seven things are, and each is a reason to cut or relocate:
|
||||
- Lines ≤ 120 characters
|
||||
- No blank non-comment lines within definition forms (except pairwise `let`/`cond`)
|
||||
|
||||
**Java interop: type-hint parameters in the arglist; prefer `ex-*` helpers:**
|
||||
|
||||
```clojure
|
||||
;; bad — reflection warning, calls Throwable methods directly
|
||||
(defn root-cause [e]
|
||||
(if-let [cause (.getCause e)] (recur cause) e))
|
||||
|
||||
;; good
|
||||
(defn- root-cause [^Throwable e]
|
||||
(if-let [cause (ex-cause e)]
|
||||
(recur cause)
|
||||
e))
|
||||
```
|
||||
|
||||
Use `ex-cause`/`ex-message`/`ex-data`/`ex-info` rather than `.getCause`/`.getMessage`/`(.getData ^ExceptionInfo …)` where possible.
|
||||
|
||||
## Style Conventions
|
||||
|
||||
**Keywords and Metadata:**
|
||||
@@ -86,37 +51,6 @@ Use `ex-cause`/`ex-message`/`ex-data`/`ex-info` rather than `.getCause`/`.getMes
|
||||
- Prefer namespaced keywords for internal use: `:query-type/normal` not `:normal`
|
||||
- Tag variables with `:arglists` metadata if they're functions but wouldn't otherwise have it
|
||||
|
||||
**Time/date: `java-time.api :as t` and `u.date`, not `java.time.*` direct interop:**
|
||||
|
||||
```clojure
|
||||
;; bad — Claude default, requires java.time interop
|
||||
(:import (java.time LocalDate))
|
||||
... (LocalDate/now) ...
|
||||
|
||||
;; good
|
||||
(:require
|
||||
[java-time.api :as t]
|
||||
[metabase.util.date-2 :as u.date])
|
||||
... (t/local-date) ...
|
||||
... (u.date/parse "2024-01-01") ...
|
||||
```
|
||||
|
||||
Only `(:import java.time.X)` when interop with a Java method genuinely needs the class.
|
||||
|
||||
**Check non-emptiness with `seq`, not `count`:**
|
||||
|
||||
```clojure
|
||||
;; bad — `count` realizes the whole seq into memory just to test emptiness
|
||||
(let [prompts (map build-prompt items)]
|
||||
(when (pos? (count prompts))
|
||||
(t2/insert! :model/Prompt prompts)))
|
||||
|
||||
;; good — `seq` realizes one cell; `insert!` streams the rest
|
||||
(let [prompts (map build-prompt items)]
|
||||
(when (seq prompts)
|
||||
(t2/insert! :model/Prompt prompts)))
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
**Organization:**
|
||||
@@ -128,33 +62,6 @@ Only `(:import java.time.X)` when interop with a Java method genuinely needs the
|
||||
|
||||
- Mark pure function tests `^:parallel`
|
||||
|
||||
**Redefining `defn`-vars under test:**
|
||||
|
||||
Use `mt/with-dynamic-fn-redefs` instead of `with-redefs` when stubbing `defn`/`defn-` vars — plain `with-redefs` is not parallel-test-safe and a clj-kondo hook (`:metabase/prefer-with-dynamic-fn-redefs`, see `.clj-kondo/config.edn`) will warn. `with-redefs` is still fine for `def` / dynamic var rebinding.
|
||||
|
||||
```clojure
|
||||
;; bad — flagged by the linter, not safe under :parallel tests
|
||||
(with-redefs [my.ns/some-fn (fn [_] :stub)] (do-test))
|
||||
|
||||
;; good
|
||||
(mt/with-dynamic-fn-redefs [my.ns/some-fn (fn [_] :stub)] (do-test))
|
||||
```
|
||||
|
||||
**Async tests: no bare `Thread/sleep` — use `deref` with a timeout sentinel:**
|
||||
|
||||
A fixed sleep is the #1 source of CI flake. Tests should fail fast and loud when a contract is violated, not hang or sleep through the bug.
|
||||
|
||||
```clojure
|
||||
;; bad — flaky on slow CI, silent on actual failure
|
||||
(Thread/sleep 300)
|
||||
(is (= :expected @seen))
|
||||
|
||||
;; good — fails fast with a clear message
|
||||
(let [v (deref some-promise 1000 ::timeout)]
|
||||
(is (not= ::timeout v) "promise never delivered")
|
||||
(is (= :expected v)))
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
**OSS Modules:**
|
||||
@@ -179,45 +86,6 @@ A fixed sleep is the #1 source of CI flake. Tests should fail fast and loud when
|
||||
|
||||
- Do not cheat module linters with `:clj-kondo/ignore [:metabase/modules]`
|
||||
|
||||
**Enterprise/OSS split: use `defenterprise`, not protocols or runtime feature checks:**
|
||||
|
||||
The OSS namespace defines the fallback body and names the EE namespace; the EE namespace overrides with the same fn name and a `:feature` clause naming the premium-features flag that gates the override. Both files use the same docstring shape — describe behavior on both sides.
|
||||
|
||||
```clojure
|
||||
;; src/metabase/remote_sync/core.clj (OSS)
|
||||
(defenterprise collection-editable?
|
||||
"Returns whether remote-synced collections are editable.
|
||||
Always true on OSS; EE consults the remote-sync settings."
|
||||
metabase-enterprise.remote-sync.core
|
||||
[_collection]
|
||||
true)
|
||||
|
||||
;; enterprise/backend/src/metabase_enterprise/remote_sync/core.clj (EE)
|
||||
(defenterprise collection-editable?
|
||||
"EE override — respects the synced-collections-editable? setting."
|
||||
:feature :remote-sync
|
||||
[collection]
|
||||
(or (synced-collections-editable?) (not (synced? collection))))
|
||||
```
|
||||
|
||||
`defenterprise-schema` is the malli-schema variant. Required from `[metabase.premium-features.core :refer [defenterprise defenterprise-schema]]`. The OSS body is what runs when the feature flag is off — never write `(when (premium/has-feature? ...) ...)` in user code; let `defenterprise` route.
|
||||
|
||||
**Security gates fail *open* under `:feature` — use `:feature :none` and check manually.** The router (`dynamic-ee-oss-fn`) calls the EE body only when both the EE namespace is loaded *and* the token currently grants the feature; otherwise it falls back to the OSS body. For a permissive default (`collection-editable?` → `true`) that's fine. But for a *restriction* — sandboxing, impersonation, row-level filtering — the OSS fallback is the unrestricted answer, so a temporarily lost/expired token silently downgrades a sandboxed user to full access. **Fail open.** When the EE override is what *decides whether to restrict*, gate it with `:feature :none` (which always routes to the EE body once EE code is loaded) and check the feature explicitly inside, so token loss fails *closed*:
|
||||
|
||||
```clojure
|
||||
;; enterprise/.../sandbox/api/util.clj — the gate that detects whether a user is sandboxed
|
||||
(defenterprise enforced-sandboxes-for-user
|
||||
"..."
|
||||
;; :none so we can decide whether sandboxing is *configured* even when the feature
|
||||
;; isn't currently available — letting us BLOCK requests that would otherwise be
|
||||
;; sandboxed, instead of returning the permissive OSS fallback. Fail closed.
|
||||
:feature :none
|
||||
[user-id]
|
||||
...)
|
||||
```
|
||||
|
||||
Reach for this only for the detection/enforcement decision point. Downstream functions that merely *act* on an already-made decision can keep their normal `:feature :sandboxes` gate.
|
||||
|
||||
## REST API Endpoints
|
||||
|
||||
**Required Elements:**
|
||||
@@ -226,20 +94,6 @@ Reach for this only for the detection/enforcement decision point. Downstream fun
|
||||
- All endpoints need Malli schemas for parameters (detailed and complete)
|
||||
- All new REST API endpoints MUST HAVE TESTS
|
||||
|
||||
**Endpoint shape: `api.macros/defendpoint`, four positional arglist:**
|
||||
|
||||
```clojure
|
||||
(api.macros/defendpoint :get "/:key/callback"
|
||||
"OIDC callback for a specific provider."
|
||||
[{provider-key :key} :- [:map [:key ProviderKey]]
|
||||
_query-params
|
||||
_body
|
||||
request]
|
||||
(oidc-integration/sso-callback provider-key request))
|
||||
```
|
||||
|
||||
The arglist is **always 4 positional bindings** in this order: `route-params`, `query-params`, `body`, `request`. Use `_`-prefixed names for unused ones. Each one gets a Malli schema via `:- <schema>` (omit when `_`). The HTTP verb is a keyword (`:get`/`:post`/`:put`/`:delete`), not a symbol. Required as `[metabase.api.macros :as api.macros]`.
|
||||
|
||||
**Naming Conventions:**
|
||||
|
||||
- Query parameters use kebab-case
|
||||
@@ -251,20 +105,6 @@ The arglist is **always 4 positional bindings** in this order: `route-params`, `
|
||||
- `GET` endpoints should not have side effects (except analytics)
|
||||
- `defendpoint` forms should be small wrappers around Toucan model code
|
||||
|
||||
**Closed response maps:**
|
||||
|
||||
Tag response Malli `:map` schemas with `{:closed true}` so the generated OpenAPI / TypeScript types reject extra keys. Open maps emit `additionalProperties: true`, which produces weak frontend types and makes discriminated unions ineffective. Tighten domain types in the same pass (`pos-int?` rather than `:int` when zero branches elsewhere).
|
||||
|
||||
```clojure
|
||||
;; weak
|
||||
{:status 200 :body [:map [:status [:= :generated]] [:prompt_count :int]]}
|
||||
|
||||
;; tight
|
||||
{:status 200 :body [:map {:closed true}
|
||||
[:status [:= :generated]]
|
||||
[:prompt_count pos-int?]]}
|
||||
```
|
||||
|
||||
## MBQL (Metabase Query Language)
|
||||
|
||||
**Restrictions:**
|
||||
@@ -304,215 +144,10 @@ Tag response Malli `:map` schemas with `{:closed true}` so the generated OpenAPI
|
||||
|
||||
**Linter Suppressions:**
|
||||
|
||||
The right shape is a **reader-discard map** placed directly above the form being silenced, listing the rule keywords explicitly:
|
||||
|
||||
```clojure
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-route-uses-kebab-case
|
||||
:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :post "/api_key" ...)
|
||||
```
|
||||
|
||||
For a single require entry, hang it as metadata on that entry:
|
||||
|
||||
```clojure
|
||||
(:require
|
||||
^{:clj-kondo/ignore [:deprecated-namespace :discouraged-namespace]}
|
||||
[metabase.query-processor.store :as qp.store])
|
||||
```
|
||||
|
||||
Common Metabase-specific rules you'll see: `:metabase/modules`, `:metabase/validate-defendpoint-route-uses-kebab-case`, `:metabase/validate-defendpoint-has-response-schema`, `:metabase/prefer-with-dynamic-fn-redefs`, `:deprecated-namespace`, `:discouraged-namespace`. Don't use the keyword form (`#_:clj-kondo/ignore`); always the map form so it's grep-able by which rule(s) are suppressed.
|
||||
- Use proper format for kondo suppressions
|
||||
- No `#_:clj-kondo/ignore` (keyword form)
|
||||
|
||||
**Configurable Options:**
|
||||
|
||||
- Don't define configurable options that can only be set with environment variables
|
||||
- Use `:internal` `defsetting` instead
|
||||
|
||||
|
||||
## Metabase Helpers — Quick Reference
|
||||
|
||||
Metabase and its libraries have **many** helper functions to make code more readable. Don't hand-roll things that already exist! Reach for the right helper *the first time*.
|
||||
|
||||
Organized by **problem you're solving**, not by namespace.
|
||||
|
||||
## "I wrote this — use that instead" cheat sheet
|
||||
|
||||
*Authors: prefer the right column. Reviewers: flag the left column in PRs.*
|
||||
|
||||
| You wrote | Reach for | When NOT to |
|
||||
| -------------------------------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `(vec (keep f xs))` | `(u/keepv f xs)` | — |
|
||||
| `(let [x (...)] (side-effect!) x)` | `(u/prog1 (...) (side-effect!))` | — (`<>` anaphor binds the result) |
|
||||
| `(into {} (map (juxt k identity)) xs)` | `(u/index-by k xs)` | — |
|
||||
| `(into {} (map (juxt k vf)) xs)` | `(u/index-by k vf xs)` | — |
|
||||
| `#(compare %2 %1)` | `u/reverse-compare` | — |
|
||||
| `(:col (t2/select-one [Model :col] …))` | `(t2/select-one-fn :col Model …)` | — |
|
||||
| `(or (:k m) default)` | `(:k m default)` | **The value can be present-but-nil** (hydrated DB null, etc.) |
|
||||
| `(let [x (...)] (if x then else))` | `(if-let [x (...)] then else)` | — |
|
||||
| `(filter #(= :a (:type %)) coll)` + same for `:b` … | `(group-by :type coll)` then dispatch | — |
|
||||
| `(->> xs (map f) (remove g) (mapv h))` | `(into [] (comp (map f) (remove g) (map h)) xs)` | when readability of the threading wins |
|
||||
| nested `let` / `if` / `if-let` | `b/cond` with `:let` | a 2-level nest may be clearer as-is |
|
||||
| `(let [{:keys [k]} arg] …)` as first form of body | destructure in the arg list | when the destructure also references something computed in `let` |
|
||||
| `(or (:k m) (get m (name k)))` per field | normalize once: `(update-keys m keyword)` then plain `:k` | when you genuinely need both shapes per-field |
|
||||
|
||||
## `metabase.util` (commonly aliased `[metabase.util :as u]`)
|
||||
|
||||
Sequences / collections:
|
||||
- `(u/keepv f coll)` — like `keep` but returns a vector (eager).
|
||||
- `(u/index-by kf coll)` / `(u/index-by kf vf coll)` — `{(kf x) (vf x)}` map.
|
||||
|
||||
Strings (locale-safe):
|
||||
- `(u/lower-case-en s)`, `(u/upper-case-en s)` — DO use these instead of `clojure.string/lower-case`/`upper-case` whenever the result feeds into matching/comparison; the locale-default versions break on Turkish `i`.
|
||||
- `(u/qualified-name x)` — keyword → `"ns/name"` or `"name"`; symbol → `(name x)`; string → identity.
|
||||
|
||||
Control flow:
|
||||
- `(u/prog1 first-form & body)` — anaphoric. Result of `first-form` is bound to `<>`. Returns first-form's value. Use for "do a thing, side-effect using its result, return the thing."
|
||||
- `(u/the-id x)` — get an entity id from an int or an instance.
|
||||
|
||||
Comparators:
|
||||
- `(u/reverse-compare x y)` — `(compare y x)`. Pass as a sort comparator: `(sort-by f u/reverse-compare coll)`.
|
||||
|
||||
Other:
|
||||
- `(u/conflicting-keys m1 m2)` — keys present in both with different values.
|
||||
|
||||
The full `metabase.util.cljc` is ~1300 lines — when in doubt, grep it.
|
||||
|
||||
## `metabase.util.namespaces` (aliased `shared.ns`)
|
||||
|
||||
- `(shared.ns/import-fns [other-ns f1 f2] [another-ns f3])` — re-export defns from another ns through the current ns. The `:require [other-ns :as other-ns]` alias **must stay** — clj-kondo may flag it as unused but it's needed at macro expansion. Add a `;; needed for import-fns` comment so it doesn't get auto-stripped.
|
||||
|
||||
## Toucan 2 (aliased `[toucan2.core :as t2]`)
|
||||
|
||||
Single-field reads (avoids fetching whole rows):
|
||||
- `(t2/select-one-fn :col Model :id 123)` — projection-friendly: when `f` is a keyword, only that column is fetched. Use instead of `(:col (t2/select-one [Model :col] …))`.
|
||||
- `(t2/select-fn-set f Model & conditions)` — set of `(f row)` values.
|
||||
- `(t2/select-fn-vec f Model & conditions)` — vector of `(f row)` values.
|
||||
- `(t2/select-pks-set Model & conditions)` — set of PKs.
|
||||
|
||||
Existence / counts:
|
||||
- `(t2/exists? Model & conditions)` → boolean.
|
||||
- `(t2/count Model & conditions)` → long.
|
||||
|
||||
Writes:
|
||||
- `(t2/insert-returning-instances! Model row-or-rows)` — returns the rows with PKs set.
|
||||
- `(t2/insert-returning-pks! Model row-or-rows)` — just the PKs.
|
||||
- `(t2/update! Model id-or-conditions changes)`.
|
||||
- `(t2/delete! Model id-or-conditions)`.
|
||||
|
||||
Transactions:
|
||||
- `(t2/with-transaction [t-conn] body…)` — opens a txn on the app DB conn.
|
||||
|
||||
Hydration:
|
||||
- `(t2/hydrate instances :key1 :key2)` — invokes `:batched-hydrate` methods.
|
||||
- Define via `(methodical/defmethod t2/batched-hydrate [:model/Foo :the-key] [_ k rows] …)`.
|
||||
|
||||
Define a model:
|
||||
- `(methodical/defmethod t2/table-name :model/Foo [_] :foo_table)`
|
||||
- `(t2/deftransforms :model/Foo {:col mi/transform-json})` — common: `transform-json`, `transform-secret-value` (encrypts), `transform-keyword`.
|
||||
|
||||
## Better-cond (`[better-cond.core :as b]`)
|
||||
|
||||
`(b/cond …)` like `cond` but supports interleaved `:let [bindings]` and `:when` clauses. Lets you flatten cascading `let`/`if`/`if-let`/`let` into a linear spine.
|
||||
|
||||
```clojure
|
||||
(b/cond
|
||||
:let [thread (load-thread id)]
|
||||
(nil? thread) :not-found
|
||||
|
||||
:let [exploration (load-exploration (:exploration-id thread))
|
||||
reason (gate-check exploration)]
|
||||
reason
|
||||
(skip! reason)
|
||||
|
||||
:let [pool (build-pool thread)]
|
||||
(empty? pool) :skip-no-data
|
||||
|
||||
:else
|
||||
(run! thread pool))
|
||||
```
|
||||
|
||||
Use it when a function would otherwise be a staircase of nested forms. Don't reach for it for a single `if`/`let`.
|
||||
|
||||
## Schemas — `mr/def` for shared, inline for single-use
|
||||
|
||||
When a schema is used in 2+ places or has a name worth documenting, extract it into the registry via `mr/def`:
|
||||
|
||||
```clojure
|
||||
(:require [metabase.util.malli.registry :as mr])
|
||||
|
||||
(mr/def ::options
|
||||
"Options for Pulse channel rendering."
|
||||
[:map
|
||||
[:channel.render/include-buttons? {:description "default: false" :optional true} :boolean]])
|
||||
```
|
||||
|
||||
`mr/def` keys are always `::namespaced` keywords; downstream callers reference via the fully-qualified keyword. Single-use schemas stay inline in the `mu/defn`'s `:-` annotation — no need to extract.
|
||||
|
||||
## Malli — `metabase.util.malli`
|
||||
|
||||
- `(mu/defn my-fn :- :string [x :- :int, y :- [:maybe :string]] …)` — inline schemas; runtime validation in dev, no-op in prod by default.
|
||||
- `(mu/disable-enforcement & body)` — wrap test/perf-sensitive blocks.
|
||||
- Common malli schemas: `:int`, `:string`, `:boolean`, `:keyword`, `:map`, `[:maybe T]`, `[:sequential T]`, `[:enum :a :b]`, `[:map [:k T]]`, `pos-int?`, `nat-int?`.
|
||||
|
||||
## i18n — `[metabase.util.i18n :refer [tru deferred-tru]]`
|
||||
|
||||
- `(tru "Hello {0}" name)` — translate at call time. Use in request-handling code.
|
||||
- `(deferred-tru "…")` — translate at read time. Use in static contexts: `defsetting :description`, `defn` metadata `:description`, top-level `def`s of user-visible labels.
|
||||
- ICU MessageFormat: doubled apostrophe for a literal: `(tru "it''s ready")`. Numbered placeholders: `{0}`, `{1}`.
|
||||
- **Don't translate** developer-only debug strings (REPL helper text, internal logs).
|
||||
|
||||
## Logging — `[metabase.util.log :as log]`
|
||||
|
||||
- Levels: `trace`, `debug`, `info`, `warn`, `error`.
|
||||
- Two flavors per level:
|
||||
- `(log/warn "msg")` or `(log/warn e "msg")` — exception first if present.
|
||||
- `(log/warnf "fmt %s" arg)` or `(log/warnf e "fmt %s" arg)` — printf-style.
|
||||
- Don't `(log/warn (format "fmt %s" arg))` — use `warnf`.
|
||||
|
||||
## Map / key normalization
|
||||
|
||||
When parsing loosely-typed external data (LLM JSON, request bodies) that may have **either** string or keyword keys:
|
||||
|
||||
- `(update-keys m keyword)` — top-level keys → keywords (Clojure 1.11+, in `clojure.core`).
|
||||
- `(clojure.walk/keywordize-keys m)` — recursive (use sparingly; can over-keywordize value strings if they happen to be in nested maps).
|
||||
- After normalizing once, downstream code uses plain `(:k m)` — don't write `(or (:k m) (get m (name k)))` at every use site.
|
||||
|
||||
Other useful map ops:
|
||||
- `(update-vals m f)` — Clojure 1.11+, in `clojure.core`.
|
||||
- `(medley.core/map-vals f m)`, `(m/map-keys f m)` — older alternatives. Medley pinned at **1.4** in this repo — `m/index-of` and some other newer fns are NOT available.
|
||||
|
||||
## Comparators & sorting
|
||||
|
||||
- `(sort-by f u/reverse-compare coll)` — descending.
|
||||
- Vector comparator: `(sort-by (juxt :primary :secondary) coll)`.
|
||||
- Use `(- x)` on a primary numeric key to invert: `(sort-by (juxt #(- (:score %)) :id) coll)` (handles ties on ascending `:id`).
|
||||
|
||||
## Anti-patterns to avoid
|
||||
|
||||
- **`(let [x (do-thing!)] (other-thing! x) x)`** — use `u/prog1`.
|
||||
- **`(vec (keep …))`, `(vec (map …))`** — use `u/keepv` or a transducer `(into [] (comp …) coll)`.
|
||||
- **Multiple `filter` passes over the same coll for different `:type`s** — one `(group-by :type coll)`.
|
||||
- **Per-field string/keyword dual-lookup** — normalize once with `update-keys keyword`.
|
||||
- **Reaching into another module's tables via raw `:left-join` SQL** — expose a function on the owning model namespace and call that.
|
||||
- **Snake-case keys on a massaged in-memory map** — appdb-row only. Convert at the parse boundary (e.g., when a json response becomes an internal record).
|
||||
- **`(double (:k m 0.0))` on a hydrated DB column** — present-but-nil NPEs. Use `(double (or (:k m) 0.0))`.
|
||||
- **`defmulti` with every impl on one page** — extension story is a fiction. Use a private lookup map + a private fn.
|
||||
- **`(pos? (count xs))` to test a lazy `xs` for non-emptiness** — `count` forces the whole seq into memory. Use `(seq xs)`. (Rebuilding the seq — `(map f xs)` twice — is the only thing that recomputes; a bound seq caches.)
|
||||
- **Plain `with-redefs` on a `defn` var in a test** — not parallel-safe; the kondo hook will warn. Use `mt/with-dynamic-fn-redefs`.
|
||||
- **`(Thread/sleep N)` in an async test** — flake source #1. `(deref p timeout-ms ::timeout)` and assert `not= ::timeout`.
|
||||
- **Hand-rolled `(str/split host #":")` for origin/host comparison** — mis-handles IPv6. Use `mw.security/parse-url` (or `try-parse-url` for client-controlled input).
|
||||
- **`(subs (pr-str huge) 0 N)` on a log path** — the multi-MB string is already allocated. Bind `*print-length*`/`*print-level*` around `pr-str` instead.
|
||||
- **New retry loop / new "find this column" helper / new transform-source-type predicate** — search first. `metabase.util.retry/with-retry`, `metabase.lib.equality/find-matching-column`, and `metabase.transforms.feature-gating/any-transforms-enabled?` already exist (along with many others). Reviewers ask "isn't there already an X for this?" more than any single idiom.
|
||||
- **Plain `defn` when the function has any real contract** — `mu/defn` (or `mu/defn-`) with `:- schema` on the return and each param is the house default; reserve plain `defn` for trivial dev/helper code.
|
||||
- **`(:import (java.time …))` + `(LocalDate/now)`** in new code — use `[java-time.api :as t]` + `[metabase.util.date-2 :as u.date]`. Direct `java.time` interop only when a Java API genuinely needs the class.
|
||||
- **Vanilla `defmethod`** — use `methodical/defmethod` (it takes a docstring; the codebase uses it uniformly).
|
||||
- **`(when (premium/has-feature? :flag) …)` in code** — gate via `defenterprise` so OSS/EE bodies live in their respective namespaces; never sprinkle runtime feature checks.
|
||||
- **No `(set! *warn-on-reflection* true)` after the `ns` form** in a new `.clj` file — every `.clj` source file in this repo has it (not `.cljc`).
|
||||
- **`(ns … (:require [a :as a] [b :as b]))` on one line; `(:import java.io.File java.net.URI)` bare** — both wrong shapes. Requires one per line under `:require`, imports as `(Package Class)` lists.
|
||||
- **Inventing a fresh `:as` alias for `metabase.util`, `metabase.util.malli`, `metabase.util.log`, `java-time.api`, etc.** — use the canonical alias (see the table in Style Conventions). Mixing aliases makes `grep`-based discovery worse.
|
||||
|
||||
## When in doubt
|
||||
|
||||
- Run `grep -n "defn " src/metabase/util.cljc | head -80` to scan helpers.
|
||||
- Run `grep -rn "defmacro\|defn-? [a-z-]*\?\b" src/metabase/util.cljc | head` for predicates.
|
||||
- Check `medley.core/` if it looks "collection-y" — but **Medley is pinned at 1.4** here; new fns aren't available.
|
||||
- Search the codebase for an existing use of the pattern you're about to write — if no one else does it, there's usually a helper.
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
# Metabase Cypress Conventions
|
||||
|
||||
These conventions apply when writing **and** reviewing Cypress E2E specs in `e2e/test/scenarios/`.
|
||||
|
||||
For framework-level guidance, the canonical source is the official Cypress best practices page: <https://docs.cypress.io/app/core-concepts/best-practices>. Specific rules from that page are folded in below as we encounter them; the page itself is the authoritative reference for anything not covered here.
|
||||
|
||||
## File location and naming
|
||||
|
||||
- Place specs in `e2e/test/scenarios/<area>/`, mirroring the URL structure.
|
||||
- Extension: `.cy.spec.ts` is preferred for new specs. `.cy.spec.js` is also valid — a large portion of the codebase still uses it. Don't convert existing `.js` specs as a side-effect of unrelated work.
|
||||
- `describe` block name follows the pattern: `"area > sub-area > feature (#issue-number)"` when the spec ties to a tracked issue.
|
||||
|
||||
## Helpers
|
||||
|
||||
All helpers are accessed via `const { H } = cy;` — NEVER via direct imports from `e2e/support/helpers`.
|
||||
|
||||
```js
|
||||
const { H } = cy;
|
||||
|
||||
describe("feature name", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Use existing navigation helpers (`H.openOrdersTable()`, `H.openNativeEditor()`, `H.visitDashboard(id)`, `H.visitQuestion(id)`, etc.) instead of raw `cy.visit()` chains. Grep `e2e/support/helpers/` to discover what's available.
|
||||
|
||||
### Changing shared helpers
|
||||
|
||||
Before changing a shared helper's default behavior, enumerate its callers and check:
|
||||
|
||||
- repeated calls with the same and different arguments;
|
||||
- cache, deduplication, or conditional paths where an expected request may not occur;
|
||||
- existing intercepts, alias reuse, and concurrent requests;
|
||||
- inherited `within` scopes and other caller-owned context.
|
||||
|
||||
If only some callers need additional synchronization or assertions, prefer an explicit opt-in option over changing every caller implicitly. Validate both an opted-in caller and an existing caller that retains the default behavior.
|
||||
|
||||
## Constants and IDs
|
||||
|
||||
**Sample Database schema** (table/field definitions) — import from `cypress_sample_database`:
|
||||
|
||||
```js
|
||||
import {
|
||||
ORDERS,
|
||||
ORDERS_ID,
|
||||
PRODUCTS,
|
||||
} from "e2e/support/cypress_sample_database";
|
||||
```
|
||||
|
||||
**Instance data** (dashboard IDs, user IDs, etc.) — import from `cypress_sample_instance_data`:
|
||||
|
||||
```js
|
||||
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
```
|
||||
|
||||
**Never hardcode numeric IDs in tests — period.** Not even for entities the test creates itself. Auto-incrementing PKs are not stable: another test or seed step earlier in the run can shift "the next ID" from 10 to 11. Always capture the ID from the create response and reuse the captured value:
|
||||
|
||||
```js
|
||||
// Good — capture and reuse
|
||||
H.createDashboard({ name: "My dashboard" }).then(({ body: dashboard }) => {
|
||||
cy.visit(`/dashboard/${dashboard.id}`);
|
||||
});
|
||||
|
||||
// Good — alias the intercept and pull the id off the response
|
||||
cy.intercept("POST", "/api/dashboard").as("createDashboard");
|
||||
// ...trigger creation...
|
||||
cy.wait("@createDashboard").its("response.body.id").then((id) => { ... });
|
||||
```
|
||||
|
||||
```js
|
||||
// Bad — `10` is whatever auto-increment happened to land on at the time
|
||||
cy.visit("/dashboard/10");
|
||||
```
|
||||
|
||||
## Selectors (priority order)
|
||||
|
||||
1. **A11y-respecting queries**: `cy.findByRole()` / `cy.findByLabelText()` — from `@testing-library/cypress`. Prefer these whenever the element has an accessible role or label; they catch a11y regressions as a side-effect.
|
||||
2. `cy.findByText()` — also from `@testing-library/cypress`. Use when an a11y query doesn't fit but the element has stable visible text.
|
||||
3. `cy.findByTestId()` — for `data-testid` attributes.
|
||||
4. Other `data-*` attributes as a fallback (e.g. `cy.get("[data-element-id='foo']")`) when a stable `data-testid` isn't available.
|
||||
|
||||
NEVER use:
|
||||
|
||||
- `cy.get("[data-testid='...']")` — always use `cy.findByTestId(...)` instead.
|
||||
- CSS class names (especially generated ones from styled-components or Mantine).
|
||||
- Ad-hoc CSS attribute selectors (`path[fill="..."]`, `[stroke="..."]`, etc.) in test specs or new helpers.
|
||||
- XPath expressions.
|
||||
|
||||
### Visualization tests — the ECharts exception
|
||||
|
||||
`e2e/support/helpers/e2e-visual-tests-helpers.js` is the **only** file allowed to reach into rendered chart DOM via raw CSS attribute selectors. ECharts renders SVG with no `data-testid` attributes and minimal a11y surface, so there's no first-class alternative for asserting on chart visuals. The helpers there (`echartsContainer`, `goalLine`, `chartPathWithFillColor`, `pieSliceWithColor`, `cartesianChartCircle`, `BoxPlot.*`, etc.) are the intentional exception.
|
||||
|
||||
When writing or reviewing visualization tests:
|
||||
|
||||
- Always go through these helpers — don't roll your own `cy.get("path[fill=...]")` inline.
|
||||
- If a chart pattern isn't covered, add a new helper to that file rather than embedding the selector in a spec.
|
||||
|
||||
### Positional selectors — use with care
|
||||
|
||||
`.eq(N)`, `.first()`, `.last()`, `:nth-child(N)` are sometimes the most readable option (e.g. tabular data where rows are intentionally ordered, or asserting on sort order itself). They aren't banned outright, but they're a flakiness risk if the collection size isn't locked. Use them only when:
|
||||
|
||||
- The order is part of the assertion itself (e.g. testing a sort), or
|
||||
- A length assertion immediately precedes them, e.g. `.should("have.length", n).eq(0)`.
|
||||
|
||||
The `metabase/no-unsafe-element-filtering` lint rule already flags `.last()` and `.eq(<negative-index>)` when not preceded by a length assertion. `.first()` and non-negative `.eq(N)` aren't lint-checked, so the same caution falls on the author/reviewer.
|
||||
|
||||
### Text selectors must be scoped
|
||||
|
||||
Top-level `cy.findByText(...)` or `cy.contains(...)` inside an `it` / `before` / `beforeEach` matches against the entire document, which is a classic false-match and flakiness vector — any unrelated copy that contains the same string can hijack the assertion. Scope text-based selectors to a container:
|
||||
|
||||
- `cy.contains("[role='dialog']", "Save")` — two-argument form with a scoping selector
|
||||
- `cy.findByRole("dialog").findByText("Save")` — chain off a scoping query
|
||||
- `cy.findByRole("dialog").within(() => { ... })` — use `within` when **multiple** commands need to share the scope (see the next rule)
|
||||
|
||||
**`within` must ALWAYS be chained off an existing selector** — `someSelector().within(() => { ... })`. A bare `cy.within(...)` has no scope, defeats the entire purpose of `within`, and is wrong by construction. Flag every standalone `cy.within(...)` on sight.
|
||||
|
||||
**Don't reach for `within` when a chain would do.** If the callback contains a single command, just chain it off the parent directly — the `within` adds noise and gains nothing. Reserve `within` for when there are two or more inner commands that genuinely benefit from a shared scope.
|
||||
|
||||
```js
|
||||
// Bad — single-statement within is just ceremony around a chain
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByText("Save").click();
|
||||
});
|
||||
|
||||
// Good — chain directly
|
||||
cy.findByRole("dialog").findByText("Save").click();
|
||||
|
||||
// Good — within earns its keep when multiple commands share the scope
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByLabelText("Name").type("Hello");
|
||||
cy.findByLabelText("Description").type("world");
|
||||
cy.findByRole("button", { name: "Save" }).click();
|
||||
});
|
||||
```
|
||||
|
||||
**Don't name the within callback parameter.** `within` passes the jQuery-wrapped subject in, but the inner Cypress commands inherit the scope automatically — the parameter is never used at runtime, and naming it suggests to the reader that it's needed (it isn't). If you actually need the subject, that's what `.then()` is for, not `within`.
|
||||
|
||||
```js
|
||||
// Bad — `modal` is unused; the .within() callback receives it but you don't need it
|
||||
cy.findByTestId("save-question-modal").within((modal) => {
|
||||
cy.findByText("Save").click();
|
||||
});
|
||||
|
||||
// Good
|
||||
cy.findByTestId("save-question-modal").within(() => {
|
||||
cy.findByText("Save").click();
|
||||
});
|
||||
|
||||
// If you actually need the jQuery subject, use .then() instead
|
||||
cy.findByTestId("save-question-modal").then(($modal) => {
|
||||
expect($modal).to.have.attr("aria-modal", "true");
|
||||
});
|
||||
```
|
||||
|
||||
The `metabase/no-unscoped-text-selectors` lint rule enforces this for the top-level case **only**. Wrapping the query in a helper function evades the rule (the rule walks up to the nearest block, which is the helper's body — not the test block). The convention still applies inside helpers: a helper that calls `cy.findByText(...)` unscoped is just as flaky as the inline version, and the lint rule won't catch it.
|
||||
|
||||
## Setup: API over UI
|
||||
|
||||
- Use `cy.request()` or existing API helpers to set up state.
|
||||
- Only drive the UI for the flow you're actually testing.
|
||||
- Before replacing UI steps with API setup, state the behavior the test must preserve. An interaction belongs to the tested flow when it is part of the regression path, appears in relevant failure evidence, or is an intended integration boundary. Keep those interactions in the UI; use APIs only for preconditions outside that behavior.
|
||||
|
||||
## Waits and timing
|
||||
|
||||
- **Never** use numeric `cy.wait(ms)`.
|
||||
- For API timing: define `cy.intercept()` BEFORE the action that triggers the request, then `cy.wait("@alias")`.
|
||||
- A network wait is valid only when the triggering action is expected to issue that request. Account for caches, request deduplication, unchanged query arguments, and conditional fetches before making a wait mandatory.
|
||||
- Treat network completion and DOM readiness as separate boundaries: first verify the expected response, then assert the resulting UI state.
|
||||
- For DOM readiness: prefer `.should("be.visible")` — it asserts the element is rendered **and** visible to the user, which is the right proxy for "the UI is ready". `.should("exist")` only proves the node is in the DOM and is **not** a readiness check; reserve it for cases where you specifically need to assert presence without visibility (hidden inputs, off-screen / portal-detached nodes, etc.).
|
||||
|
||||
```js
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
// ... trigger action ...
|
||||
cy.wait("@dataset");
|
||||
```
|
||||
|
||||
## Never assign return values from `cy.*` commands
|
||||
|
||||
`cy.*` commands are not synchronous — they enqueue work on Cypress's command queue and resolve asynchronously. Assigning the return value to a variable yields a _chainer_, not the underlying DOM element / string / response. Treating that variable as if it held the resolved value is one of the most common foot-guns in Cypress and a frequent source of "but my console.log printed something weird" confusion. See the [official anti-pattern doc](https://docs.cypress.io/app/core-concepts/best-practices#Assigning-Return-Values).
|
||||
|
||||
```js
|
||||
// Bad — `button` is a chainer, NOT a DOM node
|
||||
const button = cy.findByRole("button", { name: "Save" });
|
||||
button.click();
|
||||
// `text` is a chainer, NOT a string
|
||||
const text = cy.findByTestId("title").invoke("text");
|
||||
expect(text).to.equal("Hello"); // always fails
|
||||
```
|
||||
|
||||
Use `.then()` to access the resolved value, or `.as()` + `cy.get("@alias")` to reference it later:
|
||||
|
||||
```js
|
||||
// Good — work with the value inside .then()
|
||||
cy.findByTestId("title")
|
||||
.invoke("text")
|
||||
.then((text) => {
|
||||
expect(text).to.equal("Hello");
|
||||
});
|
||||
|
||||
// Good — alias when you need to refer to the same element later in the test
|
||||
cy.findByRole("button", { name: "Save" }).as("saveButton");
|
||||
// ...other steps...
|
||||
cy.get("@saveButton").should("be.disabled");
|
||||
cy.get("@saveButton").click();
|
||||
```
|
||||
|
||||
If you only need the element at the point of grabbing it, skip the alias entirely and chain — `.as()` + `cy.get("@alias")` only earns its keep when there's distance between the lookup and the use:
|
||||
|
||||
```js
|
||||
cy.findByRole("button", { name: "Save" }).click();
|
||||
```
|
||||
|
||||
If you want to **name** a query so it reads better at the call site, wrap it in a function — never in a `const`:
|
||||
|
||||
```js
|
||||
// Bad — this is just the assigning-return-values trap with extra steps.
|
||||
// `foo` is a one-shot chainer; using it later won't re-query, won't retry, and is
|
||||
// almost certainly not what you intended.
|
||||
const foo = cy.findByText("Foo");
|
||||
foo.click();
|
||||
|
||||
// Good — each call to foo() enqueues a fresh query with full retry semantics.
|
||||
// This is the idiom the visualization helpers use (echartsContainer(), goalLine(), etc.).
|
||||
const foo = () => cy.findByText("Foo");
|
||||
foo().click();
|
||||
```
|
||||
|
||||
The difference looks subtle on the page but is huge in implication: a `const` captures the chainer at definition time (already in-flight, not reusable); a function defers the lookup so every call re-runs it.
|
||||
|
||||
The `cypress/no-assigning-return-values` lint rule (already on at error level in our e2e config) catches the simple cases. Anything that slips past it — values returned from helper functions, destructuring, indirection through wrapper objects — has to be caught manually.
|
||||
|
||||
## Assertions
|
||||
|
||||
- Assert on user-visible state: text, URL, aria attributes.
|
||||
- Don't assert on DOM structure or implementation details.
|
||||
- `expect()` only inside `cy.then()` / `cy.wrap()` callbacks.
|
||||
- **Always pair a negative assertion with a positive one.** A standalone `should("not.exist")` / `should("not.be.visible")` will pass by accident if the UI simply hasn't rendered yet — the thing you're claiming is absent was never going to be there at the moment of the check, and you've green-lit a state the test never actually reached. First assert that the page is in the expected state (something positive — text, URL, aria, a settled API response), then assert the thing-that-shouldn't-be-there is absent.
|
||||
|
||||
```js
|
||||
// Bad — passes whenever the page is empty, including pre-render
|
||||
cy.findByText("Editing").should("not.exist");
|
||||
|
||||
// Good — anchor on a positive signal first, then assert absence
|
||||
cy.findByText("Saved").should("be.visible");
|
||||
cy.findByText("Editing").should("not.exist");
|
||||
```
|
||||
|
||||
- **Collapse multiple text checks against the same parent into one assertion chain.** When you're checking that a container contains (or doesn't contain) several strings, three separate `findByText().should(...)` queries are slower (each retries independently), noisier, and not atomic — the DOM can change between queries. A single chain on the parent yields the same checks against one snapshot, with one retry budget.
|
||||
|
||||
```js
|
||||
// Bad — three queries, three retry timeouts, no atomicity
|
||||
parent().findByText("Foo").should("exist");
|
||||
parent().findByText("Bar").should("exist");
|
||||
parent().findByText("Baz").should("not.exist");
|
||||
|
||||
// Good — one query, one retry budget, atomic
|
||||
parent()
|
||||
.should("contain", "Foo")
|
||||
.and("contain", "Bar")
|
||||
.and("not.contain", "Baz");
|
||||
```
|
||||
|
||||
## Isolation
|
||||
|
||||
- Each `it()` block must be independently runnable.
|
||||
- Don't rely on state from a previous `it()`.
|
||||
- Use `beforeEach()` for state reset, not `before()`.
|
||||
- When both `H.restore()` and `H.resetTestTable()` appear, `H.restore()` must come first (enforced by `metabase/no-unordered-test-helpers`).
|
||||
|
||||
## Annotate steps with `cy.log()`, not comments
|
||||
|
||||
Prefer `cy.log("...")` over a JS comment when documenting a step or section of a test. Both are equally readable in source, but `cy.log` is dramatically better when something fails:
|
||||
|
||||
- It shows up in the Cypress command panel as a logged step.
|
||||
- It appears in screenshots and videos at the moment it ran, so a CI failure screenshot tells you which phase of the test was active.
|
||||
- A JS `//` comment is stripped at runtime and is invisible in any failure artifact.
|
||||
|
||||
```js
|
||||
// Bad — invisible at runtime
|
||||
// Verify the dashboard renders
|
||||
cy.findByText("My Dashboard").should("be.visible");
|
||||
|
||||
// Good — visible in command panel, screenshots, videos
|
||||
cy.log("Verify the dashboard renders");
|
||||
cy.findByText("My Dashboard").should("be.visible");
|
||||
```
|
||||
|
||||
Reserve JS comments for things that aren't runtime steps (TODOs, explanations of why an unusual approach was chosen, links to issues).
|
||||
|
||||
The same "don't restate the obvious" rule that applies to comments applies to `cy.log` too. A log line that just paraphrases the next command is noise — it doesn't aid debugging, it just clutters the command panel.
|
||||
|
||||
```js
|
||||
// Bad — log adds nothing the helper name doesn't already say
|
||||
cy.log("Visit dashboard");
|
||||
H.visitDashboard(id);
|
||||
|
||||
// Good — log marks a phase / explains intent
|
||||
cy.log("Switch to a non-admin and confirm the hidden column is gone");
|
||||
cy.signInAsNormalUser();
|
||||
cy.visit(`/question/${questionId}`);
|
||||
cy.findByText("internal_notes").should("not.exist");
|
||||
```
|
||||
|
||||
Use `cy.log` for phase markers, non-obvious intent ("wait for cache to invalidate before retry"), and section headings in long flows — not as a redundant pre-amble for self-describing commands.
|
||||
|
||||
## Performance
|
||||
|
||||
### Don't split one flow into many tiny tests
|
||||
|
||||
A common anti-pattern carried over from unit-testing habits: one `it()` block per assertion. Every additional `it()` is far more expensive in Cypress than people coming from Jest expect, because the cost stacks twice:
|
||||
|
||||
1. **Cypress's own per-test teardown and setup** — resetting the browser context, tearing down and re-initializing the runner, re-bootstrapping plugins/spec environment. In this codebase this overhead empirically lands in the **5–10 second** range per test, before any of your code runs.
|
||||
2. **Your `beforeEach`** — `H.restore()` + sign-in + page navigation, typically several more seconds on top.
|
||||
|
||||
A flow split into 8 tiny `it()` blocks pays that combined overhead 8 times. See the [official anti-pattern doc](https://docs.cypress.io/app/core-concepts/best-practices#Creating-Tiny-Tests-With-A-Single-Assertion).
|
||||
|
||||
#### Before merging tiny tests, ask whether they belong in E2E at all
|
||||
|
||||
A test whose **only** job is to assert that some elements exist or are visible — no backend interaction worth testing through the wire, no flow that traverses multiple screens, no real-data dependency — is almost always a unit test misfiled into the e2e suite. `frontend/CLAUDE.md` already says "prefer unit tests over E2E tests"; this is the most common failure mode.
|
||||
|
||||
Strong signals the test should be a Jest + React Testing Library unit test instead:
|
||||
|
||||
- The whole body is `cy.findBy*(...).should("be.visible")` calls inside a single component or panel.
|
||||
- The test is verifying token-gated UI (a feature flag toggling something on/off in render output).
|
||||
- The test would still pass against a static mock — the backend's role is incidental.
|
||||
- There's no user flow; just "render this thing and check it's there."
|
||||
|
||||
Real example from a recent PR — this is a unit test masquerading as E2E:
|
||||
|
||||
```js
|
||||
// Bad — boots the whole app + a real DB to assert four pieces of static copy render.
|
||||
// Belongs in a Jest unit test for the side-panel component.
|
||||
it("should show side panel with help content when 'Help is here' is clicked", () => {
|
||||
cy.findByRole("button", { name: /Help is here/ }).click();
|
||||
cy.findByTestId("database-help-side-panel").within(() => {
|
||||
cy.findByText("Add PostgreSQL").should("be.visible");
|
||||
cy.findByRole("link", { name: /Read the full docs/ }).should("be.visible");
|
||||
cy.findByRole("link", { name: /Talk to an expert/ }).should("be.visible");
|
||||
cy.findByRole("button", { name: /Invite a teammate to help you/ }).should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Two distinct outcomes for these tests:
|
||||
|
||||
- **Move to a unit test** — when the static-UI check is genuinely useful coverage but doesn't need the e2e boot. The right home is the component's Jest spec.
|
||||
- **Delete outright** — when the same issue is already covered by an existing Jest or backend test. The e2e version is just paying full freight for coverage the cheaper layer already provides.
|
||||
|
||||
Test isolation does **not** mean one assertion per test. It means each `it()` should be independently runnable. Within a single `it()`, asserting on multiple things across a user flow is correct and expected.
|
||||
|
||||
```js
|
||||
// Bad — three separate boots of the app for one user flow
|
||||
describe("question editor", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("opens the editor", () => {
|
||||
H.openOrdersTable();
|
||||
cy.findByText("Pick a function").should("be.visible");
|
||||
});
|
||||
|
||||
it("can pick a function", () => {
|
||||
H.openOrdersTable();
|
||||
cy.findByText("Pick a function").click();
|
||||
cy.findByText("Sum of...").should("be.visible");
|
||||
});
|
||||
|
||||
it("can apply the function", () => {
|
||||
H.openOrdersTable();
|
||||
cy.findByText("Pick a function").click();
|
||||
cy.findByText("Sum of...").click();
|
||||
cy.findByText("Total").click();
|
||||
cy.findByText("Sum of Total").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
// Good — one boot, one flow, multiple checkpoints
|
||||
describe("question editor", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("can apply a sum aggregation from the editor", () => {
|
||||
H.openOrdersTable();
|
||||
cy.findByText("Pick a function").should("be.visible").click();
|
||||
cy.findByText("Sum of...").click();
|
||||
cy.findByText("Total").click();
|
||||
cy.findByText("Sum of Total").should("be.visible");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Extend an existing test before adding a near-duplicate
|
||||
|
||||
Before writing a new `it()` block, look at sibling tests in the same `describe`. If the new test shares 80–90% of its setup and flow with an existing one and only diverges at the end (a different click, an additional assertion, a tweaked param), extend the existing test instead of spawning a clone.
|
||||
|
||||
A near-duplicate test pays the full `beforeEach` cost again, doubles the maintenance surface (two places to update when the flow changes), and obscures intent — readers can't tell why the second test exists if it's not visibly different from the first.
|
||||
|
||||
Signals that this is the case:
|
||||
|
||||
- The new `it()` repeats the same `H.openOrdersTable()` / `H.visitDashboard(id)` / opening sequence as the test above it.
|
||||
- The new `it()` differs only in the last few lines.
|
||||
- The new `it()` title is a near-paraphrase of an existing one ("can pick a sum function" + "can pick an avg function" — these are likely the same test parameterised over function name).
|
||||
|
||||
When in doubt, prefer extending the existing test over copy-paste. If the divergence is genuinely large enough to warrant a separate test, the title and the diverging steps will make that obvious.
|
||||
|
||||
### Each `cy.visit()` is expensive — navigate via the UI between screens
|
||||
|
||||
Metabase's frontend is a React app with a large Redux store. Booting it from cold (which is what `cy.visit()` does) is **expensive** — store hydration, route bootstrapping, settings/permissions/user fetches, etc. Within a single test, the second and third `cy.visit()` are not free "page changes"; they're full app re-boots, each adding multiple seconds.
|
||||
|
||||
Once you're already in the app, prefer in-app navigation: click the link, the breadcrumb, the sidebar item, or the entity tile. You keep the warm Redux store, the queries you've already issued, and the rendered shell.
|
||||
|
||||
```js
|
||||
// Bad — three cold boots
|
||||
cy.visit("/collection/root");
|
||||
cy.findByText("My Dashboard").should("be.visible");
|
||||
cy.visit("/dashboard/" + dashboardId);
|
||||
cy.findByText("Some chart").click();
|
||||
cy.visit("/question/" + questionId);
|
||||
|
||||
// Good — one boot, navigate via UI
|
||||
cy.visit("/collection/root");
|
||||
cy.findByText("My Dashboard").click();
|
||||
cy.findByText("Some chart").click(); // drills into the question
|
||||
```
|
||||
|
||||
`cy.visit()` is still the right call for the **first** navigation in a test (or after an action that genuinely requires a full reload, like a permissions change). It's the second and third unnecessary visit that's the smell.
|
||||
|
||||
## Spec structure template
|
||||
|
||||
```js
|
||||
const { H } = cy;
|
||||
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import { ORDERS, ORDERS_ID } from "e2e/support/cypress_sample_database";
|
||||
|
||||
describe("area > sub-area > feature (#issue-number)", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("should do the primary happy-path thing", () => {
|
||||
// test
|
||||
});
|
||||
|
||||
it("should handle the edge case", () => {
|
||||
// test
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Common Cypress framework gotchas
|
||||
|
||||
- No JS conditionals on async chains: `cy.get(...).then(el => { if (...) })` runs **once** and yields whatever the DOM looked like at that instant, which is the classic flakiness vector. `.should()` re-runs the previous query and the assertion until it passes or the command times out — that automatic retry is what makes it the right tool for "wait until this is true". Use `.should()` matchers instead.
|
||||
- No mixing of native Promises (`Promise.resolve`, `async/await`) with cy chains. Cypress has its own thenable.
|
||||
- Avoid `forEach` over cy queries. Use `cy.each(...)` if you need iteration.
|
||||
- `.within()` scopes must be properly closed — assertions outside the callback can leak.
|
||||
- Beware detached DOM: re-query (call `cy.findByText(...)` again) instead of caching a yielded element across re-renders.
|
||||
- `.should("not.exist")` and `.should("not.be.visible")` mean different things — use the one that matches your intent.
|
||||
- No `.only` / `.skip` in committed code. A pre-commit hook should block this, but it occasionally slips through.
|
||||
@@ -6,4 +6,3 @@
|
||||
- Run targeted tests, and lint continuously during development
|
||||
- Prioritize understanding existing patterns before implementing
|
||||
- Don't commit changes, leave it for the user to review and make commits
|
||||
- Minimize comments. Document only non-obvious intent, trade-offs, or constraints that the code itself cannot convey. Do not narrate what the code does — keep code self-descriptive through naming and structure.
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# React, Redux, and Data-Layer Patterns
|
||||
|
||||
## API access and RTK Query
|
||||
|
||||
- **Use RTK Query for server data.** Add typed endpoints under `metabase/api` or `metabase-enterprise/api`; don't add ad-hoc `fetch`, service wrappers, or new thunks for ordinary API access.
|
||||
- **Keep API files transport-focused.** Endpoint files should cover request/response typing, URLs, params, light response normalization, and cache tags — not feature business logic.
|
||||
- **Use canonical API types.** Prefer `metabase-types/api`; fix or derive missing types instead of casting endpoint results. Refer to API endpoint implementation in Clojure when building new endpoint connections.
|
||||
- **Tag caches deliberately.** Use `listTag`, `idTag`, `provide*Tags`, and `invalidateTags(error, tags)` where appropriate.
|
||||
- **Guard missing query inputs.** Use `skipToken` or `skip` until all required inputs exist. Build query requirements where the query is created, not in a downstream render path.
|
||||
- **Use lazy queries only for events.** Data needed to render a screen belongs in a regular query hook at the owner component/hook.
|
||||
- **Handle mutations with `unwrap()`.** Prefer `await trigger(args).unwrap()` in submit handlers; otherwise branch on `result.error` immediately.
|
||||
|
||||
## Where data should live
|
||||
|
||||
- **Server data:** RTK Query cache, not duplicated Redux state.
|
||||
- **Form state:** Form library or local component state, not global Redux.
|
||||
- **Transient UI state:** Component state or narrow context, not store-wide flags.
|
||||
- **Cross-route/global tasks:** Redux slice with public selectors.
|
||||
- **Derived values:** Selectors or pure helpers, not stored copies of computed data. Memoized if identity is important.
|
||||
|
||||
## Redux state modeling
|
||||
|
||||
- **Avoid using Redux**. Most problems can be solved with local component state or RTK Query.
|
||||
- **Use Redux only when needed.** Reach for Redux only when distant parts of the app need durable shared state.
|
||||
- **Model async status explicitly.** Use a discriminated union or explicit `status`/`isLoading`/`error`; don't infer loaded or failed state from truthy/falsy `data`.
|
||||
- **Store canonical state only.** Derive filtered lists, labels, permissions, booleans, and display variants in selectors.
|
||||
- **Read through selectors.** Name selectors after what they return (`getCurrentTask`, `getHasPendingMutation`), not how they compute it.
|
||||
- **Add missing public actions/selectors.** Don't reach into another feature's private state shape because an action or selector is missing.
|
||||
- **Use `createSelector` for derived state.** Keep selectors pure, cheap, and stable by reference when consumers depend on identity.
|
||||
|
||||
## React hooks and components
|
||||
|
||||
- **`useMemo` computes render values.** Side effects belong in `useEffect`; event-driven changes belong in event handlers or mutation callbacks.
|
||||
- **Keep effects narrow and idempotent.** Include all dependencies, guard inside the effect, and clean up timers/subscriptions. Complex logic should be split to multiple effects or custom hooks, also consider extracting functional pieces.
|
||||
- **Hooks can be used to encapsulate complex logic.** For example, a hook can normalize data, handle form's state and validation, or a hook can manage a table's pagination and sorting. If component's logic becomes too complex, consider extracting a hook.
|
||||
- **Hooks orchestrate behavior.** Custom hooks should return a small typed result object: data, loading/error state, and callbacks when relevant.
|
||||
- **Don't mirror props/query data into state** unless the consumer can edit that local copy. Keep derived render data as such.
|
||||
- **Use `useCallback` for stable identity needs, but deliberately.** Wrap callbacks passed to memoized children, dependency arrays, context values, or hooks that require stable identity — not every local handler.
|
||||
- **Memoize object/array props only when identity matters.** Prefer simpler plain values otherwise.
|
||||
- **Stories for presentational components**. Create Storybook stories for presentational components to test different states and props. Stories live next to the component.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Optimize only where identity or scale matters.** Prefer clear code first, then add `memo`, `useMemo`, `useCallback`, selectors, virtualization, or split components when render cost, prop identity, or list size justifies it.
|
||||
- **Keep context values stable and avoid frequent updates.** Values updated often should be avoided in context consumed by many components, or stored by reference with a stable context value and explicit subscriptions/selectors.
|
||||
- **Read global state high in the tree.** Let parent components select context/Redux/API state, derive the view model, and pass narrow props top-down. But avoid passing props too deep (reconsider if more thatn 2 levels).
|
||||
- **Avoid unstable props in hot paths.** Don't pass fresh object/array/function literals to memoized children; memoize them or move them outside render when identity matters.
|
||||
- **Render large lists deliberately.** Use pagination, windowing/virtualization, stable keys, and row-level memoization for large or frequently updating lists.
|
||||
- **Consider memoization for leaf components.** If the source of data updates often, but the component doesn't need to re-render, consider memoizing it.
|
||||
|
||||
## Loading, error, and empty states
|
||||
|
||||
- **Every async operation needs loading UI.** Use `LoadingAndErrorWrapper` or `DelayedLoadingAndErrorWrapper` for full-page/panel loading; use disabled controls and inline progress for local actions.
|
||||
- **Every async operation needs an error branch.** Use wrappers or feature-specific alerts for query errors; use `getErrorMessage(error, fallback)` plus a toast/form error for mutation failures.
|
||||
- **Empty is its own state.** Render no-results, empty-list, and permission-limited states intentionally; don't let them look like unfinished loading.
|
||||
- **Combine multiple query states explicitly.** Example: `isLoading = a.isLoading || b.isLoading`; `error = a.error || b.error`.
|
||||
- **Distinguish first load from refresh.** Use `isLoading` for first-load blockers and `isFetching` for background refresh indicators.
|
||||
- **Test all async branches.** Cover loading, error, empty, and success states when adding or changing data access.
|
||||
|
||||
## Review checklist
|
||||
|
||||
- Server data uses a typed RTK Query endpoint with correct cache tags.
|
||||
- Query inputs are guarded with `skipToken`/`skip` until ready.
|
||||
- Global state is justified, minimal, and read through selectors.
|
||||
- Context values are stable; frequently changing values don't force broad re-renders.
|
||||
- Leaf components stay dumb and receive narrow props from parent containers.
|
||||
- Loading, error, empty, and success branches are visible and tested.
|
||||
- Hooks are pure during render; side effects are confined to effects or event handlers.
|
||||
- Mutation failures are surfaced to people using the UI.
|
||||
@@ -1,9 +1,9 @@
|
||||
## Linting and Formatting
|
||||
|
||||
- **Lint:** `bun run lint-oxlint-pure`
|
||||
- Run oxlint on the codebase
|
||||
- **Format:** `bun run format`
|
||||
- Format code with oxfmt (`oxfmt --write`); use `bun run lint-format-pure` to check without writing
|
||||
- **Lint:** `bun run lint-eslint-pure`
|
||||
- Run ESLint on the codebase
|
||||
- **Format:** `bun run prettier`
|
||||
- Format code using Prettier
|
||||
- **Type Check:** `bun run type-check-pure`
|
||||
- Run TypeScript type checking
|
||||
|
||||
|
||||
@@ -149,7 +149,8 @@ ms/NonBlankString ;; Non-empty string
|
||||
ms/BooleanValue ;; String "true"/"false" or boolean
|
||||
ms/MaybeBooleanValue ;; BooleanValue or nil
|
||||
ms/TemporalString ;; ISO-8601 date/time string (for REQUEST params only!)
|
||||
ms/OpaqueJSONObject ;; JSON object whose keys are not ours to declare; string-keyed
|
||||
ms/Map ;; Any map
|
||||
ms/JSONString ;; JSON-encoded string
|
||||
ms/PositiveNum ;; Positive number
|
||||
ms/IntGreaterThanOrEqualToZero ;; 0 or positive
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
--b-
|
||||
name: add-tracing
|
||||
description: Add OpenTelemetry tracing spans to Clojure code following Metabase tracing conventions. Use when instrumenting backend code with trace coverage.
|
||||
---
|
||||
|
||||
@@ -10,23 +10,23 @@ This skill helps you add product analytics (Snowplow) events to track user inter
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Analytics events in Metabase use Snowplow with typed event schemas. Simple events are declared **where they are used** — `trackSimpleEvent` is generic and validates the payload at the call site.
|
||||
Analytics events in Metabase use Snowplow with typed event schemas. All events must be defined in TypeScript types before use.
|
||||
|
||||
**Key Files:**
|
||||
- `frontend/src/metabase/analytics/event.ts` - Core tracking functions, `trackSimpleEvent` / `trackSchemaEvent` (import from `metabase/analytics`)
|
||||
- `frontend/src/metabase-types/analytics/event.ts` - The shared `SimpleEventSchema` only. **Do not add event types here** (see below)
|
||||
- `frontend/src/metabase-types/analytics/schema.ts` - Schema registry (custom/legacy schemas only)
|
||||
- Feature-specific `analytics.ts` files - Where your tracking functions and any local types live
|
||||
- `frontend/src/metabase-types/analytics/event.ts` - Event type definitions
|
||||
- `frontend/src/metabase-types/analytics/schema.ts` - Schema registry
|
||||
- `frontend/src/metabase/lib/analytics.ts` - Core tracking functions
|
||||
- Feature-specific `analytics.ts` files - Tracking function wrappers
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
When adding a new analytics event:
|
||||
|
||||
- [ ] Pick an event name (snake_case, past tense)
|
||||
- [ ] Add a tracking function to the feature's `analytics.ts` file, calling `trackSimpleEvent()`
|
||||
- [ ] Keep any field unions (e.g. `"success" | "failure"`) as local types in that same file
|
||||
- [ ] Import and call the tracking function at the interaction point
|
||||
- [ ] Do **not** add an event type to `metabase-types/analytics/event.ts` or to any union
|
||||
- [ ] Define event type in `frontend/src/metabase-types/analytics/event.ts`
|
||||
- [ ] Add event to appropriate union type (e.g., `DataStudioEvent`, `SimpleEvent`)
|
||||
- [ ] Create tracking function in feature's `analytics.ts` file
|
||||
- [ ] Import and call tracking function at the interaction point
|
||||
- [ ] Use `trackSimpleEvent()` for basic events (most common)
|
||||
|
||||
## Event Schema Types
|
||||
|
||||
@@ -47,25 +47,6 @@ type SimpleEventSchema = {
|
||||
|
||||
**When to use:** 90% of events fit this schema. Use for clicks, opens, closes, creates, deletes, etc.
|
||||
|
||||
`trackSimpleEvent` is generic and enforces this schema on the object literal you pass it:
|
||||
|
||||
```typescript
|
||||
// frontend/src/metabase/analytics/event.ts
|
||||
export function trackSimpleEvent<
|
||||
T extends SimpleEventSchema &
|
||||
Record<Exclude<keyof T, keyof SimpleEventSchema>, never>,
|
||||
>(event: T) {
|
||||
trackSchemaEvent("simple_event", event);
|
||||
}
|
||||
```
|
||||
|
||||
That means a missing `event` or any field outside `SimpleEventSchema` is a compile error at the call
|
||||
site. There is no separate event type to declare and no `satisfies` clause to add — the old
|
||||
`ValidateEvent<...>` helper is no longer exported and is not part of the workflow.
|
||||
|
||||
`trackSchemaEvent` is generic too: it correlates the schema name with the payload type, so you can't
|
||||
send a dashboard event under the `simple_event` schema.
|
||||
|
||||
### 2. Custom Schemas (legacy, no events are being added)
|
||||
|
||||
Consider adding new event schema only in very special cases.
|
||||
@@ -76,12 +57,41 @@ Consider adding new event schema only in very special cases.
|
||||
|
||||
### Example: Track when a user applies filters in a table picker
|
||||
|
||||
#### Step 1: Create Tracking Functions
|
||||
#### Step 1: Define Event Types
|
||||
|
||||
Add event type definitions to `frontend/src/metabase-types/analytics/event.ts`:
|
||||
|
||||
```typescript
|
||||
export type DataStudioTablePickerFiltersAppliedEvent = ValidateEvent<{
|
||||
event: "data_studio_table_picker_filters_applied";
|
||||
}>;
|
||||
|
||||
export type DataStudioTablePickerFiltersClearedEvent = ValidateEvent<{
|
||||
event: "data_studio_table_picker_filters_cleared";
|
||||
}>;
|
||||
```
|
||||
|
||||
#### Step 2: Add to Union Type
|
||||
|
||||
Find or create the appropriate union type and add your events:
|
||||
|
||||
```typescript
|
||||
export type DataStudioEvent =
|
||||
| DataStudioLibraryCreatedEvent
|
||||
| DataStudioTablePublishedEvent
|
||||
| DataStudioGlossaryCreatedEvent
|
||||
| DataStudioGlossaryEditedEvent
|
||||
| DataStudioGlossaryDeletedEvent
|
||||
| DataStudioTablePickerFiltersAppliedEvent // <- Add here
|
||||
| DataStudioTablePickerFiltersClearedEvent; // <- Add here
|
||||
```
|
||||
|
||||
#### Step 3: Create Tracking Functions
|
||||
|
||||
In your feature's `analytics.ts` file (e.g., `enterprise/frontend/src/metabase-enterprise/data-studio/analytics.ts`):
|
||||
|
||||
```typescript
|
||||
import { trackSimpleEvent } from "metabase/analytics";
|
||||
import { trackSimpleEvent } from "metabase/lib/analytics";
|
||||
|
||||
export const trackDataStudioTablePickerFiltersApplied = () => {
|
||||
trackSimpleEvent({
|
||||
@@ -96,7 +106,7 @@ export const trackDataStudioTablePickerFiltersCleared = () => {
|
||||
};
|
||||
```
|
||||
|
||||
#### Step 2: Use in Components
|
||||
#### Step 4: Use in Components
|
||||
|
||||
Import and call the tracking function at the interaction point:
|
||||
|
||||
@@ -128,11 +138,16 @@ function FilterPopover({ filters, onSubmit }) {
|
||||
|
||||
## Using SimpleEventSchema Fields
|
||||
|
||||
All examples below live in the feature's own `analytics.ts` — nothing is registered centrally.
|
||||
|
||||
### Example: Event with target_id
|
||||
|
||||
```typescript
|
||||
// Type definition
|
||||
export type DataStudioLibraryCreatedEvent = ValidateEvent<{
|
||||
event: "data_studio_library_created";
|
||||
target_id: number | null;
|
||||
}>;
|
||||
|
||||
// Tracking function
|
||||
export const trackDataStudioLibraryCreated = (id: CollectionId) => {
|
||||
trackSimpleEvent({
|
||||
event: "data_studio_library_created",
|
||||
@@ -147,10 +162,14 @@ trackDataStudioLibraryCreated(newLibrary.id);
|
||||
### Example: Event with triggered_from
|
||||
|
||||
```typescript
|
||||
// Local union, exported only if another feature needs to pass the same value
|
||||
export type NewButtonLocation = "app-bar" | "empty-collection";
|
||||
// Type definition
|
||||
export type NewButtonClickedEvent = ValidateEvent<{
|
||||
event: "new_button_clicked";
|
||||
triggered_from: "app-bar" | "empty-collection";
|
||||
}>;
|
||||
|
||||
export const trackNewButtonClicked = (location: NewButtonLocation) => {
|
||||
// Tracking function
|
||||
export const trackNewButtonClicked = (location: "app-bar" | "empty-collection") => {
|
||||
trackSimpleEvent({
|
||||
event: "new_button_clicked",
|
||||
triggered_from: location,
|
||||
@@ -168,31 +187,44 @@ export const trackNewButtonClicked = (location: NewButtonLocation) => {
|
||||
|
||||
### Example: Event with event_detail
|
||||
|
||||
Real example — `frontend/src/metabase/metadata/pages/shared/analytics.ts`:
|
||||
|
||||
```typescript
|
||||
export type MetadataEditEventDetail =
|
||||
| "type_casting"
|
||||
| "semantic_type_change"
|
||||
| "visibility_change";
|
||||
// Type definition
|
||||
export type MetadataEditEvent = ValidateEvent<{
|
||||
event: "metadata_edited";
|
||||
event_detail: "type_casting" | "semantic_type_change" | "visibility_change";
|
||||
triggered_from: "admin" | "data_studio";
|
||||
}>;
|
||||
|
||||
export const trackMetadataChange = (detail: MetadataEditEventDetail) => {
|
||||
// Tracking function
|
||||
export const trackMetadataChange = (
|
||||
detail: "type_casting" | "semantic_type_change" | "visibility_change",
|
||||
location: "admin" | "data_studio"
|
||||
) => {
|
||||
trackSimpleEvent({
|
||||
event: "metadata_edited",
|
||||
event_detail: detail,
|
||||
triggered_from: "admin",
|
||||
triggered_from: location,
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
trackMetadataChange("semantic_type_change");
|
||||
trackMetadataChange("semantic_type_change", "data_studio");
|
||||
```
|
||||
|
||||
### Example: Event with result and duration
|
||||
|
||||
See `frontend/src/metabase/archive/analytics.ts` for the real version of this.
|
||||
|
||||
```typescript
|
||||
// Type definition
|
||||
export type MoveToTrashEvent = ValidateEvent<{
|
||||
event: "moved-to-trash";
|
||||
target_id: number | null;
|
||||
triggered_from: "collection" | "detail_page" | "cleanup_modal";
|
||||
duration_ms: number | null;
|
||||
result: "success" | "failure";
|
||||
event_detail: "question" | "model" | "metric" | "dashboard";
|
||||
}>;
|
||||
|
||||
// Tracking function
|
||||
export const trackMoveToTrash = (params: {
|
||||
targetId: number | null;
|
||||
triggeredFrom: "collection" | "detail_page" | "cleanup_modal";
|
||||
@@ -248,16 +280,17 @@ try {
|
||||
"filters-applied" // Use underscore, not hyphen
|
||||
```
|
||||
|
||||
### Local Field Types (PascalCase, named after the field)
|
||||
|
||||
There is usually no `...Event` type to name anymore. When you do need a union for a field, name it
|
||||
after the field it feeds:
|
||||
### Event Type Names (PascalCase with "Event" suffix)
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
type MetricDimensionResult = "success" | "failure"; // -> result
|
||||
export type MetadataEditEventDetail = "type_casting"; // -> event_detail
|
||||
type NewButtonLocation = "app-bar" | "empty-collection"; // -> triggered_from
|
||||
DataStudioLibraryCreatedEvent
|
||||
TablePickerFiltersAppliedEvent
|
||||
MetabotChatOpenedEvent
|
||||
|
||||
// Bad
|
||||
dataStudioLibraryCreated // Wrong case
|
||||
DataStudioLibraryCreated // Missing "Event" suffix
|
||||
```
|
||||
|
||||
### Tracking Function Names (camelCase with "track" prefix)
|
||||
@@ -276,28 +309,29 @@ logLibraryCreated // Use "track" prefix
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Sharing Field Types Across Features
|
||||
### Pattern 1: Feature-Specific Union Types
|
||||
|
||||
When two features send the same event with a different `triggered_from`, export the field union from
|
||||
the owning feature's `analytics.ts` and import it — don't hoist anything into `metabase-types`:
|
||||
Group related events together:
|
||||
|
||||
```typescript
|
||||
// frontend/src/metabase/data-studio/data-model/analytics.ts
|
||||
import { trackSimpleEvent } from "metabase/analytics";
|
||||
import type { MetadataEditEventDetail } from "metabase/metadata/pages/shared/analytics";
|
||||
export type DataStudioEvent =
|
||||
| DataStudioLibraryCreatedEvent
|
||||
| DataStudioTablePublishedEvent
|
||||
| DataStudioGlossaryCreatedEvent;
|
||||
|
||||
export function trackMetadataChange(detail: MetadataEditEventDetail) {
|
||||
trackSimpleEvent({
|
||||
event: "metadata_edited",
|
||||
event_detail: detail,
|
||||
triggered_from: "data_studio",
|
||||
});
|
||||
}
|
||||
export type MetabotEvent =
|
||||
| MetabotChatOpenedEvent
|
||||
| MetabotRequestSentEvent
|
||||
| MetabotFixQueryClickedEvent;
|
||||
|
||||
// Then add to SimpleEvent union
|
||||
export type SimpleEvent =
|
||||
| /* other events */
|
||||
| DataStudioEvent
|
||||
| MetabotEvent
|
||||
| /* more events */;
|
||||
```
|
||||
|
||||
This is the point of the extensible-events design: enterprise and feature-tier types stay in their
|
||||
own module instead of being imported down into a shared union.
|
||||
|
||||
### Pattern 2: Conditional Tracking
|
||||
|
||||
Track different events based on user action:
|
||||
@@ -316,10 +350,10 @@ const handleSave = async () => {
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Don't: Add custom fields to a simple event
|
||||
### Don't: Add custom fields to SimpleEvent
|
||||
|
||||
```typescript
|
||||
// WRONG - SimpleEventSchema doesn't support custom fields (this is a compile error)
|
||||
// WRONG - SimpleEvent doesn't support custom fields
|
||||
export const trackFiltersApplied = (filters: FilterState) => {
|
||||
trackSimpleEvent({
|
||||
event: "filters_applied",
|
||||
@@ -345,43 +379,23 @@ export const trackFilterApplied = (filterType: string) => {
|
||||
};
|
||||
```
|
||||
|
||||
### Don't: Add event types to `metabase-types/analytics/event.ts`
|
||||
|
||||
The central `SimpleEvent` union was removed — it forced feature-tier types to be imported down into
|
||||
shared code, causing module-boundary violations. `trackSimpleEvent` is generic now, so the type adds
|
||||
nothing but duplication.
|
||||
### Don't: Forget to add event to union type
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - central declaration + re-import for a `satisfies` clause
|
||||
// frontend/src/metabase-types/analytics/event.ts
|
||||
// Define the event
|
||||
export type NewFeatureClickedEvent = ValidateEvent<{
|
||||
event: "new_feature_clicked";
|
||||
target_id: number;
|
||||
}>;
|
||||
|
||||
// frontend/src/metabase/my-feature/analytics.ts
|
||||
import type { NewFeatureClickedEvent } from "metabase-types/analytics";
|
||||
// ❌ WRONG - Forgot to add to SimpleEvent union
|
||||
// Event won't be recognized by TypeScript
|
||||
|
||||
export const trackNewFeatureClicked = (id: number) => {
|
||||
trackSimpleEvent({
|
||||
event: "new_feature_clicked",
|
||||
target_id: id,
|
||||
} satisfies NewFeatureClickedEvent);
|
||||
};
|
||||
|
||||
// ✓ RIGHT - the object literal is already checked by the generic
|
||||
// frontend/src/metabase/my-feature/analytics.ts
|
||||
export const trackNewFeatureClicked = (id: number) => {
|
||||
trackSimpleEvent({
|
||||
event: "new_feature_clicked",
|
||||
target_id: id,
|
||||
});
|
||||
};
|
||||
// ✓ RIGHT - Add to appropriate union
|
||||
export type SimpleEvent =
|
||||
| /* other events */
|
||||
| NewFeatureClickedEvent;
|
||||
```
|
||||
|
||||
A few `...Event` types still sit in `metabase-types/analytics/event.ts`. They are leftovers from PRs
|
||||
that landed around the refactor — don't copy them, and don't add to them.
|
||||
|
||||
### Don't: Mix up event name formats
|
||||
|
||||
```typescript
|
||||
@@ -449,44 +463,41 @@ Example console output:
|
||||
### Where to put tracking functions:
|
||||
|
||||
```
|
||||
Tracking functions AND their local field types (this is where new events live):
|
||||
Feature-specific analytics functions:
|
||||
frontend/src/metabase/{feature}/analytics.ts
|
||||
enterprise/frontend/src/metabase-enterprise/{feature}/analytics.ts
|
||||
|
||||
Core tracking utilities:
|
||||
frontend/src/metabase/analytics/ (import from `metabase/analytics`)
|
||||
|
||||
Shared SimpleEventSchema only — nothing new goes here:
|
||||
Event type definitions (all in one place):
|
||||
frontend/src/metabase-types/analytics/event.ts
|
||||
```
|
||||
|
||||
In embedding SDK code, use `trackSdkSimpleEvent`
|
||||
(`frontend/src/embedding-sdk-bundle/analytics/snowplow.ts`) instead — the main-app `"sp"` tracker
|
||||
isn't initialized in the customer's page, so `trackSimpleEvent`'s Snowplow leg is a no-op there.
|
||||
Core tracking utilities:
|
||||
frontend/src/metabase/lib/analytics.ts
|
||||
```
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
See these files for reference:
|
||||
|
||||
- **Simple events + local field union**: `frontend/src/metabase/metadata/pages/shared/analytics.ts`
|
||||
- **Reusing another feature's field type**: `frontend/src/metabase/data-studio/data-model/analytics.ts`
|
||||
- **Result + duration timing**: `frontend/src/metabase/archive/analytics.ts`
|
||||
- **Enterprise feature events**: `enterprise/frontend/src/metabase-enterprise/google_drive/analytics.ts`
|
||||
- **Simple events**: `enterprise/frontend/src/metabase-enterprise/data-studio/analytics.ts`
|
||||
- **Events with variants**: `frontend/src/metabase/dashboard/analytics.ts`
|
||||
- **Complex events**: `frontend/src/metabase/query_builder/analytics.js`
|
||||
- **Event type examples**: `frontend/src/metabase-types/analytics/event.ts`
|
||||
|
||||
## Workflow Summary
|
||||
|
||||
1. **Identify the user interaction** to track
|
||||
2. **Decide on event name** (snake_case, descriptive)
|
||||
3. **Create tracking function** in feature's `analytics.ts`, calling `trackSimpleEvent()`
|
||||
4. **Add local field unions** in that same file if a field has a fixed set of values
|
||||
5. **Import and call** at the interaction point
|
||||
6. **Test** that events fire correctly
|
||||
3. **Define event type** in `event.ts` using `ValidateEvent`
|
||||
4. **Add to union type** (create feature union if needed)
|
||||
5. **Create tracking function** in feature's `analytics.ts`
|
||||
6. **Import and call** at the interaction point
|
||||
7. **Test** that events fire correctly
|
||||
|
||||
## Tips
|
||||
|
||||
- **Be specific** - `filters_applied` is better than `action_performed`
|
||||
- **Use past tense** - `library_created` not `create_library`
|
||||
- **Group related events** - Keep a feature's tracking functions together in its `analytics.ts`
|
||||
- **Group related events** - Create feature-specific event union types
|
||||
- **Track meaningful actions** - Not every click needs tracking
|
||||
- **Consider the data** - What would you want to analyze later?
|
||||
- **Stay consistent** - Follow existing naming patterns in the codebase
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
---
|
||||
name: api-changelog
|
||||
description: Draft or audit the REST API changelog (docs/developers-guide/api-changelog.md) by semantically diffing the checked-in OpenAPI spec between two git refs. Use when asked "has the API changelog been updated", "what are the breaking API changes in vNN", when cutting a release branch, or when reviewing whether a PR needs a changelog entry.
|
||||
---
|
||||
|
||||
# API changelog
|
||||
|
||||
Answers "what changed in the REST API between two versions, and is the changelog
|
||||
honest about it?"
|
||||
|
||||
## Ground truth
|
||||
|
||||
`resources/openapi/openapi.json` is generated from the Malli endpoint schemas, so
|
||||
the spec at any git ref is what the API actually was at that ref. Diff the spec,
|
||||
do not read endpoint source or boot a server.
|
||||
|
||||
Response coverage is partial: about 199 response entries declare a real schema
|
||||
and are compared, but roughly 1,700 are description-only `2XX/4XX/5XX` stubs. For
|
||||
an endpoint with no declared response schema, a response-shape change is
|
||||
invisible here. Say so rather than implying the diff is complete.
|
||||
|
||||
### Check the spec is fresh first - it usually is not
|
||||
|
||||
The self-healing CI job (`.github/workflows/openapi-check.yml`) only runs on PRs
|
||||
labelled `openapi-self-healing`, so the committed spec drifts behind master by
|
||||
default. Verify before trusting a diff:
|
||||
|
||||
```bash
|
||||
./bin/mage openapi-staleness
|
||||
```
|
||||
|
||||
If the source is newer, regenerate before diffing the new ref:
|
||||
|
||||
```bash
|
||||
bun run generate-openapi # rewrites resources/openapi/openapi.json in place
|
||||
```
|
||||
|
||||
This staleness check only matters when you are diffing the committed spec.
|
||||
`--refs` (below) generates each ref's spec from its own source, so it is not
|
||||
subject to this drift at all - prefer it.
|
||||
|
||||
When you do read a committed spec, a change present in source but absent from
|
||||
that spec is a **false negative**: the diff will not report it, and an empty
|
||||
result is indistinguishable from an API that did not change. Confirm suspected
|
||||
gaps with `grep -rn "defendpoint" src/.../api.clj`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Pick the refs. Default old ref = the previous release branch
|
||||
(`origin/release-x.63.x`), new ref = the one being released
|
||||
(`origin/release-x.64.x`) or `origin/master`. Confirm with the user if
|
||||
ambiguous. `git fetch origin` first.
|
||||
|
||||
2. Extract and diff:
|
||||
|
||||
```bash
|
||||
# Preferred: generate both specs from source. ~2 min (two JVM boots), no drift.
|
||||
./bin/mage openapi-diff --refs origin/release-x.63.x origin/release-x.64.x --severity breaking
|
||||
|
||||
# Fast, but reads the committed spec, which lags source. Warns when it does.
|
||||
./bin/mage openapi-diff --refs --committed origin/release-x.63.x origin/release-x.64.x
|
||||
|
||||
# Two spec files directly.
|
||||
./bin/mage openapi-diff /tmp/old.json /tmp/new.json --severity breaking
|
||||
```
|
||||
|
||||
`--refs` checks each ref out into a throwaway worktree and runs that ref's own
|
||||
`generate-openapi-spec`, so it is not limited to refs that carry a committed spec
|
||||
and never touches your checkout. Very old refs may not build with the current
|
||||
toolchain; it falls back to the committed spec and says so when that happens.
|
||||
Prefer it. `--committed` compares whatever blob each ref happens to carry, and
|
||||
when a release branch and master share a stale one it reports far fewer findings
|
||||
than the API actually changed - an artifact of the spec, not of the API.
|
||||
|
||||
### Start from the grouped view
|
||||
|
||||
A single upstream change can produce hundreds of findings. `--grouped` collapses
|
||||
identical findings and sorts by blast radius, which is the shape you draft from:
|
||||
|
||||
```bash
|
||||
./bin/mage openapi-diff --refs <old> <new> --severity breaking --grouped
|
||||
```
|
||||
|
||||
On the real v63 -> master delta that turns 239 breaking findings into 113
|
||||
distinct changes. The top entry spans 217 endpoints and is one PR (#82447,
|
||||
closing `mu/defn` argument schemas) - **one changelog entry, not 217**.
|
||||
|
||||
Drop `--grouped` when you need the per-endpoint view to check a specific route.
|
||||
|
||||
A change spanning many endpoints is usually one upstream PR: write it once, name
|
||||
the cause, and say which endpoints it spans. The long tail of single-endpoint
|
||||
changes is where the individually-interesting entries are - the v63 -> master run
|
||||
surfaced a `pinned_state` -> `pinned-state` query param rename there.
|
||||
|
||||
Findings are classified and sorted breaking-first.
|
||||
|
||||
**A change is breaking when an existing caller, sending exactly what it sent
|
||||
before, can now fail or get a different result.** That covers three cases: the
|
||||
API requires more of the request, provides less in the response, or behaves
|
||||
differently for the same request. The first two invert between request and
|
||||
response:
|
||||
|
||||
| | Breaking | Not breaking |
|
||||
|---|---|---|
|
||||
| **Request** | field or param becomes required; type, enum, or bound (`minimum`, `minLength`, `pattern`, ...) narrowed; `additionalProperties: false` added; default changed or dropped | new *optional* field or param; type, enum, or bound widened; field made nullable; schema opened; default added |
|
||||
| **Response** | field removed or no longer always returned; field may now be `null`; new enum value | new field returned; field that was nullable never is |
|
||||
| **Endpoint** | removed | added |
|
||||
|
||||
When the tool cannot tell, it ranks the change breaking: a false alarm costs
|
||||
you one look, a miss ships undocumented. Two rules follow from that, not from
|
||||
the definition, so check them before drafting an entry:
|
||||
|
||||
- **A removed request field** is ranked breaking because the spec does not say
|
||||
whether the server rejects or acts on keys it does not declare. If the
|
||||
endpoint ignores unknown keys, the removal is not breaking.
|
||||
- **A change to a schema keyword the tool does not model** is ranked breaking
|
||||
so that it is never hidden. Read the finding to decide.
|
||||
|
||||
A reworded description, title, or example is DOC_ONLY.
|
||||
|
||||
Adding an optional parameter is not breaking. Returning extra data is not
|
||||
breaking. Existing callers keep working in both cases.
|
||||
|
||||
Start from `--severity breaking`. The long tail of API change is additive
|
||||
(a public Stripe-spec diff found 663 of 679 changes additive); reading the
|
||||
full diff to find the breaking few is how entries get missed.
|
||||
|
||||
**Identical or near-identical committed specs do not mean the API did not
|
||||
change.** Two refs often carry the same stale blob, so `--committed` can report
|
||||
zero findings for a pair that generating from source shows has removed endpoints.
|
||||
Before reporting "no API surface changes", re-run without `--committed`. Only a
|
||||
clean generated diff supports that conclusion.
|
||||
|
||||
3. Review the classification. The tool decides severity structurally, but two
|
||||
cases still need your judgement:
|
||||
- **A removed + added pair is often one endpoint moving** (method change, or a
|
||||
param moving between query/path/body). The tool reports two findings; read
|
||||
the docstrings to pair them and write ONE changelog entry describing the move.
|
||||
- **An `ADDITIVE` finding can still be worth an entry** when it is a new
|
||||
endpoint clients should know about. Additive means "will not break existing
|
||||
callers", not "not worth mentioning".
|
||||
|
||||
4. For every breaking change, decide whether you can state **what it does for a
|
||||
client** from the spec's `description` alone. If you cannot, do not guess and
|
||||
do not paper over it - list it under "Needs author input" with the endpoint
|
||||
name and what is unclear. Thin endpoint docstrings are the actual finding;
|
||||
report them loudly.
|
||||
|
||||
5. Cross-check `docs/developers-guide/api-changelog.md` for the target version.
|
||||
Report three buckets:
|
||||
- **Undocumented**: breaking changes with no changelog entry -> draft entries
|
||||
- **Stale**: changelog entries with no matching spec change -> verify by hand
|
||||
(may be a response-shape or behavior change the spec cannot see - do not
|
||||
delete without asking)
|
||||
- **Covered**: matched, no action
|
||||
|
||||
6. Draft entries in the existing house style: `## Metabase 0.64.0` heading,
|
||||
one `-` bullet per change, endpoint as `` `POST /api/foo/:id` `` with
|
||||
**`:id` colon-style params, not OpenAPI `{id}` braces**. Say what changed, what
|
||||
clients must do, and whether there was a deprecation period. Apply the
|
||||
`docs-write` skill. Show the diff before writing to the file.
|
||||
|
||||
## Attribution
|
||||
|
||||
Find who to ask about an unclear change. Search the endpoint SOURCE, not `openapi.json`: the committed spec only changes on
|
||||
PRs carrying the `openapi-self-healing` label, which is usually a later, unrelated
|
||||
PR, so its history names the wrong author.
|
||||
|
||||
```bash
|
||||
git log <old-ref>..<new-ref> --oneline -- \
|
||||
'src/metabase/**/api.clj' 'src/metabase/**/api/*.clj' 'src/metabase/**/routes.clj' \
|
||||
'enterprise/backend/src/**/api.clj' 'enterprise/backend/src/**/api/*.clj' 'enterprise/backend/src/**/routes.clj'
|
||||
```
|
||||
|
||||
Attribution by route search is unreliable in both directions - `git log -S` reports
|
||||
where a string's count *changed*, which for a long-lived route is its creation years
|
||||
ago rather than its recent removal. Treat any commit you find as a candidate to
|
||||
verify, never as the established cause.
|
||||
|
||||
## Backports
|
||||
|
||||
A change landing in both `0.64.0` and older lines gets a full entry under the
|
||||
newest version and a one-line pointer under each backport version
|
||||
("See the 0.64.0 entry."). Match the existing `POST /api/slack/bug-report` pattern.
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
name: backend-module-conventions
|
||||
description: Where Metabase backend code goes and how it reaches the app DB. Covers module layout, the `<module>.db` rule, `[:auto/param]` value binding, module config, and pre-handoff checks. Use when adding or moving backend namespaces, writing app-DB queries, or touching module boundaries. Preloaded by every `*-backend-expert` agent.
|
||||
---
|
||||
|
||||
# Backend module conventions
|
||||
|
||||
This skill decides where a line of backend code lives and how it reaches the app DB. Project `CLAUDE.md`
|
||||
covers the module config keys, nested modules, test commands, and ratchets. When the two disagree, the
|
||||
source tree wins. Read the linter or test that enforces a rule before you trust any prose about it,
|
||||
including this file.
|
||||
|
||||
## Split a module into standard namespaces
|
||||
|
||||
A module is `metabase.<module>` (OSS, `src/`) or `metabase-enterprise.<module>` (EE,
|
||||
`enterprise/backend/src/`). Most modules split into these namespaces:
|
||||
|
||||
| Namespace | Holds |
|
||||
|---|---|
|
||||
| `<module>.core` | The public API. Other modules call only this (plus anything else listed in the module's `:api`). Often a `potemkin/import-vars` facade. |
|
||||
| `<module>.db` | Every app-DB query the module makes. See the next section. |
|
||||
| `<module>.models.*` | Toucan 2 model definitions: `methodical/defmethod t2/table-name`, hooks, transforms. |
|
||||
| `<module>.settings` | `defsetting`s. |
|
||||
| `<module>.init` | Requires the namespaces that register things as a side effect (settings, tasks, event handlers). `metabase.core.init` or `metabase-enterprise.core.init` requires it. |
|
||||
| `<module>.api`, or the nested `<module>.rest` module (`:ns-prefix "metabase.<module>-rest"`, dir `<module>_rest/`) | HTTP endpoints. Domain logic stays in the base module. The `.rest` child calls the parent's `.core`. |
|
||||
| `enterprise/<module>` | The EE companion of an OSS module. It nests under the OSS module, which exports it automatically. |
|
||||
|
||||
If a setting, task, or event handler never takes effect, look for a missing link in the `.init` chain.
|
||||
|
||||
## Keep app-DB access in `<module>.db`
|
||||
|
||||
Call Toucan 2 query functions (`t2/select*`, `t2/insert!`, `t2/update!`, `t2/delete!`, `t2/count`,
|
||||
`t2/exists?`, `t2/query`, ...) and the `metabase.app-db.core` wrappers (`mdb/query`,
|
||||
`mdb/update-or-insert!`, ...) only from `metabase[-enterprise].<module>.db`. Driver code uses
|
||||
`metabase.driver.<driver>.db`. Every other namespace calls functions from its own module's `db`.
|
||||
The kondo hook `hooks.metabase.toucan.db-ns` reports violations as `:metabase/t2-query-namespace`.
|
||||
Test files are exempt.
|
||||
|
||||
Write `db` functions in the style of `src/metabase/settings/db.clj`:
|
||||
|
||||
- Make each function a thin `mu/defn` that wraps one query and adds no logic. Give it a docstring that says what it returns.
|
||||
- Name functions after the domain question (`setting-value`, `delete-setting!`), not after SQL.
|
||||
- Keep branching, permission checks, and data shaping in the caller. A `db` function that grows an `if` belongs somewhere else.
|
||||
- Get cross-module data through the other module's `.core`, not its `db`. A few modules list their `db` in `:api` as legacy exceptions. Don't add more.
|
||||
|
||||
### Bind values with `[:auto/param v]`
|
||||
|
||||
`metabase.app-db.value-guard` binds a value written as `[:auto/param v]` as a SQL parameter. The
|
||||
`:metabase/unsafe-app-db-query` lint is off globally. `.clj-kondo/config.edn` turns it on per namespace
|
||||
under `:config-in-ns`, one converted `db.clj` at a time. Follow these rules in every `db.clj`, whether or
|
||||
not the lint covers it yet:
|
||||
|
||||
1. Mark a value that comes from a variable when it sits in a **value slot**. Value slots are the right-hand side of `:=`, `:in`, `:like`, ..., and kv-arg values used as filters.
|
||||
2. Never mark a literal. A literal cannot carry a request value.
|
||||
3. Never mark a value in an **identifier slot** (`:select`, `:from`, joins, aliases, and similar). `value-guard` throws `::marker-outside-value-slot` at compile time. In `:order-by` and `:group-by` a marker compiles to `ORDER BY ?`, which is useless but does not throw. Don't write one there either.
|
||||
4. Leave a possibly-empty collection unmarked. Toucan rewrites `[:in col []]` to `false` before the marker step.
|
||||
5. Leave values that `t2/update!` *writes* unmarked. Model hooks such as encryption must still see them.
|
||||
6. Keep kv-arg style (`:engine engine`) for a column with a Toucan type transform. If you move a keyword value into a raw `{:where ...}` map, the transform is skipped. HoneySQL then formats the keyword as an identifier, and the query returns wrong rows without failing.
|
||||
|
||||
The docstring of `metabase.app-db.value-guard` explains what the guard refuses without any marker.
|
||||
|
||||
## Add app-DB migrations as one file per change
|
||||
|
||||
`./bin/lint-migrations-file.sh` enforces these rules. Run it after every change.
|
||||
|
||||
- Starting with v60, each change is its own file at `resources/migrations/<NNN>/<yyyyMMdd>_<snake_name>.yaml`. `NNN` is the earliest release the change ships in. New work goes in the newest directory. Don't add to `001_update_migrations.yaml`; its footer describes the legacy format.
|
||||
- Changeset IDs are `v<NN>.<unique suffix>`, where `NN` matches the directory. The suffix can be a timestamp from `(dev/migration-timestamp)` or any unique string. IDs are not ordered within a directory.
|
||||
- Don't edit a shipped changeset. Add a new one. (Liquibase does recompute checksums and some edits are technically harmless, but the line between "cosmetic" and "ran instances now diverge from fresh installs" is easy to misjudge, so just add a new changeset.) Never delete a shipped changeset either: instances keep the row in `DATABASECHANGELOG` forever; removing the id makes Liquibase log "not rolled back" and any replacement that recreates the same object fails with a duplicate-object error. To retire one when you must, add a NEW changeset that does `DELETE FROM DATABASECHANGELOG WHERE id IN (...)` the retired ids. Put it in the same file as the retired changeset when it ships in the same release (`063/20260714_field_fk_target_field_id_fk.yaml::v63.2026-07-22T12:00:00`), or in the current release's file when it retires an older release's changeset (`059_update_migrations.yaml::v59.2026-01-09T11:30:00` retires `v58.2026-01-09T12:00:00`).
|
||||
- To backport, copy the changesets into the older directory with `v<older>` IDs. Guard each copy with `preConditions: onFail: MARK_RAN` plus `not: changeSetExecuted` that points at the newer ID, so databases that already ran it skip it. `resources/migrations/064/20260911_glossary_entity_id.yaml` shows the pattern.
|
||||
- Give every `indexExists`, `primaryKeyExists`, and `uniqueConstraintExists` precondition a `tableName`, and every `foreignKeyConstraintExists` a `foreignKeyTableName`. Without it, Liquibase snapshots the whole schema, which takes seconds per check.
|
||||
- Prefer SQL changesets. A custom migration (`metabase.app-db.custom-migrations`, `define-migration` / `define-reversible-migration`) must use table names, never `:model/*` or other application code. Application code changes later, and then the migration breaks. Decrypt encrypted columns such as `metabase_database.details` and `setting.value` before reading them.
|
||||
- The whole `metabase.app-db.*` module, custom migrations included, is exempt from the `<module>.db` rule (the `app-db-namespaces` group in `.clj-kondo/config.edn`).
|
||||
- Test with the `test-migrations` macro (`metabase.app-db.schema-migrations-test.impl`) on H2 and Postgres. Add MySQL when the change touches DDL or JSON columns.
|
||||
|
||||
## Cross module boundaries through `:api`
|
||||
|
||||
- `.clj-kondo/config/modules/config.edn` declares `:api`, `:uses`, `:model-exports`, and `:model-imports` per module. Run `./bin/mage fix-modules-config` after you add or remove a namespace, a cross-module require, or a cross-module `:model/X` reference. If nothing drifted, it does nothing.
|
||||
- `./bin/mage modules-tree` prints the hierarchy. The `dev.deps-graph` and `dev.module-score` namespaces show who uses a module externally.
|
||||
- If a module needs something outside another module's `:api`, choose the first fix that works:
|
||||
1. Call something that is already public.
|
||||
2. Add the var to that module's `.core`.
|
||||
3. Invert the dependency with an event (`metabase.events`).
|
||||
|
||||
Don't add a `:clj-kondo/ignore`.
|
||||
- If you change a suppression or a module escape hatch, run `./bin/mage kondo-ratchets`.
|
||||
|
||||
## Verify before handing work back
|
||||
|
||||
1. Load each changed namespace in the REPL (`clojure-eval` skill). If no REPL is available, run `./bin/test-agent :only '[the.ns-test]'`.
|
||||
2. Run the narrowest tests that cover the change. Use `./bin/test-agent :module <module>` to check blast radius.
|
||||
3. Run `./bin/mage kondo-updated` on the changed files.
|
||||
4. If a boundary moved, run `./bin/mage fix-modules-config`.
|
||||
|
||||
Report which of these steps ran, and what each one showed.
|
||||
@@ -1,173 +0,0 @@
|
||||
---
|
||||
name: clojure-eval
|
||||
description: Evaluate Clojure code via nREPL using clj-nrepl-eval. Use this when you need to test code, check if edited files compile, verify function behavior, or interact with a running REPL session.
|
||||
---
|
||||
|
||||
# Clojure REPL Evaluation
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when you need to:
|
||||
- **Verify that edited Clojure files compile and load correctly**
|
||||
- Test function behavior interactively
|
||||
- Check the current state of the REPL
|
||||
- Debug code by evaluating expressions
|
||||
- Require or load namespaces for testing
|
||||
- Validate that code changes work before committing
|
||||
|
||||
## How It Works
|
||||
|
||||
The `clj-nrepl-eval` command evaluates Clojure code against an nREPL server. **Session state persists between evaluations**, so you can require a namespace in one evaluation and use it in subsequent calls. Each host:port combination maintains its own session file.
|
||||
|
||||
## Instructions
|
||||
|
||||
### 0. Discover and select nREPL server
|
||||
|
||||
First, discover what nREPL servers are running in the current directory:
|
||||
|
||||
```bash
|
||||
clj-nrepl-eval --discover-ports
|
||||
```
|
||||
|
||||
This will show all nREPL servers (Clojure, Babashka, shadow-cljs, etc.) running in the current project directory.
|
||||
|
||||
**Then use the AskUserQuestion tool:**
|
||||
|
||||
- **If ports are discovered:** Prompt user to select which nREPL port to use:
|
||||
- **question:** "Which nREPL port would you like to use?"
|
||||
- **header:** "nREPL Port"
|
||||
- **options:** Present each discovered port as an option with:
|
||||
- **label:** The port number
|
||||
- **description:** The server type and status (e.g., "Clojure nREPL server in current directory")
|
||||
- Include up to 4 discovered ports as options
|
||||
- The user can select "Other" to enter a custom port number
|
||||
|
||||
- **If no ports are discovered:** Prompt user how to start an nREPL server:
|
||||
- **question:** "No nREPL servers found. How would you like to start one?"
|
||||
- **header:** "Start nREPL"
|
||||
- **options:**
|
||||
- **label:** "deps.edn alias", **description:** "Find and use an nREPL alias in deps.edn"
|
||||
- **label:** "Leiningen", **description:** "Start nREPL using 'lein repl'"
|
||||
- The user can select "Other" for alternative methods or if they already have a server running on a specific port
|
||||
|
||||
IMPORTANT: IF you start a REPL do not supply a port let the nREPL start and return the port that it was started on.
|
||||
|
||||
### 1. Evaluate Clojure Code
|
||||
|
||||
> Evaluation automatically connects to the given port
|
||||
|
||||
Use the `-p` flag to specify the port and pass your Clojure code.
|
||||
|
||||
**Recommended: Pass code as a command-line argument:**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(+ 1 2 3)"
|
||||
```
|
||||
|
||||
**For multiple expressions (single line):**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(def x 10) (+ x 20)"
|
||||
```
|
||||
|
||||
**Alternative: Using heredoc (may require permission approval for multiline commands):**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> <<'EOF'
|
||||
(def x 10)
|
||||
(+ x 20)
|
||||
EOF
|
||||
```
|
||||
|
||||
**Alternative: Via stdin pipe:**
|
||||
```bash
|
||||
echo "(+ 1 2 3)" | clj-nrepl-eval -p <PORT>
|
||||
```
|
||||
|
||||
### 2. Display nREPL Sessions
|
||||
|
||||
**Discover all nREPL servers in current directory:**
|
||||
```bash
|
||||
clj-nrepl-eval --discover-ports
|
||||
```
|
||||
Shows all running nREPL servers in the current project directory, including their type (clj/bb/basilisp) and whether they match the current working directory.
|
||||
|
||||
**Check previously connected sessions:**
|
||||
```bash
|
||||
clj-nrepl-eval --connected-ports
|
||||
```
|
||||
Shows only connections you have made before (appears after first evaluation on a port).
|
||||
|
||||
### 3. Common Patterns
|
||||
|
||||
**Require a namespace (always use :reload to pick up changes):**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(require '[my.namespace :as ns] :reload)"
|
||||
```
|
||||
|
||||
**Test a function after requiring:**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(ns/my-function arg1 arg2)"
|
||||
```
|
||||
|
||||
**Check if a file compiles:**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(require 'my.namespace :reload)"
|
||||
```
|
||||
|
||||
**Multiple expressions:**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(def x 10) (* x 2) (+ x 5)"
|
||||
```
|
||||
|
||||
**Complex multiline code (using heredoc):**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> <<'EOF'
|
||||
(def x 10)
|
||||
(* x 2)
|
||||
(+ x 5)
|
||||
EOF
|
||||
```
|
||||
*Note: Heredoc syntax may require permission approval.*
|
||||
|
||||
**With custom timeout (in milliseconds):**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> --timeout 5000 "(long-running-fn)"
|
||||
```
|
||||
|
||||
**Reset the session (clears all state):**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> --reset-session
|
||||
clj-nrepl-eval -p <PORT> --reset-session "(def x 1)"
|
||||
```
|
||||
|
||||
## Available Options
|
||||
|
||||
- `-p, --port PORT` - nREPL port (required)
|
||||
- `-H, --host HOST` - nREPL host (default: 127.0.0.1)
|
||||
- `-t, --timeout MILLISECONDS` - Timeout (default: 120000 = 2 minutes)
|
||||
- `-r, --reset-session` - Reset the persistent nREPL session
|
||||
- `-c, --connected-ports` - List previously connected nREPL sessions
|
||||
- `-d, --discover-ports` - Discover nREPL servers in current directory
|
||||
- `-h, --help` - Show help message
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Prefer command-line arguments:** Pass code as quoted strings: `clj-nrepl-eval -p <PORT> "(+ 1 2 3)"` - works with existing permissions
|
||||
- **Heredoc for complex code:** Use heredoc (`<<'EOF' ... EOF`) for truly multiline code, but note it may require permission approval
|
||||
- **Sessions persist:** State (vars, namespaces, loaded libraries) persists across invocations until the nREPL server restarts or `--reset-session` is used
|
||||
- **Automatic delimiter repair:** The tool automatically repairs missing or mismatched parentheses
|
||||
- **Always use :reload:** When requiring namespaces, use `:reload` to pick up recent changes
|
||||
- **Default timeout:** 2 minutes (120000ms) - increase for long-running operations
|
||||
- **Input precedence:** Command-line arguments take precedence over stdin
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
1. Discover nREPL servers: `clj-nrepl-eval --discover-ports`
|
||||
2. Use **AskUserQuestion** tool to prompt user to select a port
|
||||
3. Require namespace:
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(require '[my.ns :as ns] :reload)"
|
||||
```
|
||||
4. Test function:
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(ns/my-fn ...)"
|
||||
```
|
||||
5. Iterate: Make changes, re-require with `:reload`, test again
|
||||
@@ -56,11 +56,6 @@ Use this to scan through changes efficiently:
|
||||
- [ ] References use `[[other-var]]` not backticks
|
||||
- [ ] `TODO` comments include author and date: `;; TODO (Name 2025-01-01) -- description`
|
||||
|
||||
Docstring content — check every new or rewritten docstring against the anti-pattern table in the
|
||||
style guide above. Read the diff's docstrings on their own, separately from the code: prose is
|
||||
where review attention slides off, and a long docstring reads as diligence rather than as the
|
||||
liability it usually is.
|
||||
|
||||
### Code Organization
|
||||
|
||||
- [ ] Everything `^:private` unless used elsewhere
|
||||
@@ -140,17 +135,6 @@ Quick scan for common issues:
|
||||
|
||||
> This public var needs a docstring explaining its purpose, inputs, and outputs.
|
||||
|
||||
**For docstrings documenting code they don't own:**
|
||||
|
||||
> This describes how `lib` decides binning strategies internally. Nothing here fails when that
|
||||
> changes, so it will go stale silently — drop it and let `available-binning-strategies` answer
|
||||
> for its own behavior.
|
||||
|
||||
**For docstring content that belongs in the body:**
|
||||
|
||||
> This paragraph explains why the branches are ordered this way, which is what someone editing
|
||||
> the `cond` needs — but a caller can't act on it. Move it to an inline comment above the `cond`.
|
||||
|
||||
**For organization issues:**
|
||||
|
||||
> This function is only used in this namespace, so it should be marked `^:private`.
|
||||
|
||||
@@ -60,60 +60,6 @@ Only fall back to `./bin/mage` commands when clojure-mcp is not available.
|
||||
Feel free to copy these REPL session trials into actual test cases using `deftest` and `is`.
|
||||
4. Once you know these functions are good, return to 1, and compose them into the task that you need to build.
|
||||
|
||||
## Writing Docstrings
|
||||
|
||||
A docstring is a contract for the *caller*, not a diary for the
|
||||
implementer. It states what the function does, what it takes, returns,
|
||||
throws, and the preconditions/invariants the caller must respect. Those
|
||||
guarantees and requirements *belong* there — they are exactly what the
|
||||
caller needs surfaced in the IDE.
|
||||
|
||||
When you find implementation context in a docstring, the default is to
|
||||
**relocate it, not delete it** — move it to an inline comment at the
|
||||
point in the body where it is actually relevant. That context is often
|
||||
genuinely valuable; it is just in the wrong place (the caller should not
|
||||
have to read it; the implementer standing at that line should). Delete
|
||||
outright only when it is blather: self-congratulation, restating the
|
||||
obvious, or documenting a property that is the expected default.
|
||||
|
||||
On that last case — narrating properties like "portable across all
|
||||
supported appdbs" earns no sentence. If it were not portable, that is
|
||||
either a bug, or it means callers must handle each case themselves — and
|
||||
in *that* case it is the *absence* of the property that must be
|
||||
documented. Document deviations from expectation, not conformance to it.
|
||||
|
||||
Heuristic — a sentence earns its place in the docstring only if it
|
||||
passes both tests:
|
||||
|
||||
1. Rewrite test (necessary, not sufficient): if rewriting the body to a
|
||||
different implementation with an identical contract could make the
|
||||
sentence false, it describes the implementation, not the contract.
|
||||
Relocate it to an inline comment at the point in the body where it
|
||||
applies.
|
||||
|
||||
2. Ownership test: if the sentence states a fact about code this
|
||||
function does not own — a callee's behavior, a library's guarantee —
|
||||
it survives any rewrite trivially, which is exactly why the rewrite
|
||||
test alone is not enough. If this body *relies* on that fact, it is
|
||||
implementation context: relocate it to the call site. If nothing here
|
||||
relies on it, it is blather about someone else's code: delete it, and
|
||||
let the callee's own docstring answer for it.
|
||||
|
||||
Say each fact once. Duplicated prose is a stale comment in advance: when
|
||||
the fact changes, one copy gets updated and the rest quietly rot. This
|
||||
holds even within code you own — a namespace docstring that
|
||||
re-summarizes its own multimethods, three sibling functions carrying the
|
||||
same paragraph about retry behavior. Give the fact one home, and have
|
||||
the other places name it rather than restate it.
|
||||
|
||||
Multi-line docstrings are not banned — a genuinely non-obvious constraint
|
||||
the code had to deal with can be worth explaining. But be prudent; the
|
||||
failure mode is far too much detail. When tempted to write a
|
||||
multi-paragraph explanatory docstring, check with the user first. And
|
||||
prefer a *test* to prose: if a future reader thinks "that's a silly way
|
||||
to do it" and changes it, a test should fail and tell them why. If that
|
||||
breakage keeps happening, *that* is the signal a comment was warranted.
|
||||
|
||||
## Critical Rules for Editing
|
||||
|
||||
- Be careful with parentheses counts when editing Clojure code
|
||||
@@ -121,7 +67,3 @@ breakage keeps happening, *that* is the signal a comment was warranted.
|
||||
- End all files with a newline
|
||||
- When editing tabular code, where the columns line up, try to keep them aligned
|
||||
- Spaces on a line with nothing after it is not allowed
|
||||
- After changing module boundaries (adding/removing/renaming a `src` namespace, a cross-module `require`
|
||||
or `:model/X` reference, or a new module), run `./bin/mage fix-modules-config` to regenerate
|
||||
`.clj-kondo/config/modules/config.edn` and keep `metabase.core.modules-test` green. No-op when nothing
|
||||
drifted; see "Module Boundaries" in the project `CLAUDE.md`.
|
||||
|
||||
@@ -66,11 +66,91 @@ curl -sf -X POST http://localhost:4000/api/testing/restore/default
|
||||
|
||||
## Phase 3 — Generate Cypress Spec
|
||||
|
||||
Follow the Metabase Cypress conventions:
|
||||
### File location & naming
|
||||
|
||||
@./../_shared/cypress-conventions.md
|
||||
Place specs in `e2e/test/scenarios/<area>/` mirroring the URL structure.
|
||||
Name: `<feature>.cy.spec.js` (NOT `.cy.spec.ts`).
|
||||
|
||||
When you identified API calls during code analysis, stub or wait on them using the intercept pattern shown above.
|
||||
### Metabase conventions (mandatory)
|
||||
|
||||
- **Helpers via `cy.H`**: All helpers are accessed via `const { H } = cy;` — NOT via direct imports from `e2e/support/helpers`.
|
||||
|
||||
```js
|
||||
const { H } = cy;
|
||||
|
||||
describe("feature name", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- **Sample Database schema** (table/field definitions): Import from `cypress_sample_database`.
|
||||
|
||||
```js
|
||||
import { ORDERS, ORDERS_ID, PRODUCTS } from "e2e/support/cypress_sample_database";
|
||||
```
|
||||
|
||||
- **Instance data** (dashboard IDs, user IDs, etc.): Import from `cypress_sample_instance_data`.
|
||||
|
||||
```js
|
||||
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
```
|
||||
|
||||
- **Selectors** (priority order):
|
||||
1. `cy.findByText()` / `cy.findByLabelText()` / `cy.findByRole()` — from `@testing-library/cypress`
|
||||
2. `cy.findByTestId()` — for `data-testid` attributes
|
||||
3. `cy.get("[data-testid='...']")` — fallback
|
||||
4. NEVER use positional selectors, CSS class names, or XPaths.
|
||||
|
||||
- **Navigation helpers**: Use existing helpers like `H.openOrdersTable()`, `H.openNativeEditor()`,
|
||||
`H.visitDashboard(id)`, `H.visitQuestion(id)` instead of raw `cy.visit()` chains.
|
||||
Grep `e2e/support/helpers/` to discover what's available for your area.
|
||||
|
||||
- **API setup over UI setup**: Use `cy.request()` or existing API helpers to set up state.
|
||||
Only use the UI for the flow you're actually testing.
|
||||
|
||||
- **Assertions**: Assert on visible text, URL, aria state — not DOM structure.
|
||||
|
||||
- **Waits**: Never use `cy.wait(ms)`. Use `cy.intercept()` + `cy.wait("@alias")` for API calls,
|
||||
or `cy.findByText().should("be.visible")` for DOM readiness.
|
||||
|
||||
- **Isolation**: Each `it()` block must be independently runnable.
|
||||
Don't depend on state from a previous `it()`.
|
||||
|
||||
### Spec structure template
|
||||
|
||||
```js
|
||||
const { H } = cy;
|
||||
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import { ORDERS, ORDERS_ID } from "e2e/support/cypress_sample_database";
|
||||
|
||||
describe("area > sub-area > feature (#issue-number)", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("should do the primary happy-path thing", () => {
|
||||
// test
|
||||
});
|
||||
|
||||
it("should handle the edge case", () => {
|
||||
// test
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Intercepts
|
||||
|
||||
When you identified API calls during code analysis, stub or wait on them:
|
||||
|
||||
```js
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
// ... trigger action ...
|
||||
cy.wait("@dataset");
|
||||
```
|
||||
|
||||
## Phase 4 — Validate
|
||||
|
||||
@@ -168,10 +248,13 @@ Do NOT use broad `pkill` patterns — there may be other Metabase instances on d
|
||||
The backend process started in Phase 2 will NOT be killed automatically when the Claude session ends.
|
||||
Leaving it running wastes resources and can interfere with future sessions. Always clean up.
|
||||
|
||||
## What NOT to do (workflow)
|
||||
## What NOT to do
|
||||
|
||||
- Do NOT use Playwright as the first step — always analyze source code first.
|
||||
- Do NOT kill the backend between phases — it stays running throughout.
|
||||
- Do NOT invent selectors you didn't find in source code or observe in the browser.
|
||||
|
||||
For convention-level "do nots" (selectors, waits, helpers, etc.), see the conventions file referenced in Phase 3.
|
||||
- Do NOT hardcode database IDs — import from `cypress_sample_database` or `cypress_sample_instance_data`.
|
||||
- Do NOT use `cy.wait(1000)` or any numeric wait.
|
||||
- Do NOT create setup flows through the UI when an API helper exists.
|
||||
- Do NOT put tests in `cypress/e2e/` — Metabase uses `e2e/test/scenarios/`.
|
||||
- Do NOT import helpers directly from `e2e/support/helpers` — use `const { H } = cy;`.
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
---
|
||||
name: e2e-test-review
|
||||
description: Review Cypress E2E spec files for Metabase conventions, common gotchas, and flakiness/performance issues. Use when reviewing pull requests or diffs containing Cypress spec files in e2e/test/scenarios/.
|
||||
allowed-tools: Read, Grep, Bash, Glob
|
||||
---
|
||||
|
||||
# E2E Test Review Skill
|
||||
|
||||
@./../_shared/cypress-conventions.md
|
||||
|
||||
## Review mode detection
|
||||
|
||||
Before starting, determine which mode to use:
|
||||
|
||||
1. **PR review mode** — if `mcp__github__create_pending_pull_request_review` is available, post issues as one cohesive pending review.
|
||||
2. **Local review mode** — if not, output a numbered list in the conversation.
|
||||
|
||||
## Review process
|
||||
|
||||
1. Detect mode.
|
||||
2. Read the changed spec end-to-end first to understand intent. Don't review line-by-line cold.
|
||||
3. **(Conditional)** If after reading the spec a specific assertion or selector is **ambiguous** — you can't tell if it's the right anchor, or whether the test actually verifies what its title claims — briefly grep the related component for the test ID / role / text. **Don't read components by default.** Only do this when there's a real signal of confusion in the spec.
|
||||
4. Scan against the checklist + pattern table below.
|
||||
5. Number all issues sequentially. Skip nits — only flag what's worth fixing.
|
||||
6. **Always** finish with the honest e2e-vs-unit breakdown (see the section of that name below). It's part of every report, not an optional add-on.
|
||||
|
||||
### When to hand off instead of review
|
||||
|
||||
If the spec references an issue (`metabase#NNNNN`) and the user wants to **fix** flakiness or assess whether the test still reproduces the original bug, that's outside the review skill's scope. Point them at the dedicated flake-fixing workflow, which knows to fetch the issue and the resolving PR's diff. The review skill stays focused on "is this test well-written and conformant" — it doesn't fetch external issue context by default.
|
||||
|
||||
## Review checklist
|
||||
|
||||
The checklist mirrors the order of the conventions file. Items marked **(lint)** are also caught by ESLint — flag only when you see them slip through (helper-wrapped, lint-disabled, etc.).
|
||||
|
||||
### File and naming
|
||||
|
||||
- [ ] Spec lives in `e2e/test/scenarios/<area>/`
|
||||
- [ ] Extension is `.cy.spec.ts` (preferred) or `.cy.spec.js` — don't flag `.js` on existing files
|
||||
- [ ] `describe` block names the area when relevant: `"area > sub-area > feature (#issue-number)"`
|
||||
- [ ] No leftover `.only` or `.skip` (pre-commit hook should catch, but flag it if it slips through)
|
||||
|
||||
### Helpers and constants
|
||||
|
||||
- [ ] Helpers accessed via `const { H } = cy;` — no direct imports from `e2e/support/helpers` **(lint)**
|
||||
- [ ] Sample DB schema imported from `cypress_sample_database`
|
||||
- [ ] Instance data IDs imported from `cypress_sample_instance_data`
|
||||
- [ ] **No hardcoded numeric IDs anywhere** — including for entities the test creates itself. Capture from the create response or alias the intercept.
|
||||
- [ ] Existing navigation helpers used (`H.openOrdersTable`, `H.visitDashboard(id)`, etc.) instead of raw `cy.visit()` chains
|
||||
|
||||
### Shared helper changes
|
||||
|
||||
When a diff changes a shared helper, inspect its callers rather than reviewing the helper in isolation:
|
||||
|
||||
- [ ] Default behavior changes are justified for every caller; otherwise the new behavior is explicit and opt-in.
|
||||
- [ ] Repeated calls with identical and different arguments are safe.
|
||||
- [ ] Mandatory network waits account for cache hits, request deduplication, unchanged query arguments, and conditional fetches where no request occurs.
|
||||
- [ ] Intercepts and aliases cannot accumulate, collide, or resolve against the wrong request.
|
||||
- [ ] Verification covers both an affected caller and a representative caller using unchanged defaults.
|
||||
|
||||
### Selectors
|
||||
|
||||
- [ ] Prefers a11y queries (`findByRole`, `findByLabelText`) over `findByText`
|
||||
- [ ] `findByText` only when an a11y query doesn't fit
|
||||
- [ ] `findByTestId` for `data-testid` attributes (never raw `cy.get("[data-testid='...']")`)
|
||||
- [ ] No CSS class names — especially generated ones from styled-components/Mantine (`.css-1abc2d`)
|
||||
- [ ] No ad-hoc CSS attribute selectors in specs or new helpers (the visualization helpers in `e2e/support/helpers/e2e-visual-tests-helpers.js` are the **only** intentional exception — see "What NOT to flag")
|
||||
- [ ] No XPath
|
||||
- [ ] **Positional selectors** (`.eq()`, `.first()`, `.last()`, `:nth-child`) only when the order is the assertion itself, or guarded by a length assertion immediately before. **(lint catches `.last()` and `.eq(<negative)`; the rest is on the reviewer)**
|
||||
- [ ] **Text selectors are scoped** — top-level `cy.findByText(...)` / `cy.contains(...)` in `it`/`before`/`beforeEach` is forbidden. Must be scoped via `cy.contains(selector, text)`, `cy.someQuery().findByText(...)`, or `someQuery().within(...)`. **(lint catches the top-level case ONLY — it does NOT catch helper-wrapped queries; manually scan helper bodies)**
|
||||
|
||||
### Setup and isolation
|
||||
|
||||
- [ ] State setup uses `cy.request` / API helpers, not the UI
|
||||
- [ ] `H.restore()` and sign-in are in `beforeEach`, not `before`
|
||||
- [ ] Each `it()` is independently runnable — no `it()` depends on prior `it()` state
|
||||
- [ ] When both appear, `H.restore()` precedes `H.resetTestTable()` **(lint)**
|
||||
|
||||
### Waits and timing
|
||||
|
||||
- [ ] No numeric `cy.wait(ms)` — even small ones
|
||||
- [ ] `cy.intercept()` defined BEFORE the action that triggers the request
|
||||
- [ ] No `setTimeout`, `Cypress.Promise.delay`, or other manual sleeps
|
||||
- [ ] No long custom timeouts (e.g. `{ timeout: 30000 }`) papering over a race
|
||||
- [ ] DOM readiness uses `.should("be.visible")`, **not** `.should("exist")`. Reserve `exist` for hidden inputs / off-screen / portal-detached cases.
|
||||
|
||||
### Never assign return values from `cy.*` commands
|
||||
|
||||
- [ ] No `const x = cy.someCommand(...)` — `x` is a one-shot chainer, not the resolved value **(lint catches simple cases)**
|
||||
- [ ] If a query needs a name, it's wrapped in a function (`const foo = () => cy.findByText("Foo")`), **not** assigned to a `const`
|
||||
- [ ] Resolved values accessed via `.then()` or aliased with `.as()` + `cy.get("@alias")`
|
||||
- [ ] Aliases only used when there's distance between lookup and use (otherwise just chain)
|
||||
|
||||
### Assertions
|
||||
|
||||
- [ ] Assertions target user-visible state (text, URL, aria) — not DOM structure
|
||||
- [ ] **Negative assertions are paired with a positive one.** A standalone `should("not.exist")` / `should("not.be.visible")` passes by accident if the page hasn't rendered yet. Anchor on a positive signal first.
|
||||
- [ ] **Multiple text checks on the same parent are collapsed into a `.should("contain", ...).and("contain", ...).and("not.contain", ...)` chain** rather than three separate `findByText().should(...)` queries. Single retry budget, atomic against one DOM snapshot.
|
||||
- [ ] `expect()` only used inside `cy.then` / `cy.wrap` callbacks
|
||||
- [ ] `.should("not.exist")` vs `.should("not.be.visible")` used correctly for intent
|
||||
- [ ] No JS conditionals on cy chains (`.then(el => if (...))`) — `.then()` runs once, `.should()` retries
|
||||
|
||||
### `cy.within` (always chained)
|
||||
|
||||
- [ ] **Every `cy.within(...)` is chained off a previous selector.** Standalone `cy.within(...)` has no scope and is wrong by construction — flag on sight.
|
||||
- [ ] **No single-statement `within` callbacks** — if the callback has only one inner command, chain directly off the parent instead. Reserve `within` for two or more commands sharing the scope.
|
||||
- [ ] **No named parameter on `within` callbacks** — `within((modal) => ...)` is redundant; the subject is inherited automatically by inner commands. If you actually need the jQuery subject, use `.then($el => ...)` instead.
|
||||
- [ ] `.within()` callback is properly closed; assertions outside the callback don't accidentally rely on the within scope
|
||||
|
||||
### Logging and annotation
|
||||
|
||||
- [ ] Step annotations use `cy.log("...")`, not `// comments` (visible in command panel, screenshots, videos)
|
||||
- [ ] `cy.log` lines aren't redundant paraphrases of the next command — they mark phases or non-obvious intent
|
||||
|
||||
### Performance
|
||||
|
||||
> **Look up actual CI duration before flagging slowness.** `e2e/support/timings.json` holds the latest CI run's per-spec wall time in ms (entries keyed as `../test/scenarios/...`). Read it instead of running the spec — running adds minutes; reading is instant. Compare the spec to its siblings in the same directory to ground the "this is expensive" claim. Per-`it()` granularity is not in this file.
|
||||
|
||||
- [ ] **Tiny tests** — multiple `it()` blocks for the same flow with the same `beforeEach` setup are a smell. Each `it()` costs 5–10s of Cypress runner overhead + the `beforeEach` cost; merge into a single flow when possible.
|
||||
- [ ] **Tiny tests that should be unit tests** — a test that only asserts on element existence/visibility (no flow, no real backend interaction, no cross-screen traversal) belongs in a Jest + RTL unit test, not e2e. Common offenders: token-gated UI checks, "renders the help panel" tests.
|
||||
- [ ] **Near-duplicate tests** — a new `it()` that shares 80–90% of its setup and steps with a sibling test should extend the existing test, not spawn a clone.
|
||||
- [ ] **Multiple `cy.visit()` calls** — each is a cold app boot (multi-second). After the first visit, navigate via the UI (click a link, breadcrumb, sidebar item) instead of issuing a second `cy.visit()`.
|
||||
- [ ] **Redundant with a cheaper-layer test** — when the spec references an issue (e.g. `metabase#12345`) in the test name or `describe`, grep the codebase for the **bare** `#NNNNN` (no `metabase` prefix — backend tests don't always include it). If a Jest spec or backend `_test.clj` already cites the same issue, the e2e test is already redundant; recommend deletion. Recipe:
|
||||
```bash
|
||||
rg "#12345" -g '*.spec.{ts,tsx,js,jsx}' -g '!*.cy.spec.*' -g '*_test.clj' -g '*_test.cljc'
|
||||
```
|
||||
The `-g '!*.cy.spec.*'` exclusion drops the e2e spec being reviewed (which will obviously match its own reference).
|
||||
|
||||
### Cypress framework anti-patterns
|
||||
|
||||
- [ ] No `forEach` over cy queries — use `cy.each()` if iteration is needed
|
||||
- [ ] No mixing native Promises / `async`-`await` with cy chains
|
||||
- [ ] No reliance on stale element references after re-render — re-query instead
|
||||
- [ ] No `cy.contains("text")` (single-arg, top-level) — same scoping rule as `findByText`
|
||||
|
||||
## Pattern matching table
|
||||
|
||||
Quick scan for common issues. **(lint)** marks patterns ESLint already catches in the simple form — flag when you see them slip through.
|
||||
|
||||
| Pattern | Issue |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `cy.wait(2000)` | Numeric wait — use intercept alias or `.should("be.visible")` |
|
||||
| `cy.get(".css-1abc2d")` | Generated CSS class — use a11y query / `findByText` / `findByTestId` |
|
||||
| `cy.get("[data-testid='foo']")` | Always use `cy.findByTestId('foo')` instead |
|
||||
| `cy.get("path[fill='#abc']")` in a spec | Ad-hoc chart selector — use `e2e-visual-tests-helpers` or add a new helper there |
|
||||
| `cy.get("li:nth-child(3)")` | Positional in a CSS selector — anchor on text or role |
|
||||
| `.last()` / `.eq(-1)` without a prior length assertion | Risk of off-by-one when collection size shifts **(lint)** |
|
||||
| `cy.visit("/dashboard/10")` | Hardcoded numeric ID — capture from create response or import from `cypress_sample_instance_data` |
|
||||
| `import { restore } from "e2e/support/helpers"` | Direct helper import — use `const { H } = cy;` **(lint)** |
|
||||
| `cy.findByText("Save")` at top of `it()` / `beforeEach` | Unscoped text selector — wrap in `cy.contains(selector, text)` or chain off a scoping query **(lint, BUT misses helper-wrapped)** |
|
||||
| `function clickSave() { cy.findByText("Save").click() }` | Helper-wrapped unscoped text — lint blind spot, scan helpers manually |
|
||||
| Standalone `cy.within(() => ...)` | `cy.within` must be chained off an existing selector — wrong by construction |
|
||||
| `cy.intercept` placed AFTER the triggering call | Intercept must precede the action |
|
||||
| `cy.get(...).then(el => { if (...) })` | JS conditional on cy chain — use `.should()` for retry semantics |
|
||||
| `await cy.something()` | Cypress chains aren't real Promises |
|
||||
| `els.forEach(el => cy....)` | Use `cy.each()` instead |
|
||||
| `{ timeout: 30000 }` on a single command | Likely papering over a race — find the root cause |
|
||||
| `const button = cy.findByRole(...)` | Assigning `cy.*` return value — `button` is a one-shot chainer, not a DOM element **(lint catches simple cases)** |
|
||||
| `const foo = () => cy.findByText("Foo")` | This is **fine** — the function form re-enqueues the query on each call |
|
||||
| `.should("not.exist")` as the only assertion in a step | Negative-only — passes by accident if page hasn't rendered. Anchor on a positive assertion first |
|
||||
| `H.resetTestTable()` before `H.restore()` | `restore` must come first **(lint)** |
|
||||
| Multiple `cy.visit()` in one `it()` block | Each is a cold boot — navigate via the UI between screens |
|
||||
| `it.only(` / `describe.only(` | Pre-commit hook should block — flag as slip |
|
||||
| New `it()` block with same setup + 80–90% same steps as sibling | Likely a near-duplicate — extend the existing test instead |
|
||||
| `it("...", () => { cy.findBy*().should("be.visible") })` only | Static-UI-only test — strong signal it should be a Jest unit test, not e2e |
|
||||
| `cy.log("Visit dashboard"); H.visitDashboard(id);` | Redundant log — paraphrases the next command |
|
||||
| `// Visit dashboard` followed by `H.visitDashboard(id);` | Use `cy.log("...")` instead — visible in screenshots/videos |
|
||||
|
||||
## What NOT to flag
|
||||
|
||||
- Don't flag the `.cy.spec.js` extension on existing files — both `.js` and `.ts` are acceptable; `.ts` is preferred only for **new** specs.
|
||||
- Don't flag CSS attribute selectors (`path[fill='...']`, `[stroke-dasharray='...']`, `text[stroke-width='3']`, etc.) inside `e2e/support/helpers/e2e-visual-tests-helpers.js`. ECharts renders SVG with no `data-testid` and minimal a11y, and that file is the intentional exception. **Do** flag the same patterns when they appear in spec files or in new helpers — those should funnel through the existing helpers or extend that file.
|
||||
- Don't flag `.first()` / `.eq(N)` / `:nth-child(N)` when the order is genuinely the assertion (e.g. testing sort order) or when a `.should("have.length", n)` immediately precedes them.
|
||||
- Don't flag `cy.findByText(...)` / `cy.contains(...)` inside a helper body **if** the helper is documented or clearly intended to be called from inside an outer `within(...)` scope at the call site (the inherited within scope makes it safe at runtime). When in doubt, ask the author.
|
||||
- Don't flag `.only` if you're doing a local-dev review (the user is intentionally focused). At PR / commit-time review, do flag it.
|
||||
- Don't post "looks good" or congratulatory comments. Only post issues.
|
||||
- Don't post comments for formatting issues that the linter handles (Prettier/ESLint).
|
||||
- Don't flag stylistic preferences that don't materially affect flakiness, speed, or correctness.
|
||||
|
||||
## Feedback format
|
||||
|
||||
Number every issue sequentially starting from `Issue 1`. Format: `**Issue N: [Brief title]**`.
|
||||
|
||||
### Local review mode
|
||||
|
||||
```markdown
|
||||
## Issues
|
||||
|
||||
**Issue 1: [Brief title]**
|
||||
File:Line — succinct description
|
||||
Suggested fix
|
||||
|
||||
**Issue 2: [Brief title]**
|
||||
...
|
||||
```
|
||||
|
||||
### PR review mode
|
||||
|
||||
Use the pending review workflow:
|
||||
|
||||
1. `mcp__github__create_pending_pull_request_review` — start a draft review.
|
||||
2. `mcp__github__get_pull_request_diff` — get file paths and line numbers.
|
||||
3. Identify ALL issues, number them sequentially.
|
||||
4. `mcp__github__add_pull_request_review_comment_to_pending_review` — post each issue as a separate comment, in **parallel** within a single response.
|
||||
5. `mcp__github__submit_pending_pull_request_review` with event `"COMMENT"` (not `REQUEST_CHANGES`), no body.
|
||||
|
||||
Each comment body starts with `**Issue N: [Brief title]**`.
|
||||
|
||||
## Honest e2e-vs-unit breakdown (always include)
|
||||
|
||||
Every report ends with this section. The numbered issues above are about whether the test is *well-written*; this section is about whether it should be an **e2e test at all**. They're orthogonal — a flawless spec can still be paying e2e prices for component-test value, and that's worth saying out loud.
|
||||
|
||||
The bar is simple: **an `it()` earns its place in e2e only if it exercises a layer you cannot fake without gutting the test's value.** In this codebase that means one or more of:
|
||||
|
||||
- a **real backend query against seeded data** (the assertion depends on the server actually filtering/sorting/bucketing rows, not on a mocked response);
|
||||
- **real rendering you then interact with** — clicking a charted ECharts SVG element at computed coordinates, drag/hit-testing, drill-through;
|
||||
- **routing across screens** — navigation, URL param round-trips that the backend then honors, browser back/forward;
|
||||
- **cross-screen traversal** — list → detail → back, where the value is the seam between screens.
|
||||
|
||||
Conversely, a test belongs in **Jest + RTL** (the cheaper layer this repo already uses for component/unit coverage) when its real subject is frontend-only:
|
||||
|
||||
- **pure frontend logic** — a bucketing/formatting/derivation decision (e.g. "single-day range → bucket by hour") that has one right answer independent of the backend;
|
||||
- **render assertions** — "the page mounts N titled cards and an SVG exists." A mocked dataset response + RTL verifies the titles; that an ECharts SVG renders is library behavior, not your logic;
|
||||
- **state/wiring** — tab → `aria-selected` → active-state, or select → querystring mapping. The *URL gets the param* half is component-level; only the *backend honors the param* half needs e2e.
|
||||
|
||||
Write the breakdown in three honest buckets. Ground each call in **which layers the test actually touches**, not in how the test is named:
|
||||
|
||||
1. **Genuinely must-be-e2e (keep)** — load-bearing. Name the layer(s) only a browser + backend can verify together.
|
||||
2. **Defensible-but-narrowable e2e** — justified as integration, but over-split or doing more boots than the coverage requires. Say what to merge.
|
||||
3. **Should be unit / component tests** — where the real savings are. Name the cheaper layer and what it would assert (often *more precisely* than the e2e test does indirectly).
|
||||
|
||||
Then a one-paragraph **bottom line**: what to keep as e2e, what to push down, and — if you pulled CI timing from `e2e/support/timings.json` for the Performance check — roughly how much of that wall time is *earned* by the must-be-e2e set versus paid by the rest.
|
||||
|
||||
Rules of engagement so this stays honest, not performative:
|
||||
|
||||
- **Don't inflate the keep bucket to look balanced.** If most of the spec is component-test value paying e2e prices, say exactly that. If every test is load-bearing, say that too — a spec where all 12 tests must be e2e is a legitimate outcome, not a failure to find candidates.
|
||||
- **Be specific.** Cite the `it()` titles and line ranges. "Several tests could be unit tests" is useless; "tests 1/17/18 assert only that N titled cards mount — RTL with a mocked dataset" is actionable.
|
||||
- **This is advice to the author, not a blocking issue.** It's a recommendation, separate from the numbered issues. Don't renumber it into the issue list.
|
||||
- **Stay rooted in what exists.** The cheaper layer here is Jest + RTL component/unit tests. Don't invent infrastructure the repo doesn't have, and don't recommend deleting backend-dependent coverage just because part of the test is frontend-only — split it, keep the integration half.
|
||||
|
||||
## Final check
|
||||
|
||||
1. Trim issues that won't materially help the author.
|
||||
2. Verify sequential numbering with no gaps.
|
||||
3. In PR mode, verify each issue was posted as a separate review comment.
|
||||
4. Verify the honest e2e-vs-unit breakdown is present — it's required in every report, even when the answer is "all of these need to be e2e."
|
||||
@@ -14,53 +14,33 @@ Only use `MB_EDITION=ee` when the user explicitly asks to write or run an enterp
|
||||
|
||||
**For `MB_EDITION=ee`:**
|
||||
These tokens must be exported in the shell before starting Claude Code:
|
||||
|
||||
- `CYPRESS_MB_ALL_FEATURES_TOKEN` — all features (most EE tests need this)
|
||||
- `CYPRESS_MB_PRO_SELF_HOSTED_TOKEN` — pro self-hosted (permissions, SSO, sandboxing)
|
||||
- `CYPRESS_MB_STARTER_CLOUD_TOKEN` — starter plan (rarely needed)
|
||||
- `CYPRESS_MB_PRO_CLOUD_TOKEN` — pro cloud (rarely needed)
|
||||
|
||||
**NEVER echo, print, or log token values. Only check if they are set:**
|
||||
|
||||
```bash
|
||||
echo "ALL_FEATURES: ${CYPRESS_MB_ALL_FEATURES_TOKEN:+set}" && echo "PRO_SELF_HOSTED: ${CYPRESS_MB_PRO_SELF_HOSTED_TOKEN:+set}" && echo "STARTER: ${CYPRESS_MB_STARTER_CLOUD_TOKEN:+set}" && echo "PRO_CLOUD: ${CYPRESS_MB_PRO_CLOUD_TOKEN:+set}"
|
||||
```
|
||||
|
||||
If tokens are missing, tell the user: "EE tokens are not set. Either export them and restart Claude Code, or add `MB_EDITION=oss` to run OSS-only tests."
|
||||
|
||||
## Looking up duration without running
|
||||
|
||||
If the user asks "how long does this test take" / "what's the timing" — **do not run it**. Read `e2e/support/timings.json`, which holds the latest CI duration (in ms) per spec. Match on the spec path (entries are stored as `../test/scenarios/...`). Per-`it()` granularity is not recorded there; for that you'd need the Cypress JSON reporter, which requires running the spec.
|
||||
|
||||
## Diagnosing flakiness
|
||||
|
||||
Before changing a flaky test:
|
||||
|
||||
1. Gather the available longitudinal CI history, failure messages, screenshots, and retries. Classify distinct failures by the UI or network boundary where they occur instead of treating every occurrence as one race.
|
||||
2. State which user interactions the test is intended to cover. Preserve interactions that are part of the regression path or relevant failure evidence; do not move them to API setup merely to make the test pass.
|
||||
3. Fix the narrowest boundary supported by evidence. Keep backend errors, failed requests, and application crashes visible unless the task explicitly targets their recovery behavior.
|
||||
4. Treat a local stress run as evidence about a candidate change, not proof that historical flakiness never existed. A small passing sample does not outweigh repeated CI failures.
|
||||
|
||||
## Running
|
||||
|
||||
First check if snapshots exist:
|
||||
|
||||
```bash
|
||||
ls e2e/snapshots/default.sql 2>/dev/null && echo "snapshots exist" || echo "snapshots missing"
|
||||
```
|
||||
|
||||
Always pass the spec path via `--spec`. The runner feeds its arguments to Cypress's CLI parser (`cypress.cli.parseRunArguments`), which silently drops bare positional paths and then runs the entire suite. A leading `run` argument is harmless, but the path itself must follow `--spec`. Sanity-check that the first `Running:` line says `(1 of 1)`.
|
||||
|
||||
If snapshots exist, skip regeneration for speed:
|
||||
|
||||
```bash
|
||||
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false GENERATE_SNAPSHOTS=false bun test-cypress --spec path/to/spec.cy.spec.js
|
||||
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false GENERATE_SNAPSHOTS=false bun test-cypress $ARGUMENTS
|
||||
```
|
||||
|
||||
If snapshots are missing (first run), let the runner generate them:
|
||||
|
||||
```bash
|
||||
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false bun test-cypress --spec path/to/spec.cy.spec.js
|
||||
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false bun test-cypress $ARGUMENTS
|
||||
```
|
||||
|
||||
When running enterprise tests, replace `MB_EDITION=oss` with `MB_EDITION=ee` in the commands above.
|
||||
@@ -68,15 +48,12 @@ When running enterprise tests, replace `MB_EDITION=oss` with `MB_EDITION=ee` in
|
||||
If `$ARGUMENTS` is empty, ask the user which spec to run.
|
||||
|
||||
If the user provides a test name or fuzzy name instead of a spec path, find the spec file:
|
||||
|
||||
```bash
|
||||
find e2e/test/scenarios -name "*FUZZY_NAME*.cy.spec.*" -type f
|
||||
```
|
||||
|
||||
Then run with `--spec "path/to/matched.cy.spec.js"`.
|
||||
|
||||
To filter by test name, use the `GREP` env var (not `--env grep=`, which breaks on commas):
|
||||
|
||||
```bash
|
||||
GREP="test name here" MB_EDITION=oss bun test-cypress --spec path/to/spec.js
|
||||
```
|
||||
@@ -86,11 +63,9 @@ GREP="test name here" MB_EDITION=oss bun test-cypress --spec path/to/spec.js
|
||||
When the user asks to verify a test is not flaky:
|
||||
|
||||
```bash
|
||||
MB_EDITION=oss bin/e2e-stress-test --spec path/to/spec.cy.spec.js
|
||||
MB_EDITION=oss bin/e2e-stress-test $ARGUMENTS
|
||||
```
|
||||
|
||||
(The stress script forwards its arguments to `bun test-cypress`, so the same `--spec` rule applies.)
|
||||
|
||||
Set `E2E_STRESS_RUNS=N` for more iterations (default: 5). Stops on first failure and prints screenshot paths. Read those screenshots to analyze the failure.
|
||||
|
||||
## Analyzing failures
|
||||
|
||||
@@ -1,834 +0,0 @@
|
||||
---
|
||||
name: emotion-migrate
|
||||
description: Migrate Emotion styled-components to Mantine components with style props and CSS modules. Use when converting .styled.tsx files or removing @emotion imports from components.
|
||||
---
|
||||
|
||||
# Emotion → Mantine + CSS Modules Migration Skill
|
||||
|
||||
Migrate Emotion styled-components (`@emotion/styled`, `@emotion/react`) to Mantine layout components with style props and CSS modules. The goal is zero Emotion imports, zero inline styles, and maximum use of design system tokens.
|
||||
|
||||
## Priority Order (Strict)
|
||||
|
||||
1. **Mantine components + style props** — `Box`, `Flex`, `Stack`, `Group`, `Text`, `Title`, `Card`. This is the DEFAULT. Every CSS property must be checked against style props FIRST.
|
||||
2. **CSS modules** (`.module.css`) — ONLY for properties that Mantine style props genuinely cannot express: pseudo-selectors (`:hover`, `:focus`, `::before`), `box-shadow`, `border` shorthand, `animation`/`@keyframes`, complex selectors, `cursor`, `pointer-events`, `overflow`, `text-overflow`, `white-space`, `transition`.
|
||||
3. **Inline styles ONLY for dynamic values** — `style={{ }}` is allowed only for truly dynamic runtime values (e.g., computed widths, positions, data-driven colors). All static styles must use Mantine props or CSS modules.
|
||||
|
||||
### Mantine-First Decision Gate (CRITICAL)
|
||||
|
||||
For EACH styled component, go through every CSS property and ask: "Can this be a Mantine style prop?" If yes → style prop. If no → CSS module. Do NOT dump an entire component into a CSS module just because one property needs it — split them.
|
||||
|
||||
**Properties that ARE style props** (use these, not CSS modules):
|
||||
|
||||
- `display` → `display` prop
|
||||
- `color` → `c` prop (`c="core-brand"`, `c="text-primary"`)
|
||||
- `background-color` → `bg` prop (`bg="background_page-primary"`)
|
||||
- `font-size` → `fz` prop (`fz="md"`)
|
||||
- `font-weight` → `fw` prop (`fw="bold"`)
|
||||
- `line-height` → `lh` prop (`lh="md"`)
|
||||
- `text-align` → `ta` prop (`ta="center"`)
|
||||
- `padding` (all variants) → `p`, `px`, `py`, `pt`, `pb`, `pl`, `pr`
|
||||
- `margin` (all variants) → `m`, `mx`, `my`, `mt`, `mb`, `ml`, `mr`
|
||||
- `width` → `w`, `min-width` → `miw`, `max-width` → `maw`
|
||||
- `height` → `h`, `min-height` → `mih`, `max-height` → `mah`
|
||||
- `flex` → `flex` prop (`flex="0 0 auto"`, `flex={1}`)
|
||||
- `gap` → `gap` prop (on Flex/Stack/Group)
|
||||
- `align-items` → `align` prop (on Flex/Stack/Group)
|
||||
- `justify-content` → `justify` prop (on Flex/Stack/Group)
|
||||
- `flex-direction` → `direction` prop (on Flex)
|
||||
- `flex-wrap` → `wrap` prop (on Flex)
|
||||
- `position` → `pos` prop
|
||||
- `top/right/bottom/left` → `top`, `right`, `bottom`, `left` props
|
||||
- `opacity` → `opacity` prop
|
||||
|
||||
**Properties that NEED CSS modules** (no style prop equivalent):
|
||||
|
||||
- `:hover`, `:focus`, `:active`, `::before`, `::after` (pseudo-selectors)
|
||||
- `box-shadow`, `border` (shorthand with color), `outline`
|
||||
- `cursor`, `pointer-events`
|
||||
- `overflow`, `text-overflow`, `white-space`
|
||||
- `animation`, `transition`, `transform`
|
||||
- `@media` queries (UNLESS it's simple responsive spacing/sizing — then use responsive syntax: `p={{ base: "md", lg: "xl" }}`)
|
||||
|
||||
**Hybrid approach** — when a component needs BOTH, put style props on the Mantine component AND add a CSS module class for the rest:
|
||||
|
||||
```tsx
|
||||
<Flex
|
||||
className={S.root} /* for :hover, box-shadow, border */
|
||||
align="center" /* style prop */
|
||||
gap="sm" /* style prop */
|
||||
p="md" /* style prop */
|
||||
bg="background_page-primary" /* style prop */
|
||||
>
|
||||
```
|
||||
|
||||
## CSS Module Conventions (Strict)
|
||||
|
||||
### Class Naming: camelCase
|
||||
|
||||
All CSS module class names MUST use **camelCase**. This is the dominant convention across the codebase (~830 camelCase vs ~620 PascalCase classes), used consistently in Mantine UI components, and matches standard CSS module conventions.
|
||||
|
||||
```css
|
||||
/* CORRECT */
|
||||
.root {
|
||||
}
|
||||
.settingsSection {
|
||||
}
|
||||
.dragHandle {
|
||||
}
|
||||
.closeIcon {
|
||||
}
|
||||
|
||||
/* WRONG — do not use PascalCase or kebab-case */
|
||||
.ItemRoot {
|
||||
}
|
||||
.settings-section {
|
||||
}
|
||||
```
|
||||
|
||||
Modifier/state classes also use camelCase:
|
||||
|
||||
```css
|
||||
.selected {
|
||||
}
|
||||
.disabled {
|
||||
}
|
||||
.interactive {
|
||||
}
|
||||
.draggable {
|
||||
}
|
||||
```
|
||||
|
||||
### No Cascading — Direct Class Assignment
|
||||
|
||||
Cascading selectors are **discouraged**. Instead of styling through parent-child relationships, assign a class directly to the element that needs styling.
|
||||
|
||||
```css
|
||||
/* WRONG — cascading/descendant selectors */
|
||||
.root > input {
|
||||
}
|
||||
.root .label {
|
||||
}
|
||||
.container > div > span {
|
||||
}
|
||||
|
||||
/* CORRECT — direct class on the target element */
|
||||
.input {
|
||||
}
|
||||
.label {
|
||||
}
|
||||
.title {
|
||||
}
|
||||
```
|
||||
|
||||
The only acceptable nesting patterns are:
|
||||
|
||||
- **Pseudo-selectors on the same element**: `.item { &:hover { } }`
|
||||
- **Modifier composition**: `.item { &.selected { } }`
|
||||
- **Hover-reveal patterns** where a parent hover affects a child: `.root:hover .showOnHover { opacity: 1; }` — but only when structurally necessary (the child has no way to know about the parent's hover state)
|
||||
|
||||
### Import Alias
|
||||
|
||||
Always import the CSS module as `S`:
|
||||
|
||||
```tsx
|
||||
import S from "./ComponentName.module.css";
|
||||
```
|
||||
|
||||
## Step-by-Step Migration Process
|
||||
|
||||
### Step 1: Read and Understand
|
||||
|
||||
Read the `.styled.tsx` file AND every component that imports from it. Understand:
|
||||
|
||||
- Which styled components are used and where
|
||||
- Which props drive dynamic styles
|
||||
- Which styles are static vs conditional
|
||||
- Which styles can map directly to Mantine style props
|
||||
|
||||
### Step 2: Classify Each Styled Component
|
||||
|
||||
For each styled component, apply the Mantine-First Decision Gate above. Then determine the migration target:
|
||||
|
||||
| Emotion Pattern | Migration Target |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `styled.div` with only layout/spacing/color | `Box`, `Flex`, `Stack`, or `Group` with style props. **NO CSS module needed.** |
|
||||
| `styled.div` with flexbox column | `Stack` component with style props |
|
||||
| `styled.div` with flexbox row | `Flex` or `Group` component with style props |
|
||||
| `styled.span` / `styled.p` with color/weight/size | **`Text component="span"` with style props** (`c`, `fw`, `fz`). NO CSS module. |
|
||||
| `styled.div` with hover/focus/pseudo-selectors | **Hybrid**: Mantine component with style props for expressible properties + CSS module class for pseudo-selectors only |
|
||||
| `styled.div` with media queries (simple spacing/sizing) | **Responsive style props**: `p={{ base: "md", lg: "xl" }}`. NO CSS module. |
|
||||
| `styled.div` with media queries (complex/non-spacing) | CSS module for the media query parts, style props for the rest |
|
||||
| `styled.div` with animations/keyframes | CSS module for animation, style props for layout |
|
||||
| `styled(SomeComponent)` with only color/spacing/flex | **Wrap in Mantine component** with style props: `<Box c="core-brand" flex="0 0 auto"><Icon /></Box>`, or pass style props if the component accepts them |
|
||||
| `styled(SomeComponent)` with pseudo-selectors/complex styles | CSS module `className` on the component |
|
||||
| Dynamic props `styled.div<{ isActive: boolean }>` | Mantine style props for simple toggles (`c={active ? "core-brand" : "text-primary"}`), `cx()` with CSS module classes for complex state combinations involving pseudo-selectors |
|
||||
|
||||
### Step 3: Create CSS Module (if needed)
|
||||
|
||||
Create `ComponentName.module.css` alongside the component file:
|
||||
|
||||
```css
|
||||
/* Use design system tokens — NEVER raw color/spacing values */
|
||||
.root {
|
||||
border: 1px solid var(--mb-color-border-neutral);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--mb-color-background_page-primary);
|
||||
}
|
||||
|
||||
/* Conditional states as separate classes, combined with cx() */
|
||||
.active {
|
||||
background-color: var(--mb-color-core-brand);
|
||||
color: var(--mb-color-text-primary-inverse);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
color: var(--mb-color-text-disabled);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Hover/focus/pseudo-selectors — nest with & */
|
||||
.interactive {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--mb-color-core-brand);
|
||||
background-color: var(--mb-color-background_surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover-reveal: acceptable parent→child nesting */
|
||||
.root:hover .showOnHover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Responsive styles */
|
||||
@media (--breakpoint-min-md) {
|
||||
.root {
|
||||
padding: var(--mantine-spacing-lg);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update the Component TSX
|
||||
|
||||
```tsx
|
||||
import cx from "classnames";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { Box, Flex, Group, Stack, Text } from "metabase/ui";
|
||||
import S from "./ComponentName.module.css";
|
||||
|
||||
// Dynamic props → cx() with conditional classes
|
||||
<Flex
|
||||
className={cx(S.root, {
|
||||
[S.active]: isActive,
|
||||
[S.disabled]: disabled,
|
||||
})}
|
||||
align="center"
|
||||
gap="sm"
|
||||
p="md"
|
||||
w="100%"
|
||||
>
|
||||
```
|
||||
|
||||
### Step 5: Delete the `.styled.tsx` File
|
||||
|
||||
Remove the old styled file entirely. Remove all imports of it from other files.
|
||||
|
||||
### Step 6: Verify
|
||||
|
||||
- Confirm zero `@emotion/styled` or `@emotion/react` imports remain in the migrated files
|
||||
- Confirm no static inline styles (dynamic runtime values are fine)
|
||||
- Confirm all colors use tokens, not raw hex/rgb values
|
||||
|
||||
## Design System Token Reference
|
||||
|
||||
### Mantine Style Props (use directly on components)
|
||||
|
||||
**Spacing** (`p`, `px`, `py`, `pt`, `pb`, `pl`, `pr`, `m`, `mx`, `my`, `mt`, `mb`, `ml`, `mr`):
|
||||
|
||||
- `"xs"` = 4px, `"sm"` = 8px, `"md"` = 16px, `"lg"` = 24px, `"xl"` = 32px
|
||||
- Custom: `rem(48)` for non-standard values (import `rem` from `metabase/ui`)
|
||||
|
||||
**Dimensions** (`w`, `h`, `maw`, `mah`, `miw`, `mih`):
|
||||
|
||||
- `"100%"`, `"100vh"`, `rem(400)`, etc.
|
||||
|
||||
**Colors** (`c`, `bg`):
|
||||
|
||||
- Text: `"text-primary"`, `"text-secondary"`, `"text-disabled"`
|
||||
- Background: `"background_page-primary"`, `"background_page-secondary"`, `"background_surface-hover"`
|
||||
- Brand: `"core-brand"`, `"feedback-negative"`, `"feedback-positive"`, `"feedback-warning"`
|
||||
|
||||
**Typography** (`fz`, `fw`, `lh`, `ta`, `ff`):
|
||||
|
||||
- Font size: `"xs"` = 11px, `"sm"` = 12px, `"md"` = 14px, `"lg"` = 17px, `"xl"` = 21px
|
||||
- Font weight: `"bold"`, `"normal"`, or numeric `700`
|
||||
- Line height: `"xs"` = 100%, `"sm"` = 115%, `"md"` = 122%, `"lg"` = 138%, `"xl"` = 150%
|
||||
- Text align: `"center"`, `"left"`, `"right"`
|
||||
|
||||
**Flexbox** (`align`, `justify`, `gap`, `direction`, `wrap`, `flex`):
|
||||
|
||||
- `align="center"`, `justify="space-between"`, `gap="sm"`, `direction="column"`, `wrap="nowrap"`
|
||||
- `flex={1}`, `flex="0 0 auto"`
|
||||
|
||||
**Other**: `pos` (position), `display`
|
||||
|
||||
**Responsive syntax**: `px={{ base: "md", md: "lg", lg: rem(48) }}`
|
||||
|
||||
### CSS Variable Tokens (use in `.module.css` files)
|
||||
|
||||
**Colors** — `var(--mb-color-<name>)`:
|
||||
|
||||
- `--mb-color-text-primary`, `--mb-color-text-secondary`, `--mb-color-text-disabled`
|
||||
- `--mb-color-background_page-primary`, `--mb-color-background_page-secondary`, `--mb-color-background_surface-hover`
|
||||
- `--mb-color-border`, `--mb-color-border-strong`, `--mb-color-border-subtle`
|
||||
- `--mb-color-core-brand`, `--mb-color-core-brand-hover`
|
||||
- `--mb-color-feedback-negative`, `--mb-color-feedback-positive`, `--mb-color-feedback-warning`
|
||||
- `--mb-color-shadow`, `--mb-color-focus`
|
||||
- `--mb-color-accent0` through `--mb-color-accent7`
|
||||
|
||||
**Mantine spacing** — `var(--mantine-spacing-<size>)`:
|
||||
|
||||
- `--mantine-spacing-xs` (4px), `--mantine-spacing-sm` (8px), `--mantine-spacing-md` (16px), `--mantine-spacing-lg` (24px), `--mantine-spacing-xl` (32px)
|
||||
|
||||
**Mantine radius** — `var(--mantine-radius-<size>)`:
|
||||
|
||||
- `--mantine-radius-xs` (4px), `--mantine-radius-sm` (6px), `--mantine-radius-md` (8px), `--mantine-radius-xl` (40px)
|
||||
|
||||
**Mantine font sizes** — `var(--mantine-font-size-<size>)`:
|
||||
|
||||
- `--mantine-font-size-xs` (11px), `--mantine-font-size-sm` (12px), `--mantine-font-size-md` (14px), `--mantine-font-size-lg` (17px), `--mantine-font-size-xl` (21px)
|
||||
|
||||
**Breakpoints** (in `.module.css` files):
|
||||
|
||||
- `@media (--breakpoint-min-sm)` (40em / 640px)
|
||||
- `@media (--breakpoint-min-md)` (60em / 960px)
|
||||
- `@media (--breakpoint-min-lg)` (80em / 1280px)
|
||||
- `@media (--breakpoint-min-xl)` (120em / 1920px)
|
||||
- Also available: `--breakpoint-max-*` variants
|
||||
|
||||
### Snap Hardcoded Literals to Nearest Design Token (CRITICAL)
|
||||
|
||||
When migrating, **never carry over hardcoded `rem`/`px` values** from the original Emotion code. Instead, snap them to the nearest design system token. The original values were often arbitrary — the migration is an opportunity to align with the design system.
|
||||
|
||||
**Spacing** — snap to the nearest Mantine spacing token:
|
||||
|
||||
| Hardcoded value | Nearest token | Style prop | CSS variable |
|
||||
| --------------------------------- | ------------- | ------------ | ------------------------------ |
|
||||
| `0.125rem` (2px) | `2px` | `2` (number) | `2px` (keep literal, no token) |
|
||||
| `0.2rem` (3.2px), `0.25rem` (4px) | **xs** (4px) | `"xs"` | `var(--mantine-spacing-xs)` |
|
||||
| `0.5rem` (8px) | **sm** (8px) | `"sm"` | `var(--mantine-spacing-sm)` |
|
||||
| `0.75rem` (12px) | between sm/md | `rem(12)` | `0.75rem` (no exact token) |
|
||||
| `1rem` (16px) | **md** (16px) | `"md"` | `var(--mantine-spacing-md)` |
|
||||
| `1.5rem` (24px) | **lg** (24px) | `"lg"` | `var(--mantine-spacing-lg)` |
|
||||
| `2rem` (32px) | **xl** (32px) | `"xl"` | `var(--mantine-spacing-xl)` |
|
||||
|
||||
**Border radius** — snap to the nearest Mantine radius token:
|
||||
|
||||
| Hardcoded value | Nearest token | CSS variable |
|
||||
| ---------------- | ----------------------------- | -------------------------- |
|
||||
| `0.25rem` (4px) | **xs** (4px) | `var(--mantine-radius-xs)` |
|
||||
| `0.375rem` (6px) | **sm** (6px) | `var(--mantine-radius-sm)` |
|
||||
| `0.5rem` (8px) | **md** (8px) | `var(--mantine-radius-md)` |
|
||||
| `1rem`+ (16px+) | **xl** (40px) or keep literal | `var(--mantine-radius-xl)` |
|
||||
|
||||
**Font sizes** — snap to the nearest Mantine font size token:
|
||||
|
||||
| Hardcoded value | Nearest token | Style prop | CSS variable |
|
||||
| -------------------------------- | ------------- | ---------- | ----------------------------- |
|
||||
| `0.6875rem` (11px) | **xs** (11px) | `fz="xs"` | `var(--mantine-font-size-xs)` |
|
||||
| `0.75rem` (12px) | **sm** (12px) | `fz="sm"` | `var(--mantine-font-size-sm)` |
|
||||
| `0.875rem` (14px), `1rem` (16px) | **md** (14px) | `fz="md"` | `var(--mantine-font-size-md)` |
|
||||
| `1.063rem` (17px) | **lg** (17px) | `fz="lg"` | `var(--mantine-font-size-lg)` |
|
||||
| `1.3rem` (21px) | **xl** (21px) | `fz="xl"` | `var(--mantine-font-size-xl)` |
|
||||
|
||||
**Rules:**
|
||||
|
||||
- If the hardcoded value is within ~2px of a token, use the token
|
||||
- If it falls exactly between two tokens, prefer the smaller one (tighter is safer)
|
||||
- If no token is close (e.g., `48px` spacing), use `rem(48)` for style props or the literal value in CSS modules
|
||||
- This applies everywhere: style props, CSS module values, Icon `size` props, etc.
|
||||
|
||||
## Common Migration Patterns
|
||||
|
||||
### Pattern 1: Static Layout Container → Mantine Component
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
// Component.styled.tsx
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
`;
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
<Flex align="center" gap="sm" p="md" w="100%">
|
||||
```
|
||||
|
||||
### Pattern 2: Vertical Stack → Stack Component
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
export const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
`;
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
<Stack gap="lg" p="xl">
|
||||
```
|
||||
|
||||
### Pattern 3: Dynamic Props → cx() with CSS Module Classes
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
export const Item = styled.div<{ isSelected: boolean; disabled: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.disabled ? color("text-disabled") : color("text-primary")};
|
||||
background-color: ${(props) =>
|
||||
props.isSelected ? color("core-brand") : "transparent"};
|
||||
cursor: ${(props) => (props.disabled ? "default" : "pointer")};
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => !props.disabled && color("core-brand")};
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
**After (CSS module):**
|
||||
|
||||
```css
|
||||
/* Item.module.css */
|
||||
.itemRoot {
|
||||
color: var(--mb-color-text-primary);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--mb-color-core-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: var(--mb-color-core-brand);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
color: var(--mb-color-text-disabled);
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
color: var(--mb-color-text-disabled);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After (TSX):**
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
className={cx(S.itemRoot, {
|
||||
[S.selected]: isSelected,
|
||||
[S.disabled]: disabled,
|
||||
})}
|
||||
>
|
||||
```
|
||||
|
||||
### Pattern 4: Extending a Component → Style Props First, CSS Module Only If Needed
|
||||
|
||||
When a styled component wraps another component, first check if the styles can be expressed as style props on a Mantine wrapper or on the component itself. Only use a CSS module when properties genuinely need it.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
export const CardIcon = styled(Icon)`
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
color: var(--mb-color-core-brand);
|
||||
`;
|
||||
|
||||
export const CardTitle = styled(Ellipsified)`
|
||||
color: var(--mb-color-text-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-left: 1rem;
|
||||
`;
|
||||
```
|
||||
|
||||
**After — style props on Mantine wrapper (preferred when ALL properties are expressible):**
|
||||
|
||||
```tsx
|
||||
<Box display="block" flex="0 0 auto" c="core-brand">
|
||||
<Icon {...icon} />
|
||||
</Box>
|
||||
<Box c="text-primary" fz="md" fw="bold" ml="md">
|
||||
<Ellipsified>{title}</Ellipsified>
|
||||
</Box>
|
||||
```
|
||||
|
||||
**After — CSS module (use only when component has pseudo-selectors or non-expressible styles):**
|
||||
|
||||
```css
|
||||
.icon {
|
||||
color: var(--mb-color-core-brand);
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--mb-color-core-brand-hover);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
<Icon className={S.icon} name="warning" />
|
||||
```
|
||||
|
||||
### Pattern 4b: `styled.span` / `styled.p` with Text Styles → `Text` Component
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
export const Label = styled.span`
|
||||
color: var(--mb-color-text-secondary);
|
||||
font-weight: bold;
|
||||
`;
|
||||
|
||||
export const Title = styled.span`
|
||||
color: var(--mb-color-text-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-left: 0.5rem;
|
||||
`;
|
||||
```
|
||||
|
||||
**After — `Text` component with style props (NO CSS module needed):**
|
||||
|
||||
```tsx
|
||||
<Text component="span" c="text-secondary" fw="bold">
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
<Text component="span" c="text-primary" fz="md" fw="bold" ml="sm">
|
||||
{title}
|
||||
</Text>
|
||||
```
|
||||
|
||||
`Text` renders as `<p>` by default. Use `component="span"` to render inline. All color, typography, and spacing props work directly — no CSS module needed for pure text styling.
|
||||
|
||||
### Pattern 5: Keyframes Animation → CSS Module
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const spin = keyframes`
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
`;
|
||||
|
||||
export const Spinner = styled.div`
|
||||
animation: ${spin} 1s infinite linear;
|
||||
`;
|
||||
```
|
||||
|
||||
**After (CSS module):**
|
||||
|
||||
```css
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s infinite linear;
|
||||
}
|
||||
```
|
||||
|
||||
**After (TSX):**
|
||||
|
||||
```tsx
|
||||
<Box className={S.spinner} />
|
||||
```
|
||||
|
||||
### Pattern 6: color() Function → CSS Variables
|
||||
|
||||
| Emotion (JS) | CSS Module | Mantine Style Prop |
|
||||
| -------------------------- | ----------------------------------------------------------------- | ------------------------------------- |
|
||||
| `color("core-brand")` | `var(--mb-color-core-brand)` | `c="core-brand"` or `bg="core-brand"` |
|
||||
| `color("text-primary")` | `var(--mb-color-text-primary)` | `c="text-primary"` |
|
||||
| `color("border-neutral")` | `var(--mb-color-border-neutral)` | N/A (use CSS module) |
|
||||
| `alpha("core-brand", 0.2)` | `color-mix(in srgb, var(--mb-color-core-brand), transparent 80%)` | N/A (use CSS module) |
|
||||
|
||||
### Pattern 7: Responsive Spacing/Sizing → Responsive Style Props (NOT CSS Media Queries)
|
||||
|
||||
When a styled component only changes spacing, sizing, or other style-prop-expressible values at breakpoints, use Mantine's responsive syntax instead of CSS module media queries.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
export const CaptionRoot = styled.div`
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
${breakpointMinExtraLarge} {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
**After — responsive style prop (NO CSS module needed):**
|
||||
|
||||
```tsx
|
||||
<Flex mb={{ base: "lg", xl: "xl" }}>
|
||||
```
|
||||
|
||||
Breakpoint keys: `base` (default), `xs` (40em), `sm` (48em), `md` (60em), `lg` (80em), `xl` (120em).
|
||||
|
||||
Only use CSS module `@media` queries when the responsive change involves non-style-prop properties (e.g., `box-shadow`, `border`, `cursor` changes at breakpoints).
|
||||
|
||||
### Pattern 8: Dynamic Computed Styles → Inline Styles
|
||||
|
||||
Inline styles are allowed **only** for truly dynamic values computed at runtime (e.g., widths, positions, colors from data). Everything else must use Mantine style props or CSS modules.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
export const Bar = styled.div<{ width: number }>`
|
||||
width: ${(props) => props.width}%;
|
||||
`;
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
<Box style={{ width: `${width}%` }} />
|
||||
```
|
||||
|
||||
### Pattern 9: Core CSS Utility Classes (Discouraged)
|
||||
|
||||
**Do NOT introduce new `CS` utility class usage.** Core CSS utilities (`CS` from `metabase/css/core/index.css`) are legacy and discouraged for new code. Prefer Mantine style props or CSS modules instead.
|
||||
|
||||
If existing code already uses `CS` classes and you're not migrating that specific code, leave them alone. But when migrating Emotion → Mantine, replace with the proper alternative:
|
||||
|
||||
| Instead of `CS.*` | Use |
|
||||
| ------------------- | ------------------------------------------------------------ |
|
||||
| `CS.overflowHidden` | CSS module: `overflow: hidden` |
|
||||
| `CS.cursorPointer` | CSS module: `cursor: pointer` |
|
||||
| `CS.flex1` | Style prop: `flex={1}` |
|
||||
| `CS.flexNoShrink` | Style prop: `flex="0 0 auto"` |
|
||||
| `CS.textBrandHover` | CSS module: `&:hover { color: var(--mb-color-core-brand); }` |
|
||||
|
||||
### Pattern 10: Shared Emotion Styles Across Files → Shared CSS Module + cx()
|
||||
|
||||
When Emotion exports a shared `css` block (like `animationStyles`) imported by many files, create ONE CSS module with the shared keyframe and a reusable class, then compose via `cx()`.
|
||||
|
||||
**Before (Emotion — shared animation used by 13+ skeleton files):**
|
||||
|
||||
```tsx
|
||||
// ChartSkeleton.styled.tsx — defines shared animation
|
||||
const fadingKeyframes = keyframes`
|
||||
0% { opacity: 0.0625; }
|
||||
50% { opacity: 0.125; }
|
||||
100% { opacity: 0.0625; }
|
||||
`;
|
||||
export const animationStyles = css`
|
||||
opacity: 0.1;
|
||||
animation: ${fadingKeyframes} 1.5s infinite;
|
||||
`;
|
||||
|
||||
// AreaSkeleton.styled.tsx — consumes it
|
||||
import { animationStyles } from ".../ChartSkeleton.styled";
|
||||
export const SkeletonImage = styled.svg`
|
||||
${animationStyles};
|
||||
flex: 1 1 0;
|
||||
margin-top: 1rem;
|
||||
border-bottom: 1px solid currentColor;
|
||||
`;
|
||||
|
||||
// BarSkeleton.styled.tsx — also consumes it
|
||||
import { animationStyles } from ".../ChartSkeleton.styled";
|
||||
export const SkeletonImage = styled.svg`
|
||||
${animationStyles};
|
||||
flex: 1 1 0;
|
||||
margin-top: 1rem;
|
||||
`;
|
||||
```
|
||||
|
||||
**After — shared CSS module:**
|
||||
|
||||
```css
|
||||
/* ChartSkeleton.module.css */
|
||||
@keyframes fading {
|
||||
0% {
|
||||
opacity: 0.0625;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.125;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.0625;
|
||||
}
|
||||
}
|
||||
|
||||
.animated {
|
||||
opacity: 0.1;
|
||||
animation: fading 1.5s infinite;
|
||||
}
|
||||
```
|
||||
|
||||
**After — per-component CSS modules with own styles only:**
|
||||
|
||||
```css
|
||||
/* AreaSkeleton.module.css */
|
||||
.skeletonImage {
|
||||
flex: 1 1 0;
|
||||
margin-top: 1rem;
|
||||
border-bottom: 1px solid currentColor;
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* BarSkeleton.module.css */
|
||||
.skeletonImage {
|
||||
flex: 1 1 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
```
|
||||
|
||||
**After — component TSX uses cx() to compose:**
|
||||
|
||||
```tsx
|
||||
// AreaSkeleton.tsx
|
||||
import cx from "classnames";
|
||||
import ChartSkeletonS from "../ChartSkeleton/ChartSkeleton.module.css";
|
||||
import S from "./AreaSkeleton.module.css";
|
||||
|
||||
const AreaSkeleton = (): JSX.Element => {
|
||||
return (
|
||||
<svg
|
||||
className={cx(ChartSkeletonS.animated, S.skeletonImage)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 371 113"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d="..." fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
The shared module owns the `@keyframes` and the `.animated` class. Each consumer composes it with its own module class via `cx()`. No duplication of the keyframe definition.
|
||||
|
||||
## Import Template
|
||||
|
||||
```tsx
|
||||
import cx from "classnames"; // only if needed for conditional classes
|
||||
|
||||
import { Box, Flex, Group, Stack, Text, rem } from "metabase/ui";
|
||||
|
||||
import S from "./ComponentName.module.css"; // only if CSS module is needed
|
||||
```
|
||||
|
||||
## Checklist Before Finishing
|
||||
|
||||
- [ ] **MANTINE-FIRST CHECK**: Every CSS property that has a style prop equivalent (`c`, `bg`, `p`, `m`, `fw`, `fz`, `flex`, `gap`, `align`, `w`, `h`, etc.) is expressed as a style prop, NOT in a CSS module
|
||||
- [ ] `styled.span`/`styled.p` with only color/weight/size are replaced with `Text component="span"` + style props, NOT CSS module classes
|
||||
- [ ] `styled.div` with only layout props are replaced with `Flex`/`Stack`/`Group`/`Box` + style props, NOT CSS module classes
|
||||
- [ ] Responsive spacing/sizing uses responsive style props (`mb={{ base: "lg", xl: "xl" }}`), NOT CSS module `@media` queries
|
||||
- [ ] CSS modules are used ONLY for: pseudo-selectors, box-shadow, border, cursor, overflow, animation, transition, transform, or complex selectors
|
||||
- [ ] All `@emotion/styled` and `@emotion/react` imports removed from migrated files
|
||||
- [ ] The `.styled.tsx` file is deleted
|
||||
- [ ] All imports of the deleted styled file are updated
|
||||
- [ ] No static inline styles — `style={{ }}` used only for truly dynamic runtime values
|
||||
- [ ] All colors use design tokens (`c="core-brand"`, `var(--mb-color-core-brand)`), not raw hex/rgb
|
||||
- [ ] All spacing uses design tokens (`p="md"`, `var(--mantine-spacing-md)`), not arbitrary px values — hardcoded `rem`/`px` literals snapped to the nearest token (see mapping table above)
|
||||
- [ ] All border-radius values use radius tokens (`var(--mantine-radius-md)`), not hardcoded `0.5rem`
|
||||
- [ ] All font sizes use font-size tokens (`fz="md"`, `var(--mantine-font-size-md)`), not hardcoded `1rem`
|
||||
- [ ] Layout uses Mantine components (`Flex`, `Stack`, `Group`, `Box`, `Text`) not raw divs/spans with CSS
|
||||
- [ ] All CSS module class names use camelCase
|
||||
- [ ] No cascading/descendant selectors targeting raw elements — use direct class assignment instead
|
||||
- [ ] Component renders identically to the original (visually verify if possible)
|
||||
|
||||
## Visual Verification with Screenshots (Optional — Only When Explicitly Asked)
|
||||
|
||||
When the user explicitly asks to create before/after screenshots, use the Playwright MCP tools to capture them. This is NOT done by default — only when requested.
|
||||
|
||||
Screenshots must be taken in **both light and dark mode** to verify token usage is correct in both themes.
|
||||
|
||||
### Switching Color Scheme via API
|
||||
|
||||
Toggle between light and dark mode using the Metabase settings API. Use Playwright's `browser_evaluate` or `browser_navigate` with fetch:
|
||||
|
||||
```js
|
||||
// Switch to dark mode
|
||||
await fetch("/api/setting/color-scheme", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: "dark" }),
|
||||
});
|
||||
|
||||
// Switch to light mode
|
||||
await fetch("/api/setting/color-scheme", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: "light" }),
|
||||
});
|
||||
```
|
||||
|
||||
After switching, reload the page for the theme to take effect.
|
||||
|
||||
### Process
|
||||
|
||||
For each state (**after** = migrated code, **before** = stashed original), capture screenshots in both themes at both viewport widths. This produces **8 screenshots total**.
|
||||
|
||||
1. **Take "after" screenshots first** (migrated code is already in place):
|
||||
|
||||
- Navigate to the page that renders the migrated components (`browser_navigate`)
|
||||
- Wait for content to load (`browser_wait_for` with key text, or `textGone: "Loading..."`)
|
||||
- Close the sidebar if it overlaps content on narrow viewports (`browser_click` the toggle button)
|
||||
- **Light mode** (set `color-scheme` to `"light"` via API, reload):
|
||||
- Desktop (1280x900): `after-light-desktop.png`
|
||||
- Narrow (640x900), close sidebar: `after-light-narrow.png`
|
||||
- **Dark mode** (set `color-scheme` to `"dark"` via API, reload):
|
||||
- Desktop (1280x900): `after-dark-desktop.png`
|
||||
- Narrow (640x900), close sidebar: `after-dark-narrow.png`
|
||||
|
||||
2. **Stash changes to capture "before" screenshots**:
|
||||
|
||||
- Run `git stash --include-untracked` to temporarily revert to the Emotion version
|
||||
- Repeat the same 4 screenshots with `before-` prefix:
|
||||
- `before-light-desktop.png`, `before-light-narrow.png`
|
||||
- `before-dark-desktop.png`, `before-dark-narrow.png`
|
||||
- Run `git stash pop` to restore the migrated code
|
||||
|
||||
3. **Restore the user's original color scheme** (set back to `"auto"` or whatever it was before).
|
||||
|
||||
4. **Display all 8 screenshots** using the `Read` tool so the user can visually compare before/after in both themes at both viewport sizes.
|
||||
|
||||
### Key Details
|
||||
|
||||
- Save screenshots in the repo root (they will be untracked — user can delete after review)
|
||||
- The dev server must be running (typically `http://localhost:3000`)
|
||||
- Use `browser_snapshot` to find element refs when you need to click buttons (e.g., sidebar toggle)
|
||||
- The greeting text on the home page changes randomly on each load — this is expected, not a regression
|
||||
- If the page being tested is not the home page, navigate to the correct URL that exercises the migrated components
|
||||
- Dark mode screenshots are critical for verifying that CSS variable tokens (`var(--mb-color-*)`) are used correctly — hardcoded colors will look wrong in dark mode
|
||||
@@ -1,248 +0,0 @@
|
||||
---
|
||||
name: fe-modularization
|
||||
description: Deciding where frontend code lives — tier model, move mechanics, extension points, side effects, naming, and the traps. Use when moving code between modules, carving new modules, fixing boundary violations, adding a barrel or an endpoint, or reviewing module-shape decisions.
|
||||
---
|
||||
|
||||
# Frontend modularization
|
||||
|
||||
The module is the unit of blast radius: dependency direction, test selection,
|
||||
bundle cones, and review scope all follow module boundaries. Every decision
|
||||
here serves one goal: **make the file locations tell the truth about who owns
|
||||
what and who may depend on whom.**
|
||||
|
||||
## Hard rules (check these before anything else)
|
||||
|
||||
- NEVER add an `export … from` re-export at an old path, and NEVER `export *`
|
||||
from a barrel. Move the code, codemod every call site, delete the old path,
|
||||
all in the same PR.
|
||||
- NEVER import past a module's `index.ts` when that module has
|
||||
`enforcePublicApi: true`, and never past `metabase/ui`'s index at all. If
|
||||
the barrel is heavy, the fix is making the module side-effect free (below),
|
||||
not a deep import.
|
||||
- NEVER rewrite a consumer to a lower-level idiom to make the linter pass. If
|
||||
the fix makes the consumer worse, the plan is wrong.
|
||||
- NEVER give a new module `enforceSharedTiers: false`; new modules ship
|
||||
enforced with `enforcePublicApi: true` and an explicit `index.ts`.
|
||||
- NEVER move code without the consumer-tier check (procedure step 2).
|
||||
- ALWAYS delete a module's `enforceSharedTiers: false` line in the PR that
|
||||
takes it to zero violations.
|
||||
- ALWAYS measure `bun run module-boundaries` before and after, and report the
|
||||
numbers.
|
||||
- ALWAYS import `dayjs` from `metabase/dayjs`, routing from `metabase/router`,
|
||||
Mantine from `metabase/ui`, react-redux hooks from `metabase/redux`; the
|
||||
raw specifiers are lint-forbidden.
|
||||
|
||||
## Files that are ground truth
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| module elements, tiers, `enforceSharedTiers`, `enforcePublicApi` | `frontend/lint/module-boundaries.mjs` |
|
||||
| shared sub-tiers and levels, cluster rules | `frontend/lint/shared-tiers.mjs` |
|
||||
| standalone boundaries lint (all violations, incl. grandfathered) | `bun run module-boundaries` (config `eslint.config.module-boundaries.mjs`) |
|
||||
| PR lint (only enforced modules fail CI) | `bun run lint-oxlint-pure` |
|
||||
| side-effect-free directories and their exceptions | `frontend/build/shared/rspack/side-effect-free-modules.js` (`SIDE_EFFECT_FREE_PATHS`, `SIDE_EFFECT_PATHS`) |
|
||||
| the lint rules that enforce that promise | `metabase/no-module-side-effects`, `metabase/no-base-api-access` in `frontend/lint/eslint-plugin-metabase/rules/` |
|
||||
| public-api enforcement | `metabase/enforce-module-public-api` rule, driven by `getPublicApiModules()` |
|
||||
|
||||
## The model
|
||||
|
||||
Tiers: `lib < basic < shared < feature < app`. Imports point downward, never
|
||||
sideways at feature tier (features may not import features; app and EE may
|
||||
import anything). The shared tier is subdivided in `shared-tiers.mjs` into
|
||||
shared-utils (U0…), shared-platform (P0…), and shared-domain, each ordered
|
||||
into levels. A module imports only *strictly lower* levels of its sub-tier
|
||||
plus the sub-tiers below; same-level peers are deliberately forbidden, which
|
||||
is what makes cycles structurally impossible. A domain may hold two seats
|
||||
when consumers demand it: a low core and a high surface (metabase-lib below
|
||||
questions below query_builder; viz-core below visualizations).
|
||||
|
||||
Enforcement is per module: `enforceSharedTiers: false` on an element exempts
|
||||
it from the level rules (counted by `bun run module-boundaries`, invisible to
|
||||
PR lint). Modules with `enforcePublicApi: true` may only be imported from
|
||||
outside via `metabase/<module>`, and import relatively inside.
|
||||
|
||||
## Deciding where code lives (apply in order; first decisive test wins)
|
||||
|
||||
1. **Who owns the concept?** Code lives with the module that owns its
|
||||
concept, not the module that renders or calls it. A mode encoding dashboard
|
||||
click behaviour is dashboard code even if viz executes it. Metabot
|
||||
conversation state is metabot state even though the store registers it.
|
||||
2. **Consumer-tier check.** The destination must sit at or below the file's
|
||||
*lowest* consumer. A single consumer below the proposed home vetoes the
|
||||
move; report it, don't force it.
|
||||
3. **Only four legitimate fixes** for a bad edge: move the code to its
|
||||
terminal home; invert through a designed extension point (a prop, an
|
||||
injected component, a contract type owned by the socket); fix the tier
|
||||
placement when the declared level is wrong; delete a thin wrapper (a rename
|
||||
with no derivation isn't an abstraction — deleting it *is* the migration).
|
||||
4. **A bridge lives above the lower endpoint.** Code that needs both sides of
|
||||
a boundary belongs in or above the higher module.
|
||||
5. **"Genuinely shared" is proven by consumers.** A shared address needs two
|
||||
or more consumers the tier rules can't serve another way. One consumer
|
||||
pretending to be shared is a feature module at the wrong address.
|
||||
|
||||
## Extension points
|
||||
|
||||
The socket's owner defines the contract; every plug lives with its extender.
|
||||
Visualization owns the `mode` prop and click interfaces, each surface supplies
|
||||
its mode; the editor owns its extension contract, metabot supplies tiptap
|
||||
extensions; querying owns `TemplateTagsSidebarProps`, parameters implements
|
||||
it.
|
||||
|
||||
- Prefer plain props/injection at composition sites. `PLUGIN_*` registries are
|
||||
for edition gating; single-reader slots move out to their reading module;
|
||||
slots with many readers live in the module that owns the concept (every
|
||||
slot the whitelabel plugin fills lives in `whitelabel`), tier permitting.
|
||||
Keep contracts type-light so implementations can load lazily.
|
||||
- Identical injection at every callsite is acceptable until a fourth consumer
|
||||
or second slot appears; then the composition deserves its own module above
|
||||
both parts. Don't pre-build the wrapper.
|
||||
|
||||
## Store shape
|
||||
|
||||
A module that has redux state owns it:
|
||||
|
||||
- `store/` is private: reducer, plain creators/action types, and selectors,
|
||||
typed against the module's own state (`QueryBuilderStoreState`), never the
|
||||
global `State`. Thunks live in `actions/` and import `store/`, never the
|
||||
reverse.
|
||||
- The barrel is the state API: selectors for reads, creators/thunks for
|
||||
writes.
|
||||
- Store roots (`reducers-main`, `reducers-public`, the SDK store) import each
|
||||
slice from its module barrel and derive their own state type; no slice
|
||||
registry, no `declare module` merging.
|
||||
- Cross-module writes: a callback prop when a composition point exists;
|
||||
otherwise dispatch the owner's exported creator; an event only when the
|
||||
emitter must not know its listeners. Raw `dispatch({ type: "…" })` strings
|
||||
are banned. A reducer or listener matching another module's *internal*
|
||||
action is the coupling to remove.
|
||||
- Slices that mirror one endpoint get deleted (the RTK cache is the state); a
|
||||
fold over many sources with one writer (the metadata store) is kept as a
|
||||
module-private slice behind the barrel.
|
||||
|
||||
## Endpoints
|
||||
|
||||
Endpoints migrate to their owning module via `Api.injectEndpoints` from the
|
||||
module's `api/` file (settings, metabot, transforms precedents); `metabase/api`
|
||||
trends toward the client only. There is one `Api` object per backend because
|
||||
tag invalidation only works within one instance. Consumers reach endpoints
|
||||
only through the owner's exports (`useGetCardQuery`, `cardApi.endpoints.…`),
|
||||
never by name through the base object; `metabase/no-base-api-access` allows
|
||||
injection only in `metabase/api` and `**/api/**` / `**/api.ts`, and reaching by
|
||||
name only in `metabase/api` and test support (`invalidateTags` /
|
||||
`resetApiState` are fine anywhere). Each endpoint move carries its
|
||||
metadata-store hookup: the owner calls `entitiesFetched` from its own
|
||||
`onQueryStarted`.
|
||||
|
||||
## Import-time side effects
|
||||
|
||||
Directories in `SIDE_EFFECT_FREE_PATHS` are marked `sideEffects: false` for
|
||||
rspack, so importing one export from their barrel no longer drags the whole
|
||||
barrel into a bundle. The price: nothing in those directories may do work at
|
||||
import time, or production silently drops it. `metabase/no-module-side-effects`
|
||||
enforces this over every listed directory (module-scope calls, writes to
|
||||
imported objects or globals, bare imports, control flow at import; escape
|
||||
hatches are `/* #__PURE__ */` and the rule's pure-callee list). Files that
|
||||
must run at import are listed in `SIDE_EFFECT_PATHS` (files or directories).
|
||||
|
||||
- **A library the app configures gets a facade module** owning the vendor
|
||||
import, the registrations, and the types, with the raw specifier
|
||||
lint-forbidden outside it: `metabase/router`, `metabase/ui`,
|
||||
`metabase/redux`, `metabase/dayjs`. Facades are the effect, so they never
|
||||
enter `SIDE_EFFECT_FREE_PATHS`. Never re-register a vendor plugin locally
|
||||
(`dayjs.extend`, `echarts` `use`).
|
||||
- **A patch a component needs is an explicit registration module,
|
||||
bare-imported by the provider that needs it** and listed in
|
||||
`SIDE_EFFECT_PATHS` (Mantine's `Popover.Dropdown` replacement, imported by
|
||||
`ThemeProvider`).
|
||||
- **Effects with no value to import** (global CSS, polyfills, the CSP nonce,
|
||||
EE plugin bootstrap) live in the entry's side-effects file and nowhere else.
|
||||
- **Registries are filled from the composition root at boot**
|
||||
(`initializePlugins`, `registerVisualizations`), never by a module
|
||||
registering itself at import.
|
||||
- **RTK injection stays at import**; when a module with an `api/` folder is
|
||||
declared side-effect free, list `<module>/api/` in `SIDE_EFFECT_PATHS`.
|
||||
|
||||
## Naming
|
||||
|
||||
The feature keeps the plain product-surface name; a shared carve is named for
|
||||
what the shared part actually is: the concept when the concept is the shared
|
||||
thing (`settings`, `current-user`, `whitelabel`, `dayjs`), or a qualified
|
||||
capability when the plain noun is taken (`search-ui`, `metrics-ui`,
|
||||
`viz-core`, `data-studio-ui`). Never two modules distinguished only by tier
|
||||
prefix; never generic `-shared` / `-feature` suffixes.
|
||||
|
||||
## Procedure for a move
|
||||
|
||||
1. **Enumerate consumers honestly** (hook-name greps miss the last three):
|
||||
```
|
||||
grep -rn "metabase/<module>" frontend/src enterprise/frontend/src frontend/test e2e
|
||||
grep -rn "jest.mock(\"metabase/<module>" frontend enterprise/frontend
|
||||
grep -rn "addMatcher(.*<endpoint>" frontend/src enterprise/frontend/src
|
||||
```
|
||||
plus sibling files importing the moved file relatively. Classify each by
|
||||
module and tier from `module-boundaries.mjs` / `shared-tiers.mjs`.
|
||||
2. **Consumer-tier check**: destination at or below the lowest consumer. If
|
||||
not, stop and report the blocking consumer.
|
||||
3. **Measure before**: `bun run module-boundaries 2>&1 | tail -1` on the base
|
||||
(write the number down).
|
||||
4. **Move**: `git mv`; codemod every importer to `metabase/<module>` (outside)
|
||||
or a relative path (inside); add named exports to the destination barrel
|
||||
for every symbol an outside consumer needs (each must have a real
|
||||
consumer). Delete the old path. If the module didn't exist: add its element
|
||||
in `module-boundaries.mjs` (ordered before any element it's carved from,
|
||||
first match wins), its level in `shared-tiers.mjs`, `enforcePublicApi:
|
||||
true`, an `index.ts`. If the move empties a module's violations, delete
|
||||
its `enforceSharedTiers: false` line. If the module has an `api/` folder
|
||||
and is side-effect free, list `<module>/api/` in `SIDE_EFFECT_PATHS`.
|
||||
5. **Verify** (all of these, report the numbers):
|
||||
```
|
||||
grep -rn "<old path>" frontend enterprise/frontend e2e # must be empty
|
||||
bunx eslint --no-warn-ignored <touched files>
|
||||
bun run lint-oxlint-pure # enforce-module-public-api included
|
||||
bun run type-check-pure
|
||||
bun run test-unit-keep-cljs <touched folders' specs>
|
||||
bun run module-boundaries 2>&1 | tail -1 # after; no violation may name the new files
|
||||
bun run build-release:static-viz # only if viz/static-viz/ui/dayjs touched; hard 3.5 MiB budget
|
||||
```
|
||||
6. **Report**: count before/after (state count-neutral explicitly), the edges
|
||||
removed by file, flags/rules deleted, barrel exports added, anything you
|
||||
skipped and why, and any consumer you found that vetoed part of the move.
|
||||
|
||||
## Traps (each has bitten at least once)
|
||||
|
||||
- **Late binding.** A bare function reference in a `createSelector` input
|
||||
array, a component identity captured for comparison, a `PLUGIN_*` object
|
||||
read at module scope: all capture at import time and dodge jest spies, EE
|
||||
overrides, and lazy loading. Read at call time (`(_state) => fn()`), or
|
||||
inject.
|
||||
- **Plans go stale.** Verify every recipe against current code before
|
||||
executing; things get merged, renamed, or turn out never to have existed.
|
||||
Skip-and-report beats force.
|
||||
- **The linter's blind spots**: `export … from` re-exports, bare-specifier
|
||||
packages (custom-viz), pre-seeded caches that never register tags. A grep
|
||||
for the old path is the stronger claim than a green lint run.
|
||||
- **Path-keyed baselines reset on rename**: a generated per-file ledger
|
||||
silently loses a file's history when it moves.
|
||||
- **A heavy barrel is a bundle hazard until its module is side-effect free.**
|
||||
One constant imported from the `metabase/ui` barrel pulled Mantine into the
|
||||
static-viz bundle and broke its budget; the fix was `SIDE_EFFECT_FREE_PATHS`,
|
||||
never a deep import.
|
||||
- **Type augmentation is program-wide.** `declare module "dayjs"` (and
|
||||
similar) makes plugin types available to any file in the same tsconfig, so
|
||||
a passing type-check never proves the setup ran; only importing the facade
|
||||
does.
|
||||
- **Environment before diagnosis**: after a dependency-touching merge,
|
||||
`bun install` before declaring a failure real; worktrees share staleness
|
||||
through the node_modules symlink, and a stale `target/cljs_dev` makes
|
||||
`type-check-pure` fail on `metabase-lib/metric/core.ts` (rebuild cljs in the
|
||||
worktree).
|
||||
|
||||
## PR shape
|
||||
|
||||
One logical change per PR; the body names the specific edges removed (files,
|
||||
imports, before/after count) and gives each move's ownership reason in plain
|
||||
language a reviewer outside the effort can follow, without campaign labels.
|
||||
Stack only for semantic dependency or same-hot-file serialisation, and say
|
||||
so. Consider codeowner surface when chunking. Open as a draft until measured
|
||||
and green.
|
||||
@@ -1,244 +0,0 @@
|
||||
---
|
||||
name: metabase-ui-component-from-figma
|
||||
description: Update or build a Metabase design-system component (frontend/src/metabase/ui — Chip, Badge, Alert, Switch, etc.) to match a Figma spec described in a Linear issue. Use when a ticket asks to restyle an existing `metabase/ui` component or implement a new one from Figma. Covers: checking out the issue, mapping the component (usage/blast radius for an existing one, or scaffolding a new one), building a Storybook showcase matrix (before styling for existing components, after for new ones), styling from exact Figma tokens while iterating with the user, and committing + migrating call sites LAST.
|
||||
---
|
||||
|
||||
# Metabase UI component from Figma
|
||||
|
||||
End-to-end workflow for bringing a `metabase/ui` (Mantine) component in line with a Figma design, driven by a Linear ticket.
|
||||
|
||||
The golden rule (existing components): **build the Storybook showcase before changing any component styles.** Build it while the old styles are still in place — so the team can see the current component at a glance and get accustomed to it before the migration, then watch it light up as you restyle. You end up with a visual-regression (Loki) candidate either way. A **new** component has nothing to render until it's built, so this rule doesn't apply to create mode — there the order flips: implement and style the component first, then build its showcase to present the result.
|
||||
|
||||
This is a frontend task — also load **typescript-write** (and **typescript-review** before handing off).
|
||||
|
||||
## Two modes
|
||||
|
||||
The ticket is one of two shapes; identify which up front, because Phase 2 differs:
|
||||
|
||||
- **Update an existing component** — the component already exists with call sites, either in `metabase/ui` *or* as a legacy component elsewhere (e.g. `frontend/src/metabase/common/components/`, possibly Emotion styled-components) that this ticket also migrates into `metabase/ui`. The risk is *regression* (call sites, behavior), so you map usage and lock behavior with tests before touching it.
|
||||
- **Create a new component** — nothing equivalent exists anywhere yet. Rarer. The risk is *API design*, so you scaffold the component and its public surface first.
|
||||
|
||||
Phases 1, 2, 5 and 6 are shared. The middle two differ in **order**: update mode builds the showcase (Phase 3) *before* styling (Phase 4); create mode does the reverse — implement and style first, then build the showcase as the last step — because there's nothing to render until the component exists.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Check out the issue
|
||||
|
||||
1. Fetch the Linear issue with the Linear MCP `get_issue`.
|
||||
2. Pull from it:
|
||||
- **Figma link(s)** — extract the `node-id` (`?node-id=250-13588` → `250-13588`) and `fileKey` (the `/design/<fileKey>/…` segment).
|
||||
- **`gitBranchName`** — switch to it if not already there.
|
||||
- **The component's intended API** — variants, sizes, and states the design system supports for this component. This defines the axes of your showcase (Phase 3).
|
||||
3. Decide the mode (see **Two modes** above): search for an existing component — in `metabase/ui` or as a legacy component elsewhere. Found → **update**; nothing equivalent anywhere → **create**. Confirm with the user when unsure.
|
||||
4. **Verify a Figma desktop MCP is connected before doing the work** — this skill depends on it for exact tokens in Phase 4 (and styling can't proceed accurately without it). Do a quick read against the issue's node (e.g. `get_metadata`/`get_variable_defs`); if it errors with "nothing selected" or the server isn't connected, prompt the user to enable it (Switch to Dev Mode → MCP section in the right sidebar) and select the relevant layer before continuing.
|
||||
|
||||
## Phase 2 — Understand the component
|
||||
|
||||
### Mode: update an existing component
|
||||
|
||||
1. Locate the component dir (typically containing `Component.config.ts` or `Component.tsx`, `Component.module.css`, `Component.stories.tsx`, `index.ts`). A ticket may also be **migrating a legacy component** — if it still uses Emotion styled-components, reach for the **emotion-migrate** skill.
|
||||
2. Map every usage across `frontend/src` and `e2e` (JSX `<Component`, sub-components like `Component.Group`, and wrapper components).
|
||||
3. Write findings to **`local/<TICKET>-<component>-usage-findings.md`**, one entry per usage: code location (`file:line`), short context, and **non-standard usage** (excessive customization, inline styling, invalid/legacy props). Invalid props are a signal that call sites will need migrating in Phase 6. This doc **stays** — the user walks it at the end to verify each real usage still looks right.
|
||||
4. State the blast radius plainly: how many call sites, where, and whether they're confined.
|
||||
5. **Lock behavior with tests before changing anything — when warranted.** Only for components that have **custom interactive behavior** (their own event handlers, state, keyboard logic, controlled/uncontrolled wiring). Add unit tests covering that current API/behavior so a regression shows up as a failing test. **Skip tests for thin Mantine wrappers** — don't test built-in Mantine behavior, and a pure visual restyle is covered by the showcase/Loki, not unit tests.
|
||||
|
||||
### Mode: create a new component
|
||||
|
||||
1. Decide whether it wraps a Mantine primitive (most common — extend it via a `.config.ts` using `<Mantine>.extend({...})` and theme registration) or is bespoke. Prefer wrapping Mantine.
|
||||
2. Scaffold the component dir + its public surface: `Component.tsx`/`Component.config.ts`, `Component.module.css`, `Component.stories.tsx`, `index.ts`, and wire the export through the group barrel and the `metabase/ui` barrel.
|
||||
3. There are no call sites to map yet; instead note the intended usage from the ticket so the eventual showcase reflects real props.
|
||||
4. **Order note:** for a new component, implement and style it (Phase 4) first, *then* build the showcase (Phase 3) to present the finished result — there's nothing to render until it exists. The phases below are written update-first; just run 4 before 3 in create mode.
|
||||
|
||||
## Phase 3 — Build the Storybook showcase
|
||||
|
||||
Goal: one story that shows **every state at a glance**, themeable, suitable as a Loki visual-regression test. **Update mode: build this before Phase 4, while the old styles are still in place. Create mode: do this after Phase 4**, once the component exists to render.
|
||||
|
||||
### Reusable showcase helpers
|
||||
|
||||
Use the shared, Storybook-only primitives in **`frontend/src/metabase/ui/stories/showcase/`**: `StoryShowcase` (titled panel), `StorySection`, `StoryRow`, and `StoryJsx` (monospace JSX with light syntax highlighting via `tokenizeJsx`). Extend the set only when a new pattern is genuinely reusable.
|
||||
|
||||
### Derive the matrix from the component's full state space
|
||||
|
||||
Enumerate **all** the states the component can be in — every supported variant, size, and interaction/selection state (from its props/types and the design-system API, not merely the subset the issue happens to mention) — then cross-reference with the Figma spec and build the matrix on that. Don't omit a supported state because the ticket didn't list it; don't invent an axis Figma doesn't show (e.g. a static component has no hover/pressed).
|
||||
|
||||
**Exclude theme from the matrix.** Figma usually models light/dark as a component *property* (because it doesn't use variable modes), but theme is **not** a real component prop in the app — it's global. So never make theme a matrix axis or column; drive light/dark with the global Storybook `theme` toggle instead (see below).
|
||||
|
||||
Typical layout (mirrors the Figma component sheet): a `StoryShowcase` title, then a single **CSS-grid matrix** — state labels down the left column, one column per axis-combination (e.g. `variant × size`), each column headed by the real JSX usage via `StoryJsx`. Use `gridTemplateColumns: \`<labelWidth> repeat(N, max-content)\`` with a `React.Fragment` per row.
|
||||
|
||||
### Theme: use the global toggle, render ONE copy
|
||||
|
||||
Render the showcase once and let Storybook's global `theme` control (defined in `.storybook/preview.tsx`) switch light/dark. Do **not** render light and dark side by side: Metabase bakes per-scheme hex into `--mantine-color-*` at `:root`, and Mantine color tokens follow the global scheme — a nested scheme wrapper won't re-scope them.
|
||||
|
||||
### Forcing hover/pressed for the matrix
|
||||
|
||||
Use **`storybook-addon-pseudo-states`** (registered in `.storybook/main.ts`); don't add `data-force-*` hooks to component CSS. Set `parameters.pseudo` to force states per cell. Two things make or break it:
|
||||
|
||||
1. **Aim the selector at the element the pseudo-class actually sits on**, which for a Mantine component is often an inner slot, not the root. A string/array value applies `.pseudo-<state>` to the matched element, so it must match that slot. Import the CSS-module class (`S.<Slot>`) to target it.
|
||||
2. **Land your per-cell hook on the root via the factory's root-props slot** (e.g. `wrapperProps`), because Mantine forwards top-level `data-*`/rest props to an inner element, not the root. Check the component's `.d.ts` for the right slot.
|
||||
|
||||
The addon toolbar toggle keeps working independently (it forces every rule in the story).
|
||||
|
||||
### Example: a finalized matrix story
|
||||
|
||||
Illustrative — adapt the axes and states to the component you're working on. This is the shape the pieces above add up to (state labels on the left, one column per `variant × size`, forced hover/pressed via `parameters.pseudo`, the root hook on `wrapperProps`, controls scoped to the meaningful knobs):
|
||||
|
||||
```tsx
|
||||
import type { StoryFn } from "@storybook/react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { Box, Chip, type ChipProps, Text } from "metabase/ui";
|
||||
import { StoryJsx, StoryShowcase } from "metabase/ui/stories/showcase";
|
||||
|
||||
import S from "./Chip.module.css";
|
||||
|
||||
// Axes derived from the component's API (here: variant × size).
|
||||
const COLUMNS = [
|
||||
{ variant: "light", size: "sm" },
|
||||
{ variant: "light", size: "md" },
|
||||
{ variant: "filled", size: "sm" },
|
||||
{ variant: "filled", size: "md" },
|
||||
] as const;
|
||||
|
||||
// Every state the component supports; `id` doubles as the pseudo-state hook.
|
||||
const STATES = [
|
||||
{ id: "default", label: "Default" },
|
||||
{ id: "hover", label: "Hover" },
|
||||
{ id: "pressed", label: "Pressed" },
|
||||
{ id: "selected", label: "Selected", checked: true },
|
||||
{ id: "hover-selected", label: "Hover selected", checked: true },
|
||||
{ id: "disabled", label: "Disabled", disabled: true },
|
||||
];
|
||||
|
||||
// Force hover/pressed by targeting the slot the pseudo-class sits on (.ChipLabel).
|
||||
const labelSelector = (id: string) => `[data-state-row="${id}"] .${S.ChipLabel}`;
|
||||
|
||||
const Overview: StoryFn<ChipProps> = ({ children }) => (
|
||||
<StoryShowcase title="Chip">
|
||||
<Box
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `9rem repeat(${COLUMNS.length}, max-content)`,
|
||||
columnGap: "2rem",
|
||||
rowGap: "1rem",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div />
|
||||
{COLUMNS.map(({ variant, size }) => (
|
||||
<StoryJsx
|
||||
key={`${variant}-${size}`}
|
||||
>{`<Chip variant="${variant}" size="${size}" />`}</StoryJsx>
|
||||
))}
|
||||
{STATES.map((state) => (
|
||||
<Fragment key={state.id}>
|
||||
<Text size="sm" c="text-secondary">
|
||||
{state.label}
|
||||
</Text>
|
||||
{COLUMNS.map(({ variant, size }) => (
|
||||
<Chip
|
||||
key={`${variant}-${size}`}
|
||||
wrapperProps={{ "data-state-row": state.id }} // hook on the ROOT
|
||||
variant={variant}
|
||||
size={size}
|
||||
checked={state.checked ?? false}
|
||||
disabled={state.disabled ?? false}
|
||||
onChange={() => {}}
|
||||
>
|
||||
{children}
|
||||
</Chip>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</Box>
|
||||
</StoryShowcase>
|
||||
);
|
||||
|
||||
export const OverviewStory = {
|
||||
render: Overview,
|
||||
parameters: {
|
||||
pseudo: {
|
||||
hover: ["hover", "hover-selected"].map(labelSelector),
|
||||
active: ["pressed"].map(labelSelector),
|
||||
},
|
||||
controls: { include: ["children", "theme"] },
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Story conventions
|
||||
|
||||
- Keep an interactive default story plus the matrix story.
|
||||
- Restrict `argTypes` to the supported API (e.g. only the sizes the DS uses).
|
||||
- **Scope controls per story.** A static matrix should hide knobs it ignores: `parameters.controls = { include: [...] }` (or `{ disable: true }`). Wire any kept knob (e.g. `children`) through to the cells so it does something.
|
||||
- `loki.config.js` has a `storiesFilter`; check it before renaming a story that's already covered.
|
||||
|
||||
Validate: `bun run lint-oxlint-pure -- <files>` and `bun run type-check-pure`.
|
||||
|
||||
### Optional: verify in the browser yourself
|
||||
|
||||
If a browser-driving MCP is connected (e.g. **chrome-devtools-mcp** or **playwright-mcp**: `navigate_page`, `take_screenshot`, `take_snapshot`, `evaluate_script`, `resize_page`), use it to check your own work against the running Storybook (`http://localhost:6006/iframe.html?id=<story-id>`, the kebab-cased title + story). Screenshot for a visual read; `evaluate_script`/`take_snapshot` to confirm DOM facts (a hook landed on the root, pseudo classes applied, a resolved `--mb-color-*`). This is a **convenience, never a requirement** — without it, rely on the user's visual check (and you may suggest installing the plugin). Storybook is usually already running; don't start it. (Screenshot writes may be sandboxed to the repo root — save under `local/` then `mv`.)
|
||||
|
||||
Pause here and let the user look. Iterate on layout/spacing only — **no component style changes yet.**
|
||||
|
||||
## Phase 4 — Style the component from Figma
|
||||
|
||||
Now change the component's `.config.ts` / `.module.css`. Get **exact tokens from Figma** — never eyeball hex from a screenshot.
|
||||
|
||||
### Figma desktop MCP is required for tokens
|
||||
|
||||
`get_screenshot` works by `nodeId`, but `get_variable_defs`, `get_design_context`, and `get_metadata` read the **current selection in the Figma desktop app** via the **Dev Mode MCP server**. If they error with "nothing selected" or the server isn't connected, **prompt the user** to enable the Dev Mode MCP server (Switch to Dev Mode → MCP section in right sidebar) and select the relevant layer(s). Don't pixel-sample a screenshot as a substitute.
|
||||
|
||||
### Extract and map tokens
|
||||
|
||||
1. `get_metadata` on the spec frame for the node id + name of each variant/size/state symbol.
|
||||
2. `get_variable_defs` per symbol for exact colors, spacing, radius, font.
|
||||
3. `get_design_context` (forceCode) on one symbol for exact CSS dimensions (padding, gap, icon size). It may ask about Code Connect — skip that.
|
||||
4. **Use the semantic token names `get_variable_defs` returns.** An up-to-date spec binds each property to a semantic token (a role like `text/hover`, `background/selected`); match it to the corresponding `--mb-color-*` key (key list: `frontend/src/metabase/ui/colors/types/color-keys.ts`) and use it directly — one definition then works in both themes. Sanity-check the resolved shade against `colors/constants/themes/light.ts` / `dark.ts` and `base-colors.ts`. Caveat: returned *values* may be the wrong mode for dark symbols — trust the variable **names**, not the dark hex.
|
||||
|
||||
### Color rules — semantic tokens only, NO `color-mix`, NO primitives
|
||||
|
||||
Per `docs/developers-guide/frontend.md` ("Colors"): every color must be a **semantic** `--mb-color-*` token. No literal hex, and no `color-mix` in component CSS to fake a shade/alpha.
|
||||
|
||||
**Guardrail:** if `get_variable_defs` returns a **primitive ramp** (e.g. `Orion-Alpha/10`, `Ocean/60` — a ramp + number, not a role) bound directly to a property, do **not** translate it to a ramp, hardcode hex, or `color-mix` your way to it. **Stop and flag it to the user** with the specific property/state — it's a design-side gap (bind to a semantic token, or add one). Resume once the spec exposes a semantic token.
|
||||
|
||||
### Non-color tokens — flag scale mismatches too
|
||||
|
||||
The same discipline applies to **dimensional** values — radius, spacing/padding/gap, elevation/shadow, and typography (font size, line-height, weight). Prefer the codebase's existing scale variable over a literal: `--mantine-radius-*`, `--mantine-spacing-*`, `--mantine-shadow-*`, and the Mantine font tokens.
|
||||
|
||||
But Figma's scales don't always line up with the codebase's. When a Figma value **has no matching step** in the corresponding scale — e.g. Figma `radius/md = 12px` while `--mantine-radius-md` is `8px`, or a Figma `xxxs` spacing step with no Mantine equivalent — **don't silently bake in a literal.** Flag it to the user the same way you flag primitive colors: name the property, the Figma value, and the nearest codebase token, so it can be resolved with design / the scale owners (align the value, or add a scale step). Only fall back to a literal if the user confirms it's an accepted one-off, and leave a short comment in the code explaining why.
|
||||
|
||||
### Mantine implementation notes (reference)
|
||||
|
||||
General gotchas when extending Mantine components — adapt per component:
|
||||
|
||||
- **Know which slot each data-attribute lands on.** Mantine puts state attributes on specific slots (e.g. `data-variant` on the root, `data-checked`/`data-disabled` on an inner slot). Scope rules accordingly, usually through a root `classNames.root` class.
|
||||
- **Out-specify Mantine's built-in variant CSS.** It styles slots at `.m_*:not([data-disabled])` specificity; a descendant combinator through your root class wins. Avoid chained `:not()` (stylelint's `selector-not-notation: complex` rejects it, and combining lowers specificity) — gain specificity via the root class / attributes instead.
|
||||
- **Size via a `vars` resolver**, returning only Mantine's typed vars for that component (custom var names fail type-check). Map size → vars in `.config.ts`.
|
||||
- **Built-in icons use `currentColor`**, so they inherit the slot's text color.
|
||||
- For text on a brand fill, follow the existing convention (`text-primary-inverse`) unless the user wants pure white; flag the dark-theme implication.
|
||||
|
||||
After edits: `npx stylelint <css>`, `LINT_CSS_MODULES=true bun run lint-oxlint-pure -- <files>`, `bun run type-check-pure`.
|
||||
|
||||
## Phase 5 — Iterate with the user
|
||||
|
||||
Show the result in the running Storybook and address feedback (color, spacing, radius, shadow, missing/extra state). Surface deliberate deviations explicitly so the user can veto them. If a browser MCP is connected, screenshot the matrix in both themes to catch issues first. **Do not commit until the user is satisfied.**
|
||||
|
||||
## Phase 6 — Commit, then migrate call sites
|
||||
|
||||
Only after sign-off:
|
||||
|
||||
1. Commit when the user explicitly asks; commit only relevant files (exclude build artifacts and the gitignored `local/` doc). Follow the user's commit conventions.
|
||||
2. **Update mode:** migrate the call sites flagged in Phase 2 if the restyle requires it (e.g. invalid/renamed props). Be surgical with bulk find/replace — a blanket prop rename can hit unrelated components; verify each hit is the target. **Create mode:** confirm the public export is wired through the barrels and used as the ticket intends.
|
||||
3. Leave the findings doc in place for the user's manual verification pass.
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Linear issue fetched; Figma node/fileKey + `gitBranchName` extracted; on the right branch; mode (update vs create) decided.
|
||||
- [ ] **Update:** usage + blast radius in `local/<TICKET>-<component>-usage-findings.md`; behavior locked with unit tests **if** the component has custom interactive logic (skip for thin Mantine wrappers / pure restyles). **Create:** component scaffolded with its public surface and barrel exports.
|
||||
- [ ] Storybook showcase matrix built from the component's full state space (theme excluded — global toggle drives light/dark), single panel, hover/pressed forced via `storybook-addon-pseudo-states`, controls scoped per story; lint + type-check pass. (Update mode: built before styling, **component styles untouched**. Create mode: built after the component is implemented in Phase 4.)
|
||||
- [ ] Figma desktop MCP available; exact tokens extracted and mapped to semantic `--mb-color-*` (NO `color-mix`, NO primitives) and to existing scale vars for radius/spacing/elevation/type — any color *or* dimensional value with no matching token/scale step flagged to the user, not baked in as a literal.
|
||||
- [ ] Component styled; stylelint + eslint(+CSS modules) + type-check pass.
|
||||
- [ ] Iterated with the user until satisfied.
|
||||
- [ ] Committed (when asked); call sites migrated/verified (update) or export wired (create); findings doc handed back.
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
name: name-module-cycle
|
||||
description: Suggest a space-themed name for a cyclic cluster of backend modules when metabase.core.module-cycles-test reports a cluster without a name, usually after a split. Use when someone needs to name a module cycle or add a line to .clj-kondo/config/modules/cycle-clusters.edn.
|
||||
allowed-tools: Read, Grep, Glob, Bash
|
||||
---
|
||||
|
||||
# Name a module cycle
|
||||
|
||||
`.clj-kondo/config/modules/cycle-clusters.edn` maps each cyclic cluster of the module require graph to an anchor
|
||||
module. When a change splits a cluster, the new half has no name, and the person who split it gets to choose one.
|
||||
Help them choose; do not pick for them.
|
||||
|
||||
## 1. See the cluster
|
||||
|
||||
The test failure lists the cluster's modules and, for a small cluster, the requires between them that make it a
|
||||
cycle. For the full picture, including teams, run:
|
||||
|
||||
```bash
|
||||
clojure -X:dev dev.module-cycles/print-clusters
|
||||
```
|
||||
|
||||
Unnamed clusters print first.
|
||||
|
||||
## 2. Split, or a new cycle?
|
||||
|
||||
Look at everything the branch changes, committed or not: `git diff $(git merge-base origin/master HEAD)`, plus new
|
||||
files from `git ls-files --others --exclude-standard`.
|
||||
If it removed requires and these modules were part of a named cluster on master, the cluster split: name the new
|
||||
half. If it added a require between these modules, it created a new cycle: help break it instead, and stop here.
|
||||
|
||||
## 3. Learn what the cluster does
|
||||
|
||||
A module's namespaces are `metabase.<module>.*`, with dashes as underscores in the path, so `cloud-migration` lives
|
||||
in `src/metabase/cloud_migration/`, and `enterprise/foo` in `enterprise/backend/src/metabase_enterprise/foo/`. Some
|
||||
modules declare an `:ns-prefix` in `.clj-kondo/config/modules/config.edn` instead: `core.cmd` lives in
|
||||
`src/metabase/cmd/`, and `mcp.oauth-server` in `src/metabase/oauth_server/`.
|
||||
|
||||
`core.clj` is often a bare facade. The `ns` docstrings of `api.clj`, `models/` and any `README.md` usually say more,
|
||||
and so do the requires that close the cycle: they show what each module needs from the others.
|
||||
|
||||
## 4. Suggest names
|
||||
|
||||
Offer three to five names, each with one line on why it fits. A good name:
|
||||
|
||||
- comes from space: astronomy, spaceflight, or science fiction about space;
|
||||
- has some link to what the cluster does, its role in the codebase, or how it came to be;
|
||||
- is a lowercase kebab-case simple symbol, not already in the file;
|
||||
- stays true as the cluster changes, so avoid sizes and counts (`binary-star` stops fitting when a third module joins).
|
||||
|
||||
The current names show the range:
|
||||
|
||||
| Name | Anchor | Why it fits |
|
||||
|---|---|---|
|
||||
| `galactic-center` | `query-processor` | The dense core everything orbits |
|
||||
| `foundation` | `app-db` | The base layer, and Asimov's Foundation |
|
||||
| `tardis` | `lib` | A plain box from the outside; internally complex, and it can take you places |
|
||||
| `stargate` | `api-routes` | The gateway every request comes through |
|
||||
| `first-contact` | `mcp` | The protocol outside agents use to talk to Metabase |
|
||||
| `deep-thought` | `metabot` | The computer built to answer the big question |
|
||||
|
||||
The `<anchor>-knot` placeholder from the test failure is a valid fallback when nothing better comes up.
|
||||
|
||||
## 5. Pick the anchor
|
||||
|
||||
The anchor can be any module in the cluster, and the name follows it. Prefer one that is central and unlikely to
|
||||
leave the cluster or be renamed. The test proposes the member with the most requires to and from the rest of the
|
||||
cluster, ties broken alphabetically; in a small cluster everything often ties, and then the proposal carries no
|
||||
signal.
|
||||
|
||||
## 6. Record it
|
||||
|
||||
Add `<name> <anchor>` to the map in `.clj-kondo/config/modules/cycle-clusters.edn`, keeping the entries sorted and
|
||||
the anchors aligned one space past the longest name, then run:
|
||||
|
||||
```bash
|
||||
./bin/test-agent :only '[metabase.core.module-cycles-test]'
|
||||
```
|
||||
|
||||
Mention the new name in the PR, so reviewers see the split.
|
||||
@@ -1,87 +0,0 @@
|
||||
---
|
||||
name: playwright-mcp-metabase
|
||||
description: Drive Metabase's UI with the Playwright MCP browser tools (mcp__playwright__browser_*). Covers the snapshot/act/check pattern, Mantine component pitfalls (Menu race, Select/MultiSelect, the Escape-closes-modal trap, portal scoping), and Metabase-specific login flows. Use whenever a session needs to interact with the Metabase UI through Playwright MCP.
|
||||
---
|
||||
|
||||
# Browser Automation with Playwright MCP (Metabase)
|
||||
|
||||
A Playwright MCP server is configured in `.mcp.json`. Load the tool schemas first with `ToolSearch`: `select:mcp__playwright__browser_navigate,mcp__playwright__browser_snapshot,mcp__playwright__browser_click,mcp__playwright__browser_fill,mcp__playwright__browser_type,mcp__playwright__browser_press_key,mcp__playwright__browser_hover,mcp__playwright__browser_take_screenshot,mcp__playwright__browser_close`
|
||||
|
||||
If ToolSearch says "MCP servers still connecting," wait a few seconds and retry — the server takes a moment to start on first use.
|
||||
|
||||
## Core pattern: Snapshot → Act → Check
|
||||
|
||||
1. **`browser_snapshot`** — see what's on screen, get element refs
|
||||
2. **Act** — `browser_click`, `browser_fill`, `browser_type`, etc. using refs from the snapshot
|
||||
3. **Check the inline response** — every action returns a snapshot in its response. Read it.
|
||||
|
||||
**When to take a separate `browser_snapshot` after acting:**
|
||||
- The inline response snapshot looks wrong, empty, or unchanged — take a fresh one (async rendering may not have completed)
|
||||
- You need refs for your NEXT action — the inline snapshot's refs are valid, use them directly
|
||||
- You're unsure if the action worked — take one more snapshot to confirm
|
||||
|
||||
**When you do NOT need a separate snapshot after acting:**
|
||||
- The inline response already shows the expected change (e.g., you clicked a link and the response shows the new page)
|
||||
- You're about to take a screenshot anyway (`browser_take_screenshot` shows the current state)
|
||||
- You're doing a chain of actions on the same form (e.g., filling multiple fields) — snapshot once at the end, not after each fill
|
||||
|
||||
**Element refs go stale after every action.** Use refs from the most recent snapshot or inline response — never from an earlier one.
|
||||
|
||||
## How to interact with Metabase's UI components
|
||||
|
||||
Metabase uses Mantine UI components. Most interactions work with a plain `browser_click`. The hover-before-click pattern is only needed for specific component types.
|
||||
|
||||
**Regular buttons, links, form inputs, checkboxes, tabs:**
|
||||
Just `browser_click` them directly. No hover needed.
|
||||
|
||||
**Buttons that open dropdown menus (e.g., "+ New", "..." action menus, filter type pickers):**
|
||||
These use Mantine's `<Menu>` component which has a race condition with direct clicks. **Hover before clicking** these:
|
||||
1. `browser_hover` on the button
|
||||
2. `browser_click` on the button
|
||||
3. Check the inline response — if the menu appeared, use its refs directly
|
||||
|
||||
How to tell if a button opens a dropdown menu: it usually has a chevron/arrow icon, a "..." label, or is labeled as creating something new (like "+ New"). If unsure, try a direct click first — if it doesn't work, retry with hover.
|
||||
|
||||
**Select and dropdown components (e.g., database picker, column picker):**
|
||||
Mantine Select/MultiSelect are NOT native `<select>` elements. `browser_select_option` will NOT work. Instead:
|
||||
1. `browser_click` on the input/trigger
|
||||
2. `browser_click` on the option you want (use refs from the inline response)
|
||||
|
||||
You can also type into the input to filter options before clicking.
|
||||
|
||||
**Modals and dialogs:**
|
||||
To dismiss: `browser_click` the close/action button, or `browser_press_key` with `Escape`.
|
||||
|
||||
**⚠️ Mantine `Escape`-inside-modal trap:** Pressing `Escape` while focus is inside a Mantine popover/Select/MultiSelect that is *itself* nested in a modal closes the **entire modal** and discards every form field you've already filled. To dismiss just an open dropdown, prefer `browser_click` outside the dropdown (or click another field). Only press `Escape` when you actually want the whole modal gone.
|
||||
|
||||
**Mantine portals + scoped snapshots:** Mantine renders dropdowns, popovers, and tooltips into a portal at `<body>` level — they are NOT children of the `[role=dialog]` element. If you scope `browser_snapshot` to the modal, dropdown options will be missing. Take a full-page snapshot when interacting with portaled content.
|
||||
|
||||
## Login
|
||||
|
||||
1. `browser_navigate` to `http://localhost:$MB_JETTY_PORT/auth/login`
|
||||
2. `browser_snapshot` → `browser_fill` email and password → `browser_click` sign-in button
|
||||
3. **`browser_navigate` to `http://localhost:$MB_JETTY_PORT/`** — always navigate explicitly to the home page after login. Do NOT rely on the login redirect alone. The redirect can leave the browser session in a state where clicks don't register.
|
||||
4. `browser_snapshot` to confirm you're logged in
|
||||
|
||||
## Quick login via API (alternative)
|
||||
|
||||
Get a session token via API and set it as a cookie — faster than filling the login form:
|
||||
```bash
|
||||
./bin/mage -bot-api-call /api/session --method POST --body '{"username":"<email>","password":"<password>"}'
|
||||
```
|
||||
Extract the `id` from the response, then use `browser_evaluate` with script:
|
||||
```javascript
|
||||
document.cookie = 'metabase.SESSION=<token>;path=/'
|
||||
```
|
||||
Then `browser_navigate` to the target page. Use the credentials from `./bin/mage -bot-server-info`.
|
||||
|
||||
## When clicks don't work
|
||||
|
||||
1. Take a fresh snapshot (async rendering). 2. Try hover + click (menu triggers). 3. Try keyboard (focus + Enter). 4. After 3 attempts, report and move on.
|
||||
|
||||
## Rules
|
||||
|
||||
- Always use `http://localhost:$MB_JETTY_PORT`
|
||||
- Close the browser when done
|
||||
- Use inline response snapshots when sufficient — only take separate `browser_snapshot` when needed
|
||||
- Browser is pre-configured: 1440x900 viewport, full snapshot mode, isolated session, 10s action timeout
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
name: serdes-workflow
|
||||
description: Export content from a running Metabase instance, validate with checkers, edit YAML, and import back. Use when the user wants to export, import, or run the full serdes round-trip workflow.
|
||||
---
|
||||
|
||||
# Serdes Workflow Skill
|
||||
|
||||
End-to-end workflow for exporting Metabase content as YAML, validating it, editing it, and importing it back.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
All commands require the `:ee` alias since export, import, and checkers live in enterprise code.
|
||||
|
||||
You need `MB_DB_CONNECTION_URI` set to point at the target Metabase Postgres database when importing. The running dev instance's connection string is in the worktree environment (check the nREPL or startup output).
|
||||
|
||||
## Export
|
||||
|
||||
```bash
|
||||
clojure -M:run:ee export /path/to/export-dir
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `-c 123,456` - export only specific collection IDs
|
||||
- `-f` - include field values
|
||||
- `-e` - continue on error
|
||||
|
||||
This writes YAML files in the serdes directory layout:
|
||||
```
|
||||
databases/<db-name>/<db-name>.yaml
|
||||
databases/<db-name>/schemas/<schema>/tables/<table>/<table>.yaml
|
||||
databases/<db-name>/schemas/<schema>/tables/<table>/fields/<field>.yaml
|
||||
collections/<eid>_<slug>/cards/<eid>_<slug>.yaml
|
||||
```
|
||||
|
||||
## Validate
|
||||
|
||||
Always run both checkers after any modification. They skip full Metabase startup (~8s each).
|
||||
|
||||
### Structural checker (fast, schema-based)
|
||||
```bash
|
||||
clojure -M:run:ee --mode checker --checker structural --export /path/to/export-dir
|
||||
```
|
||||
Validates YAML shapes against Malli schemas. Catches typos, missing required fields, wrong types.
|
||||
|
||||
### Cards checker (query validation)
|
||||
```bash
|
||||
clojure -M:run:ee --mode checker --checker cards --export /path/to/export-dir
|
||||
```
|
||||
Validates that card queries resolve correctly against exported metadata (tables, fields, FKs). Uses Lib under the hood.
|
||||
|
||||
Additional cards checker flags:
|
||||
- `--lenient` - fabricate metadata on demand (when no database schema files exist)
|
||||
- `--manifest /path/to/manifest.yaml` - write manifest of all referenced entities (lenient mode only)
|
||||
- `--output /path/to/results.edn` - write detailed results to file
|
||||
|
||||
## Edit YAML
|
||||
|
||||
See the **serdes-yaml-edit** skill for guidance on editing YAML files. The critical rule: **run both checkers after every edit before proceeding**.
|
||||
|
||||
## Import
|
||||
|
||||
```bash
|
||||
MB_DB_CONNECTION_URI="postgres://user:pass@localhost:5432/metabase_dbname" \
|
||||
clojure -M:run:ee import /path/to/export-dir -e
|
||||
```
|
||||
|
||||
The `-e` flag continues on error. The `MB_DB_CONNECTION_URI` must point at the same Postgres database the running Metabase instance uses - without it, import defaults to H2 which is wrong.
|
||||
|
||||
## Full Demo Sequence
|
||||
|
||||
1. Export: `clojure -M:run:ee export /tmp/metabase-export`
|
||||
2. Validate baseline: run both checkers
|
||||
3. Edit YAML files (see serdes-yaml-edit skill)
|
||||
4. Re-validate: run both checkers again
|
||||
5. Import: `MB_DB_CONNECTION_URI=... clojure -M:run:ee import /tmp/metabase-export -e`
|
||||
6. Verify in browser
|
||||
@@ -1,243 +0,0 @@
|
||||
---
|
||||
name: serdes-yaml-edit
|
||||
description: Edit Metabase serdes YAML files (cards, dashboards, databases) with correct portable references and structural conventions. Use when modifying exported YAML content.
|
||||
---
|
||||
|
||||
# Serdes YAML Edit Skill
|
||||
|
||||
## Golden Rule
|
||||
|
||||
**Run both checkers after every edit.** No exceptions. Do not batch multiple edits before validating.
|
||||
|
||||
```bash
|
||||
clojure -M:run:ee --mode checker --checker structural --export /path/to/export-dir
|
||||
clojure -M:run:ee --mode checker --checker cards --export /path/to/export-dir
|
||||
```
|
||||
|
||||
If either checker fails, fix the issue before making further edits.
|
||||
|
||||
## Portable References
|
||||
|
||||
Serdes YAML uses portable references instead of integer IDs. This is the most important concept for editing.
|
||||
|
||||
### Database references
|
||||
String name: `"Sample Database"`
|
||||
|
||||
### Table references
|
||||
Array of `[database, schema, table]`:
|
||||
```yaml
|
||||
table_id:
|
||||
- Sample Database
|
||||
- PUBLIC
|
||||
- ACCOUNTS
|
||||
```
|
||||
|
||||
### Field references
|
||||
Array of `[database, schema, table, field]`:
|
||||
```yaml
|
||||
id:
|
||||
- Sample Database
|
||||
- PUBLIC
|
||||
- ACCOUNTS
|
||||
- EMAIL
|
||||
```
|
||||
|
||||
### Field refs in queries
|
||||
```yaml
|
||||
field_ref:
|
||||
- field
|
||||
- - Sample Database
|
||||
- PUBLIC
|
||||
- ACCOUNTS
|
||||
- EMAIL
|
||||
- null
|
||||
```
|
||||
The outer array is `[field, <field-path>, <options>]`. The options are usually `null`.
|
||||
|
||||
### Card references
|
||||
Entity ID string (21 characters): `"Qk5TgsNx4ubXIUtsQmT8G"`
|
||||
|
||||
### Source table in queries
|
||||
```yaml
|
||||
dataset_query:
|
||||
database: Sample Database
|
||||
query:
|
||||
source-table:
|
||||
- Sample Database
|
||||
- PUBLIC
|
||||
- ACCOUNTS
|
||||
type: query
|
||||
```
|
||||
|
||||
## Safe Edits (low risk)
|
||||
|
||||
These rarely break validation:
|
||||
- `name` - card/dashboard display name
|
||||
- `description` - card/dashboard description
|
||||
- `display` - visualization type (table, bar, line, pie, etc.)
|
||||
- `visualization_settings` - chart configuration
|
||||
- `archived` - true/false
|
||||
- `collection_id` - move to a different collection (use entity_id of target collection, or null for root)
|
||||
|
||||
## Structural Edits (must match schema)
|
||||
|
||||
These must use valid portable refs and will be caught by the cards checker if wrong:
|
||||
- `dataset_query` - the query definition
|
||||
- `result_metadata` - column metadata (must match the query's output columns)
|
||||
- `table_id` - must reference a table that exists in the export
|
||||
- `database_id` - must reference a database that exists in the export
|
||||
|
||||
## Editing result_metadata
|
||||
|
||||
Each entry in `result_metadata` describes an output column. When changing a query's source table or fields, you must update result_metadata to match. Each field entry needs at minimum:
|
||||
- `name` - column name (e.g., `EMAIL`)
|
||||
- `base_type` - Metabase type (e.g., `type/Text`, `type/Integer`, `type/DateTime`)
|
||||
- `display_name` - human-readable name
|
||||
- `field_ref` - portable field reference
|
||||
- `id` - portable field path
|
||||
- `table_id` - portable table path
|
||||
- `source` - usually `fields`
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Rename a card
|
||||
Change `name:` at the top level. Safe, no ref changes needed.
|
||||
|
||||
### Change a card's source table
|
||||
Update all of these consistently:
|
||||
1. `table_id` - top-level
|
||||
2. `dataset_query.query.source-table` - in the query
|
||||
3. `result_metadata` - every field entry's `id`, `field_ref`, and `table_id`
|
||||
|
||||
### Add a filter to a structured query
|
||||
```yaml
|
||||
dataset_query:
|
||||
database: Sample Database
|
||||
query:
|
||||
source-table:
|
||||
- Sample Database
|
||||
- PUBLIC
|
||||
- ORDERS
|
||||
filter:
|
||||
- ">"
|
||||
- - field
|
||||
- - Sample Database
|
||||
- PUBLIC
|
||||
- ORDERS
|
||||
- TOTAL
|
||||
- null
|
||||
- 100
|
||||
type: query
|
||||
```
|
||||
|
||||
### Change visualization type
|
||||
```yaml
|
||||
display: bar # was: table
|
||||
```
|
||||
Valid types: `table`, `bar`, `line`, `pie`, `scalar`, `row`, `area`, `combo`, `scatter`, `funnel`, `map`, `pivot`, `progress`, `gauge`, `waterfall`
|
||||
|
||||
## Looking Up Valid References
|
||||
|
||||
The export directory IS the reference catalog. When you need to find the correct name for a database, table, or field, look it up directly from the export.
|
||||
|
||||
### List valid databases
|
||||
```bash
|
||||
ls databases/
|
||||
```
|
||||
Each entry is a database name (directory for serdes format, .yaml for concise format).
|
||||
|
||||
### List valid tables for a database
|
||||
```bash
|
||||
ls databases/<db-name>/schemas/<schema>/tables/
|
||||
```
|
||||
Example: `ls databases/Sample\ Database/schemas/PUBLIC/tables/` shows `ACCOUNTS`, `ORDERS`, `PRODUCTS`, etc.
|
||||
|
||||
### List valid fields for a table
|
||||
```bash
|
||||
ls databases/<db-name>/schemas/<schema>/tables/<table>/fields/
|
||||
```
|
||||
Example: `ls databases/Sample\ Database/schemas/PUBLIC/tables/PRODUCTS/fields/` shows `CATEGORY.yaml`, `TITLE.yaml`, `PRICE.yaml`, etc. The filename (minus `.yaml`) is the field name.
|
||||
|
||||
### Find card entity IDs
|
||||
Card filenames encode the entity ID: `<entity-id>_<slug>.yaml`. The entity ID is the part before the first underscore (21 characters).
|
||||
|
||||
## Self-Healing Loop
|
||||
|
||||
When a checker reports errors, follow this loop:
|
||||
|
||||
1. **Read the error message** - it tells you what's wrong and often suggests the fix
|
||||
2. **Look up the correct value** from the export directory (see "Looking Up Valid References" above)
|
||||
3. **Fix the YAML**
|
||||
4. **Re-run both checkers**
|
||||
5. **Repeat until clean**
|
||||
|
||||
Do not guess at fixes. Always look up the correct value from the export.
|
||||
|
||||
## Understanding Checker Errors
|
||||
|
||||
### Structural checker errors
|
||||
|
||||
The structural checker validates YAML shape against Malli schemas. Common errors:
|
||||
|
||||
**Missing required key with typo suggestion:**
|
||||
```
|
||||
Missing required key 'name' - found 'nameee' which may be a typo
|
||||
```
|
||||
Fix: rename the typo'd key back to the correct name. The checker tells you what it expected and what it found.
|
||||
|
||||
**Wrong type:**
|
||||
```
|
||||
'archived' should be a boolean, got: "yes"
|
||||
```
|
||||
Fix: use `true`/`false`, not strings.
|
||||
|
||||
**Unknown key:**
|
||||
```
|
||||
Unknown key 'foobar' in card
|
||||
```
|
||||
Fix: remove the key, or check if it's a typo of a known key.
|
||||
|
||||
### Cards checker errors
|
||||
|
||||
The cards checker validates that queries resolve against exported metadata. Error types:
|
||||
|
||||
**UNRESOLVED REFERENCES:**
|
||||
```
|
||||
UNRESOLVED REFERENCES:
|
||||
- field: Sample Database.PUBLIC.PRODUCTS.CATEGORYYY
|
||||
```
|
||||
The dotted path tells you exactly which reference failed. Look up the correct value:
|
||||
- Last segment is the field name - check `databases/.../tables/<table>/fields/`
|
||||
- Third segment is the table name - check `databases/.../schemas/<schema>/tables/`
|
||||
- First segment is the database name - check `databases/`
|
||||
|
||||
**ERROR (query construction failed):**
|
||||
```
|
||||
ERROR: Error creating query from legacy query: Invalid output: ...
|
||||
```
|
||||
Usually means the query structure is malformed. This often follows unresolved references - fix the refs first, then re-check.
|
||||
|
||||
**ERROR (nil name):**
|
||||
```
|
||||
ERROR: Invalid output: {:name ["should be a string, got: nil"]}
|
||||
```
|
||||
The card is missing its `name` field (likely a structural issue that also affects the cards checker).
|
||||
|
||||
### Exit codes
|
||||
|
||||
- Exit 0: all checks passed
|
||||
- Exit 1: one or more failures
|
||||
|
||||
## File Naming Convention
|
||||
|
||||
Card files: `collections/<collection-path>/cards/<entity-id>_<slug>.yaml`
|
||||
|
||||
The filename slug should match the card name (lowercase, underscored). If you rename a card, consider renaming the file to match, though import works on entity_id not filename.
|
||||
|
||||
## What NOT to Edit
|
||||
|
||||
- `entity_id` - this is the identity of the object, changing it creates a new object on import
|
||||
- `serdes/meta` - internal serdes metadata, leave it alone
|
||||
- `created_at` - timestamp, no reason to change
|
||||
- `creator_id` - email reference, leave it
|
||||
- `metabase_version` - informational, leave it
|
||||
@@ -1,34 +1,20 @@
|
||||
---
|
||||
name: typescript-review
|
||||
description: Review TypeScript and JavaScript code changes for compliance with Metabase coding standards, style violations, and code quality issues. Use when reviewing pull requests or diffs containing TypeScript/JavaScript code.
|
||||
allowed-tools: Read, Grep, Bash, Glob, Skill
|
||||
allowed-tools: Read, Grep, Bash, Glob
|
||||
---
|
||||
|
||||
# TypeScript/JavaScript Code Review Skill
|
||||
|
||||
@./../_shared/typescript-commands.md
|
||||
@./../_shared/react-redux-patterns.md
|
||||
|
||||
## Main Focus
|
||||
## Code Review Guidelines
|
||||
|
||||
**Primary standard: the [`typescript-write`](../typescript-write/SKILL.md) skill.** Load it first — it defines the authoring rules this review enforces, alongside `frontend/CLAUDE.md` and `docs/developers-guide/frontend.md`.
|
||||
Review pull requests with a focus on:
|
||||
|
||||
Adherence to `typescript-write` is the **highest-priority** review dimension: rank any violation of its provisions above all other findings. Treat its **no-`any` hard rule** (no explicit *or* implicit `any` in new code) as **blocking**. Use TypeScript LSP tools to inspect inferred types when available; otherwise rely on type-checking and linting.
|
||||
|
||||
Review in this priority order:
|
||||
|
||||
1. **Violations of [`typescript-write`](../typescript-write/SKILL.md) provisions** — no-`any`, type tightening, type modeling, function signatures, null/undefined handling, naming, structure, comments. Highest priority; block on the no-`any` rule. Apply conditional guidance in context: explain the unsupported type guarantee or concrete readability problem, rather than treating every preference as a blanket ban.
|
||||
2. Compliance with `frontend/CLAUDE.md`.
|
||||
3. Readability and maintainability.
|
||||
4. Appropriate test coverage. For internal typed callers, avoid requesting tests solely for inputs the type system excludes. External API data, deserialised values, storage and JavaScript callers can violate annotations: test runtime validation and nontrivial assumptions at those boundaries. Types do not replace behavioural, security or data-integrity tests.
|
||||
|
||||
## Blind spots — act as the missing reviewer
|
||||
|
||||
These rarely surface in team reviews, so this skill should raise them. They are **additive** — raise them, but rank them below `typescript-write` violations:
|
||||
|
||||
- **Accessibility.** Interactive elements need keyboard support, focus management, and accessible names. Flag missing `aria-label`/`aria-labelledby`, non-semantic click targets, modals without focus trap, icon-only buttons without labels, and form inputs without a linked label.
|
||||
- **Performance.** Flag areas that scale poorly and aren't memoized; inline object/array literals passed to memoized children; effects that fire on every batch of a progressive load; and new dependencies added to hot paths.
|
||||
- **Security.** Evaluate potential security issues in new code.
|
||||
- **Bundle size.** Flag new large dependencies, default imports from icon or util libs, and heavy modules imported at route-load time.
|
||||
- **Analytics.** User-facing flows should emit tracking events. If a PR adds a new flow (button, modal, navigation) without a tracking event, ask whether one is expected.
|
||||
- **Public API surface** (embedding SDK). Consumers should be able to use public signatures and name types they need to import. Export those types deliberately and document public behaviour, including `@deprecated` for deprecated APIs; a referenced structural type does not automatically need its own named export.
|
||||
- Readability and maintainability
|
||||
- Appropriate test coverage
|
||||
- Compliance with project coding standards and conventions
|
||||
- Code quality and best practices
|
||||
- Type safety and proper TypeScript usage
|
||||
- React best practices (when applicable)
|
||||
|
||||
@@ -7,91 +7,3 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
|
||||
|
||||
@./../_shared/development-workflow.md
|
||||
@./../_shared/typescript-commands.md
|
||||
@./../_shared/react-redux-patterns.md
|
||||
|
||||
## No `any` — hard rule
|
||||
|
||||
- **New code must not introduce `any`, explicit or implicit.** No `any` annotations, no `as any` / `as unknown as`, no untyped parameters or returns that infer `any`, no implicitly-`any` destructures or array/object literals.
|
||||
- **Untyped third-party / boundary values** must be typed at the boundary (a declared type, `unknown` + type guard, or a small typed wrapper) — never let `any` propagate inward.
|
||||
- **Mandatory type verification.** Before finishing a TS/TSX change, run `bun run type-check-pure`. If TypeScript LSP tools are available, also inspect changed symbols with hover and go-to-definition; otherwise skip LSP check.
|
||||
|
||||
## Type tightening
|
||||
|
||||
- **Avoid type casts and loose `unknown`** — fix the signature instead.
|
||||
- **If a function only needs one field of a wide object, accept that field** — not the wide object. The cast often disappears once the signature is right.
|
||||
- **Reach for `Partial<T>`, `Pick<T, K>`, `Record<K, V>`, and generics** before reaching for a cast.
|
||||
- **Match dictionary keys to the data.** Use a finite key union when the keys are known. Open dictionaries can use an index signature, `Record<string, T>`, or `Map`; index signatures still constrain values, and changing their spelling to `Record<string, T>` does not make missing-key access safe.
|
||||
- **Prefer making props/components generic** (`<T>`) when a value flows through unchanged and the caller knows the type.
|
||||
- **Prefer `unknown` over loose typing** and narrow before use — an `unknown` value forces a guard at the point of use.
|
||||
- **`satisfies` for object literals** that must conform without widening (config objects, lookup maps, discriminated literals) — better than `: T` (widens) or `as T` (unsafe).
|
||||
- **Avoid non-null assertions (`!`)**. Prefer a guard, early return, or `?.`. Use `!` only when non-nullness is provably true and localized, with a comment.
|
||||
- **Guard indexed lookups that may miss.** Arrays and dictionaries can return `undefined` even when the inferred type omits it. Use an iteration form that preserves the key/value relationship, and only assert `keyof` when the runtime keys are known to belong to the declared type.
|
||||
- **No redundant runtime coercion** — don't wrap already-typed values in `Number()` / `String()` / `Boolean()`.
|
||||
- **Type guards belong in `frontend/src/metabase-types/guards/`**. Do not redefine them locally.
|
||||
- **A cast you can't avoid needs a real justification comment.** The `metabase/no-unjustified-type-casts` rule accepts any preceding comment — state the actual reason the cast is safe. NEVER write the legacy `// Unjustified type cast. FIXME` placeholder; it exists only on casts that predated the rule, and copying it sneaks an unjustified cast past the linter. If you can't articulate why the cast is correct, the cast is wrong — fix the types.
|
||||
- **Keep unavoidable assertions local.** Isolate a repeated or complex assertion behind a helper when that makes its invariant easier to enforce. Test nontrivial runtime assumptions that justify it; a trivial assertion does not automatically need a new helper or test. Do not weaken a public signature just to silence implementation errors.
|
||||
|
||||
## Type modeling
|
||||
|
||||
- **Reuse existing types; don't re-declare them.** Use canonical IDs and domain entity types from `metabase-types/api` (`FieldId`, `TableId`, `ConcreteTableId`, `SchemaName`, …) and key data structures by them (`new Map<ConcreteTableId, …>()`). Don't duplicate generated/API types — compose or derive (`Pick`, `Omit`, indexed access `SomeType["field"]`, `ReturnType`).
|
||||
- **Generics must make promises the implementation can keep.** A caller-selected `get<T>(): T` must not disguise an unchecked assertion about external data. Return `unknown` and validate, or accept a validator that establishes `T`. A factory such as `function empty<T>(): T[] { return []; }` is valid; judge the implementation, not how often `T` appears in the signature.
|
||||
- **Prefer an honest type over false precision.** Use generics when they express a real relationship. If a complex type cannot model the behaviour accurately, choose a simpler type or `unknown` with narrowing instead of asserting an unsupported guarantee.
|
||||
- **Model the actual data contract; keep types narrow.** Optional `field?: T` for a key that may be absent, `field: T | undefined` only when the key is always present but the value may be undefined, `| null` for explicit API nulls. Prefer domain unions over broad `string` / `number` / loose `Record`.
|
||||
- **Refer to API implementation** when defining or refining types to ensure they match the actual data structure. When considering a type cast, first consider if the type should be refined to match the actual data structure.
|
||||
- **Optionality must reflect absence.** Keep required fields required and optional fields optional. Normalise input when the application has a meaningful default, not merely to remove a type error. Preserve the actual wire shape in API types.
|
||||
- **Represent related absence together when modelling internal state.** If several fields exist or disappear together, consider a nullable containing object or a discriminated union. Do not reshape a raw API declaration unless the data actually has that shape.
|
||||
- **Prefer explicit special states when designing a format.** Use nullability or named union variants when sentinel values such as `-1` hide meaning. Preserve established protocol sentinel values unless the behaviour is deliberately changed, or normalise them at an explicit boundary.
|
||||
- **Discriminated unions for variant state, with exhaustive checks.** Model "one of N shapes" as a union with a literal discriminant rather than a bag of optional fields, and exhaust it with ts-pattern's `.exhaustive()` so adding a variant becomes a compile error:
|
||||
```ts
|
||||
import { match } from "ts-pattern";
|
||||
|
||||
const result = match(status)
|
||||
.with({ type: "loading" }, () => <Spinner />)
|
||||
.with({ type: "error", error: P.select() }, (error) => <Error message={error.message} />)
|
||||
.with({ type: "success", data: P.select() }, (data) => <Content data={data} />)
|
||||
.exhaustive(); // Compile-time guarantee all cases handled
|
||||
```
|
||||
- **Derive union types from constants** (`as const` + `typeof`/`keyof`) so the type and the values can't drift.
|
||||
- **`readonly` / immutability where mutation isn't intended** — component props, shared constants, exported config, and unmutated parameters. Prefer `readonly T[]` / `ReadonlyArray<T>` for inputs you don't mutate. Functions that mutate caller-owned data should make that behaviour explicit.
|
||||
- **Construct complete, well-typed objects where practical.** Prefer an object expression when it avoids partially initialised state or assertions. Incremental construction is fine when it is clearer and maintains the type's invariants.
|
||||
- **Treat `metabase-lib` opaque types as black boxes.** Use `metabase-lib` functions to work with types such as `Lib.Query`, rather than casting into their internal representation.
|
||||
- **Type async and error states explicitly** (a discriminated union or the data-layer's typed result) — never leave loading/error/empty implicit.
|
||||
|
||||
## Function signatures
|
||||
|
||||
- **Make public contracts explicit where it improves stability and clarity.** Annotate parameters and return types at shared boundaries when useful; let local values infer. Use `satisfies` or an annotation when a declaration needs an explicit shape check.
|
||||
- **Accept the inputs the operation supports and return the most precise honest result.** Narrow avoidable uncertainty inside the function, but preserve meaningful nullability and union variants in its return type.
|
||||
- **Use named options when positional arguments are easy to confuse.** Consecutive arguments with the same type are a useful warning sign, not an automatic requirement to rewrite a clear API.
|
||||
- **Use `async`/`await` when it clarifies control flow or error handling.** Returning an existing promise directly is also valid; a Promise return type alone does not require adding `async`.
|
||||
|
||||
## Null and undefined
|
||||
|
||||
- **Narrow at the source**. If a value is optional only in a corner case, don't thread `undefined` through every layer — guard at the producer.
|
||||
- **Sensible defaults for optional values**. Use `?.` and `??` at the consumer.
|
||||
- **Narrow nullable list elements when the operation requires present values.** Filter with a type guard when missing entries should be discarded; preserve them when their absence or position carries meaning.
|
||||
- **Avoid non-strict null comparisons** (`X != null`) when `X` can never be `null` — use a strict check or narrow the type. Use `checkNotNull` where necessary.
|
||||
- **Check actual nullability against API implementation**. Find the API endpoint implementation and check if the field can actually be null.
|
||||
|
||||
## Naming
|
||||
|
||||
- **Names describe the entity, not the mechanism**. A name must reflect what the value holds.
|
||||
- **Use domain vocabulary and include units where useful** (`timeoutMs`, `widthPx`, `temperatureC`). Prefer a more specific name when `Info`, `Data`, or `Entity` obscures the meaning, while keeping established terminology when it is clear.
|
||||
- **Align sibling concepts**: keep verb conventions consistent across a related API.
|
||||
- **No names that encode implementation history** rather than current meaning. Suffixes like `Base`, `New`, `Old`, `Initial` need a real semantic distinction, otherwise drop them.
|
||||
- **Avoid cryptic identifiers** (`v`, `n`, `$n`) for domain values; short names are fine only in tiny conventional contexts (loop index `i`, coordinates `x`/`y`, generic params `T`/`K`/`V`).
|
||||
|
||||
## Code structure and organization
|
||||
|
||||
- **Prioritize reusability over duplication**. The codebase already has many utility functions — leverage them. If you introduce duplicated logic, extract it to a shared utility.
|
||||
- **Generic helpers do not belong in feature folders**. Promote to a shared utility.
|
||||
- **Keep functions small and single-purpose**. A 100+ line function is hard to review — split into focused named helpers, each with one responsibility and a minimal dependency surface. When necessary, cover with unit tests.
|
||||
- **Extract distinct complex JSX into named components**. Choose same-file vs separate-file by reuse, coupling, testability, and readability.
|
||||
|
||||
## Comments
|
||||
|
||||
- **No comments by default**. Well-named identifiers carry the `what`.
|
||||
- **Comments should be concise**. Add a short, concise comment only when the `why` is non-obvious: a workaround, a hidden invariant, a subtle ordering constraint, a clever reduction. Never document the actual implementation, focus on the intent and the why.
|
||||
|
||||
## Verify before done
|
||||
|
||||
- **Run the project type-check** when finished (see the shared TypeScript commands above).
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
:aliased-namespace-symbol {:level :warning}
|
||||
:case-symbol-test {:level :warning}
|
||||
:condition-always-true {:level :warning}
|
||||
:def-fn {:level :warning}
|
||||
:dynamic-var-not-earmuffed {:level :warning}
|
||||
:equals-expected-position {:level :warning, :only-in-test-assertion true}
|
||||
@@ -38,7 +39,7 @@
|
||||
;;
|
||||
|
||||
;; TODO (cam): most of these either have a ton of things that need to be fixed or cause issues
|
||||
;; with [[metabase.util.match/match]] and friends -- we need to write a custom hook for those macros so Kondo
|
||||
;; with [[metabase.lib.util.match/match]] and friends -- we need to write a custom hook for those macros so Kondo
|
||||
;; can understand them.
|
||||
|
||||
#_:docstring-leading-trailing-whitespace
|
||||
@@ -49,11 +50,8 @@
|
||||
;; disabled linters
|
||||
;;
|
||||
|
||||
:constant-condition {:level :off} ; TODO (sashko): too confused by defenterprises, some hook/config is needed
|
||||
:redundant-ignore {:level :off} ; lots of false positives, see https://github.com/clj-kondo/clj-kondo/issues/2414
|
||||
:unexpected-recur {:level :off} ; TODO (cam): I think we just need to tell it how to handle MBQL match and we can enable this?
|
||||
:redundant-primitive-coercion {:level :off} ; there are many defensive boolean coercions, kondo looks too much into it
|
||||
:type-mismatch {:level :off} ; Still quite raw, can sometimes give useful insights, but sometimes is just confused
|
||||
|
||||
;;
|
||||
;; globally-enabled custom linters
|
||||
@@ -62,30 +60,19 @@
|
||||
:metabase/check-def-check-not-uppercase-name {:level :warning}
|
||||
:metabase/check-def-no-underscores {:level :warning}
|
||||
:metabase/check-me-humanize {:level :warning}
|
||||
:metabase/check-testing-not-empty {:level :warning}
|
||||
:metabase/defsetting-must-specify-export {:level :warning}
|
||||
:metabase/defsetting-namespace {:level :warning}
|
||||
:metabase/discourage-dynamic-vars {:level :warning}
|
||||
:metabase/discourage-millis-duration {:level :warning}
|
||||
:metabase/mbql-query-first-arg {:level :warning}
|
||||
:metabase/missing-test-expr-requires-in-cljs {:level :warning}
|
||||
:metabase/modules {:level :warning} ; more config in `.clj-kondo/config/modules/config.edn`
|
||||
:metabase/namespace-name {:level :warning}
|
||||
:metabase/no-jsqlparser-imports {:level :warning}
|
||||
:metabase/prefer-with-dynamic-fn-redefs {:level :warning}
|
||||
:metabase/t2-query-namespace {:level :warning}
|
||||
:metabase/table-or-field-query {:level :warning}
|
||||
;; App-DB query hygiene: a value reaching a value slot unmarked, and a query written as
|
||||
;; `:column value` pairs rather than a map. Off by default and enabled per namespace under
|
||||
;; `:config-in-ns`; flip it on globally once every `db.clj` has been converted.
|
||||
:metabase/unsafe-app-db-query {:level :off}
|
||||
:metabase/toucan-model-ns {:level :warning}
|
||||
:metabase/require-shape-checker {:level :warning}
|
||||
:metabase/tests-must-live-in-known-modules {:level :warning}
|
||||
:metabase/validate-defendpoint-route-uses-kebab-case {:level :warning}
|
||||
:metabase/validate-defendpoint-query-params-use-kebab-case {:level :warning}
|
||||
:metabase/validate-logging {:level :warning}
|
||||
:metabase/validate-mb-app-db-migrations-test {:level :warning}
|
||||
:metabase/validate-mb-driver-tests {:level :warning}
|
||||
:metabase/validate-mb-once {:level :warning}
|
||||
|
||||
@@ -188,70 +175,25 @@
|
||||
clojure.core.async/take!
|
||||
clojure.core.async/to-chan!
|
||||
clojure.core.async/to-chan!!
|
||||
;; writes the ratchet files from a :once fixture that finishes before the parallel tests
|
||||
dev.kondo-ratchet/fix!
|
||||
;; builds Babashka's classpath cache from a :once fixture, before the parallel tests start, so that
|
||||
;; they don't each race to create it; the side effect is process-external and idempotent
|
||||
metabase.core.modules-consistency-test/warm-babashka-classpath!
|
||||
metabase.ai-tracing.core/record!
|
||||
metabase.api.macros.defendpoint.tools-manifest/assert-optional-fields-nullable!
|
||||
;; MCP v2 tool tests: call-tool! dispatches a tool with no shared mutable state across tests
|
||||
metabase.mcp.v2.tools.alert-test/call-tool!
|
||||
metabase.mcp.v2.tools.definitions-test/call-tool!
|
||||
metabase.mcp.v2.tools.metric-test/call-tool!
|
||||
;; the `!` is for the throw (a teaching error), not a mutation; no shared mutable state
|
||||
metabase.mcp.v2.tools.browse/validate-args-for-action!
|
||||
metabase.channel.core/send!
|
||||
metabase.channel.render.pdf/draw-too-large!
|
||||
metabase.channel.render.pdf/draw-no-results!
|
||||
metabase.channel.render.pdf/render-card-cell!
|
||||
metabase.channel.render.pdf.font/load-fonts!
|
||||
metabase.warehouses.models.database/assert-not-h2!
|
||||
metabase.driver.sql-jdbc.execute/execute-prepared-statement!
|
||||
;; only inspects a details map and throws; the `!` is for the throw, not for a mutation
|
||||
metabase.driver/validate-db-details!
|
||||
metabase.mcp.usage/record-mcp-session!
|
||||
metabase.mcp.usage/record-mcp-session-end!
|
||||
metabase.mcp.usage/record-mcp-tool-call!
|
||||
metabase.notification.models/create-notification!
|
||||
metabase.oauth-server.core/reset-provider!
|
||||
metabase.pulse.send/send-pulse!
|
||||
metabase-enterprise.transforms-python.python-runner-test/execute!
|
||||
;; MCP usage tests isolate their rows by unique keys (session id / tool name / duration /
|
||||
;; client version) and clean up only those, so these deletes are safe in parallel.
|
||||
metabase-enterprise.mcp.usage-test/cleanup!
|
||||
metabase-enterprise.mcp.usage-test/cleanup-calls!
|
||||
;; Workspace isolation functions are safe in parallel tests because they operate on
|
||||
;; isolated, test-specific workspaces and databases that don't affect other tests
|
||||
metabase-enterprise.workspaces.isolation/destroy-workspace-isolation!
|
||||
metabase.lib-metric.ast.plan/validate-arithmetic-ast!
|
||||
metabase-enterprise.workspaces.isolation/ensure-database-isolation!
|
||||
metabase-enterprise.workspaces.test-util/create-ready-ws!
|
||||
metabase-enterprise.workspaces.core/clear-instance-workspace!
|
||||
metabase-enterprise.workspaces.core/set-instance-workspace!
|
||||
metabase.workspaces.core/clear-instance-workspace!
|
||||
metabase.workspaces.core/set-instance-workspace!
|
||||
;; Safe in tests, where we typically use a private, temporary index per thread.
|
||||
metabase.search.core/reindex!
|
||||
metabase.util/run-count!
|
||||
next.jdbc/execute!
|
||||
;; pure validator: parses a keyword and throws on a bad one, touches no state
|
||||
metabase.mcp.v2.tools.question/check-semantic-type!}}
|
||||
next.jdbc/execute!}}
|
||||
|
||||
:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests
|
||||
{:level :warning
|
||||
:max-length 150}
|
||||
|
||||
;; Warn on long tests (tests over `:min-length`) that look like
|
||||
;;
|
||||
;; (deftest my-test
|
||||
;; (testing ...)
|
||||
;; (testing ...)
|
||||
;; (testing ...))
|
||||
:metabase/validate-deftest-logically-separate-tests
|
||||
{:level :warning
|
||||
:min-length 60}
|
||||
|
||||
;; this is not enabled here, it is enabled below only for specific namespaces.
|
||||
:metabase/disallow-hardcoded-driver-names-in-tests
|
||||
;; don't include `:h2` since that will probably lead to a mountain of tests where we are specifically testing
|
||||
@@ -284,6 +226,10 @@
|
||||
;; misc config for built-in linters
|
||||
;;
|
||||
|
||||
:invalid-arity {:skip-args [metabase.lib.util.match/match
|
||||
metabase.lib.util.match/match-lite
|
||||
metabase.lib.util.match/match-many
|
||||
metabase.lib.util.match/replace-lite]} ; TODO (cam): can we fix these?
|
||||
:refer-all {:level :warning, :exclude [clojure.test]}
|
||||
:unused-referred-var {:exclude {compojure.core [GET DELETE POST PUT]}}
|
||||
|
||||
@@ -299,13 +245,25 @@
|
||||
instaparse.core/transform
|
||||
(metabase.server.streaming-response/streaming-response)
|
||||
(metabase.driver.druid.query-processor-test/druid-query-returning-rows)
|
||||
(metabase.lib.util.match/match)
|
||||
(metabase.lib.util.match/match-one)
|
||||
(metabase.lib.util.match/match-lite)
|
||||
(metabase.lib.util.match/match-many)
|
||||
(metabase.lib.util.match/replace)
|
||||
(metabase.lib.util.match/replace-in)
|
||||
(metabase.lib.util.match/replace-lite)
|
||||
(metabase.driver-api.core/match)
|
||||
(metabase.driver-api.core/match-one)
|
||||
(metabase.driver-api.core/match-lite)
|
||||
(metabase.driver-api.core/match-many)
|
||||
(metabase.driver-api.core/replace)
|
||||
(metabase.driver-api.core/replace-in)
|
||||
(metabase.driver-api.core/replace-lite)
|
||||
(metabase.query-processor.middleware.cache-backend.interface/with-cached-results)
|
||||
(metabase.util.regex/rx [opt])
|
||||
(metabase.util/prog1 [<>])]}
|
||||
(metabase.util/prog1 [<>])
|
||||
(taoensso.nippy/extend-freeze)
|
||||
(taoensso.nippy/extend-thaw)]}
|
||||
|
||||
;; TODO (Cam 8/22/25) -- what's the point of marking stuff deprecated if we don't have Kondo enforce it? We should
|
||||
;; remove all of these and add individual ignores as needed.
|
||||
@@ -318,16 +276,7 @@
|
||||
metabase.query-processor.util/normalize-token {:namespaces ["metabase.*"]}}}
|
||||
|
||||
:discouraged-var
|
||||
{metabase-enterprise.entity-retrieval.core/search-unfiltered {:message "Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
|
||||
metabase.entity-retrieval.core/search-unfiltered {:message "Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
|
||||
metabase.entity-retrieval.mirror/search-unfiltered {:message "Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
|
||||
metabase-enterprise.transform-testing.expectations.equals/->EqualsRows {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
|
||||
metabase-enterprise.transform-testing.expectations.equals/map->EqualsRows {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
|
||||
metabase-enterprise.transform-testing.expectations.equals/->EqualsSql {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
|
||||
metabase-enterprise.transform-testing.expectations.equals/map->EqualsSql {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
|
||||
metabase-enterprise.transform-testing.expectations.empty/->Empty {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
|
||||
metabase-enterprise.transform-testing.expectations.empty/map->Empty {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
|
||||
clojure.core/print {:message "Use metabase.util.log instead of clojure.core/print"}
|
||||
{clojure.core/print {:message "Use metabase.util.log instead of clojure.core/print"}
|
||||
clojure.core/println {:message "Use metabase.util.log instead of clojure.core/println"}
|
||||
clojure.core/printf {:message "Use metabase.util.log instead of clojure.core/printf"}
|
||||
clojure.core/prn {:message "Use metabase.util.log instead of clojure.core/prn"}
|
||||
@@ -361,10 +310,14 @@
|
||||
metabase.lib.convert/->legacy-MBQL {:message "Conversion to legacy MBQL is discouraged, please use Lib to manipulate MBQL queries"}
|
||||
metabase.lib.core/->legacy-MBQL {:message "Conversion to legacy MBQL is discouraged, please use Lib to manipulate MBQL queries"}
|
||||
metabase.lib.equality/find-column-indexes-for-refs {:message "Use lib.equality/closest-matches-in-metadata or lib.equality/find-closest-matches-for-refs instead of lib.equality/find-column-indexes-for-refs"}
|
||||
metabase.lib.util.match/match {:message "Use lib.util.match/match-many instead of lib.util.match/match."}
|
||||
metabase.lib.util.match/match-one {:message "Use lib.util.match/match-lite instead of lib.util.match/match-one."}
|
||||
metabase.lib.util.match/replace {:message "Use lib.util.match/replace-lite instead of lib.util.match/replace."}
|
||||
metabase-enterprise.transforms.interface/execute! {:message "Use metabase-enterprise.transforms.execute/execute! instead of metabase-enterprise.transforms.interface/execute!"}
|
||||
next.jdbc/active-tx? {:message "Use metabase.app-db.core/in-transaction? to see if we are in a transaction"}
|
||||
toucan2.core/debug {:message "Don't commit code using t2/debug"}
|
||||
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}}
|
||||
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}
|
||||
}
|
||||
|
||||
:discouraged-namespace
|
||||
{camel-snake-kebab.core {:message "CSK is not Turkish-safe, use the versions in metabase.util instead."}
|
||||
@@ -393,6 +346,7 @@
|
||||
clj-time.coerce time.coerce
|
||||
clj-time.format time.format
|
||||
clojure.core.async a
|
||||
clojure.core.match clojure.core.match
|
||||
clojure.data data
|
||||
clojure.math.combinatorics math.combo
|
||||
clojure.pprint pprint
|
||||
@@ -424,10 +378,8 @@
|
||||
metabase.analyze.classifiers.core classifiers
|
||||
metabase.channel.core channel
|
||||
metabase.channel.render.core channel.render
|
||||
metabase.channel.render.markdown markdown
|
||||
metabase.cloud-migration.core cloud-migration
|
||||
metabase.eid-translation.core eid-translation
|
||||
metabase.llm.test-util llm.tu
|
||||
metabase.login-history.core login-history
|
||||
metabase.model-persistence.core model-persistence
|
||||
metabase.notification.core notification
|
||||
@@ -461,6 +413,7 @@
|
||||
metabase.util.macros u.macros
|
||||
metabase.util.malli mu
|
||||
metabase.util.malli.schema ms
|
||||
metabase.util.markdown markdown
|
||||
metabase.util.password u.password
|
||||
metabase.util.ui-logic ui-logic
|
||||
metabase.xrays.core xrays
|
||||
@@ -483,22 +436,13 @@
|
||||
instaparse.core/defparser clojure.core/def
|
||||
metabase-enterprise.action-v2.test-util/with-test-tables! clojure.core/let
|
||||
metabase-enterprise.content-translation.routes-test/with-new-secret-key! clojure.core/fn
|
||||
metabase-enterprise.entity-retrieval.reconcile-test/with-isolated-index clojure.core/fn
|
||||
metabase-enterprise.mfa.enrollment-flow-test/with-fresh-user-session! clojure.core/fn
|
||||
metabase-enterprise.mfa.login-flow-test/with-enrolled-rasta! clojure.core/fn
|
||||
metabase-enterprise.mfa.recovery-codes-test/with-confirmed-enrollment! clojure.core/fn
|
||||
metabase-enterprise.serialization.metadata-file-import.wire-format-test/with-tiny-fixture! clojure.core/fn
|
||||
metabase-enterprise.serialization.test-util/with-dbs clojure.core/fn
|
||||
metabase-enterprise.serialization.test-util/with-random-dump-dir clojure.core/let
|
||||
metabase-enterprise.tenants.pulse-api-test/with-tenant-pulse-fixture! clojure.core/fn
|
||||
metabase-enterprise.workspaces.test-util/with-resources! clojure.core/let
|
||||
metabase-enterprise.workspaces.test-util/with-workspaces! clojure.core/let
|
||||
metabase.actions.test-util/with-actions clojure.core/let
|
||||
metabase.transforms.test-util/with-transform-cleanup! clojure.core/let
|
||||
metabase.transforms.test-util/with-transforms-api-users! clojure.core/fn
|
||||
metabase.api.common/let-404 clojure.core/let
|
||||
metabase.app-db.connection/with-unshared-connection clojure.core/fn
|
||||
metabase.app-db.core/with-unshared-connection clojure.core/fn
|
||||
metabase.app-db.custom-migrations.util/with-temp-scheduler! clojure.core/fn
|
||||
metabase.app-db.custom-migrations/define-migration clj-kondo.lint-as/def-catch-all
|
||||
metabase.app-db.custom-migrations/define-reversible-migration clj-kondo.lint-as/def-catch-all
|
||||
@@ -507,7 +451,6 @@
|
||||
metabase.channel.impl.http-test/with-captured-http-requests clojure.core/fn
|
||||
metabase.channel.impl.http-test/with-server clojure.core/let
|
||||
metabase.collections.models.collection-test/with-collection-in-location clojure.core/let
|
||||
metabase.collections.test-utils/with-library clojure.core/fn
|
||||
metabase.driver.mongo.connection/with-mongo-client clojure.core/let
|
||||
metabase.driver.mongo.connection/with-mongo-database clojure.core/let
|
||||
metabase.driver.mongo.query-processor/mongo-let clojure.core/let
|
||||
@@ -516,30 +459,24 @@
|
||||
metabase.driver.sql-jdbc.connection/with-connection-spec-for-testing-connection clojure.core/let
|
||||
metabase.driver.sql-jdbc.execute.diagnostic/capturing-diagnostic-info clojure.core/fn
|
||||
metabase.driver-api.core/defsetting hooks.metabase.settings.models.setting/defsetting
|
||||
metabase.mcp.v2.registry/deftool clojure.core/defn
|
||||
metabase.models.json-migration/def-json-migration clj-kondo.lint-as/def-catch-all
|
||||
metabase.models.util.spec-update/define-spec clojure.core/def
|
||||
metabase.notification.test-util/with-card-notification clojure.core/let
|
||||
metabase.notification.test-util/with-system-event-notification! clojure.core/let
|
||||
metabase.notification.test-util/with-temp-notification clojure.core/let
|
||||
metabase.oauth-server.test-util/with-oauth-client clojure.core/fn
|
||||
metabase.osi.ai-context.api-test/with-test-entry clojure.core/let
|
||||
metabase.premium-features.core/defenterprise-schema clj-kondo.lint-as/def-catch-all
|
||||
metabase.premium-features.defenterprise/defenterprise-schema clj-kondo.lint-as/def-catch-all
|
||||
metabase.premium-features.settings/define-premium-feature clojure.core/def
|
||||
metabase.pulse.dashboard-subscription-test/with-dashboard-sub-for-card clojure.core/let
|
||||
metabase.pulse.send-test/with-pulse-for-card clojure.core/let
|
||||
metabase.query-processor.pipeline-queries-test/mbql5-query clojure.core/->
|
||||
metabase.query-processor.pipeline-queries-test/run-mbql5-query clojure.core/->
|
||||
metabase.query-processor.pipeline-queries-test/pmbql-query clojure.core/->
|
||||
metabase.query-processor.pipeline-queries-test/run-pmbql-query clojure.core/->
|
||||
metabase.query-processor.error-type/deferror clojure.core/def
|
||||
metabase.query-processor.core/with-reducible-deserialized-results clojure.core/let
|
||||
metabase.query-processor.middleware.cache.impl/with-reducible-deserialized-results clojure.core/let
|
||||
metabase.query-processor.result-serialization/with-reducible-deserialized-results clojure.core/let
|
||||
metabase.query-processor.middleware.process-userland-query-test/with-query-execution! clojure.core/let
|
||||
metabase.query-processor.setup/with-qp-setup clojure.core/let
|
||||
metabase.search.api-test/do-test-users clojure.core/let
|
||||
metabase.settings.core/define-multi-setting clojure.core/def
|
||||
metabase.api-scope.core/defscope clj-kondo.lint-as/def-catch-all
|
||||
metabase.settings.core/defsetting clj-kondo.lint-as/def-catch-all
|
||||
metabase.settings.models.setting.multi-setting/define-multi-setting clojure.core/def
|
||||
metabase.settings.models.setting/defsetting clj-kondo.lint-as/def-catch-all
|
||||
@@ -550,7 +487,7 @@
|
||||
metabase.task.impl/defjob clojure.core/defn
|
||||
metabase.test.data.interface/defdataset clojure.core/def
|
||||
metabase.test.data.interface/defdataset-edn clojure.core/def
|
||||
metabase.test.embedded-postgres.core/with-system clojure.core/let
|
||||
metabase.test.gentest/defgentest clojure.test/deftest
|
||||
metabase.test/defdataset clojure.core/def
|
||||
metabase.test/let-url clojure.core/let
|
||||
metabase.test/with-actions clojure.core/let
|
||||
@@ -561,7 +498,6 @@
|
||||
metabase.test/with-temp-empty-app-db clojure.core/let
|
||||
metabase.test/with-temp-file clojure.core/let
|
||||
metabase.test/with-user-in-groups clojure.core/let
|
||||
metabase.transforms-rest.api.transform-runs-test/with-runs-fixture! clojure.core/fn
|
||||
metabase.upload.impl-test/with-upload-table! clojure.core/let
|
||||
metabase.util/for-map clojure.core/for
|
||||
metabase.util.files/with-open-path-to-resource clojure.core/let
|
||||
@@ -590,13 +526,8 @@
|
||||
|
||||
:hooks
|
||||
{:analyze-call
|
||||
{cljs.core/def hooks.clojure.core.def/lint-dynamic
|
||||
cljs.core/defn hooks.clojure.core.def/lint-dynamic
|
||||
cljs.core/defn- hooks.clojure.core.def/lint-dynamic
|
||||
cljs.core/defonce hooks.clojure.core.def/lint-dynamic
|
||||
cljs.test/deftest hooks.clojure.test/deftest
|
||||
{cljs.test/deftest hooks.clojure.test/deftest
|
||||
cljs.test/is hooks.clojure.test/is
|
||||
cljs.test/testing hooks.clojure.test/testing
|
||||
cljs.test/use-fixtures hooks.clojure.test/use-fixtures
|
||||
clojure.core/def hooks.clojure.core.def/lint-def
|
||||
clojure.core/definterface hooks.clojure.core.def/lint-def
|
||||
@@ -608,15 +539,12 @@
|
||||
clojure.core/defprotocol hooks.clojure.core.def/lint-def
|
||||
clojure.core/defrecord hooks.clojure.core.def/lint-def
|
||||
clojure.core/deftype hooks.clojure.core.def/lint-def
|
||||
clojure.core/ex-info hooks.metabase.agent-message/lint-ex-info
|
||||
clojure.core/ns hooks.clojure.core.ns/lint-ns
|
||||
clojure.core/require hooks.clojure.core.require/lint-require
|
||||
clojure.core/requiring-resolve hooks.clojure.core.require/lint-requiring-resolve
|
||||
clojure.core/with-redefs hooks.clojure.core.with-redefs/lint-with-redefs
|
||||
clojure.core/- hooks.metabase.util.timing/discourage-millis-duration
|
||||
clojure.test/deftest hooks.clojure.test/deftest
|
||||
clojure.test/is hooks.clojure.test/is
|
||||
clojure.test/testing hooks.clojure.test/testing
|
||||
clojure.test/use-fixtures hooks.clojure.test/use-fixtures
|
||||
malli.error/humanize hooks.malli.error/humanize
|
||||
metabase-enterprise.advanced-permissions.models.permissions.application-permissions-test/with-new-group-and-current-graph hooks.common/with-two-top-level-bindings
|
||||
@@ -624,15 +552,14 @@
|
||||
metabase-enterprise.cache.config-test/with-temp-persist-models hooks.common/with-seven-bindings
|
||||
metabase-enterprise.dependencies.api-test/with-dependents-test! hooks.common/with-two-bindings
|
||||
metabase-enterprise.metabot-v3.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
|
||||
metabase-enterprise.remote-sync.metric-dimension-api-test/with-synced-metric hooks.common/with-one-binding
|
||||
metabase-enterprise.semantic-search.db.connection/with-migrate-tx hooks.common/let-one-with-optional-value
|
||||
metabase-enterprise.serialization.api-test/with-serialization-test-data! hooks.common/with-three-bindings
|
||||
metabase-enterprise.serialization.test-util/with-temp-dpc hooks.toucan2.tools.with-temp/with-temp
|
||||
metabase-enterprise.transforms-python.drivers-test/with-test-table hooks.common/with-two-bindings
|
||||
metabase.api.common.internal-test/with-jetty-server hooks.common/with-one-binding
|
||||
metabase.api.macros/defendpoint hooks.metabase.api.macros/defendpoint
|
||||
metabase.app-db.schema-migrations-test.impl/test-migrations hooks.metabase.app-db.schema-migrations-test.impl/test-migrations!
|
||||
metabase.channel.render.js.pool/with-static-viz-context hooks.common/with-one-top-level-binding
|
||||
metabase.app-db.schema-migrations-test.impl/test-migrations hooks.metabase.app-db.schema-migrations-test.impl/test-migrations
|
||||
metabase.channel.render.js.svg/with-static-viz-context hooks.common/with-one-top-level-binding
|
||||
metabase.channel.template.handlebars-helper/defhelper hooks.metabase.channel.template.handlebars-helper/defhelper
|
||||
metabase.channel.template.handlebars-test/with-temp-template! hooks.common/with-vec-first-binding
|
||||
metabase.classloader.core/require hooks.clojure.core.require/lint-require
|
||||
@@ -659,68 +586,13 @@
|
||||
metabase.lib.schema.mbql-clause/define-catn-mbql-clause hooks.metabase.lib.schema.mbql-clause/define-mbql-clause
|
||||
metabase.lib.schema.mbql-clause/define-mbql-clause hooks.metabase.lib.schema.mbql-clause/define-mbql-clause
|
||||
metabase.lib.schema.mbql-clause/define-tuple-mbql-clause hooks.metabase.lib.schema.mbql-clause/define-mbql-clause
|
||||
methodical.core/defmethod hooks.metabase.toucan.table-name/lint-defmethod
|
||||
methodical.macros/defmethod hooks.metabase.toucan.table-name/lint-defmethod
|
||||
metabase.app-db.core/query hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.core/streaming-reducible-query hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.core/select-or-insert! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.core/update-or-insert! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.core/current-timestamp-string hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.query/query hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.query/streaming-reducible-query hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.query/select-or-insert! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.query/update-or-insert! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.query/current-timestamp-string hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/count hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/delete! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/exists? hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/insert! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/insert-returning-instance! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/insert-returning-instances! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/insert-returning-pk! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/insert-returning-pks! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/query hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/query-one hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/reducible-query hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/reducible-select hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/reducible-update hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/reducible-update-returning-pks hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/save! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-fn->fn hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-fn->pk hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-fn-reducible hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-fn-set hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-fn-vec hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-one hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-one-fn hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-one-pk hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-pk->fn hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-pks-set hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-pks-vec hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/update! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/update-returning-pks! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.lib.test-util.macros/$ids hooks.metabase.test.data/$ids
|
||||
metabase.lib.test-util.macros/mbql-query hooks.metabase.test.data/mbql-query
|
||||
metabase.lib.test-util.macros/mbql-5-query hooks.metabase.test.data/mbql-query
|
||||
metabase.lib.test-util.macros/with-testing-against-standard-queries hooks.metabase.lib.test-util.macros/with-testing-against-standard-queries
|
||||
metabase.mcp.transport/jsonrpc-error hooks.metabase.agent-message/lint-jsonrpc-error
|
||||
metabase.mcp.v2.common/ellipsize hooks.metabase.agent-message/lint-ellipsize
|
||||
metabase.mcp.v2.common/error-content hooks.metabase.agent-message/lint-teaching-exit
|
||||
metabase.mcp.v2.common/list-content hooks.metabase.agent-message/lint-list-content
|
||||
metabase.mcp.v2.common/success-content hooks.metabase.agent-message/lint-success-content
|
||||
metabase.mcp.v2.common/throw-teaching-error hooks.metabase.agent-message/lint-teaching-exit
|
||||
metabase.mcp.v2.dashboard-ops/op-error! hooks.metabase.agent-message/lint-op-error
|
||||
metabase.mcp.v2.message/msg hooks.metabase.agent-message/lint-msg
|
||||
metabase.mcp.v2.tools.browse/paged-list-content hooks.metabase.agent-message/lint-list-content
|
||||
metabase.mcp.v2.tools.parameters-test/with-fixtures hooks.common/with-one-binding
|
||||
metabase.metrics.api-dimension-test/with-seeded-metric hooks.common/with-one-binding
|
||||
metabase.model-persistence.api-test/with-persistence-setup! hooks.common/with-one-top-level-binding
|
||||
metabase.model-persistence.api-test/with-setup! hooks.common/with-one-top-level-binding
|
||||
metabase.model-persistence.test-util/with-persistence-enabled! hooks.metabase.model-persistence.test-util/with-persistence-enabled!
|
||||
metabase.mq.core/with-queue hooks.common/let-second
|
||||
metabase.mq.test-util/with-test-mq hooks.common/with-one-binding
|
||||
metabase.mq.core/def-listener! hooks.metabase.mq.impl/def-listener
|
||||
metabase.models.interface/define-batched-hydration-method hooks.metabase.models.interface/define-hydration-method
|
||||
metabase.models.interface/define-simple-hydration-method hooks.metabase.models.interface/define-hydration-method
|
||||
metabase.permissions.core/allow-changing-all-users-group-members hooks.common/do*
|
||||
@@ -763,6 +635,7 @@
|
||||
metabase.test.data/dataset hooks.metabase.test.data/dataset
|
||||
metabase.test.data/mbql-query hooks.metabase.test.data/mbql-query
|
||||
metabase.test.data/run-mbql-query hooks.metabase.test.data/mbql-query
|
||||
metabase.test.gentest/iterate hooks.metabase.test.gentest/iterate*
|
||||
metabase.test.util.async/with-open-channels hooks.common/let-with-optional-value-for-last-binding
|
||||
metabase.test.util.generators.jvm/with-random-cards hooks.metabase.test.util.generators.jvm/with-random-cards
|
||||
metabase.test.util.log/with-log-level hooks.common/with-ignored-first-arg
|
||||
@@ -791,7 +664,6 @@
|
||||
metabase.test/with-temp hooks.toucan2.tools.with-temp/with-temp
|
||||
metabase.test/with-temp-file hooks.metabase.test.util/with-temp-file
|
||||
metabase.test/with-temporary-setting-values hooks.metabase.test.util/with-temporary-setting-values
|
||||
metabase.tracing.test-util/with-span-exporter hooks.common/with-one-binding
|
||||
metabase.users-rest.api-test/with-temp-user-email! hooks.common/with-one-binding
|
||||
metabase.util.i18n/deferred-trs hooks.metabase.util.i18n/deferred-trs
|
||||
metabase.util.i18n/deferred-tru hooks.metabase.util.i18n/deferred-tru
|
||||
@@ -810,21 +682,10 @@
|
||||
metabase.util.log/tracef hooks.metabase.util.log/infof
|
||||
metabase.util.log/warn hooks.metabase.util.log/info
|
||||
metabase.util.log/warnf hooks.metabase.util.log/infof
|
||||
metabase.util.malli.defn/defn hooks.clojure.core.def/lint-dynamic
|
||||
metabase.util.malli.defn/defn- hooks.clojure.core.def/lint-dynamic
|
||||
metabase.util.malli.registry/def hooks.metabase.util.malli.registry/def
|
||||
metabase.util.malli/defn hooks.clojure.core.def/lint-dynamic
|
||||
metabase.util.malli/defn- hooks.clojure.core.def/lint-dynamic
|
||||
metabase.util.match/match-one hooks.metabase.util.match/match-one
|
||||
metabase.util.match/match-many hooks.metabase.util.match/match-many
|
||||
metabase.util.match/matches? hooks.metabase.util.match/matches?
|
||||
metabase.util.match/replace hooks.metabase.util.match/replace
|
||||
metabase.util.match/replace-in hooks.metabase.util.match/replace-in
|
||||
metabase.util/case-enum hooks.metabase.util/case-enum
|
||||
metabase.util/format-color hooks.metabase.util/format-color
|
||||
metabase.xrays.api.automagic-dashboards-test/with-indexed-model! hooks.metabase.xrays.api.automagic-dashboards-test/with-indexed-model!
|
||||
taoensso.nippy/extend-freeze hooks.taoensso.nippy/extend-freeze
|
||||
taoensso.nippy/extend-thaw hooks.taoensso.nippy/extend-thaw}
|
||||
metabase.xrays.api.automagic-dashboards-test/with-indexed-model! hooks.metabase.xrays.api.automagic-dashboards-test/with-indexed-model!}
|
||||
|
||||
:macroexpand
|
||||
{clojurewerkz.quartzite.jobs/build macros.quartz/build-job
|
||||
@@ -833,7 +694,6 @@
|
||||
clojurewerkz.quartzite.schedule.simple/schedule macros.quartz/simple-schedule
|
||||
clojurewerkz.quartzite.triggers/build macros.quartz/build-trigger
|
||||
metabase-enterprise.database-routing.e2e-test/with-temp-dbs! macros.metabase-enterprise.database-routing.e2e-test/with-temp-dbs!
|
||||
metabase-enterprise.database-routing.e2e-test/with-routing-setup! macros.metabase-enterprise.database-routing.e2e-test/with-routing-setup!
|
||||
metabase-enterprise.impersonation.util-test/with-impersonations! macros.metabase-enterprise.impersonation.util-test/with-impersonations!
|
||||
metabase-enterprise.query-reference-validation.api-test/with-test-setup! macros.metabase-enterprise.query-reference-validation.api-test/with-test-setup!
|
||||
metabase-enterprise.sandbox.test-util/with-gtaps! macros.metabase-enterprise.sandbox.test-util/with-gtaps!
|
||||
@@ -864,14 +724,7 @@
|
||||
metabase.test/with-temporary-raw-setting-values macros.metabase.test.util/with-temporary-raw-setting-values
|
||||
metabase.users.models.user-test/with-groups! macros.metabase.users.models.user-test/with-groups!
|
||||
metabase.util.namespaces/import-fn macros.metabase.util.namespaces/import-fn
|
||||
metabase.util.namespaces/import-macro macros.metabase.util.namespaces/import-macro
|
||||
metabase.xrays.related-test/with-world macros.metabase.xrays.related-test/with-world
|
||||
potemkin/import-def macros.potemkin.namespaces/import-def
|
||||
potemkin/import-fn macros.potemkin.namespaces/import-fn
|
||||
potemkin/import-macro macros.potemkin.namespaces/import-macro
|
||||
potemkin.namespaces/import-def macros.potemkin.namespaces/import-def
|
||||
potemkin.namespaces/import-fn macros.potemkin.namespaces/import-fn
|
||||
potemkin.namespaces/import-macro macros.potemkin.namespaces/import-macro}}
|
||||
metabase.xrays.related-test/with-world macros.metabase.xrays.related-test/with-world}}
|
||||
|
||||
:config-in-comment
|
||||
{:linters {:unresolved-symbol {:level :off}}}
|
||||
@@ -892,7 +745,7 @@
|
||||
:name test-namespaces}
|
||||
{:pattern "^(?!.*-test(?:\\..*)?$)(?!.*test-util)(?!^metabase\\.test.*).*$"
|
||||
:name source-namespaces}
|
||||
{:pattern "(?:(?:^metabase\\.cmd.*)|(?:^build.*)|(?:^metabuild-common.*)|(?:^release.*)|(?:^i18n.*)|(?:^lint-migrations-file$))|(?:^mage.*)|(?:^load-namespaces.*)|(?:^dev\\..*)"
|
||||
{:pattern "(?:(?:^metabase\\.cmd.*)|(?:^build.*)|(?:^metabuild-common.*)|(?:^release.*)|(?:^i18n.*)|(?:^lint-migrations-file$))|(?:^mage.*)"
|
||||
:name printable-namespaces}
|
||||
{:pattern "^metabase\\.(?:(?:lib)|(?:legacy-mbql))\\..*"
|
||||
:name metabase-lib}
|
||||
@@ -928,98 +781,28 @@
|
||||
;;
|
||||
{:pattern "^metabase(?:((?:-enterprise\\.sandbox)?\\.query-processor)|(?:\\.driver\\.sql(?!server)(?!ite))).*-test$"
|
||||
:name general-qp-and-driver-test-namespaces}
|
||||
|
||||
{:pattern "(?:^mage.*)"
|
||||
:name mage-namespaces}
|
||||
;;
|
||||
;; the app-db module is the application database layer itself (connections, migrations, encryption), so it gets to
|
||||
;; run Toucan 2 queries anywhere.
|
||||
;;
|
||||
{:pattern "^metabase\\.app-db\\..*"
|
||||
:name app-db-namespaces}
|
||||
;;
|
||||
;; MCP source namespaces, OSS and enterprise, whose prose reaches an LLM agent, excluding test namespaces.
|
||||
;;
|
||||
{:pattern "^(?!.*-test(?:\\..*)?$)(?!.*test-util)metabase(?:-enterprise)?\\.mcp\\..*"
|
||||
:name agent-message-namespaces}
|
||||
;;
|
||||
;; MCP source namespaces whose caller-facing errors are MCP exits: `metabase.mcp.transport` and `metabase.mcp.v2.*`,
|
||||
;; and their enterprise counterparts. Other MCP namespaces' 4xx `ex-info`s are admin API and middleware errors, not
|
||||
;; MCP exits.
|
||||
;;
|
||||
{:pattern "^(?!.*-test(?:\\..*)?$)(?!.*test-util)metabase(?:-enterprise)?\\.mcp\\.(?:transport$|v2\\..*)"
|
||||
:name agent-exit-namespaces}]
|
||||
:name mage-namespaces}]
|
||||
|
||||
:config-in-ns
|
||||
{metabase.app-db.custom-migrations
|
||||
{:linters
|
||||
;; a migration reads the rows as they were when it ran; applying today's user settings would make it drift
|
||||
{:metabase/table-or-field-query {:level :off}}}
|
||||
|
||||
test-namespaces
|
||||
{test-namespaces
|
||||
{:linters
|
||||
{:inline-def {:level :off}
|
||||
:missing-docstring {:level :off}
|
||||
:private-call {:level :off}
|
||||
:metabase/defsetting-must-specify-export {:level :off}
|
||||
:metabase/defsetting-namespace {:level :off}
|
||||
;; Test fixtures and per-test overrides use `binding`-scoped dynamic vars legitimately; this ratchet
|
||||
;; isn't aimed at that pattern. Mocking functions that way is the narrower case
|
||||
;; `:metabase/prefer-with-dynamic-fn-redefs` already covers.
|
||||
:metabase/discourage-dynamic-vars {:level :off}
|
||||
:metabase/t2-query-namespace {:level :off}
|
||||
:metabase/test-helpers-use-non-thread-safe-functions {:level :warning} ; more config in `.clj-kondo/config/test-helpers/config.edn`
|
||||
:metabase/test-helpers-use-non-thread-safe-functions {:level :warning} ; more config in `.clj-kondo/config/test-helpers/config.edn
|
||||
|
||||
:discouraged-var
|
||||
{metabase.driver/database-supports? {:level :off}
|
||||
metabase.lib.convert/->legacy-MBQL {:level :off}
|
||||
metabase.lib.core/->legacy-MBQL {:level :off}}}}
|
||||
|
||||
app-db-namespaces
|
||||
{:linters {:metabase/t2-query-namespace {:level :off}}}
|
||||
|
||||
agent-message-namespaces
|
||||
{:linters {:metabase/agent-message-lines {:level :warning}}}
|
||||
|
||||
agent-exit-namespaces
|
||||
{:linters {:metabase/agent-message-exit {:level :warning}}}
|
||||
|
||||
;; Namespaces whose values are marked. Adding one here is what keeps it that way; see
|
||||
;; `metabase.app-db.value-guard`.
|
||||
metabase.content-translation.db
|
||||
{:linters {:metabase/unsafe-app-db-query {:level :warning}}}
|
||||
metabase.settings.db
|
||||
{:linters {:metabase/unsafe-app-db-query {:level :warning}}}
|
||||
|
||||
metabase.auth-identity.db
|
||||
{:linters {:metabase/unsafe-app-db-query {:level :warning}}}
|
||||
|
||||
metabase.usage-metadata.db
|
||||
{:linters {:metabase/unsafe-app-db-query {:level :warning}}}
|
||||
|
||||
metabase.user-key-value.db
|
||||
{:linters {:metabase/unsafe-app-db-query {:level :warning}}}
|
||||
|
||||
metabase.public-sharing.db
|
||||
{:linters {:metabase/unsafe-app-db-query {:level :warning}}}
|
||||
|
||||
;; .cljc tests run in both Clojure and ClojureScript — `with-dynamic-fn-redefs` is JVM-only,
|
||||
;; so leave these alone.
|
||||
metabase.lib.aggregation-test
|
||||
{:linters {:metabase/prefer-with-dynamic-fn-redefs {:level :off}}}
|
||||
metabase.util.time-test
|
||||
{:linters {:metabase/prefer-with-dynamic-fn-redefs {:level :off}}}
|
||||
metabase.lib.metadata.calculation-test
|
||||
{:linters {:metabase/prefer-with-dynamic-fn-redefs {:level :off}}}
|
||||
|
||||
;; classloader sits below `util` in the module graph, so it can't use `metabase.util.log` without creating
|
||||
;; a cycle; it gets to use tools.logging directly.
|
||||
metabase.classloader.impl
|
||||
{:linters {:discouraged-namespace {clojure.tools.logging {:level :off}}}}
|
||||
|
||||
source-namespaces
|
||||
{:linters
|
||||
{:metabase/discourage-millis-duration {:level :warning}
|
||||
:metabase/prefer-with-dynamic-fn-redefs {:level :off}
|
||||
:metabase/validate-defendpoint-has-response-schema {:level :warning}
|
||||
:metabase/validate-escaped-single-quotes-in-i18n {:level :warning}
|
||||
:metabase/validate-string-or-str-args-to-i18n {:level :warning}
|
||||
@@ -1029,7 +812,9 @@
|
||||
clojure.core/eval {:message "Don't use eval outside of tests"}}
|
||||
|
||||
:discouraged-namespace
|
||||
{metabase.legacy-mbql.normalize {:message "Avoid legacy MBQL normalization, use MBQL 5 + lib/normalize instead"}
|
||||
{metabase.legacy-mbql.jvm-util {:message "Avoid legacy MBQL utils, use Lib instead"}
|
||||
metabase.legacy-mbql.normalize {:message "Avoid legacy MBQL normalization, use MBQL 5 + lib/normalize instead"}
|
||||
metabase.legacy-mbql.predicates {:message "Avoid legacy MBQL utils, use Lib instead"}
|
||||
metabase.legacy-mbql.schema {:message "Avoid the legacy MBQL schema, use MBQL 5 + lib.schema instead"}
|
||||
metabase.legacy-mbql.schema.helpers {:message "Avoid the legacy MBQL schema, use MBQL 5 + lib.schema instead"}
|
||||
metabase.legacy-mbql.schema.macros {:message "Avoid the legacy MBQL schema, use MBQL 5 + lib.schema instead"}
|
||||
@@ -1081,7 +866,9 @@
|
||||
legacy-mbql-namespaces
|
||||
{:linters
|
||||
{:discouraged-namespace
|
||||
{metabase.legacy-mbql.normalize {:level :off}
|
||||
{metabase.legacy-mbql.jvm-util {:level :off}
|
||||
metabase.legacy-mbql.normalize {:level :off}
|
||||
metabase.legacy-mbql.predicates {:level :off}
|
||||
metabase.legacy-mbql.schema {:level :off}
|
||||
metabase.legacy-mbql.schema.helpers {:level :off}
|
||||
metabase.legacy-mbql.schema.macros {:level :off}
|
||||
@@ -1101,7 +888,9 @@
|
||||
;; don't warn about the legacy MBQL namespaces being deprecated or discouraged in driver code now, we don't have a
|
||||
;; real alternative YET so it's not a helpful warning.
|
||||
:discouraged-namespace
|
||||
{metabase.legacy-mbql.normalize {:level :off}
|
||||
{metabase.legacy-mbql.jvm-util {:level :off}
|
||||
metabase.legacy-mbql.normalize {:level :off}
|
||||
metabase.legacy-mbql.predicates {:level :off}
|
||||
metabase.legacy-mbql.schema {:level :off}
|
||||
metabase.legacy-mbql.schema.helpers {:level :off}
|
||||
metabase.legacy-mbql.schema.macros {:level :off}
|
||||
@@ -1110,7 +899,14 @@
|
||||
|
||||
:deprecated-namespace
|
||||
{:exclude
|
||||
[metabase.legacy-mbql.normalize
|
||||
[metabase.driver.common.parameters
|
||||
metabase.driver.common.parameters.dates
|
||||
metabase.driver.common.parameters.operators
|
||||
metabase.driver.common.parameters.parse
|
||||
metabase.driver.common.parameters.values
|
||||
metabase.legacy-mbql.jvm-util
|
||||
metabase.legacy-mbql.normalize
|
||||
metabase.legacy-mbql.predicates
|
||||
metabase.legacy-mbql.schema
|
||||
metabase.legacy-mbql.schema.helpers
|
||||
metabase.legacy-mbql.schema.macros
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
||||
;; Named cyclic clusters of the module require graph: cluster name => anchor module.
|
||||
;; The cluster holding the anchor carries the name; its members are computed, so it can grow and shrink freely.
|
||||
;; The test metabase.core.module-cycles-test fails until every cluster holds exactly one anchor.
|
||||
;; When a cluster splits, whoever split it names the new half.
|
||||
;; Names follow a space theme with some link to what the cluster does; the name-module-cycle skill helps pick one.
|
||||
{deep-thought metabot
|
||||
first-contact mcp
|
||||
foundation app-db
|
||||
galactic-center query-processor
|
||||
stargate api-routes
|
||||
tardis lib}
|
||||
@@ -1,7 +0,0 @@
|
||||
;; Budgets for escape hatches in config.edn. Counts may fall but not exceed these values.
|
||||
;; Leave reductions to the post-merge shrink workflow; explain increases in the PR.
|
||||
{:api-any 1
|
||||
:friend-edges 1
|
||||
:model-imports-bypass 2
|
||||
:ns-prefixes 81
|
||||
:uses-any 4}
|
||||
@@ -36,9 +36,6 @@
|
||||
;; Definitely thread safe
|
||||
metabase.test.util.dynamic-redefs/patch-vars!
|
||||
|
||||
;; test-only cleanup of in-memory throttle state (atom resets)
|
||||
metabase.session.api/reset-throttlers-for-testing!
|
||||
|
||||
;; TODO: most of these symbols shouldn't be here, we should go through them and
|
||||
;; find the functions/macros that use them and make sure their names end with !
|
||||
;; best way to do this is try remove each of these and rely on kondo output to find places where it's used
|
||||
@@ -58,7 +55,6 @@
|
||||
metabase.app-db.core/finish-db-setup!
|
||||
metabase.app-db.core/migrate!
|
||||
metabase.app-db.core/setup-db!
|
||||
metabase.app-db.encryption-test-util/do-with-encrypted-app-db!
|
||||
metabase.app-db.schema-migrations-test.impl/run-migrations-in-range!
|
||||
metabase.app-db.setup/migrate!
|
||||
metabase.app-db.setup/setup-db!
|
||||
@@ -114,7 +110,6 @@
|
||||
metabase.settings.models.setting-test/test-user-local-allowed-setting!
|
||||
metabase.settings.models.setting-test/test-user-local-only-setting!
|
||||
metabase.settings.models.setting.cache/restore-cache!
|
||||
metabase.settings.models.setting/log-deprecated-env-var-usage!
|
||||
metabase.settings.models.setting/set!
|
||||
metabase.settings.models.setting/validate-settings-formatting!
|
||||
metabase.setup.core/create-token!
|
||||
|
||||
@@ -6,8 +6,3 @@
|
||||
`(def ~(-> sym name symbol) "docstring" ~sym))
|
||||
([sym name]
|
||||
`(def ~name "docstring" ~sym)))
|
||||
|
||||
(defmacro import-macro
|
||||
"Kondo macro replacement for [[metabase.util.namespaces/import-macro]]."
|
||||
[sym]
|
||||
`(def ~(-> sym name symbol) "docstring" ~sym))
|
||||
|
||||
@@ -3,8 +3,3 @@
|
||||
(defmacro with-temp-dbs! [bindings & body]
|
||||
`(let [~@(interleave bindings (repeat nil))]
|
||||
~@body))
|
||||
|
||||
(defmacro with-routing-setup! [[router-binding destinations] & body]
|
||||
`(let [~router-binding nil
|
||||
~@(interleave (map first destinations) (repeat nil))]
|
||||
~@body))
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
(ns macros.potemkin.namespaces)
|
||||
|
||||
(defn- import-var-expansion
|
||||
"Expansion shared by all of the `import-*` replacements below. Synthetic \"docstring\" is needed to silence
|
||||
`:missing-docstring` linter."
|
||||
([sym]
|
||||
(import-var-expansion sym (-> sym name symbol)))
|
||||
([sym new-name]
|
||||
`(def ~new-name "docstring" ~sym)))
|
||||
|
||||
(defmacro import-def
|
||||
([sym] (import-var-expansion sym))
|
||||
([sym new-name] (import-var-expansion sym new-name)))
|
||||
|
||||
(defmacro import-fn
|
||||
([sym] (import-var-expansion sym))
|
||||
([sym new-name] (import-var-expansion sym new-name)))
|
||||
|
||||
(defmacro import-macro
|
||||
([sym] (import-var-expansion sym))
|
||||
([sym new-name] (import-var-expansion sym new-name)))
|
||||
@@ -1,56 +0,0 @@
|
||||
;; Budgets for kondo suppressions in test code: inline `:clj-kondo/ignore` forms per linter
|
||||
;; (:ignore-counts). Production code has its own budgets in .clj-kondo/ratchets.edn, which also holds
|
||||
;; every config-level waiver (:config-counts) -- config.edn's scoping doesn't cleanly split.
|
||||
;; The :discouraged-var-counts and :discouraged-namespace-counts fields budget those linters' inline
|
||||
;; ignores per symbol, keyed by the symbol with `metabase.` dropped and `metabase-enterprise.` shortened
|
||||
;; to `ee.`.
|
||||
;; Each :ignore-counts value is a non-negative integer budget, or :unlimited for no ceiling; every other
|
||||
;; budget is a non-negative integer.
|
||||
;; Checks fail when a count exceeds its numeric budget; unused budget is allowed.
|
||||
;; Any ignore outside :comment-exempt needs an explanatory comment directly above or trailing on its line.
|
||||
;; `./bin/mage kondo-ratchets-shrink` lowers budgets unless `--seed` explicitly adds or raises one;
|
||||
;; a seed like `:discouraged-var/clojure.core/println` seeds one symbol, and `:discouraged-var` all of them.
|
||||
;; The workflow runs it on master, so feature branches do not need to record reductions.
|
||||
;; Raising or adding a budget (`--seed` for inline ignores, a manual edit otherwise) or widening the
|
||||
;; exemptions must be explained in the PR.
|
||||
;; :all is the vector-less ignore form, which suppresses every linter on the next form.
|
||||
{:ignore-counts {:case-symbol-test 1
|
||||
:clojure-lsp/unused-public-var 1
|
||||
:deprecated-namespace 66
|
||||
:deprecated-var 32
|
||||
:equals-true 1
|
||||
:metabase/disallow-hardcoded-driver-names-in-tests 34
|
||||
:metabase/discourage-dynamic-vars 41
|
||||
:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests 13
|
||||
:metabase/namespace-name 1
|
||||
:metabase/prefer-with-dynamic-fn-redefs :unlimited
|
||||
:metabase/test-helpers-use-non-thread-safe-functions 18
|
||||
:metabase/validate-deftest 9
|
||||
:missing-protocol-method 3
|
||||
:type-mismatch 6
|
||||
:uninitialized-var 1
|
||||
:unresolved-symbol 2
|
||||
:unused-binding 6
|
||||
:unused-private-var 2}
|
||||
:discouraged-namespace-counts {:clojure.tools.logging 5
|
||||
:toucan2.core 1}
|
||||
:discouraged-var-counts {:clojure.core/pr 1
|
||||
:clojure.core/print 1
|
||||
:clojure.core/printf 2
|
||||
:clojure.core/println 9
|
||||
:clojure.core/prn 1
|
||||
:clojure.core/time 1
|
||||
:clojure.java.jdbc/get-connection 1
|
||||
:clojure.java.jdbc/with-db-connection 1
|
||||
:clojure.pprint/print-table 1
|
||||
:clojure.string/lower-case 1
|
||||
:compojure.core/routes 2
|
||||
:ee.entity-retrieval.core/search-unfiltered 6
|
||||
:entity-retrieval.core/search-unfiltered 1
|
||||
:entity-retrieval.mirror/search-unfiltered 1
|
||||
:lib.equality/find-column-indexes-for-refs 2
|
||||
:test/with-temp 28}
|
||||
:comment-exempt #{:deprecated-namespace
|
||||
:discouraged-namespace
|
||||
:discouraged-var
|
||||
:metabase/discourage-dynamic-vars}}
|
||||
@@ -1,118 +0,0 @@
|
||||
;; Budgets for kondo suppressions: inline `:clj-kondo/ignore` forms per linter (:ignore-counts), and
|
||||
;; config-level waivers in .clj-kondo/config.edn (:config-counts -- :off switches and :exclude entries).
|
||||
;; The :discouraged-var-counts and :discouraged-namespace-counts fields budget those linters' inline
|
||||
;; ignores per symbol, keyed by the symbol with `metabase.` dropped and `metabase-enterprise.` shortened
|
||||
;; to `ee.`.
|
||||
;; Each :ignore-counts value is a non-negative integer budget, or :unlimited for no ceiling; every other
|
||||
;; budget is a non-negative integer.
|
||||
;; Checks fail when a count exceeds its numeric budget; unused budget is allowed.
|
||||
;; Any ignore outside :comment-exempt needs an explanatory comment directly above or trailing on its line.
|
||||
;; `./bin/mage kondo-ratchets-shrink` lowers budgets unless `--seed` explicitly adds or raises one;
|
||||
;; a seed like `:discouraged-var/clojure.core/println` seeds one symbol, and `:discouraged-var` all of them.
|
||||
;; The workflow runs it on master, so feature branches do not need to record reductions.
|
||||
;; Raising or adding a budget (`--seed` for inline ignores, a manual edit otherwise) or widening the
|
||||
;; exemptions must be explained in the PR.
|
||||
;; :all is the vector-less ignore form, which suppresses every linter on the next form.
|
||||
;;
|
||||
;; Test code's inline ignores have their own budgets in .clj-kondo/ratchets-test.edn.
|
||||
;; :config-counts stays here either way -- config.edn's scoping doesn't cleanly split.
|
||||
{:ignore-counts {:aliased-namespace-symbol 3
|
||||
:clojure-lsp/unused-public-var 7
|
||||
:consistent-alias 2
|
||||
:def-fn 1
|
||||
:deprecated-namespace 29
|
||||
:deprecated-var 53
|
||||
:discouraged-java-method 4
|
||||
:duplicate-require 1
|
||||
:main-without-gen-class 1
|
||||
:metabase/discourage-dynamic-vars 324
|
||||
:metabase/discourage-millis-duration 1
|
||||
:metabase/modules 23
|
||||
:metabase/namespace-name 3
|
||||
:metabase/validate-defendpoint-has-response-schema 427
|
||||
:metabase/validate-defendpoint-query-params-use-kebab-case 48
|
||||
:metabase/validate-defendpoint-route-uses-kebab-case 44
|
||||
:missing-docstring 4
|
||||
:non-arg-vec-return-type-hint 1
|
||||
:reduce-without-init 3
|
||||
:redundant-do 1
|
||||
:syntax 2
|
||||
:type-mismatch 2
|
||||
:unquote-not-syntax-quoted 1
|
||||
:unresolved-namespace 3
|
||||
:unresolved-symbol 9
|
||||
:unsorted-required-namespaces :unlimited
|
||||
:unused-alias 2
|
||||
:unused-binding 3
|
||||
:unused-excluded-var 2
|
||||
:unused-namespace 2
|
||||
:unused-private-var 5
|
||||
:unused-value 1}
|
||||
:discouraged-namespace-counts {:cheshire.core 4
|
||||
:clojure.tools.logging 3
|
||||
:grouper.core 1
|
||||
:legacy-mbql.normalize 14
|
||||
:legacy-mbql.schema 14
|
||||
:legacy-mbql.util 2
|
||||
:query-processor.store 8
|
||||
:toucan2.core 3
|
||||
:util.malli.schema 2}
|
||||
:discouraged-var-counts {:clojure.core/eval 6
|
||||
:clojure.core/print 1
|
||||
:clojure.core/println 9
|
||||
:clojure.core/prn 3
|
||||
:clojure.core/tap> 5
|
||||
:clojure.core/update-keys 1
|
||||
:clojure.java.jdbc/get-connection 2
|
||||
:clojure.pprint/pprint 2
|
||||
:clojure.string/lower-case 2
|
||||
:clojure.string/upper-case 1
|
||||
:clojurewerkz.quartzite.jobs/defjob 1
|
||||
:compojure.core/context 2
|
||||
:compojure.core/defroutes 4
|
||||
:compojure.core/routes 2
|
||||
:ee.transform-testing.expectations.empty/->Empty 1
|
||||
:ee.transform-testing.expectations.equals/->EqualsRows 1
|
||||
:ee.transform-testing.expectations.equals/->EqualsSql 1
|
||||
:entity-retrieval.core/search-unfiltered 1
|
||||
:entity-retrieval.mirror/search-unfiltered 1
|
||||
:lib.core/->legacy-MBQL 10
|
||||
:lib.equality/find-column-indexes-for-refs 1
|
||||
:malli.core/explain 1
|
||||
:malli.core/explainer 1
|
||||
:malli.core/validate 1
|
||||
:malli.core/validator 1
|
||||
:me.flowthing.pp/pprint 2
|
||||
:next.jdbc/active-tx? 1}
|
||||
:config-counts {:constant-condition 1
|
||||
:deprecated-namespace 7
|
||||
:deprecated-var 16
|
||||
:discouraged-namespace 14
|
||||
:discouraged-var 14
|
||||
:inline-def 1
|
||||
:main-without-gen-class 1
|
||||
:metabase/defsetting-must-specify-export 1
|
||||
:metabase/defsetting-namespace 1
|
||||
:metabase/discourage-dynamic-vars 1
|
||||
:metabase/prefer-with-dynamic-fn-redefs 4
|
||||
:metabase/t2-query-namespace 2
|
||||
:metabase/table-or-field-query 1
|
||||
:metabase/unsafe-app-db-query 1
|
||||
:missing-docstring 1
|
||||
:private-call 1
|
||||
:redundant-ignore 1
|
||||
:redundant-primitive-coercion 1
|
||||
:refer-all 1
|
||||
:type-mismatch 1
|
||||
:unexpected-recur 1
|
||||
:unresolved-symbol 15
|
||||
:unresolved-var 1
|
||||
:unused-referred-var 4}
|
||||
:comment-exempt #{:deprecated-namespace
|
||||
:discouraged-namespace
|
||||
:discouraged-var
|
||||
:metabase/discourage-dynamic-vars
|
||||
:metabase/modules
|
||||
:metabase/validate-defendpoint-has-response-schema
|
||||
:metabase/validate-defendpoint-query-params-use-kebab-case
|
||||
:metabase/validate-defendpoint-route-uses-kebab-case}}
|
||||
@@ -32,60 +32,14 @@
|
||||
:message "Use kebab-case names for functions and variables. [:metabase/check-def-no-underscores]"
|
||||
:type :metabase/check-def-no-underscores)))))
|
||||
|
||||
(defn- attr-map
|
||||
"The attr-map of a `defn`-like form, which Clojure merges into the var metadata, or nil.
|
||||
A map in a `def` or `defonce` is the value, not an attr-map."
|
||||
[node]
|
||||
(let [[head _name & args] (:children node)]
|
||||
(when-not (#{"def" "defonce"} (name (hooks/sexpr head)))
|
||||
(loop [[arg & more :as args] args]
|
||||
(cond
|
||||
(hooks/string-node? arg) (recur more)
|
||||
;; a Malli `:- schema` return annotation
|
||||
(and (hooks/keyword-node? arg) (= :- (hooks/sexpr arg))) (recur (rest more))
|
||||
(hooks/map-node? arg) (hooks/sexpr arg)
|
||||
;; a multi-arity body can end with a trailing attr-map
|
||||
(hooks/list-node? arg) (let [trailing (last args)]
|
||||
(when (hooks/map-node? trailing)
|
||||
(hooks/sexpr trailing))))))))
|
||||
|
||||
(defn- dynamic?
|
||||
"Whether the var defined by `def-node` is dynamic, through metadata on `symbol-node` or a `defn` attr-map."
|
||||
[def-node symbol-node]
|
||||
(or (some (fn [meta-node]
|
||||
(let [m (hooks/sexpr meta-node)]
|
||||
(or (= m :dynamic)
|
||||
(and (map? m) (true? (:dynamic m))))))
|
||||
(:meta symbol-node))
|
||||
(true? (:dynamic (attr-map def-node)))))
|
||||
|
||||
(defn- check-symbol-is-not-dynamic [def-node symbol-node]
|
||||
(when (dynamic? def-node symbol-node)
|
||||
;; Report on the form, not the name: `kondo-insert-ignores` puts the ignore on the line above the finding,
|
||||
;; and the line above the name can be inside the form, where an ignore has no effect.
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta def-node)
|
||||
:message "Avoid new dynamic vars; pass the value as an argument or hang it on a stateful component. [:metabase/discourage-dynamic-vars]"
|
||||
:type :metabase/discourage-dynamic-vars))))
|
||||
|
||||
(defn- name-symbol [node]
|
||||
(let [[_def & args] (:children node)]
|
||||
(some (fn [arg]
|
||||
(when (and (hooks/token-node? arg)
|
||||
(symbol? (hooks/sexpr arg)))
|
||||
arg))
|
||||
args)))
|
||||
|
||||
(defn lint-def* [{:keys [node]}]
|
||||
(let [symbol-node (name-symbol node)]
|
||||
(check-symbol-is-kebab-case symbol-node)
|
||||
(check-symbol-is-not-dynamic node symbol-node)))
|
||||
|
||||
(defn lint-dynamic
|
||||
"Hook for def-like macros that should get only the dynamic-var check. Leaves the node unchanged."
|
||||
[{:keys [node]}]
|
||||
(check-symbol-is-not-dynamic node (name-symbol node))
|
||||
nil)
|
||||
(let [[_def & args] (:children node)
|
||||
name-symbol (some (fn [arg]
|
||||
(when (and (hooks/token-node? arg)
|
||||
(symbol? (hooks/sexpr arg)))
|
||||
arg))
|
||||
args)]
|
||||
(check-symbol-is-kebab-case name-symbol)))
|
||||
|
||||
(defn lint-def [x]
|
||||
(lint-def* x)
|
||||
|
||||
@@ -71,28 +71,10 @@
|
||||
(some-> (ns-form-node->ns-symb-node ns-form-node)
|
||||
hooks/sexpr))
|
||||
|
||||
(defn- lint-test-parallel-metadata [ns-form-node]
|
||||
(when-let [ns-symb-node (ns-form-node->ns-symb-node ns-form-node)]
|
||||
(let [ns-metadata (meta (hooks/sexpr ns-symb-node))]
|
||||
(cond
|
||||
(:synchronous ns-metadata)
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta ns-symb-node)
|
||||
:message (str "Use `^:synchronized` to mark this namespace explicitly non-parallel; "
|
||||
"`^:synchronous` is ignored by Hawk. [:metabase/validate-deftest]")
|
||||
:type :metabase/validate-deftest))
|
||||
|
||||
(and (contains? ns-metadata :parallel)
|
||||
(false? (:parallel ns-metadata)))
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta ns-symb-node)
|
||||
:message "Use `^:synchronized` instead of `^{:parallel false}`. [:metabase/validate-deftest]"
|
||||
:type :metabase/validate-deftest))))))
|
||||
|
||||
(defn- lint-modules [ns-form-node config]
|
||||
(let [ns-symb (ns-form-node->ns-symb ns-form-node)]
|
||||
(when-not (modules/ignored-namespace? config ns-symb)
|
||||
(when-let [current-module (modules/module config ns-symb)]
|
||||
(when-let [current-module (modules/module ns-symb)]
|
||||
(let [required-namespace-symb-nodes (-> ns-form-node
|
||||
ns-form-node->require-node
|
||||
require-node->namespace-symb-nodes)]
|
||||
@@ -158,7 +140,6 @@
|
||||
lint-requires-on-new-lines
|
||||
(lint-modules (modules/config x))
|
||||
lint-namespace-name
|
||||
lint-test-parallel-metadata
|
||||
lint-jsqlparser-imports)
|
||||
x)
|
||||
|
||||
|
||||
@@ -11,32 +11,15 @@
|
||||
(every? simple-symbol? required-namespaces)]}
|
||||
(when-not (modules/ignored-namespace? config current-ns)
|
||||
;; ignore namespaces outside of the module system
|
||||
(when-let [current-module (modules/module config current-ns)]
|
||||
(when-let [current-module (modules/module current-ns)]
|
||||
(doseq [required-namespace required-namespaces]
|
||||
;; ignore namespaces outside of the module system.
|
||||
(when (modules/module config required-namespace)
|
||||
(when (modules/module required-namespace)
|
||||
(when-let [error (modules/usage-error config current-module required-namespace)]
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
:message error
|
||||
:type :metabase/modules))))))))
|
||||
|
||||
(defn- lint-dynamic-require* [node current-ns config]
|
||||
(when (and (not (modules/ignored-namespace? config current-ns))
|
||||
(modules/module config current-ns))
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
:message "Module dependency cannot be statically determined from a dynamic require"
|
||||
:type :metabase/modules))))
|
||||
|
||||
(defn- quoted-require? [node]
|
||||
(or (= (hooks/tag node) :quote)
|
||||
(and (= (hooks/tag node) :list)
|
||||
(let [[x] (:children node)]
|
||||
(and (hooks/token-node? x)
|
||||
(= (hooks/sexpr x) 'quote))))))
|
||||
|
||||
(defn- keyword-node? [node]
|
||||
(keyword? (hooks/sexpr node)))
|
||||
|
||||
(defn- unwrap-require [node]
|
||||
(case (hooks/tag node)
|
||||
;; some.namespace of some.namespace/some-var
|
||||
@@ -59,11 +42,7 @@
|
||||
|
||||
;; something like [some.namespace :as whatever]
|
||||
:vector
|
||||
(recur (first (:children node)))
|
||||
|
||||
;; anything else -- e.g. a runtime expression like `(requiring-resolve (:failing-test-var opts))` --
|
||||
;; has no statically-known namespace
|
||||
nil))
|
||||
(recur (first (:children node)))))
|
||||
|
||||
(comment
|
||||
(unwrap-require (-> "(requiring-resolve 'metabase.notification.payload.execute/execute-dashboard)"
|
||||
@@ -72,24 +51,20 @@
|
||||
second)))
|
||||
|
||||
(defn- lint-require* [node current-ns config]
|
||||
(let [[_require & args] (:children node)
|
||||
;; Keywords such as `:reload` and `:reload-all` are options, not namespace dependencies.
|
||||
namespace-nodes (remove keyword-node? args)
|
||||
static-nodes (filter quoted-require? namespace-nodes)
|
||||
required-namespaces (->> (keep unwrap-require static-nodes)
|
||||
(filter simple-symbol?))]
|
||||
(lint* node current-ns config required-namespaces)
|
||||
(when (or (not= (count static-nodes) (count namespace-nodes))
|
||||
(not= (count required-namespaces) (count static-nodes)))
|
||||
(lint-dynamic-require* node current-ns config))))
|
||||
(let [[_require & args] (:children node)
|
||||
required-namespaces (->> (keep unwrap-require args)
|
||||
;; `require` runs through `cc/load-libs` which allows
|
||||
;; #{:as :reload :reload-all :require :use :verbose :refer :as-alias}
|
||||
;; as args. If you use a REPL tool that applies clj-kondo linting (e.g., Clojure MCP)
|
||||
;; you may wish to `:reload` or `:reload-all` a namespace. So we shouldn't assume all
|
||||
;; args are keywords:
|
||||
(remove keyword?))]
|
||||
(lint* node current-ns config required-namespaces)))
|
||||
|
||||
(defn- lint-requiring-resolve* [node current-ns config]
|
||||
(let [[_requiring-resolve symb-node] (:children node)
|
||||
required-namespace (unwrap-require symb-node)]
|
||||
(if (and (quoted-require? symb-node)
|
||||
(simple-symbol? required-namespace))
|
||||
(lint* node current-ns config [required-namespace])
|
||||
(lint-dynamic-require* node current-ns config))))
|
||||
(lint* node current-ns config [required-namespace])))
|
||||
|
||||
(defn lint-require [x]
|
||||
(lint-require* (:node x) (:ns x) (modules/config x))
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
(ns hooks.clojure.core.with-redefs
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]))
|
||||
|
||||
(defn- defn-arity?
|
||||
"Look up `var-sym` in `analysis` (the result of `hooks/ns-analysis`, keyed by language)
|
||||
and return true iff kondo recorded a non-empty arity for it. clj-kondo only emits
|
||||
arities for `defn`-style fns — `defmulti` and plain `def` have neither `:fixed-arities`
|
||||
nor `:varargs-min-arity`, so the presence of either is a clean, dynamic signal that
|
||||
the var is a regular function we can safely nudge.
|
||||
|
||||
We coerce to boolean and `seq`-check `:fixed-arities` so a hypothetical empty set
|
||||
doesn't leak through as truthy. The smoke test in `with-redefs-test` empirically
|
||||
verifies the \"arities iff `defn`\" invariant against a real `clj-kondo` run — if a
|
||||
future kondo release starts emitting arities for `defmulti` it will fail there
|
||||
rather than silently producing wrong nudges."
|
||||
[analysis var-sym]
|
||||
(boolean
|
||||
(some (fn [lang-vars]
|
||||
(when-let [v (get lang-vars var-sym)]
|
||||
(or (seq (:fixed-arities v))
|
||||
(:varargs-min-arity v))))
|
||||
(vals analysis))))
|
||||
|
||||
(defn- safely-nudgeable-lhs?
|
||||
"Is this LHS a regular function (defn) according to kondo's analysis?
|
||||
|
||||
Returns false for:
|
||||
- unresolved symbols (e.g. namespace alias not in scope, ns not yet analysed)
|
||||
- vars defined by `defmulti` (no arity recorded)
|
||||
- vars defined by `def` (no arity recorded)
|
||||
Returns true only when kondo has arity info for the var, which is the case for `defn`
|
||||
and `defn-`. This means the nudge fires only when we're sure the target is a plain
|
||||
function — no manual list of multimethod targets to maintain.
|
||||
|
||||
We deliberately bias toward false (skip the nudge) when uncertain. The cost of a missed
|
||||
nudge is small; the cost of a wrong nudge is a runtime error from
|
||||
`with-dynamic-fn-redefs` refusing to proxy a multimethod."
|
||||
[lhs]
|
||||
(and (hooks/token-node? lhs)
|
||||
(symbol? (hooks/sexpr lhs))
|
||||
(let [resolved (hooks/resolve {:name (hooks/sexpr lhs)})
|
||||
ns-sym (:ns resolved)]
|
||||
(and (symbol? ns-sym) ; not :clj-kondo/unknown-namespace
|
||||
(defn-arity? (hooks/ns-analysis ns-sym) (:name resolved))))))
|
||||
|
||||
(defn lint-with-redefs
|
||||
"Suggest `with-dynamic-fn-redefs` when every LHS is known to be a `defn`-style var.
|
||||
|
||||
We don't gate on the RHS — `with-dynamic-fn-redefs` accepts any `IFn` replacement
|
||||
(fns, keywords, colls), and the LHS check alone is enough to know the form is
|
||||
migratable as a whole. The `every?` keeps it whole-form: mixed bindings that include
|
||||
a non-defn LHS (defmulti, plain `def`, unresolved) can't be split usefully — the
|
||||
leftover `with-redefs` still does a global root-swap, so the form remains thread-unsafe.
|
||||
|
||||
The LHS check uses kondo's own analysis cache rather than a hand-maintained list of
|
||||
multimethod names — adding a new `defmulti` doesn't require touching this hook."
|
||||
[{:keys [node]}]
|
||||
(let [[_with-redefs bindings-vec] (:children node)]
|
||||
(when (hooks/vector-node? bindings-vec)
|
||||
(let [pairs (partition-all 2 (:children bindings-vec))]
|
||||
(when (and (seq pairs)
|
||||
(every? (fn [[lhs rhs]]
|
||||
(and rhs (safely-nudgeable-lhs? lhs)))
|
||||
pairs))
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta node)
|
||||
:message (str "Every binding here redefines a defn-style var — prefer "
|
||||
"metabase.test/with-dynamic-fn-redefs for thread-safe "
|
||||
"redefs. [:metabase/prefer-with-dynamic-fn-redefs]")
|
||||
:type :metabase/prefer-with-dynamic-fn-redefs))))))
|
||||
{:node node})
|
||||
@@ -21,19 +21,15 @@
|
||||
(error! form (format "destructive functions like %s are not allowed inside a ^:parallel test or test fixture. If this should be allowed, add it to the whitelist in the Kondo config file [:metabase/validate-deftest]"
|
||||
qualified-symbol)))))
|
||||
(walk [form]
|
||||
(when-not (contains? (hooks.common/ignored-linters form) :metabase/validate-deftest)
|
||||
(f form)
|
||||
(doseq [child (:children form)]
|
||||
(walk child))))]
|
||||
(f form)
|
||||
(doseq [child (:children form)]
|
||||
(walk child)))]
|
||||
(walk form)))
|
||||
|
||||
(defn- deftest-check-parallel
|
||||
"1. Check if test is marked `^:parallel` / `^:synchronized` correctly.
|
||||
2. Make sure disallowed forms are not used in ^:parallel tests.
|
||||
3. Disallow `^:parallel` tests in namespaces marked `^:synchronized` --
|
||||
the ns marker is the author's blanket promise that nothing inside runs
|
||||
in parallel, so an individual `^:parallel` deftest contradicts it."
|
||||
[{[_ test-name & body] :children, :as _node} ns-sym config]
|
||||
"1. Check if test is marked ^:parallel / ^:synchronized correctly
|
||||
2. Make sure disallowed forms are not used in ^:parallel tests"
|
||||
[{[_ test-name & body] :children, :as _node} config]
|
||||
(let [test-metadata (:meta test-name)
|
||||
metadata-sexprs (map hooks/sexpr test-metadata)
|
||||
combined-metadata (transduce
|
||||
@@ -44,48 +40,19 @@
|
||||
(completing merge)
|
||||
{}
|
||||
metadata-sexprs)
|
||||
parallel? (true? (:parallel combined-metadata))
|
||||
synchronized? (true? (:synchronized combined-metadata))
|
||||
explicitly-nonparallel? (or synchronized?
|
||||
(and (contains? combined-metadata :parallel)
|
||||
(false? (:parallel combined-metadata))))
|
||||
ns-metadata (meta ns-sym)
|
||||
ns-explicitly-nonparallel? (or (true? (:synchronized ns-metadata))
|
||||
(and (contains? ns-metadata :parallel)
|
||||
(false? (:parallel ns-metadata))))]
|
||||
(when (and (contains? combined-metadata :parallel)
|
||||
(false? (:parallel combined-metadata)))
|
||||
(hooks/reg-finding! (assoc (meta test-name)
|
||||
:message (str "Use `^:synchronized` instead of `^{:parallel false}`. "
|
||||
"[:metabase/validate-deftest]")
|
||||
:type :metabase/validate-deftest)))
|
||||
(when (:sequential combined-metadata)
|
||||
(hooks/reg-finding! (assoc (meta test-name)
|
||||
:message (str "Use `^:synchronized` to mark this test explicitly non-parallel; "
|
||||
"`^:sequential` is ignored by Hawk. "
|
||||
"[:metabase/validate-deftest]")
|
||||
:type :metabase/validate-deftest)))
|
||||
parallel? (:parallel combined-metadata)
|
||||
synchronized? (:synchronized combined-metadata)]
|
||||
(when (and parallel? synchronized?)
|
||||
(hooks/reg-finding! (assoc (meta test-name)
|
||||
:message "Test should not be marked both `^:parallel` and `^:synchronized`"
|
||||
:message "Test should not be marked both ^:parallel and ^:synchronized"
|
||||
:type :metabase/validate-deftest)))
|
||||
(when (and parallel? ns-explicitly-nonparallel?)
|
||||
;; only when the custom `:metabase/deftest-not-marked-parallel-or-synchronized` is enabled: complain if tests are
|
||||
;; not explicitly marked `^:parallel` or `^:synchronized`. This is mostly to encourage people to mark everything
|
||||
;; `^:parallel` in places like `metabase.lib` tests unless there is a really good reason not to.
|
||||
(when-not (or parallel? synchronized?)
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta test-name)
|
||||
:message (str "Test should not be marked `^:parallel` in a namespace marked "
|
||||
"`^:synchronized` -- "
|
||||
"the ns marker promises no parallel execution. "
|
||||
"Drop `^:parallel` from the deftest, or drop `^:synchronized` from the ns. "
|
||||
"[:metabase/validate-deftest]")
|
||||
:type :metabase/validate-deftest)))
|
||||
;; Only when the custom `:metabase/deftest-not-marked-parallel-or-synchronized` is enabled:
|
||||
;; complain if tests are not explicitly marked `^:parallel` or `^:synchronized`. This is
|
||||
;; mostly to encourage people to mark everything `^:parallel` in places like `metabase.lib`
|
||||
;; tests unless there is a really good reason not to.
|
||||
(when-not (or parallel? explicitly-nonparallel?)
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta test-name)
|
||||
:message "Test should be marked either `^:parallel` or `^:synchronized`"
|
||||
:message "Test should be marked either ^:parallel or ^:synchronized"
|
||||
:type :metabase/deftest-not-marked-parallel-or-synchronized)))
|
||||
(when parallel?
|
||||
(doseq [form body]
|
||||
@@ -104,36 +71,6 @@
|
||||
"Split it up into smaller tests! 🥰")
|
||||
:type :metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests)))))))
|
||||
|
||||
(defn- every-top-level-form-in-deftest-is-testing? [{[_deftest _test-name & forms] :children, :as _node}]
|
||||
(every? (fn [{[first-child] :children, :as form}]
|
||||
(and (hooks/list-node? form)
|
||||
(= (hooks.common/node->qualified-symbol first-child)
|
||||
'clojure.test/testing)))
|
||||
forms))
|
||||
|
||||
(defn- num-top-level-forms-in-deftest [{[_deftest _test-name & forms] :children, :as _node}]
|
||||
(count forms))
|
||||
|
||||
(defn- deftest-check-should-not-be-multiple-separate-tests
|
||||
"Warn on long tests (tests over `min-length`) that look like
|
||||
|
||||
(deftest my-test
|
||||
(testing ...)
|
||||
(testing ...)
|
||||
(testing ...))"
|
||||
[node {:keys [min-length], :as _config}]
|
||||
(let [{:keys [row end-row]} (meta node)
|
||||
test-length (- end-row row)]
|
||||
(when (and min-length
|
||||
(> test-length min-length)
|
||||
(every-top-level-form-in-deftest-is-testing? node)
|
||||
(> (num-top-level-forms-in-deftest node) 1))
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta node)
|
||||
:message (str "This test looks like it contains several logically separate testing forms... break it"
|
||||
" out into separate deftests to make it easier to test and debug")
|
||||
:type :metabase/validate-deftest-logically-separate-tests)))))
|
||||
|
||||
(defn- ignore? [node error-type]
|
||||
(contains? (hooks.common/ignored-linters node) error-type))
|
||||
|
||||
@@ -171,11 +108,8 @@
|
||||
(str/starts-with? (name ns-symb) prefix))
|
||||
["build-drivers."
|
||||
"build."
|
||||
"hooks." ; clj-kondo hook tests, outside the module system
|
||||
"i18n." ; bin/i18n
|
||||
"lint-migrations-file-test"
|
||||
"load-namespaces." ; bin/load-namespaces
|
||||
"mage."
|
||||
"main-test" ; bin/release-list
|
||||
"metabase.deps-edn-test"
|
||||
"metabase.driver."
|
||||
@@ -194,21 +128,20 @@
|
||||
(not (contains? @warned-namespaces ns-symb)))
|
||||
(swap! warned-namespaces conj ns-symb)
|
||||
(let [known-modules (set (keys (:metabase/modules (modules/config input))))
|
||||
current-module (modules/module (modules/config input) ns-symb)]
|
||||
current-module (modules/module ns-symb)]
|
||||
(when (or (not current-module)
|
||||
(not (contains? known-modules current-module)))
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
:message (format "All tests must live in a known module; %s is not a known module" (pr-str current-module))
|
||||
:type :metabase/tests-must-live-in-known-modules)))))))
|
||||
|
||||
(defn deftest [{:keys [node cljc lang config], ns-sym :ns, :as input}]
|
||||
(defn deftest [{:keys [node cljc lang config], :as input}]
|
||||
;; run [[deftest-check-parallel]] only once... if this is a `.cljc` file only run it for the `:clj` analysis, no point
|
||||
;; in running it twice.
|
||||
(when (or (not cljc)
|
||||
(= lang :clj))
|
||||
(deftest-check-parallel node ns-sym (get-in config [:linters :metabase/validate-deftest])))
|
||||
(deftest-check-parallel node (get-in config [:linters :metabase/validate-deftest])))
|
||||
(deftest-check-not-horrifically-long node (get-in config [:linters :metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests]))
|
||||
(deftest-check-should-not-be-multiple-separate-tests node (get-in config [:linters :metabase/validate-deftest-logically-separate-tests]))
|
||||
(deftest-check-no-driver-keywords node (get-in config [:linters :metabase/disallow-hardcoded-driver-names-in-tests]))
|
||||
(deftest-check-in-valid-module input)
|
||||
input)
|
||||
@@ -247,40 +180,11 @@
|
||||
|
||||
nil)))))))
|
||||
|
||||
(defn is [{:keys [node lang], :as input}]
|
||||
(defn is [{:keys [node lang]}]
|
||||
(when (= lang :cljs)
|
||||
(warn-about-missing-test-expr-requires-in-cljs node))
|
||||
input)
|
||||
{:node node})
|
||||
|
||||
(defn use-fixtures
|
||||
"Flag `:parallel/unsafe` forms inside fixtures. Skipped when the surrounding
|
||||
namespace is marked `^:synchronized` -- the ns marker is the author's
|
||||
explicit opt-in to a single-threaded test ns where destructive setup is
|
||||
safe. Without that opt-in, kondo can't know whether sibling deftests will
|
||||
be `^:parallel`, so the conservative default is to flag.
|
||||
|
||||
Sister to [[deftest-check-parallel]]: the deftest hook rejects `^:parallel`
|
||||
tests inside a `^:synchronized` ns, so the two checks together form a
|
||||
coherent opt-in: either the whole ns is explicitly non-parallel (fixtures can be
|
||||
anything; no parallel tests allowed) or it isn't (fixtures must be
|
||||
parallel-safe)."
|
||||
[{:keys [node config], ns-sym :ns, :as input}]
|
||||
(when-not (or (true? (:synchronized (meta ns-sym)))
|
||||
(and (contains? (meta ns-sym) :parallel)
|
||||
(false? (:parallel (meta ns-sym)))))
|
||||
(let [linter-config (get-in config [:linters :metabase/validate-deftest])]
|
||||
(doseq [form (rest (:children node))]
|
||||
(warn-about-disallowed-parallel-forms form linter-config))))
|
||||
input)
|
||||
|
||||
(defn testing
|
||||
"Check that we don't have an empty `testing` form like
|
||||
|
||||
(testing \"message\")"
|
||||
[{{[_testing _message & body] :children, :as node} :node, :as input}]
|
||||
(when (empty? body)
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta node)
|
||||
:message "A `testing` form that doesn't wrap anything doesn't do anything"
|
||||
:type :metabase/check-testing-not-empty)))
|
||||
input)
|
||||
(defn use-fixtures [{:keys [node config]}]
|
||||
(warn-about-disallowed-parallel-forms node config)
|
||||
{:node node})
|
||||
|
||||
@@ -500,8 +500,8 @@
|
||||
(and resolved (:ns resolved))
|
||||
(symbol (name (:ns resolved)) (name (:name resolved)))
|
||||
|
||||
;; if it wasn't resolved but is still qualified it's probably using the full namespace name rather than an
|
||||
;; alias.
|
||||
;; if it wasn't resolved but is still qualified it's probably using the full namespace name rather than an
|
||||
;; alias.
|
||||
(qualified-symbol? sexpr)
|
||||
sexpr)))))
|
||||
;; some symbols like `*count/Integer` aren't resolvable.
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
(ns hooks.common.modules
|
||||
"Resolves namespaces to modules and enforces module boundaries.
|
||||
|
||||
The Kondo linter, config generator, and CI tooling share this namespace so
|
||||
they apply the same rules."
|
||||
(:require
|
||||
[clojure.string :as str]))
|
||||
|
||||
@@ -16,146 +12,30 @@
|
||||
(merge (get-in config [:linters :metabase/modules])
|
||||
(select-keys config [:metabase/modules])))
|
||||
|
||||
;;;; Module tree. These functions take the `:metabase/modules` map, or the prefix map built from it.
|
||||
;;;;
|
||||
;;;; Dots express nesting: `lib.schema` is a child of `lib`. A declared
|
||||
;;;; `enterprise/X` module is also a child of OSS module `X`. A namespace
|
||||
;;;; belongs to the module with the most specific matching `:ns-prefix`.
|
||||
|
||||
(defn default-ns-prefix
|
||||
"Namespace prefix a module owns unless it sets `:ns-prefix`: `metabase.lib.schema` for `lib.schema`,
|
||||
`metabase-enterprise.foo` for `enterprise/foo`."
|
||||
[module]
|
||||
(str (if (= "enterprise" (namespace module)) "metabase-enterprise." "metabase.") (name module)))
|
||||
|
||||
(defn module-ns-prefix
|
||||
"Namespace prefix `module` owns: its `:ns-prefix`, else [[default-ns-prefix]]."
|
||||
[modules module]
|
||||
(or (get-in modules [module :ns-prefix])
|
||||
(default-ns-prefix module)))
|
||||
|
||||
(defn build-prefix->module
|
||||
"Map each declared namespace prefix to its module, for [[declared-module]]."
|
||||
[modules]
|
||||
(into {}
|
||||
(map (fn [module] [(module-ns-prefix modules module) module]))
|
||||
(keys modules)))
|
||||
|
||||
(defn declared-module
|
||||
"Resolve `ns-symb` to its declared module, or `nil`.
|
||||
|
||||
Chooses the most specific dotted prefix and ignores a trailing `-test`."
|
||||
[prefix->module ns-symb]
|
||||
(loop [candidate (str/replace (str ns-symb) #"-test$" "")]
|
||||
(or (get prefix->module candidate)
|
||||
(when-let [dot (str/last-index-of candidate ".")]
|
||||
(recur (subs candidate 0 dot))))))
|
||||
|
||||
(defn resolve-module
|
||||
(defn module
|
||||
"E.g.
|
||||
|
||||
(resolve-module prefix->module 'metabase.qp.middleware.wow) => 'qp
|
||||
(resolve-module prefix->module 'metabase-enterprise.whatever.core) => enterprise/whatever
|
||||
|
||||
An unmatched Metabase namespace resolves to its first segment so the linter can report an undeclared module."
|
||||
[prefix->module ns-symb]
|
||||
(module 'metabase.qp.middleware.wow) => 'qp
|
||||
(module 'metabase-enterprise.whatever.core) => enterprise/whatever"
|
||||
[ns-symb]
|
||||
{:pre [(simple-symbol? ns-symb)]}
|
||||
;; treat something like `metabase.driver-test` (for a module that hasn't fully been updated to use `.core`
|
||||
;; namespaces) as being in the `driver` module
|
||||
(let [ns-symb (if (str/ends-with? (name ns-symb) "-test")
|
||||
(symbol (str/replace (name ns-symb) #"-test$" ""))
|
||||
ns-symb)]
|
||||
(or (declared-module prefix->module ns-symb)
|
||||
(some->> (re-find #"^metabase-enterprise\.([^.]+)" (str ns-symb))
|
||||
(or (some->> (re-find #"^metabase-enterprise\.([^.]+)" (str ns-symb))
|
||||
second
|
||||
(symbol "enterprise"))
|
||||
(some-> (re-find #"^metabase\.([^.]+)" (str ns-symb))
|
||||
second
|
||||
symbol))))
|
||||
|
||||
(defn parent-module
|
||||
"The direct parent of `module`, or `nil` when it is top-level."
|
||||
[modules module]
|
||||
(let [module-name (name module)]
|
||||
(if-let [dot (str/last-index-of module-name ".")]
|
||||
(symbol (namespace module) (subs module-name 0 dot))
|
||||
(when (= "enterprise" (namespace module))
|
||||
(let [oss (symbol module-name)]
|
||||
(when (contains? modules oss)
|
||||
oss))))))
|
||||
|
||||
(defn descendant-of?
|
||||
"Whether `module` is `ancestor` or one of its descendants."
|
||||
[modules module ancestor]
|
||||
(boolean (some #{ancestor} (take-while some? (iterate #(parent-module modules %) module)))))
|
||||
|
||||
(defn module-team
|
||||
"Team owning `module`: its own `:team`, else its nearest ancestor's."
|
||||
[modules module]
|
||||
(some #(get-in modules [% :team])
|
||||
(take-while some? (iterate #(parent-module modules %) module))))
|
||||
|
||||
(defn- rest-module? [module]
|
||||
(re-find #"[.-]rest$" (str module)))
|
||||
|
||||
(defn- exports-child?
|
||||
[modules parent child]
|
||||
(or (contains? (set (get-in modules [parent :module-exports])) child)
|
||||
;; A `.rest` child is its parent's public HTTP surface.
|
||||
(rest-module? child)
|
||||
;; OSS module `X` implicitly exports its `enterprise/X` companion.
|
||||
(and (not= (namespace parent) (namespace child))
|
||||
(contains? modules child))))
|
||||
|
||||
(defn- blocking-export
|
||||
"The first missing export from `module` toward the root.
|
||||
|
||||
Returns `{:ancestor parent, :child child}`, or `nil` when every link is
|
||||
exported."
|
||||
[modules module]
|
||||
(loop [child module]
|
||||
(when-let [ancestor (parent-module modules child)]
|
||||
(if (exports-child? modules ancestor child)
|
||||
(recur ancestor)
|
||||
{:ancestor ancestor, :child child}))))
|
||||
|
||||
(defn namability-error
|
||||
"Explain why `caller` may not refer to `target` in `:uses`, or return `nil` if it may.
|
||||
|
||||
A nested module is private to the nearest ancestor with a missing export.
|
||||
Exporting every link makes it public."
|
||||
[modules caller target]
|
||||
(when-let [{:keys [ancestor child]} (blocking-export modules target)]
|
||||
(when-not (descendant-of? modules caller ancestor)
|
||||
(format (str "Module %s is nested and not exported by its ancestors; %s may not use it. Add %s to %s's "
|
||||
":module-exports, or move the caller into the %s subtree. [:metabase/modules %s :module-exports]")
|
||||
target caller child ancestor ancestor ancestor))))
|
||||
|
||||
;;;; Lint rules. These functions take the full linter config.
|
||||
|
||||
(defonce ^:private prefix->module-cache
|
||||
(atom nil))
|
||||
|
||||
(defn- prefix->module
|
||||
"[[build-prefix->module]] for `config`, cached because Kondo hands every hook call the same `:metabase/modules` map."
|
||||
[config]
|
||||
(let [modules (:metabase/modules config)
|
||||
cached @prefix->module-cache]
|
||||
(if (identical? modules (:modules cached))
|
||||
(:prefix->module cached)
|
||||
(:prefix->module (reset! prefix->module-cache {:modules modules
|
||||
:prefix->module (build-prefix->module modules)})))))
|
||||
|
||||
(defn module
|
||||
"Resolve `ns-symb` to its module under `config`; see [[resolve-module]]."
|
||||
[config ns-symb]
|
||||
(resolve-module (prefix->module config) ns-symb))
|
||||
|
||||
(defn module-api-namespaces
|
||||
(defn- module-api-namespaces
|
||||
"Set of API namespace symbols for a given module. `:any` means you can use anything, there are no API namespaces for
|
||||
this module (yet). If unspecified, the defaults are `<prefix>.api`, `<prefix>.core`, and `<prefix>.init`."
|
||||
[modules module]
|
||||
(let [module-config (get-in modules [module :api])]
|
||||
this module (yet). If unspecified, the default is just the `<module>.core` namespace."
|
||||
[config module]
|
||||
(let [module-config (get-in config [:metabase/modules module :api])]
|
||||
(cond
|
||||
(= module-config :any)
|
||||
nil
|
||||
@@ -164,7 +44,9 @@
|
||||
module-config
|
||||
|
||||
:else
|
||||
(let [ns-prefix (module-ns-prefix modules module)]
|
||||
(let [ns-prefix (if (= (namespace module) "enterprise")
|
||||
(str "metabase-enterprise." (name module))
|
||||
(name module))]
|
||||
#{(symbol (str ns-prefix ".api"))
|
||||
(symbol (str ns-prefix ".core"))
|
||||
(symbol (str ns-prefix ".init"))}))))
|
||||
@@ -186,14 +68,15 @@
|
||||
(contains? (set allowed-modules) required-module))))
|
||||
|
||||
(defn- allowed-module-namespace? [config current-module ns-symb]
|
||||
(let [module (module config ns-symb)
|
||||
module-api-namespaces (module-api-namespaces (:metabase/modules config) module)
|
||||
(let [module (module ns-symb)
|
||||
module-api-namespaces (module-api-namespaces config module)
|
||||
module-friends (module-friends config module)]
|
||||
(or (nil? module-api-namespaces)
|
||||
(or (empty? module-api-namespaces)
|
||||
(contains? module-api-namespaces ns-symb)
|
||||
(contains? module-friends current-module)
|
||||
;; a child may use its ancestors' internals; a parent still goes through its child's `:api`
|
||||
(descendant-of? (:metabase/modules config) current-module module))))
|
||||
(contains? module-friends current-module))))
|
||||
|
||||
(defn- rest-module? [module]
|
||||
(str/ends-with? module "-rest"))
|
||||
|
||||
(defn- routes-module? [module]
|
||||
(str/ends-with? module "-routes"))
|
||||
@@ -206,7 +89,7 @@
|
||||
error type if there is one, otherwise `nil` if there are no errors."
|
||||
[config current-module required-namespace]
|
||||
;; ignore stuff not in a module i.e. non-Metabase stuff.
|
||||
(when-let [required-module (module config required-namespace)]
|
||||
(when-let [required-module (module required-namespace)]
|
||||
(when-not (= current-module required-module)
|
||||
(cond
|
||||
(not (allowed-module? config current-module required-module))
|
||||
@@ -230,8 +113,4 @@
|
||||
required-module
|
||||
current-module
|
||||
required-module
|
||||
(symbol (str/replace required-module #"[.-]rest$" "")))
|
||||
|
||||
;; Config tests check explicit `:uses`; wildcard callers are checked here.
|
||||
(= :any (allowed-modules config current-module))
|
||||
(namability-error (:metabase/modules config) current-module required-module)))))
|
||||
(symbol (str/replace required-module #"-rest$" "")))))))
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
(ns hooks.metabase.agent-message
|
||||
"Hooks linting agent-facing prose built with `metabase.mcp.v2.message/msg` (`:metabase/agent-message-lines`) and the
|
||||
exits and helpers that carry it to the agent (`:metabase/agent-message-exit`). Each hook checks only when its
|
||||
linter's level is configured and not `:off`, and returns its input unchanged."
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn- level-on?
|
||||
[config linter-key]
|
||||
(not= :off (get-in config [:linters linter-key :level] :off)))
|
||||
|
||||
(defn- call-name
|
||||
"The symbol at the head of list `node`, or nil."
|
||||
[node]
|
||||
(when (hooks/list-node? node)
|
||||
(let [head (first (:children node))]
|
||||
(when (hooks/token-node? head)
|
||||
(let [v (hooks/sexpr head)]
|
||||
(when (symbol? v) v))))))
|
||||
|
||||
(defn- calls?
|
||||
"Whether `node` is a call to a var named `var-name` in one of `ns-names` (nil for an unqualified call)."
|
||||
[node ns-names var-name]
|
||||
(when-let [sym (call-name node)]
|
||||
(and (= var-name (name sym))
|
||||
(contains? ns-names (namespace sym)))))
|
||||
|
||||
(def ^:private core-ns #{nil "clojure.core"})
|
||||
(def ^:private string-ns #{"str" "string" "clojure.string"})
|
||||
|
||||
(defn- literal-string
|
||||
"The string value of `node` when it is a string literal or a `str` call over string literals, else nil."
|
||||
[node]
|
||||
(cond
|
||||
(hooks/string-node? node)
|
||||
(hooks/sexpr node)
|
||||
|
||||
(and (calls? node core-ns "str")
|
||||
(every? hooks/string-node? (rest (:children node))))
|
||||
(apply str (map hooks/sexpr (rest (:children node))))))
|
||||
|
||||
(defn- digits->long
|
||||
"The value of a string of decimal digits."
|
||||
[s]
|
||||
;; Kondo's hook interpreter has no `parse-long`.
|
||||
(reduce (fn [n c] (+ (* 10 n) (- (int c) (int \0)))) 0 s))
|
||||
|
||||
(def ^:private format-specifier
|
||||
"A `java.util.Formatter` specifier; groups are explicit index, flags, width, precision, and conversion. The index is
|
||||
capped at nine digits, so a longer one reads as malformed rather than a number too large to work with."
|
||||
#"%(?:(\d{1,9})\$)?([-#+ 0,(<]*)(\d+)?(?:\.(\d+))?([tT]?[a-zA-Z%])")
|
||||
|
||||
(defn- consumed-args
|
||||
"`[index conversion]` for each argument-consuming specifier in `fmt`, with zero-based argument indexes. An index of
|
||||
-1 marks a reference no argument can satisfy: an explicit `0$`, or a `<` with no specifier before it to repeat."
|
||||
[fmt]
|
||||
(loop [[[_ explicit flags _width _precision conversion] & more] (re-seq format-specifier fmt)
|
||||
next-index 0
|
||||
last-index nil
|
||||
acc []]
|
||||
(if-not conversion
|
||||
acc
|
||||
(let [consumes? (not (contains? #{"%" "n"} conversion))
|
||||
index (cond
|
||||
(not consumes?) nil
|
||||
explicit (dec (digits->long explicit))
|
||||
(str/includes? flags "<") (or last-index -1)
|
||||
:else next-index)
|
||||
implicit? (and consumes? (not explicit) (not (str/includes? flags "<")))]
|
||||
(recur more
|
||||
(cond-> next-index implicit? inc)
|
||||
(or index last-index)
|
||||
(cond-> acc
|
||||
index (conj [index conversion])))))))
|
||||
|
||||
(def ^:private msg-linter :metabase/agent-message-lines)
|
||||
|
||||
(defn- reg-msg-finding!
|
||||
[node message]
|
||||
(hooks/reg-finding! (assoc (meta node) :message message :type msg-linter)))
|
||||
|
||||
(defn- line-break?
|
||||
"Whether format string `line` contains a line-breaking, control, or private-use character, or a `%n` specifier."
|
||||
[line]
|
||||
;; Private-use characters delimit the argument markers `render` formats with, so a line holding one could fake one.
|
||||
(boolean (or (re-find #"[\p{Cc}\p{Cf}\p{Co}\p{Zl}\p{Zp}]" line)
|
||||
(some #(= "n" (nth % 5)) (re-seq format-specifier line)))))
|
||||
|
||||
(defn- allowed-specifier?
|
||||
"Whether specifier match `specifier` renders its value as `render` passes it: a bare `%%`, a `%s` with no flags but
|
||||
`<`, or a `%d` with any flags and width but no precision. An explicit argument index is allowed on each."
|
||||
[[_ explicit flags width precision conversion]]
|
||||
(case conversion
|
||||
"%" (not (or explicit (seq flags) width precision))
|
||||
"s" (not (or width precision (seq (str/replace flags "<" ""))))
|
||||
"d" (not precision)
|
||||
false))
|
||||
|
||||
(defn- disallowed-specifier
|
||||
"The first specifier in format string `line` that isn't [[allowed-specifier?]], as it is written, or nil."
|
||||
[line]
|
||||
(some #(when-not (allowed-specifier? %) (first %)) (re-seq format-specifier line)))
|
||||
|
||||
(def ^:private valid-conversion
|
||||
"The conversions `java.util.Formatter` accepts, with each date/time conversion's required suffix."
|
||||
#"[bBhHsScCdoxXeEfgGaA%n]|[tT][HIklMSLNpzZsQBbhAaCYyjmdeRTrDFc]")
|
||||
|
||||
(defn- malformed-specifier?
|
||||
"Whether format string `line` has a `%` that doesn't begin a valid `java.util.Formatter` specifier."
|
||||
[line]
|
||||
(let [valid-removed (str/replace line format-specifier
|
||||
(fn [[specifier & groups]]
|
||||
(if (re-matches valid-conversion (last groups)) "" specifier)))]
|
||||
(str/includes? valid-removed "%")))
|
||||
|
||||
(defn- line-problem
|
||||
"The finding message for the first problem with a `msg` line, given its literal string or nil; nil when it has none."
|
||||
[line]
|
||||
(cond
|
||||
(nil? line)
|
||||
(str "Each line of a `msg` must be a string literal or a `str` of string literals; "
|
||||
"pass values as arguments after the vector.")
|
||||
|
||||
(line-break? line)
|
||||
(str "A `msg` line can't contain a line break, `%n`, or a control or private-use character; "
|
||||
"put each line in its own string.")
|
||||
|
||||
(malformed-specifier? line)
|
||||
"A `%` in a `msg` line must begin a format specifier; write a literal percent sign as `%%`."
|
||||
|
||||
:else
|
||||
(when-let [specifier (disallowed-specifier line)]
|
||||
(str "A `msg` line may only use `%s`, `%d`, or `%%`; `" specifier "` would alter or hide the value."))))
|
||||
|
||||
(defn- lint-lines!
|
||||
"Flag each of `line-nodes` whose literal string, the matching element of `lines`, has a problem."
|
||||
[line-nodes lines]
|
||||
(doseq [[line-node line] (map vector line-nodes lines)]
|
||||
(when-let [problem (line-problem line)]
|
||||
(reg-msg-finding! line-node problem))))
|
||||
|
||||
(defn- plural
|
||||
[n word]
|
||||
(str n " " word (when-not (= 1 n) "s")))
|
||||
|
||||
(defn- lint-arg-count!
|
||||
"Flag `msg` call `node` when a literal line refers to an argument that can't exist, or when its `args` don't number
|
||||
the arguments its literal `lines` consume."
|
||||
[node lines args]
|
||||
(let [indexes (map first (consumed-args (str/join "\n" lines)))
|
||||
expected (if (seq indexes) (inc (apply max indexes)) 0)
|
||||
given (count args)]
|
||||
(cond
|
||||
;; `String/format` throws on these, so the message would render with every part quoted instead.
|
||||
(some neg? indexes)
|
||||
(reg-msg-finding! node (str "A `msg` line refers to an argument that can't exist; "
|
||||
"argument indexes start at `1$`, and `%<` repeats the specifier before it."))
|
||||
|
||||
(not= expected given)
|
||||
(reg-msg-finding! node (format "The `msg` lines take %s but %d %s given."
|
||||
(plural expected "argument") given (if (= 1 given) "is" "are"))))))
|
||||
|
||||
(defn lint-msg
|
||||
"Flag `msg` calls whose lines aren't a literal vector of single-line literal strings, that hold a malformed
|
||||
specifier or a conversion other than `%s`, `%d`, or `%%`, or whose arguments don't match the lines' specifiers."
|
||||
[{:keys [node config] :as input}]
|
||||
(when (level-on? config msg-linter)
|
||||
(let [[fn-node lines-node & args] (:children node)]
|
||||
(if-not (and lines-node (hooks/vector-node? lines-node))
|
||||
(reg-msg-finding! (or lines-node fn-node)
|
||||
"`msg` takes a literal vector of line strings, one string per line of the message.")
|
||||
(let [line-nodes (:children lines-node)
|
||||
lines (map literal-string line-nodes)]
|
||||
(lint-lines! line-nodes lines)
|
||||
;; A malformed specifier can't be counted, and its line already has a finding.
|
||||
(when (and (every? some? lines) (not-any? malformed-specifier? lines))
|
||||
(lint-arg-count! node lines args))))))
|
||||
input)
|
||||
|
||||
(def ^:private exit-linter :metabase/agent-message-exit)
|
||||
|
||||
(defn- exit-enabled?
|
||||
[{:keys [config]}]
|
||||
(level-on? config exit-linter))
|
||||
|
||||
(defn- reg-exit-finding!
|
||||
[node message]
|
||||
(hooks/reg-finding! (assoc (meta node) :message message :type exit-linter)))
|
||||
|
||||
(defn- calls-core?
|
||||
"Whether `node` is a call to a `clojure.core` var or special form named in `var-names`."
|
||||
[node var-names]
|
||||
(when-let [sym (call-name node)]
|
||||
(and (contains? var-names (name sym))
|
||||
(contains? core-ns (namespace sym)))))
|
||||
|
||||
(defn- calls-i18n?
|
||||
[node]
|
||||
(when-let [sym (call-name node)]
|
||||
(contains? #{"tru" "trs" "deferred-tru" "deferred-trs"} (name sym))))
|
||||
|
||||
(defn- calls-json-encode?
|
||||
"Whether `node` calls `encode` in a namespace or alias whose name ends in `json`."
|
||||
[node]
|
||||
(when-let [sym (call-name node)]
|
||||
(and (= "encode" (name sym))
|
||||
(boolean (some-> (namespace sym) (str/ends-with? "json"))))))
|
||||
|
||||
(defn- builds-string?
|
||||
"Whether `node` is a string literal or a call that builds a string."
|
||||
[node]
|
||||
(or (hooks/string-node? node)
|
||||
(calls-core? node #{"str" "format" "pr-str" "ex-message"})
|
||||
(calls-i18n? node)
|
||||
(calls? node string-ns "join")
|
||||
(calls-json-encode? node)))
|
||||
|
||||
(defn- clause-results
|
||||
"The result nodes of `clauses`, alternating test and result nodes, with a trailing default when their count is odd."
|
||||
[clauses]
|
||||
(concat (take-nth 2 (rest clauses))
|
||||
(when (odd? (count clauses)) [(last clauses)])))
|
||||
|
||||
(defn- result-fn-marker?
|
||||
[node]
|
||||
(and (hooks/keyword-node? node) (= :>> (hooks/sexpr node))))
|
||||
|
||||
(defn- condp-results
|
||||
"The result nodes of `condp` `clauses`: each test's result, skipping `:>>` result functions, and any default."
|
||||
[clauses]
|
||||
(loop [[_test result & more :as clauses] clauses
|
||||
acc []]
|
||||
(cond
|
||||
(empty? clauses) acc
|
||||
(nil? (next clauses)) (conj acc (first clauses))
|
||||
(result-fn-marker? result) (recur (next more) acc)
|
||||
:else (recur more (conj acc result)))))
|
||||
|
||||
(defn- branch-results
|
||||
"The nodes branching form `node` can evaluate to, or nil when it isn't one."
|
||||
[node]
|
||||
(let [args (rest (:children node))]
|
||||
(cond
|
||||
(calls-core? node #{"if" "if-not" "if-let"}) (rest args)
|
||||
(calls-core? node #{"or"}) args
|
||||
(calls-core? node #{"cond"}) (clause-results args)
|
||||
(calls-core? node #{"case"}) (clause-results (rest args))
|
||||
(calls-core? node #{"condp"}) (condp-results (drop 2 args)))))
|
||||
|
||||
(defn- step-call
|
||||
"Threading step `node` as the call it stands for: a list as is, anything else as a call with no written arguments."
|
||||
[node]
|
||||
(if (hooks/list-node? node) node (hooks/list-node [node])))
|
||||
|
||||
(defn- threading-results
|
||||
"The nodes whose text-ness decides what threading form `node` evaluates to, or nil when it isn't one."
|
||||
[node]
|
||||
(let [[init & steps] (rest (:children node))]
|
||||
(cond
|
||||
(calls-core? node #{"->" "->>" "some->" "some->>"}) [(if steps (step-call (last steps)) init)]
|
||||
(calls-core? node #{"cond->" "cond->>"}) (cons init (map step-call (take-nth 2 (rest steps))))
|
||||
(calls-core? node #{"as->"}) [(or (last (rest steps)) init)]
|
||||
(calls-core? node #{"doto"}) [init])))
|
||||
|
||||
(defn- tail-result
|
||||
"The node body form `node` evaluates to, or nil when it isn't one."
|
||||
[node]
|
||||
(let [args (rest (:children node))]
|
||||
(cond
|
||||
(calls-core? node #{"when" "when-not" "when-let" "let"}) (when (next args) (last args))
|
||||
(calls-core? node #{"do"}) (last args))))
|
||||
|
||||
(defn- stringy?
|
||||
"Whether `node` syntactically evaluates to text: a string literal, a call that builds a string, or a threading,
|
||||
branching, or body form whose result is one."
|
||||
[node]
|
||||
(boolean
|
||||
(or (builds-string? node)
|
||||
(some stringy? (branch-results node))
|
||||
(some stringy? (threading-results node))
|
||||
(some-> (tail-result node) stringy?))))
|
||||
|
||||
(defn- lint-exit-text!
|
||||
"Flag `arg`, the message passed to `target` (a description of where it goes), when it is text rather than a `msg`."
|
||||
[arg target]
|
||||
(when (and arg (stringy? arg))
|
||||
(reg-exit-finding! arg (format (str "`%s` passes text to %s; "
|
||||
"build agent-facing text with `msg` so interpolated values are cleaned.")
|
||||
(pr-str (hooks/sexpr arg))
|
||||
target))))
|
||||
|
||||
(defn- lint-exit-arg!
|
||||
"Flag the argument at child `index` of call `node` when it is text rather than a `msg`."
|
||||
[node index]
|
||||
(lint-exit-text! (nth (:children node) index nil) (str "`" (name (call-name node)) "`")))
|
||||
|
||||
(defn lint-teaching-exit
|
||||
"Flag a call throwing or returning a teaching error whose message, the first argument, is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-arg! node 1))
|
||||
input)
|
||||
|
||||
(defn lint-jsonrpc-error
|
||||
"Flag a `jsonrpc-error` whose message, the third argument, is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-arg! node 3))
|
||||
input)
|
||||
|
||||
(defn lint-op-error
|
||||
"Flag an `op-error!` whose detail message, the third argument, is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-arg! node 3))
|
||||
input)
|
||||
|
||||
(defn lint-ellipsize
|
||||
"Flag an `ellipsize` whose message, the first argument, is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-arg! node 1))
|
||||
input)
|
||||
|
||||
(defn- map-value
|
||||
"The value node of keyword `k` in map literal `node`, or nil."
|
||||
[node k]
|
||||
(when (hooks/map-node? node)
|
||||
(some (fn [[key-node value-node]]
|
||||
(when (and (hooks/keyword-node? key-node) (= k (hooks/sexpr key-node)))
|
||||
value-node))
|
||||
(partition 2 (:children node)))))
|
||||
|
||||
(defn lint-list-content
|
||||
"Flag a `list-content`, or a wrapper taking its options map as the third argument too, whose literal options map
|
||||
has an `:empty-hint` that is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-text! (map-value (nth (:children node) 3 nil) :empty-hint)
|
||||
(str "`" (name (call-name node)) "`'s `:empty-hint`")))
|
||||
input)
|
||||
|
||||
(defn lint-success-content
|
||||
"Flag a `success-content` whose first argument is text rather than a payload or a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(let [text (second (:children node))]
|
||||
(when (and text (stringy? text))
|
||||
(reg-exit-finding! text (str "`success-content` takes data to JSON-encode or a `msg`; "
|
||||
"pass the payload itself, or build the text with `msg`.")))))
|
||||
input)
|
||||
|
||||
(defn- caller-facing-data?
|
||||
"Whether `node` is a map literal with a 4xx `:status-code` literal or an `error-code` key."
|
||||
[node]
|
||||
(and (hooks/map-node? node)
|
||||
(some (fn [[k v]]
|
||||
(when (hooks/keyword-node? k)
|
||||
(let [kw (hooks/sexpr k)]
|
||||
(or (= "error-code" (name kw))
|
||||
(and (= :status-code kw)
|
||||
(hooks/token-node? v)
|
||||
(let [status (hooks/sexpr v)]
|
||||
(and (number? status) (<= 400 status 499))))))))
|
||||
(partition 2 (:children node)))))
|
||||
|
||||
(defn lint-ex-info
|
||||
"Flag an `ex-info` whose data marks it as a caller-facing error."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(when (caller-facing-data? (nth (:children node) 2 nil))
|
||||
(reg-exit-finding! node (str "Caller-facing errors must be thrown with `throw-teaching-error` and a `msg`, "
|
||||
"so their text is rendered and cleaned."))))
|
||||
input)
|
||||
@@ -1,22 +1,8 @@
|
||||
(ns hooks.metabase.app-db.schema-migrations-test.impl
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]))
|
||||
(:require [clj-kondo.hooks-api :as hooks]))
|
||||
|
||||
(defn- validate-mb-app-db-migrations-test!
|
||||
"CI runs the namespaces tagged `:mb/app-db-migrations-test` on a shard of their own, because a count-based split
|
||||
can't balance them (see `.github/workflows/app-db.yml`). An untagged one still runs, just on a shard sized for
|
||||
everything else."
|
||||
[{:keys [ns node]}]
|
||||
(when-not (:mb/app-db-migrations-test (meta ns))
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta node)
|
||||
:message (str "Namespaces that run migration tests (test-migrations) should be marked"
|
||||
" ^:mb/app-db-migrations-test [:metabase/validate-mb-app-db-migrations-test]")
|
||||
:type :metabase/validate-mb-app-db-migrations-test))))
|
||||
|
||||
(defn test-migrations!
|
||||
[{{[_ migration-range {[binding] :children} & body] :children} :node, :as x}]
|
||||
(validate-mb-app-db-migrations-test! x)
|
||||
(defn test-migrations
|
||||
[{{[_ migration-range {[binding] :children} & body] :children} :node}]
|
||||
(let [node* (hooks/list-node
|
||||
(list*
|
||||
(hooks/token-node `let)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
(ns hooks.metabase.mq.impl
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]))
|
||||
|
||||
(defn def-listener
|
||||
"Kondo hook for `mq/def-listener`. Rewrites to a `fn` form so kondo can analyze bindings.
|
||||
|
||||
Form:
|
||||
(mq/def-listener! :queue/bar [msgs] body...)"
|
||||
[{:keys [node]}]
|
||||
(let [[_ _channel-name bindings & body] (:children node)
|
||||
;; rewrite as: (fn [bindings...] body...)
|
||||
new-node (hooks/list-node
|
||||
(list*
|
||||
(hooks/token-node 'fn)
|
||||
bindings
|
||||
body))]
|
||||
{:node new-node}))
|
||||
@@ -4,9 +4,9 @@
|
||||
[hooks.common :as common]))
|
||||
|
||||
(defn lint-defsetting-namespace [node context]
|
||||
(when-not (re-matches #"^metabase(?:-enterprise)?\.([^\.]+\.)+settings$" (name (:ns context)))
|
||||
(when-not (re-matches #"^metabase(?:-enterprise)?\.[^\.]+\.settings$" (name (:ns context)))
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
:message "All defsettings should live in a namespace ending in .settings under metabase[-enterprise]"
|
||||
:message "All defsettings should live in metabase[-enterprise].<module>.settings namespaces"
|
||||
:type :metabase/defsetting-namespace))))
|
||||
|
||||
;;; TODO -- move this into a Kondo config file in `.clj-kondo/config/`
|
||||
@@ -95,8 +95,6 @@
|
||||
metabot-feedback-url
|
||||
metabot-get-prompt-templates-url
|
||||
metabot-prompt-generator-token-limit
|
||||
metaplow-tracking-enabled
|
||||
metaplow-url
|
||||
multi-setting-read-only
|
||||
notebook-native-preview-sidebar-width
|
||||
notification-link-base-url
|
||||
@@ -286,7 +284,6 @@
|
||||
(update context :node update-node)))
|
||||
|
||||
(comment
|
||||
(require '[clojure.pprint])
|
||||
(defn- defsetting* [form]
|
||||
(hooks/sexpr
|
||||
(:node
|
||||
@@ -294,6 +291,7 @@
|
||||
{:node
|
||||
(hooks/parse-string
|
||||
(with-out-str
|
||||
#_{:clj-kondo/ignore [:unresolved-namespace]}
|
||||
(clojure.pprint/pprint
|
||||
form)))}))))
|
||||
|
||||
|
||||
14
.clj-kondo/src/hooks/metabase/test/gentest.clj
Normal file
14
.clj-kondo/src/hooks/metabase/test/gentest.clj
Normal file
@@ -0,0 +1,14 @@
|
||||
(ns hooks.metabase.test.gentest
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]))
|
||||
|
||||
(defn iterate*
|
||||
"(iterate {:as limit-spec} [s1 val1 & more] body-form body-form...)"
|
||||
[{{:keys [children] :as node} :node :as orig}]
|
||||
(let [[token limit-spec bindings & body-nodes] children
|
||||
node* (-> (hooks/list-node
|
||||
(list token limit-spec
|
||||
(hooks/list-node (list* (hooks/token-node 'let) bindings
|
||||
body-nodes))))
|
||||
(with-meta (meta node)))]
|
||||
(assoc orig :node node*)))
|
||||
@@ -28,8 +28,8 @@
|
||||
(mapcat (fn [[setting-name v]]
|
||||
(concat
|
||||
[(with-meta (hooks/token-node '_) (meta setting-name)) v]
|
||||
;; if the setting name is namespace-qualified add a `_`
|
||||
;; entry for it too.
|
||||
;; if the setting name is namespace-qualified add a `_`
|
||||
;; entry for it too.
|
||||
(when (namespaced-symbol-node? setting-name)
|
||||
[(with-meta (hooks/token-node '_) (meta setting-name)) setting-name]))))
|
||||
(partition 2 (:children bindings))))
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
(ns hooks.metabase.toucan.db-ns
|
||||
"Lint that application database query calls -- Toucan 2's `t2/select`, `t2/query`, `t2/insert!`, `t2/update!`,
|
||||
`t2/delete!` and friends, plus the `metabase.app-db.core` wrappers around them such as `mdb/query` and
|
||||
`mdb/update-or-insert!` -- live in a module's `db` namespace, i.e. `metabase[-enterprise].<module>.db` (or `metabase.driver.<driver>.db` for
|
||||
driver modules). Every other namespace in a module goes through those functions instead of talking to the
|
||||
application database directly.
|
||||
|
||||
Registered as an `:analyze-call` hook on each of those functions in `.clj-kondo/config.edn`. The hook
|
||||
returns its input unchanged so Kondo's normal analysis of the call (arity, var usage) still runs."
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[hooks.common.modules :as modules]))
|
||||
|
||||
(def ^:private driver-db-namespace
|
||||
"Matches `metabase.driver.<driver>.db`; individual drivers are not modules."
|
||||
#"^metabase\.driver\.[^.]+\.db$")
|
||||
|
||||
(defn- db-namespace? [config ns-sym]
|
||||
(let [ns-str (name ns-sym)]
|
||||
(boolean
|
||||
;; Resolve ownership first: a nested `.db` name alone does not make a module.
|
||||
(or (when-let [module (modules/module config ns-sym)]
|
||||
(= ns-str (str (modules/module-ns-prefix (:metabase/modules config) module) ".db")))
|
||||
(re-matches driver-db-namespace ns-str)))))
|
||||
|
||||
(defn- test-file?
|
||||
"Whether `filename` is in a test source tree. Test namespaces are exempt through the `test-namespaces` group in
|
||||
`config.edn`, but helper namespaces like `metabase.sso.test-helpers` don't match that group's pattern."
|
||||
[filename]
|
||||
(boolean (and filename (re-find #"(?:^|/)test/" filename))))
|
||||
|
||||
(def ^:private value-operators
|
||||
"Operators whose second argument is a value rather than a column."
|
||||
'#{= not= != <> is-distinct-from is-not-distinct-from
|
||||
< > <= >= like not-like ilike not-ilike in not-in between not-between})
|
||||
|
||||
(defn- marked?
|
||||
"Whether `node` is an `[:auto/param v]` marker."
|
||||
[node]
|
||||
(and (hooks/vector-node? node)
|
||||
(= :auto/param (some-> (first (:children node)) hooks/sexpr))))
|
||||
|
||||
(defn- distinct-by
|
||||
"`coll` with only the first element for each distinct `(f element)`."
|
||||
[f coll]
|
||||
(->> coll (reduce (fn [[seen acc] x]
|
||||
(let [k (f x)]
|
||||
(if (contains? seen k) [seen acc] [(conj seen k) (conj acc x)])))
|
||||
[#{} []])
|
||||
second))
|
||||
|
||||
(def ^:private identifier-clauses
|
||||
"Clause keys whose entries name a column or a table rather than carrying a value.
|
||||
|
||||
A symbol here is a name, and `metabase.app-db.value-guard` refuses a marker in one, so reporting
|
||||
it would ask for a fix that throws at compile. `:order-by` and `:group-by` are included even
|
||||
though the guard allows a marker there: it compiles to `ORDER BY ?` and loses the ordering, so
|
||||
asking for one is still wrong."
|
||||
#{:select :select-distinct :select-top :from :join :left-join :right-join :inner-join :full-join
|
||||
:cross-join :update :insert-into :delete-from :returning :with :with-columns :using
|
||||
:order-by :group-by :partition-by :window})
|
||||
|
||||
(def ^:private expression-entry-clauses
|
||||
"Identifier clauses where index 0 of an `[expr alias]` entry is an expression, not a name.
|
||||
|
||||
HoneySQL binds a parameter there -- `SELECT ? AS a` -- so the guard accepts a marker, and mirrors
|
||||
this set as `expression-entry-clauses` in `metabase.app-db.value-guard`. Everything after index 0
|
||||
is an alias, and a bare entry is a name; the guard refuses a marker in both."
|
||||
#{:select :select-distinct :select-top :returning :partition-by})
|
||||
|
||||
(defn- comparison-node?
|
||||
"Whether `node` is a keyword-headed comparison -- the one shape inside an identifier clause that
|
||||
does hold values, as in the computed projection `[[:= :engine v] :is_match]`."
|
||||
[node]
|
||||
(and (hooks/vector-node? node)
|
||||
(let [op (some-> (first (:children node)) hooks/sexpr)]
|
||||
(and (keyword? op) (contains? value-operators (symbol (name op)))))))
|
||||
|
||||
(declare value-nodes)
|
||||
|
||||
(defn- identifier-clause-value-nodes
|
||||
"The value slots reachable inside an identifier clause: a subquery's own clauses, and the
|
||||
arguments of a comparison. The names themselves are not candidates."
|
||||
[node]
|
||||
(cond
|
||||
(hooks/map-node? node) (value-nodes node)
|
||||
(comparison-node? node) (value-nodes node)
|
||||
(hooks/vector-node? node) (mapcat identifier-clause-value-nodes (:children node))
|
||||
(hooks/list-node? node) (mapcat identifier-clause-value-nodes (:children node))
|
||||
:else nil))
|
||||
|
||||
(defn- expression-entry-value-nodes
|
||||
"The value slots of an [[expression-entry-clauses]] clause: index 0 of each `[expr alias]` entry."
|
||||
[node]
|
||||
(when (hooks/vector-node? node)
|
||||
(mapcat (fn [entry]
|
||||
(when (hooks/vector-node? entry)
|
||||
(when-let [expr (first (:children entry))]
|
||||
;; Keep the expression AND descend, so a bare symbol is reported and a computed
|
||||
;; projection or a subquery sitting there has its own values checked.
|
||||
(cons expr (value-nodes expr)))))
|
||||
(:children node))))
|
||||
|
||||
(defn- clause-value-nodes
|
||||
"The value slots of the value under clause key `clause`."
|
||||
[clause node]
|
||||
(cond
|
||||
(contains? expression-entry-clauses clause) (expression-entry-value-nodes node)
|
||||
(contains? identifier-clauses clause) (identifier-clause-value-nodes node)
|
||||
:else (value-nodes node)))
|
||||
|
||||
(defn- value-nodes
|
||||
"The nodes sitting in a value slot of `node`, following the clause shapes a query map uses."
|
||||
[node]
|
||||
(cond
|
||||
;; A query map -- walk its clause values, not its keys. A clause that names columns or tables
|
||||
;; contributes only what is nested inside it.
|
||||
(hooks/map-node? node)
|
||||
(mapcat (fn [[k v]]
|
||||
(if (hooks/keyword-node? k)
|
||||
(clause-value-nodes (hooks/sexpr k) v)
|
||||
(value-nodes v)))
|
||||
(partition 2 (:children node)))
|
||||
|
||||
(hooks/vector-node? node)
|
||||
(let [[head & args] (:children node)
|
||||
op (some-> head hooks/sexpr)]
|
||||
(cond
|
||||
;; A marker IS the value slot -- never descend into its payload, or the marked value
|
||||
;; gets reported as though it were bare.
|
||||
(marked? node)
|
||||
nil
|
||||
|
||||
(contains? #{:and :or :not} op)
|
||||
(mapcat value-nodes args)
|
||||
|
||||
;; `[:= col v]`, `[:in col vs]`, `[:between col lo hi]` -- the first argument is the
|
||||
;; column and the rest are values, whatever the arity. Keep each value AND descend into it,
|
||||
;; so a subquery in a value slot -- `[:in :id {:select ... :where [:= :z z]}]` -- has its
|
||||
;; own values checked too. Descending alone would drop the plain-token case.
|
||||
(and (keyword? op) (contains? value-operators (symbol (name op))))
|
||||
(mapcat #(cons % (value-nodes %)) (rest args))
|
||||
|
||||
;; A function-call form -- `[:lower v]` -- or a literal collection -- `[a b]` in an `:in`.
|
||||
;; Both hold values, so yield the children themselves as candidates as well as
|
||||
;; descending, since a token child returns nothing on its own.
|
||||
:else
|
||||
(mapcat #(cons % (value-nodes %)) (:children node))))
|
||||
|
||||
;; A clause built conditionally -- `(when flag [:= :col v])`, `(if ... )`, `(cond-> ...)`. The
|
||||
;; value slots are inside, so walk the children rather than stopping. Without this a value
|
||||
;; wrapped in `when` is invisible to the check, which is a false negative in a security lint.
|
||||
;;
|
||||
;; An `assoc` call gets its clause context back: `(assoc q :order-by [[col dir]])` is the same
|
||||
;; clause as `{:order-by [[col dir]]}`, but written outside a map literal, where the walk would
|
||||
;; otherwise fall back to shape alone and report the column and the direction as values.
|
||||
;; `cond->` is covered because the walk descends into the nested `assoc`.
|
||||
(hooks/list-node? node)
|
||||
(let [children (vec (:children node))
|
||||
assoc? (= 'assoc (some-> (first children) hooks/sexpr))
|
||||
;; index of a clause value -> its clause key
|
||||
clauses (when assoc?
|
||||
(into {} (keep-indexed (fn [i child]
|
||||
(when (and (hooks/keyword-node? child)
|
||||
(contains? identifier-clauses (hooks/sexpr child))
|
||||
(< (inc i) (count children)))
|
||||
[(inc i) (hooks/sexpr child)]))
|
||||
children)))]
|
||||
(mapcat (fn [i child]
|
||||
(if-let [clause (get clauses i)]
|
||||
(clause-value-nodes clause child)
|
||||
(value-nodes child)))
|
||||
(range)
|
||||
children))
|
||||
|
||||
:else nil))
|
||||
|
||||
(def ^:private write-fns
|
||||
"Calls whose values are written rather than filtered on. A value here is not a where-clause value
|
||||
an attacker can turn into SQL structure, and a `define-before-insert` hook may read it before the
|
||||
query compiles, so a marker would break it rather than protect anything."
|
||||
'#{insert! insert-returning-instance! insert-returning-instances! insert-returning-pk!
|
||||
insert-returning-pks! save!})
|
||||
|
||||
(defn- read-call?
|
||||
"Whether `node` is a query call whose values are filtered on rather than written."
|
||||
[node]
|
||||
(let [f (some-> (first (:children node)) hooks/sexpr)]
|
||||
(not (contains? write-fns (some-> f name symbol)))))
|
||||
|
||||
(def ^:private clause-keys
|
||||
"Query-map clause keys. A map keyed by these is a query map rather than a map of conditions."
|
||||
#{:select :select-distinct :from :where :join :left-join :right-join :inner-join :full-join
|
||||
:cross-join :group-by :having :order-by :limit :offset :for :union :union-all :with
|
||||
:with-columns :returning :values :set})
|
||||
|
||||
(defn- query-map-node?
|
||||
"Whether `node` is a query map rather than a map of column/value conditions.
|
||||
|
||||
`(t2/delete! :model/X {:where [:= :k k]})` passes a query map, whose values [[value-nodes]]
|
||||
already walks -- treating it as conditions too would report every value twice."
|
||||
[node]
|
||||
(boolean (some #(and (hooks/keyword-node? %) (contains? clause-keys (hooks/sexpr %)))
|
||||
(take-nth 2 (:children node)))))
|
||||
|
||||
(defn- model-node?
|
||||
"Whether `node` names a literal model: `:model/X`, or `[:model/X & columns]`."
|
||||
[node]
|
||||
(letfn [(model-keyword? [n]
|
||||
(and (hooks/keyword-node? n)
|
||||
(= "model" (namespace (hooks/sexpr n)))))]
|
||||
(or (model-keyword? node)
|
||||
(and (hooks/vector-node? node)
|
||||
(some-> node :children first model-keyword?)))))
|
||||
|
||||
(def ^:private changes-map-fns
|
||||
"Conditions-map fns whose arglist also ends in a changes map, so a lone trailing map is changes."
|
||||
'#{update! update-or-insert!})
|
||||
|
||||
(def ^:private conditions-map-fns
|
||||
"Calls whose first argument after the model is a map of CONDITIONS rather than a query map.
|
||||
|
||||
`(t2/update! :model/X {:key k} {:v 1})` filters on `k`, so it is a where-clause value -- but it
|
||||
reaches the query as a plain map entry rather than a `:where` clause, so neither the query-map
|
||||
walker nor the kv-arg walker sees it. It is a shape the sweep leaves in place, so it has to be
|
||||
checked here."
|
||||
'#{update! update-or-insert! delete!})
|
||||
|
||||
(defn- conditions-map-value-nodes
|
||||
"The values of a conditions map passed to one of [[conditions-map-fns]]."
|
||||
[f args]
|
||||
(when (contains? conditions-map-fns (some-> f name symbol))
|
||||
(let [after-model (rest (drop-while (complement model-node?) args))]
|
||||
;; Only the map immediately after the model is conditions. A later map is the changes map,
|
||||
;; whose values are written rather than filtered on, and a written value is never marked: a
|
||||
;; column's `:in` transform runs on the marker itself and stores it as data.
|
||||
;; `update!`'s arglist ENDS in the changes map, so its first map is conditions only when
|
||||
;; another argument follows: `(t2/update! model {:v written})` is the two-arity call whose
|
||||
;; single map is CHANGES, so flagging it would ask for a marker that corrupts the write.
|
||||
;; `delete!` has no changes map, so its map is always conditions.
|
||||
(when-let [m (when (or (not (contains? changes-map-fns (some-> f name symbol)))
|
||||
(next after-model))
|
||||
(first after-model))]
|
||||
(when (and (hooks/map-node? m) (not (query-map-node? m)))
|
||||
(mapcat (fn [v]
|
||||
;; The column comes from the map key, so an operator form here holds only
|
||||
;; values -- `{:key [:in ks]}` -- exactly as a kv-arg pair does.
|
||||
(if (and (hooks/vector-node? v) (not (marked? v)))
|
||||
(let [[head & op-args] (:children v)
|
||||
op (some-> head hooks/sexpr)]
|
||||
(if (and (keyword? op) (contains? value-operators (symbol (name op))))
|
||||
(mapcat #(cons % (value-nodes %)) op-args)
|
||||
(cons v (value-nodes v))))
|
||||
(cons v (value-nodes v))))
|
||||
(take-nth 2 (rest (:children m)))))))))
|
||||
|
||||
(defn- kv-arg-value-nodes
|
||||
"The value nodes of a call written as `:column value` pairs.
|
||||
|
||||
`(t2/select :model/X :locale locale)`. Several of these fns take an argument before the model --
|
||||
`(t2/select-one-fn :value :model/X :key k)` -- so the pairs do not start at a fixed offset; they
|
||||
start after the model. A call that does not name a literal model is not checked.
|
||||
|
||||
A trailing query map is not a pair, and its values are reached by [[value-nodes]] instead. A
|
||||
value that is itself an operator form -- `:id [:in ids]` -- has its own values walked, so the
|
||||
collection inside is checked rather than the form."
|
||||
[args]
|
||||
(let [after-model (rest (drop-while (complement model-node?) args))]
|
||||
(->> after-model
|
||||
;; `partition-all` rather than `partition` so a trailing odd argument (a query map, which
|
||||
;; is not a pair) is still seen and skipped by the keyword-node? test below.
|
||||
(partition-all 2)
|
||||
(mapcat (fn [[k v]]
|
||||
(when (and v (hooks/keyword-node? k))
|
||||
(if-not (hooks/vector-node? v)
|
||||
[v]
|
||||
(let [[head & op-args] (:children v)
|
||||
op (some-> head hooks/sexpr)]
|
||||
(cond
|
||||
;; Already marked -- the marker is the value slot.
|
||||
(marked? v) [v]
|
||||
|
||||
;; `:id [:in ids]`. Toucan folds the column in from the pair key, so
|
||||
;; EVERY argument here is a value -- unlike a query-map clause, where
|
||||
;; the first argument is the column.
|
||||
(and (keyword? op)
|
||||
(contains? value-operators (symbol (name op))))
|
||||
(mapcat #(cons % (value-nodes %)) op-args)
|
||||
|
||||
:else (cons v (value-nodes v)))))))))))
|
||||
|
||||
(defn- lint-unmarked-values!
|
||||
"Register a finding for each argument of the enclosing function that reaches a value slot unmarked.
|
||||
|
||||
Only a symbol is reported. A literal cannot carry a request value, and a value built inside the
|
||||
function is out of reach of a check that does not follow it across a call."
|
||||
[node]
|
||||
(doseq [value (let [args (rest (:children node))
|
||||
f (some-> (first (:children node)) hooks/sexpr)]
|
||||
;; The three walkers overlap on some shapes -- a multi-arg operator in a kv-arg
|
||||
;; reaches both `value-nodes` and `kv-arg-value-nodes` -- so dedupe by source
|
||||
;; position rather than trying to keep them disjoint.
|
||||
(->> (concat (mapcat value-nodes args)
|
||||
(kv-arg-value-nodes args)
|
||||
(conditions-map-value-nodes f args))
|
||||
(distinct-by #(select-keys (meta %) [:row :col]))))
|
||||
:when (and (hooks/token-node? value)
|
||||
(symbol? (hooks/sexpr value))
|
||||
(not (marked? value)))]
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta value)
|
||||
:message (format "`%s` reaches a SQL value slot unmarked. Write it as [:auto/param %s] so it is bound as a parameter."
|
||||
(hooks/sexpr value) (hooks/sexpr value))
|
||||
:type :metabase/unsafe-app-db-query))))
|
||||
|
||||
(defn lint-query-call
|
||||
"Register a `:metabase/t2-query-namespace` finding when a Toucan 2 query call appears outside a `<module>.db`
|
||||
namespace."
|
||||
[{:keys [node ns filename] :as input}]
|
||||
(when (and ns
|
||||
(not (db-namespace? (modules/config input) ns))
|
||||
(not (test-file? filename)))
|
||||
(let [fn-node (first (:children node))]
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta fn-node)
|
||||
:message (format "Application database query calls like `%s` must live in metabase[-enterprise].<module>.db (or metabase.driver.<driver>.db) namespaces"
|
||||
(hooks/sexpr fn-node))
|
||||
:type :metabase/t2-query-namespace))))
|
||||
((requiring-resolve 'hooks.metabase.warehouse-schema-overlay.table-or-field-query/lint-read) input)
|
||||
(when (and ns
|
||||
(db-namespace? (modules/config input) ns)
|
||||
(not (test-file? filename))
|
||||
(read-call? node))
|
||||
(lint-unmarked-values! node))
|
||||
input)
|
||||
@@ -1,43 +0,0 @@
|
||||
(ns hooks.metabase.toucan.table-name
|
||||
"Lint that toucan2 model namespaces (those defining `t2/table-name`) live
|
||||
under `metabase[-enterprise]/<module>/models[/…]`.
|
||||
|
||||
This wraps the methodical `defmethod` hook: we delegate to
|
||||
`hooks.methodical.macros/defmethod` for the actual analysis (so arg bindings
|
||||
etc. still work), and additionally check the file location when the
|
||||
multimethod being defined is `t2/table-name`."
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]))
|
||||
|
||||
(defn- models-file? [filename]
|
||||
(boolean
|
||||
(and filename
|
||||
(re-find #"/(?:metabase|metabase_enterprise)/[^/]+/models(?:/|\.clj[cs]?$)"
|
||||
filename))))
|
||||
|
||||
(defn- table-name-dispatch? [sym]
|
||||
(and (symbol? sym)
|
||||
(= (name sym) "table-name")
|
||||
(#{"t2" "toucan2.core"} (namespace sym))))
|
||||
|
||||
(defn lint-defmethod
|
||||
"Hook registered at the project level on `methodical.core/defmethod` (and
|
||||
`methodical.macros/defmethod`). Performs the table-name check, then
|
||||
delegates to the vendored methodical hook so kondo's normal analysis of the
|
||||
defmethod body still runs."
|
||||
[{:keys [filename node] :as input}]
|
||||
(let [[_defmethod multimethod & _args] (:children node)
|
||||
dispatch (when multimethod
|
||||
(try (hooks/sexpr multimethod) (catch Exception _ nil)))]
|
||||
(when (and (table-name-dispatch? dispatch)
|
||||
(not (models-file? filename)))
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta multimethod)
|
||||
:message (format "Toucan2 `table-name` defmethods must live under `<module>/models[/…]` (got %s)"
|
||||
filename)
|
||||
:type :metabase/toucan-model-ns))))
|
||||
;; Delegate to the vendored methodical defmethod hook so kondo's normal
|
||||
;; analysis of the defmethod body still runs. Resolved lazily because
|
||||
;; `hooks.methodical.macros` isn't on the classpath during `.clj-kondo/test`
|
||||
;; unit-test runs.
|
||||
((requiring-resolve 'hooks.methodical.macros/defmethod) input))
|
||||
@@ -1,6 +1,7 @@
|
||||
(ns hooks.metabase.util.i18n
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as api]
|
||||
[clojure.string :as str]
|
||||
[hooks.common]))
|
||||
|
||||
(defn- valid-apostrophes?
|
||||
@@ -12,23 +13,23 @@
|
||||
(let [c (first chars)]
|
||||
(if (= c \')
|
||||
(cond
|
||||
;; Doubled quotes: skip both
|
||||
;; Doubled quotes: skip both
|
||||
(and (next chars) (= (second chars) \'))
|
||||
(recur (nthrest chars 2))
|
||||
|
||||
;; Placeholder escape. We allow for intentional escaping, like either '{0}` or '{{`, but not 'arbitrary
|
||||
;; strings' So basically, just require the *next* character to be a `{`, find the next single-quote, and
|
||||
;; continue on from there.
|
||||
;;
|
||||
;; Technically a string like "foo bar's happy but i'm not" is totally fine, but we'll say it's invalid
|
||||
;; because... that inner string literal is probably not what we actually want. So since we've never done
|
||||
;; this intentionally as far as I can tell, let's just remove the possibility to avoid ambiguity.
|
||||
;; Placeholder escape. We allow for intentional escaping, like either '{0}` or '{{`, but not 'arbitrary
|
||||
;; strings' So basically, just require the *next* character to be a `{`, find the next single-quote, and
|
||||
;; continue on from there.
|
||||
;;
|
||||
;; Technically a string like "foo bar's happy but i'm not" is totally fine, but we'll say it's invalid
|
||||
;; because... that inner string literal is probably not what we actually want. So since we've never done
|
||||
;; this intentionally as far as I can tell, let's just remove the possibility to avoid ambiguity.
|
||||
(= (second chars) \{)
|
||||
(let [at-closing (drop-while #(not= \' %) (next chars))]
|
||||
(if (seq at-closing)
|
||||
(recur (next at-closing))
|
||||
false))
|
||||
;; Unpaired single quote: invalid
|
||||
;; Unpaired single quote: invalid
|
||||
:else
|
||||
false)
|
||||
(recur (rest chars)))))))
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
(ns hooks.metabase.util.match
|
||||
(:require [clj-kondo.hooks-api :as api]))
|
||||
|
||||
(defn- token-node? [node token-name]
|
||||
(and (api/token-node? node)
|
||||
(= (str (:value node)) token-name)))
|
||||
|
||||
(defn- keyword-node? [node k]
|
||||
(and (api/keyword-node? node)
|
||||
(= (:k node) k)))
|
||||
|
||||
(defn- binding-symbol?
|
||||
"True when the token is a plain symbol that should be treated as a binding
|
||||
(not a keyword, not a boolean literal, not nil, not a number, not `_`)."
|
||||
[node]
|
||||
(and (api/token-node? node)
|
||||
(let [v (:value node)]
|
||||
(and (symbol? v)
|
||||
(not (contains? '#{_ nil true false} v))))))
|
||||
|
||||
;; To establish fake let bindings, we need to bind them to something. This "something" has to be vague enough to trick
|
||||
;; kondo into not looking too much into it. E.g., if we bind everything to `nil`, Kondo linters will get fired up.
|
||||
(def opaque-node (api/list-node (list (api/token-node 'rand-nth) (api/list-node []))))
|
||||
|
||||
(defn collect-bindings
|
||||
"Return a list of token nodes (symbols) introduced as bindings by `pattern`."
|
||||
[pattern]
|
||||
(cond
|
||||
(binding-symbol? pattern)
|
||||
[[pattern opaque-node]]
|
||||
|
||||
;; Lists: (:or ...), (:and ...), (sym :guard pred ...), or a quoted symbol.
|
||||
(api/list-node? pattern)
|
||||
(let [[head & rst] (:children pattern)]
|
||||
(cond (or (keyword-node? head :or)
|
||||
(keyword-node? head :and))
|
||||
(mapcat collect-bindings rst)
|
||||
|
||||
(api/token-node? head)
|
||||
(cond-> []
|
||||
(binding-symbol? head) (conj [head opaque-node])
|
||||
(keyword-node? (first rst) :guard) (conj [(api/token-node '_) (second rst)]))
|
||||
|
||||
;; anything else (e.g. a function call used as predicate) — no bindings
|
||||
:else []))
|
||||
|
||||
(api/vector-node? pattern)
|
||||
(mapcat (fn [node]
|
||||
(when-not (token-node? node "&")
|
||||
(collect-bindings node)))
|
||||
(:children pattern))
|
||||
|
||||
;; Maps: keys are literal selectors, values are sub-patterns.
|
||||
;; Special value-symbols &truthy and _ are NOT user bindings.
|
||||
(api/map-node? pattern)
|
||||
(let [children (:children pattern)
|
||||
pairs (partition 2 children)]
|
||||
(mapcat (fn [[_k v]]
|
||||
(when-not (or (token-node? v "&truthy")
|
||||
(token-node? v "_"))
|
||||
(collect-bindings v)))
|
||||
pairs))
|
||||
|
||||
;; For sets, make sure to use them because they may reference some of the outer local bindings, so if we don't
|
||||
;; include them, Kondo will complain about those bindings being unused.
|
||||
(api/set-node? pattern)
|
||||
[[(api/token-node '_) pattern]]
|
||||
|
||||
;; Anything else — no bindings.
|
||||
:else []))
|
||||
|
||||
(defn- dedupe-bindings
|
||||
"Prevent Kondo complaining that we have multiple fake bindings."
|
||||
[pairs]
|
||||
(loop [[[sym v] & r] pairs, seen #{}, result []]
|
||||
(cond (nil? sym) result
|
||||
(and (seen sym) (not (token-node? sym "_"))) (recur r seen result)
|
||||
:else (recur r (conj seen sym) (conj result sym v)))))
|
||||
|
||||
(defn match-one
|
||||
"clj-kondo :analyze-call hook for match-one.
|
||||
|
||||
Rewrites
|
||||
|
||||
(match-one value
|
||||
pat1 ret1
|
||||
pat2 ret2
|
||||
...)
|
||||
|
||||
into a `let` form that declares all binding symbols introduced by the patterns, so clj-kondo knows they are
|
||||
legitimate and won't warn about unresolved/unused symbols:
|
||||
|
||||
(let [<all-bindings> (rand-nth []) ...]
|
||||
(or ret1 ret2 ...))"
|
||||
[{:keys [node]}]
|
||||
(let [[_ value & clauses] (:children node)]
|
||||
(when (odd? (count clauses))
|
||||
(api/reg-finding!
|
||||
{:message "match-one/match-many/replace requires an even number of pattern/result pairs"
|
||||
:type :metabase/match-odd-clauses
|
||||
:row (:row node)
|
||||
:col (:col node)
|
||||
:filename (-> node meta :filename)}))
|
||||
(let [pairs (partition 2 clauses)
|
||||
all-bindings (->> pairs
|
||||
(mapcat (fn [[pat _ret]] (collect-bindings pat)))
|
||||
dedupe-bindings)
|
||||
special-bindings [(api/token-node '_) value
|
||||
(api/token-node '&match) opaque-node
|
||||
(api/token-node '&parents) opaque-node
|
||||
(api/token-node '&recur) opaque-node
|
||||
;; Pretend to use &match, &parents, and &recur so that Kondo doesn't complain about them
|
||||
;; if they are not used by the user code.
|
||||
(api/token-node '_) (api/token-node '&match)
|
||||
(api/token-node '_) (api/token-node '&parents)
|
||||
(api/token-node '_) (api/token-node '&recur)]
|
||||
binding-nodes (into special-bindings all-bindings)
|
||||
result-exprs (map second pairs)
|
||||
new-node (api/list-node
|
||||
(list
|
||||
(api/token-node 'let)
|
||||
(api/vector-node binding-nodes)
|
||||
(api/list-node
|
||||
(concat [(api/token-node 'clojure.core/or)] result-exprs [opaque-node]))))]
|
||||
{:node new-node})))
|
||||
|
||||
(defn matches?
|
||||
"clj-kondo :analyze-call hook for matches?. Similar to `match-one`, but supports just a single clause with no result."
|
||||
[{:keys [node]}]
|
||||
(let [[_ value pat] (:children node)]
|
||||
(let [all-bindings (->> (collect-bindings pat)
|
||||
dedupe-bindings)
|
||||
special-bindings [(api/token-node '_) value
|
||||
(api/token-node '&match) opaque-node
|
||||
;; Pretend to use &match so that Kondo doesn't complain about it.
|
||||
(api/token-node '_) (api/token-node '&match)]
|
||||
binding-nodes (into special-bindings all-bindings)
|
||||
new-node (api/list-node
|
||||
(list
|
||||
(api/token-node 'let)
|
||||
(api/vector-node binding-nodes)
|
||||
(api/list-node
|
||||
(list opaque-node))))]
|
||||
{:node new-node})))
|
||||
|
||||
(def match-many match-one)
|
||||
(def replace match-one)
|
||||
|
||||
(defn replace-in [context]
|
||||
;; Everything is the same as in match-one, just drop the middle argument.
|
||||
(match-one (update-in context [:node :children] (fn [[sym value path & clauses]] (list* sym value clauses)))))
|
||||
@@ -1,52 +0,0 @@
|
||||
(ns hooks.metabase.warehouse-schema-overlay.table-or-field-query
|
||||
"Lint that a query over `:model/Field` or `:model/Table` names its source with `field-query`/`table-query` from
|
||||
`metabase.warehouse-schema-overlay.core`, or reads sync's values with `{:user-settings? false}`."
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]))
|
||||
|
||||
(def ^:private read-fn?
|
||||
"The Toucan 2 query functions that read rows. Writes are not affected: they go to `metabase_field` itself."
|
||||
#{"select" "select-one" "select-fn->fn" "select-fn->pk" "select-fn-reducible" "select-fn-set" "select-fn-vec"
|
||||
"select-one-fn" "select-one-pk" "select-pk->fn" "select-pks-set" "select-pks-vec" "select-reducible"
|
||||
"reducible-select" "reducible-query" "query" "query-one" "count" "exists?"})
|
||||
|
||||
(def ^:private model->helper
|
||||
{":model/Field" "field-query"
|
||||
":model/Table" "table-query"})
|
||||
|
||||
(defn- test-file? [filename]
|
||||
(boolean (and filename (re-find #"(?:^|/)test/" filename))))
|
||||
|
||||
(defn- tokens
|
||||
"Every token in `node`'s tree, as strings."
|
||||
[node]
|
||||
(tree-seq :children :children node))
|
||||
|
||||
(defn- mentions?
|
||||
[node pred]
|
||||
(boolean (some (fn [n] (when-let [s (some-> (hooks/sexpr n) pr-str)] (pred s)))
|
||||
(filter (complement :children) (tokens node)))))
|
||||
|
||||
(defn- writes?
|
||||
"Whether a `t2/query` form is really a write spelled as a query."
|
||||
[node]
|
||||
(mentions? node #{":update" ":delete-from" ":insert-into"}))
|
||||
|
||||
(defn lint-read
|
||||
"Register a `:metabase/table-or-field-query` finding when a read of `:model/Field` or `:model/Table` does not name
|
||||
its source with the matching helper."
|
||||
[{:keys [node filename]}]
|
||||
(let [fn-node (first (:children node))
|
||||
fn-name (some-> (hooks/sexpr fn-node) name)]
|
||||
(when (and fn-name
|
||||
(read-fn? fn-name)
|
||||
(not (test-file? filename))
|
||||
(not (writes? node)))
|
||||
(doseq [[model helper] model->helper
|
||||
:when (mentions? node #(= % model))
|
||||
:when (not (mentions? node #(re-find (re-pattern (str "\\b" helper "$")) %)))]
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta fn-node)
|
||||
:message (format "A read of %s must name its source: add {:from [(%s)]} (or {:user-settings? false} for sync's own values)"
|
||||
model helper)
|
||||
:type :metabase/table-or-field-query))))))
|
||||
@@ -1,22 +0,0 @@
|
||||
(ns hooks.taoensso.nippy
|
||||
(:require [clj-kondo.hooks-api :as api]))
|
||||
|
||||
(defn extend-thaw
|
||||
[{:keys [node]}]
|
||||
(let [[_ _ arglist & body] (:children node)]
|
||||
{:node (with-meta (api/list-node
|
||||
(list*
|
||||
(api/token-node 'fn)
|
||||
arglist
|
||||
body))
|
||||
(meta node))}))
|
||||
|
||||
(defn extend-freeze
|
||||
[{:keys [node]}]
|
||||
(let [[_ _ _ arglist & body] (:children node)]
|
||||
{:node (with-meta (api/list-node
|
||||
(list*
|
||||
(api/token-node 'fn)
|
||||
arglist
|
||||
body))
|
||||
(meta node))}))
|
||||
@@ -1,82 +0,0 @@
|
||||
(ns hooks.clojure.core.def-test
|
||||
(:require
|
||||
[clj-kondo.core :as kondo]
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clj-kondo.impl.utils]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.clojure.core.def]))
|
||||
|
||||
(defn- lint-def
|
||||
"Lint the def form in string `s`. A string, not a quoted form, because `pr-str` drops `^:dynamic`."
|
||||
[s]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/discourage-dynamic-vars {:level :warning}}}
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(hooks.clojure.core.def/lint-def {:node (hooks/parse-string s)})
|
||||
@(:findings clj-kondo.impl.utils/*ctx*)))
|
||||
|
||||
(defn- config-findings
|
||||
"Lint string `s` as `lang` with the real `.clj-kondo` config and return the findings whose type is in `types`."
|
||||
[types lang s]
|
||||
(->> (with-in-str s (kondo/run! {:lint ["-"] :lang lang :config-dir ".clj-kondo" :cache false}))
|
||||
:findings
|
||||
(filter (comp types :type))))
|
||||
|
||||
(deftest ^:parallel discourage-dynamic-vars-test
|
||||
(testing "BEGUILD-37: new dynamic vars are flagged so the ratchet budget stops them from growing"
|
||||
(are [s] (=? [{:type :metabase/discourage-dynamic-vars}]
|
||||
(lint-def s))
|
||||
"(def ^:dynamic *x* nil)"
|
||||
"(def ^:private ^:dynamic *x* nil)"
|
||||
"(def ^:dynamic ^:private *x* nil)"
|
||||
"(def ^{:dynamic true} *x* nil)"
|
||||
"(def ^{:private true, :dynamic true} *x* nil)"
|
||||
"(def ^:dynamic *x* \"docstring\" nil)"
|
||||
"(defonce ^:dynamic *x* nil)"
|
||||
"(defn f {:dynamic true} [] nil)"
|
||||
"(defn f \"docstring\" {:dynamic true} [] nil)"
|
||||
"(defn f \"docstring\" ([] 1) ([a] a) {:dynamic true})"
|
||||
"(mu/defn f :- :int \"docstring\" {:dynamic true} [] 1)")))
|
||||
|
||||
(deftest ^:parallel discourage-dynamic-vars-location-test
|
||||
(testing "the finding sits on the form, so `kondo-insert-ignores` puts the ignore above the form, not inside it"
|
||||
(is (=? [{:row 1 :col 1}]
|
||||
(lint-def "(def ^{:dynamic true\n :doc \"docs\"}\n *x*\n nil)")))))
|
||||
|
||||
(deftest ^:parallel discourage-dynamic-vars-ok-test
|
||||
(testing "non-dynamic defs are not flagged"
|
||||
(are [s] (= [] (lint-def s))
|
||||
"(def x nil)"
|
||||
"(def ^:private x nil)"
|
||||
"(def ^{:dynamic false} x nil)"
|
||||
"(def ^{:private true} x nil)"
|
||||
"(def x {:dynamic true})"
|
||||
"(defonce x {:dynamic true})"
|
||||
"(clojure.core/def x {:dynamic true})"
|
||||
"(clojure.core/defonce x {:dynamic true})"
|
||||
"(cljs.core/defonce x {:dynamic true})"
|
||||
"(defn f {:private true} [] nil)"
|
||||
"(defn f [] {:dynamic true})"
|
||||
"(defn f ([] 1) ([a] {:dynamic true}))")))
|
||||
|
||||
(deftest ^:synchronized discourage-dynamic-vars-config-test
|
||||
(testing "BEGUILD-37: config.edn sends defn, the mu/defn variants, and cljs defs to the dynamic-var check"
|
||||
(are [lang s] (=? [{:row 1}] (config-findings #{:metabase/discourage-dynamic-vars} lang s))
|
||||
:clj "(ns x) (defn ^:dynamic *f* [] nil)"
|
||||
:clj "(ns x (:require [metabase.util.malli :as mu])) (mu/defn ^:dynamic *f* :- :int [] 1)"
|
||||
:clj "(ns x (:require [metabase.util.malli :as mu])) (mu/defn- ^:dynamic *f* :- :int [] 1)"
|
||||
:clj "(ns x (:require [metabase.util.malli.defn :as mu.defn])) (mu.defn/defn ^:dynamic *f* :- :int [] 1)"
|
||||
:clj "(ns x (:require [metabase.util.malli.defn :as mu.defn])) (mu.defn/defn- ^:dynamic *f* :- :int [] 1)"
|
||||
:cljs "(ns x) (def ^:dynamic *x* 1)"
|
||||
:cljs "(ns x) (defn ^:dynamic *f* [] nil)"
|
||||
:cljs "(ns x) (defn- ^:dynamic *f* [] nil)"
|
||||
:cljs "(ns x) (defonce ^:dynamic *x* 1)"
|
||||
:cljc "(ns x) #?(:cljs (def ^:dynamic *x* 1))")))
|
||||
|
||||
(deftest ^:synchronized discourage-dynamic-vars-naming-checks-test
|
||||
(testing "lint-dynamic runs only the dynamic check, not the def naming checks"
|
||||
(are [lang s] (= [] (config-findings #{:metabase/check-def-no-underscores :metabase/check-def-check-not-uppercase-name}
|
||||
lang s))
|
||||
:clj "(ns x (:require [metabase.util.malli :as mu])) (mu/defn SOME_FN :- :string [x :- :int] (str x))"
|
||||
:cljs "(ns x) (def SOME_VALUE 1)")))
|
||||
@@ -19,19 +19,6 @@
|
||||
(defn- lint-modules [form config]
|
||||
(lint-ns form (assoc-in config [:linters :metabase/modules :level] :warning)))
|
||||
|
||||
(deftest ^:parallel explicit-nonparallel-namespace-metadata-test
|
||||
(let [config {:linters {:metabase/validate-deftest {:level :warning}}}]
|
||||
(testing "Hawk's supported namespace marker passes"
|
||||
(is (empty? (lint-ns '(ns ^:synchronized example.test) config))))
|
||||
(testing "the verbose Hawk spelling is not accepted as Metabase style"
|
||||
(is (=? [{:type :metabase/validate-deftest
|
||||
:message #(re-find #"Use `\^:synchronized` instead" %)}]
|
||||
(lint-ns '(ns ^{:parallel false} example.test) config))))
|
||||
(testing "the legacy marker explains that Hawk ignores it"
|
||||
(is (=? [{:type :metabase/validate-deftest
|
||||
:message #(re-find #"`\^:synchronous` is ignored by Hawk" %)}]
|
||||
(lint-ns '(ns ^:synchronous example.test) config))))))
|
||||
|
||||
(deftest ^:parallel module-checker-allowed-modules-test
|
||||
(are [task-namespace] (=? [{:message "Module task should not be used in the api module. [:metabase/modules api :uses]"
|
||||
:type :metabase/modules}]
|
||||
@@ -56,16 +43,6 @@
|
||||
'{:metabase/modules {api {:uses #{search}}
|
||||
search {:api #{metabase.search.core}}}}))))
|
||||
|
||||
(deftest ^:parallel module-checker-empty-api-test
|
||||
(is (=? [{:message "Namespace metabase.embeddings.core is not an allowed external API namespace for the embeddings module. [:metabase/modules embeddings :api]"
|
||||
:type :metabase/modules}]
|
||||
(lint-modules
|
||||
'(ns metabase.metabot.core
|
||||
(:require
|
||||
[metabase.embeddings.core :as embeddings]))
|
||||
'{:metabase/modules {metabot {:uses #{embeddings}}
|
||||
embeddings {:api #{}}}}))))
|
||||
|
||||
(deftest ^:parallel module-checker-friends-test
|
||||
(is (= []
|
||||
(lint-modules
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
(ns hooks.clojure.core.require-test
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clj-kondo.impl.utils]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.clojure.core.require]))
|
||||
|
||||
(def ^:private config
|
||||
'{:linters {:metabase/modules {:level :warning}}
|
||||
:metabase/modules {search {:uses #{}}
|
||||
task {:api #{metabase.task.core}}}})
|
||||
|
||||
(defn- lint [hook form current-ns]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config config
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(hook {:node (hooks/parse-string (pr-str form))
|
||||
:ns current-ns
|
||||
:config config})
|
||||
@(:findings clj-kondo.impl.utils/*ctx*)))
|
||||
|
||||
(defn- lint-require [form current-ns]
|
||||
(lint hooks.clojure.core.require/lint-require form current-ns))
|
||||
|
||||
(defn- lint-requiring-resolve [form current-ns]
|
||||
(lint hooks.clojure.core.require/lint-requiring-resolve form current-ns))
|
||||
|
||||
(def ^:private dynamic-require-finding
|
||||
{:message "Module dependency cannot be statically determined from a dynamic require"
|
||||
:type :metabase/modules})
|
||||
|
||||
(def ^:private cross-module-finding
|
||||
{:message "Module task should not be used in the search module. [:metabase/modules search :uses]"
|
||||
:type :metabase/modules})
|
||||
|
||||
(deftest ^:parallel dynamic-requiring-resolve-test
|
||||
(testing "outside the module system"
|
||||
(is (= [] (lint-requiring-resolve '(requiring-resolve (:failing-test-var opts)) 'dev))))
|
||||
(testing "inside a module"
|
||||
(are [form] (=? [dynamic-require-finding]
|
||||
(lint-requiring-resolve form 'metabase.search.core))
|
||||
'(requiring-resolve (:failing-test-var opts))
|
||||
'(requiring-resolve @some-atom)
|
||||
'(requiring-resolve some-local)
|
||||
'(requiring-resolve "not-a-symbol"))))
|
||||
|
||||
(deftest ^:parallel dynamic-require-test
|
||||
(testing "outside the module system"
|
||||
(is (= [] (lint-require '(require some-local) 'dev))))
|
||||
(testing "inside a module"
|
||||
(is (=? [dynamic-require-finding]
|
||||
(lint-require '(require some-local) 'metabase.search.core)))))
|
||||
|
||||
(deftest ^:parallel static-requiring-resolve-still-linted-test
|
||||
(is (=? [cross-module-finding]
|
||||
(lint-requiring-resolve '(requiring-resolve 'metabase.task.impl/go)
|
||||
'metabase.search.core))
|
||||
"a statically-known cross-module violation is still reported"))
|
||||
|
||||
(deftest ^:parallel static-require-still-linted-test
|
||||
(is (=? [cross-module-finding]
|
||||
(lint-require '(require 'metabase.task.impl :reload)
|
||||
'metabase.search.core))
|
||||
"a statically-known cross-module violation is still reported while keyword flags are ignored"))
|
||||
@@ -1,152 +0,0 @@
|
||||
(ns hooks.clojure.core.with-redefs-test
|
||||
(:require
|
||||
[clj-kondo.core :as kondo]
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clj-kondo.impl.utils]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.clojure.core.with-redefs]))
|
||||
|
||||
;;; The new hook delegates the "is this LHS a regular defn?" decision to clj-kondo's own
|
||||
;;; analysis cache via [[hooks/resolve]] and [[hooks/ns-analysis]]. In unit tests we have
|
||||
;;; no cache, so we stub those calls. The mapping below describes what each stubbed name
|
||||
;;; should pretend to be.
|
||||
|
||||
(def ^:private stub-vars
|
||||
"Map of unqualified-name → kondo-style var-definition. Names not in the map are treated
|
||||
as `:clj-kondo/unknown-namespace` (unresolved). A `:fixed-arities` (or
|
||||
`:varargs-min-arity`) entry means \"this is a `defn`\" and is the only thing that lets
|
||||
the nudge fire."
|
||||
'{plain-fn {:ns example.ns :name plain-fn :fixed-arities #{1}}
|
||||
plain-fn-2 {:ns example.ns :name plain-fn-2 :fixed-arities #{0}}
|
||||
a-multimethod {:ns example.ns :name a-multimethod} ; defmulti — no arities
|
||||
can-read? {:ns example.ns :name can-read?} ; defmulti
|
||||
can-query? {:ns example.ns :name can-query?} ; defmulti
|
||||
a-value {:ns example.ns :name a-value}}) ; (def a-value 42) — no arities
|
||||
|
||||
(defn- stub-resolve [{nm :name}]
|
||||
(when (symbol? nm)
|
||||
(let [bare (symbol (name nm))]
|
||||
(if (contains? stub-vars bare)
|
||||
{:ns 'example.ns :name bare}
|
||||
{:ns :clj-kondo/unknown-namespace :name bare}))))
|
||||
|
||||
(defn- stub-ns-analysis [_ns-sym]
|
||||
;; All stubbed vars live in `example.ns` for simplicity.
|
||||
{:clj stub-vars})
|
||||
|
||||
(defn- lint
|
||||
"Run the hook on the given source. Pass a string to preserve reader-macro literals like
|
||||
`#(...)` (whose `:fn` node tag is distinct from a regular list); pass a quoted form
|
||||
for the common case where exact node shape doesn't matter. The optional
|
||||
`analysis-fn` stub overrides `hooks/ns-analysis` for tests that want to simulate a
|
||||
cache miss or other non-default cache state."
|
||||
([src] (lint src stub-ns-analysis))
|
||||
([src analysis-fn]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/prefer-with-dynamic-fn-redefs {:level :warning}}}
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(with-redefs [hooks/resolve stub-resolve
|
||||
hooks/ns-analysis analysis-fn]
|
||||
(hooks.clojure.core.with-redefs/lint-with-redefs
|
||||
{:node (hooks/parse-string (if (string? src) src (pr-str src)))}))
|
||||
@(:findings clj-kondo.impl.utils/*ctx*))))
|
||||
|
||||
(deftest ^:synchronized flags-when-every-lhs-is-defn-test
|
||||
(testing "RHS shape doesn't matter — once every LHS resolves to a defn, the form is a
|
||||
migration candidate regardless of what's on the right. We deliberately stick
|
||||
to `IFn`-valued shapes here (fns, keywords, colls) — pairing a fn-var LHS
|
||||
with a non-`IFn` value (`42`) would be a weird shape callers wouldn't realistically
|
||||
write, and `with-dynamic-fn-redefs` would throw at proxy time anyway."
|
||||
(doseq [rhs ['(fn [x] x)
|
||||
'(constantly 42)
|
||||
'(partial + 1)
|
||||
'(comp inc dec)
|
||||
'identity ; bare symbol resolving to an IFn
|
||||
'(foo/constantly 42) ; namespaced lookalike — no longer special-cased
|
||||
:a-key ; keyword (IFn)
|
||||
{:a 1} ; map (IFn)
|
||||
[1 2 3]]] ; vector (IFn)
|
||||
(is (=? [{:type :metabase/prefer-with-dynamic-fn-redefs}]
|
||||
(lint (list 'with-redefs ['plain-fn rhs] :body)))
|
||||
(str "expected nudge for RHS: " (pr-str rhs)))))
|
||||
(testing "reader-macro `#(...)` literal — passed as a string so it parses as a
|
||||
`:fn`-tagged node, not the post-expansion `(fn* …)` list. Still works because
|
||||
it's just one more shape the LHS-only rule treats uniformly."
|
||||
(is (=? [{:type :metabase/prefer-with-dynamic-fn-redefs}]
|
||||
(lint "(with-redefs [plain-fn #(inc %)] (plain-fn 1))"))))
|
||||
(testing "multiple bindings, every LHS is a defn"
|
||||
(is (=? [{:type :metabase/prefer-with-dynamic-fn-redefs}]
|
||||
(lint '(with-redefs [plain-fn (constantly 1)
|
||||
plain-fn-2 (fn [] 2)]
|
||||
(plain-fn)))))))
|
||||
|
||||
(deftest ^:synchronized skips-multimethod-and-value-targets-test
|
||||
(testing "defmulti — no arity in analysis, so don't nudge"
|
||||
(is (= [] (lint '(with-redefs [a-multimethod (fn [& _] nil)] :body))))
|
||||
(is (= [] (lint '(with-redefs [can-read? (constantly true)] :body))))
|
||||
(is (= [] (lint '(with-redefs [can-query? (constantly false)] :body)))))
|
||||
(testing "plain `def` value also has no arity — don't nudge"
|
||||
(is (= [] (lint '(with-redefs [a-value (fn [] 1)] :body)))))
|
||||
(testing "any unresolved symbol skips the nudge — we never know what it is"
|
||||
(is (= [] (lint '(with-redefs [unknown.ns/something (fn [& _] nil)] :body)))))
|
||||
(testing "empty analysis (cache miss for the resolved ns) → skip the nudge"
|
||||
(is (= [] (lint '(with-redefs [plain-fn (fn [x] x)] :body)
|
||||
(constantly {})))))
|
||||
(testing "mixed bindings — even one non-defn LHS suppresses the nudge"
|
||||
(is (= [] (lint '(with-redefs [plain-fn (fn [x] x)
|
||||
a-multimethod (fn [& _] nil)]
|
||||
:body))))))
|
||||
|
||||
(defn- spit-fixture! [^java.io.File f content]
|
||||
(.mkdirs (.getParentFile f))
|
||||
(spit f content))
|
||||
|
||||
(defn- delete-tree! [^java.io.File f]
|
||||
(when (.isDirectory f)
|
||||
(run! delete-tree! (.listFiles f)))
|
||||
(.delete f))
|
||||
|
||||
(defn- run-kondo-twice
|
||||
"Lint `src` first to populate `cache-dir` (mirroring the real `kondo --lint src test`
|
||||
pipeline), then lint `test-file` against that cache and return its findings. The
|
||||
two-pass shape is required because the hook reads its decisions from the cache, which
|
||||
is only written *after* a file is analysed."
|
||||
[cache-dir src-file test-file]
|
||||
(kondo/run! {:lint [(.getPath src-file)] :cache-dir cache-dir :config-dir ".clj-kondo"})
|
||||
(:findings (kondo/run! {:lint [(.getPath test-file)] :cache-dir cache-dir :config-dir ".clj-kondo"})))
|
||||
|
||||
(deftest ^:synchronized integration-arities-iff-defn-smoke-test
|
||||
(testing "real kondo run validates the load-bearing invariant: only `defn`-style vars
|
||||
get arities recorded — `defmulti` and plain `def` do not. If a future kondo
|
||||
release breaks this, the smoke test fails here rather than the hook silently
|
||||
producing wrong nudges."
|
||||
(let [tmp-dir (.toFile (java.nio.file.Files/createTempDirectory
|
||||
"with-redefs-smoke" (into-array java.nio.file.attribute.FileAttribute [])))
|
||||
cache-dir (str tmp-dir "/cache")
|
||||
src (io/file tmp-dir "smoke_fixture.clj")
|
||||
tst (io/file tmp-dir "smoke_fixture_test.clj")]
|
||||
(try
|
||||
(spit-fixture! src "(ns smoke-fixture)
|
||||
(defmulti the-multi {:arglists '([x])} (fn [x] x))
|
||||
(defn the-defn [x] x)
|
||||
(def the-value 42)
|
||||
")
|
||||
(spit-fixture! tst "(ns smoke-fixture-test
|
||||
(:require [smoke-fixture :as f]))
|
||||
(with-redefs [f/the-multi (constantly nil)] :a)
|
||||
(with-redefs [f/the-defn (constantly nil)] :b)
|
||||
(with-redefs [f/the-value (constantly nil)] :c)
|
||||
")
|
||||
(let [findings (run-kondo-twice cache-dir src tst)
|
||||
nudges (filter #(= :metabase/prefer-with-dynamic-fn-redefs (:type %)) findings)
|
||||
nudge-rows (set (map :row nudges))]
|
||||
;; Row 3 = the-multi, row 4 = the-defn, row 5 = the-value (matches the
|
||||
;; with-redefs lines in the test fixture above).
|
||||
(testing "the defn binding gets nudged"
|
||||
(is (contains? nudge-rows 4)))
|
||||
(testing "the defmulti and def bindings do not get nudged"
|
||||
(is (not (contains? nudge-rows 3)))
|
||||
(is (not (contains? nudge-rows 5)))))
|
||||
(finally (delete-tree! tmp-dir))))))
|
||||
@@ -21,54 +21,6 @@
|
||||
#{:athena}}}}})
|
||||
(mapv :message @(:findings clj-kondo.impl.utils/*ctx*))))
|
||||
|
||||
(defn- parallel-metadata-findings
|
||||
([form]
|
||||
(parallel-metadata-findings form 'example.test))
|
||||
([form ns-sym]
|
||||
(let [linters {:metabase/deftest-not-marked-parallel-or-synchronized {:level :warning}
|
||||
:metabase/validate-deftest {:level :warning}}]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters linters}
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(hooks.clojure.test/deftest {:node (api/parse-string form)
|
||||
:lang :clj
|
||||
:ns ns-sym
|
||||
:config {:linters {:metabase/validate-deftest {}}}})
|
||||
@(:findings clj-kondo.impl.utils/*ctx*)))))
|
||||
|
||||
(deftest ^:parallel explicit-nonparallel-metadata-test
|
||||
(testing "Hawk's supported test markers satisfy the explicit metadata linter"
|
||||
(are [metadata] (empty? (parallel-metadata-findings
|
||||
(format "(deftest %s example-test (is true))" metadata)))
|
||||
"^:parallel"
|
||||
"^:synchronized"))
|
||||
(testing "an unmarked test still gets the opt-in linter's guidance"
|
||||
(is (=? [{:type :metabase/deftest-not-marked-parallel-or-synchronized
|
||||
:message "Test should be marked either `^:parallel` or `^:synchronized`"}]
|
||||
(parallel-metadata-findings "(deftest example-test (is true))"))))
|
||||
(testing "the unsupported marker explains that Hawk ignores it"
|
||||
(is (some #(and (= (:type %) :metabase/validate-deftest)
|
||||
(re-find #"ignored by Hawk" (:message %)))
|
||||
(parallel-metadata-findings
|
||||
"(deftest ^:sequential example-test (is true))"))))
|
||||
(testing "the verbose Hawk spelling is not accepted as Metabase style"
|
||||
(is (some #(and (= (:type %) :metabase/validate-deftest)
|
||||
(re-find #"Use `\^:synchronized` instead" (:message %)))
|
||||
(parallel-metadata-findings
|
||||
"(deftest ^{:parallel false} example-test (is true))"))))
|
||||
(testing "a test cannot request both execution modes"
|
||||
(is (some #(and (= (:type %) :metabase/validate-deftest)
|
||||
(re-find #"both `\^:parallel` and `\^:synchronized`" (:message %)))
|
||||
(parallel-metadata-findings
|
||||
"(deftest ^:parallel ^:synchronized example-test (is true))"))))
|
||||
(testing "a namespace-level non-parallel promise rejects parallel tests"
|
||||
(is (=? [{:type :metabase/validate-deftest
|
||||
:message #(re-find #"namespace marked.*`\^:synchronized`" %)}]
|
||||
(parallel-metadata-findings
|
||||
"(deftest ^:parallel example-test (is true))"
|
||||
(with-meta 'example.test {:synchronized true}))))))
|
||||
|
||||
(deftest ^:parallel disallow-hardcoded-driver-names-in-tests-test
|
||||
(is (= []
|
||||
(deftest-warnings
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
|
||||
(deftest ^:parallel module-test
|
||||
(are [symb expected] (= expected
|
||||
(hooks.common.modules/module {} symb))
|
||||
(hooks.common.modules/module symb))
|
||||
'metabase.qp.middleware.wow 'qp
|
||||
'metabase-enterprise.whatever.core 'enterprise/whatever))
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
(ns hooks.metabase.agent-message-test
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clj-kondo.impl.utils]
|
||||
[clojure.edn :as edn]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.metabase.agent-message :as agent-message]))
|
||||
|
||||
(defn- findings
|
||||
"Run `hook-fn` over `form`, a form or its source string, with the linters at `level` (unconfigured when nil) in
|
||||
namespace `ns` of file `filename`; return the findings' messages."
|
||||
([hook-fn form]
|
||||
(findings hook-fn form :warning))
|
||||
([hook-fn form level]
|
||||
(findings hook-fn form level 'metabase.mcp.v2.tools.browse "src/metabase/mcp/v2/tools/browse.clj"))
|
||||
([hook-fn form level ns filename]
|
||||
(let [config (if level
|
||||
{:linters {:metabase/agent-message-lines {:level level}
|
||||
:metabase/agent-message-exit {:level level}}}
|
||||
{})]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config config
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(let [input {:node (hooks/parse-string (if (string? form) form (pr-str form)))
|
||||
:ns ns
|
||||
:filename filename
|
||||
:config config}
|
||||
output (hook-fn input)]
|
||||
(is (identical? (:node input) (:node output))
|
||||
"the hook must return the node unchanged so Kondo's normal analysis still runs")
|
||||
(mapv :message @(:findings clj-kondo.impl.utils/*ctx*)))))))
|
||||
|
||||
(def ^:private hook-cases
|
||||
"`[hook form]` pairs that each report one finding when their linter is enabled."
|
||||
[[agent-message/lint-msg '(msg lines x)]
|
||||
[agent-message/lint-teaching-exit '(throw-teaching-error "x")]
|
||||
[agent-message/lint-jsonrpc-error '(jsonrpc-error id -32602 "x")]
|
||||
[agent-message/lint-success-content '(success-content "x")]
|
||||
[agent-message/lint-ex-info "(ex-info \"x\" {:status-code 404})"]
|
||||
[agent-message/lint-op-error '(op-error! idx "add_link" "x")]
|
||||
[agent-message/lint-ellipsize '(ellipsize "x" 300)]
|
||||
[agent-message/lint-list-content '(list-content data total {:empty-hint "x"})]])
|
||||
|
||||
(defn- linter-enabled-in-ns?
|
||||
"Whether `config` enables `linter` in namespace `ns-name` through an ns-group's `:config-in-ns` entry."
|
||||
[config linter ns-name]
|
||||
(boolean
|
||||
(some (fn [{:keys [pattern name]}]
|
||||
(and (string? pattern)
|
||||
(re-find (re-pattern pattern) ns-name)
|
||||
(not= :off (get-in config [:config-in-ns name :linters linter :level] :off))))
|
||||
(:ns-groups config))))
|
||||
|
||||
(deftest ^:parallel config-scope-test
|
||||
(let [config (edn/read-string (slurp ".clj-kondo/config.edn"))]
|
||||
(testing "GHY-4544: `msg` lines are checked in every OSS and enterprise MCP source namespace, and no test one"
|
||||
(doseq [[ns-name enabled?] {"metabase.mcp.ui-resource" true
|
||||
"metabase.mcp.transport" true
|
||||
"metabase.mcp.v2.tools.browse" true
|
||||
"metabase-enterprise.mcp.usage" true
|
||||
"metabase-enterprise.mcp.v2.tools.browse" true
|
||||
"metabase.mcp.ui-resource-test" false
|
||||
"metabase.mcp.v2.tools.browse-test" false
|
||||
"metabase.mcp.test-util" false
|
||||
"metabase-enterprise.mcp.usage-test" false
|
||||
"metabase-enterprise.mcp.v2.tools.browse-test" false
|
||||
"metabase.queries.models.card" false}]
|
||||
(testing ns-name
|
||||
(is (= enabled? (linter-enabled-in-ns? config :metabase/agent-message-lines ns-name))))))
|
||||
(testing "GHY-4544: exits are checked in MCP transport and v2 source namespaces, whose 4xx errors reach the agent"
|
||||
(doseq [[ns-name enabled?] {"metabase.mcp.transport" true
|
||||
"metabase.mcp.v2.common" true
|
||||
"metabase.mcp.v2.tools.browse" true
|
||||
"metabase-enterprise.mcp.v2.common" true
|
||||
"metabase.mcp.settings" false
|
||||
"metabase.mcp.validation" false
|
||||
"metabase-enterprise.mcp.usage" false
|
||||
"metabase.mcp.transport-test" false
|
||||
"metabase.mcp.v2.tools.browse-test" false
|
||||
"metabase-enterprise.mcp.v2.tools.browse-test" false
|
||||
"metabase.queries.models.card" false}]
|
||||
(testing ns-name
|
||||
(is (= enabled? (linter-enabled-in-ns? config :metabase/agent-message-exit ns-name))))))
|
||||
(testing "GHY-4544: `:empty-hint` is checked on `list-content` and on wrappers passing their options map to it"
|
||||
(doseq [fn-sym '[metabase.mcp.v2.common/list-content
|
||||
metabase.mcp.v2.tools.browse/paged-list-content]]
|
||||
(testing fn-sym
|
||||
(is (= 'hooks.metabase.agent-message/lint-list-content
|
||||
(get-in config [:hooks :analyze-call fn-sym]))))))
|
||||
(testing (str "GHY-4544: `check-execute-sql-enabled!` isn't checked, since it interpolates its subject into "
|
||||
"its own `msg` and a plain string is the intended way to name the tool")
|
||||
(is (nil? (get-in config [:hooks
|
||||
:analyze-call
|
||||
(symbol "metabase.mcp.v2.queries" "check-execute-sql-enabled!")]))))))
|
||||
|
||||
(deftest ^:parallel scope-test
|
||||
(testing "GHY-4544: the hooks leave scoping to config, reporting in any namespace where their linter is enabled"
|
||||
(doseq [[hook form] hook-cases
|
||||
[ns filename] [['metabase.mcp.v2.tools.browse "src/metabase/mcp/v2/tools/browse.clj"]
|
||||
['metabase.queries.models.card "src/metabase/queries/models/card.clj"]
|
||||
['metabase.mcp.v2.tools.browse-test "test/metabase/mcp/v2/tools/browse_test.clj"]]]
|
||||
(testing (str ns " " (pr-str form))
|
||||
(is (= 1 (count (findings hook form :warning ns filename)))))))
|
||||
(testing "nothing is reported when the linter is off or unconfigured"
|
||||
(doseq [[hook form] hook-cases
|
||||
level [:off nil]]
|
||||
(testing (str level " " (pr-str form))
|
||||
(is (empty? (findings hook form level)))))))
|
||||
|
||||
(deftest ^:parallel msg-test
|
||||
(testing "GHY-4544: a vector of single-line string literals with matching arguments is accepted"
|
||||
(is (empty? (findings agent-message/lint-msg
|
||||
'(msg ["Table %s: %d of %d fields." "Continue with `offset: %d`."] t n total next)))))
|
||||
(testing "specifiers that consume no argument aren't counted, and an explicit index can repeat an argument"
|
||||
(is (empty? (findings agent-message/lint-msg '(msg ["100%% done."]))))
|
||||
(is (empty? (findings agent-message/lint-msg '(msg ["%1$s, then %1$s again, then %<s"] x)))))
|
||||
(testing "the lines must be a literal vector"
|
||||
(is (=? [#".*vector.*"]
|
||||
(findings agent-message/lint-msg '(msg lines x))))
|
||||
(is (=? [#".*vector.*"]
|
||||
(findings agent-message/lint-msg '(msg "one line" x)))))
|
||||
(testing "a line may be split across a `str` of string literals"
|
||||
(is (empty? (findings agent-message/lint-msg '(msg [(str "Table %s has " "%d fields.")] t n))))
|
||||
(is (=? [#".*1 argument.*2.*"]
|
||||
(findings agent-message/lint-msg '(msg [(str "only " "%s")] a b)))))
|
||||
(testing "each line must be a string literal or a `str` of string literals"
|
||||
(is (=? [#".*string literal or a `str` of string literals.*"]
|
||||
(findings agent-message/lint-msg '(msg ["ok" line]))))
|
||||
(is (=? [#".*string literal or a `str` of string literals.*"]
|
||||
(findings agent-message/lint-msg '(msg [(str "split " x)])))))
|
||||
(testing "a line can't contain a line break, control, or private-use character; each line is its own string"
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg '(msg ["first\nsecond"]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg '(msg [(str "first\n" "second")]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg (list 'msg [(str "first" (char 0x2028) "second")]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg '(msg ["tab\tseparated"]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg '(msg ["done.%n"]))))
|
||||
(testing "GHY-4544: a private-use character, which the truncation markers use, is flagged too"
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg (list 'msg [(str "first" (char 0xE000) "second")]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg (list 'msg [(str "marked" (char 0xF8FF))]))))))
|
||||
(testing "the argument count must match the specifiers across all lines"
|
||||
(is (=? [#".*2 arguments.*1.*"]
|
||||
(findings agent-message/lint-msg '(msg ["%s and" "%s"] a))))
|
||||
(is (=? [#".*1 argument.*2.*"]
|
||||
(findings agent-message/lint-msg '(msg ["only %s"] a b)))))
|
||||
(testing (str "GHY-4544: a reference `String/format` can't resolve is flagged, since the runtime would fall back "
|
||||
"to rendering every part quoted")
|
||||
(testing "`%<` with no specifier before it to repeat, which counts as no argument at all"
|
||||
(is (=? [#".*can't exist.*"]
|
||||
(findings agent-message/lint-msg '(msg ["Value: %<s"]))))
|
||||
(is (=? [#".*can't exist.*"]
|
||||
(findings agent-message/lint-msg '(msg ["100%% done," "then %<d"])))))
|
||||
(testing "an explicit index of zero, which is below the first argument"
|
||||
(is (=? [#".*can't exist.*"]
|
||||
(findings agent-message/lint-msg '(msg ["Value: %0$s"]))))
|
||||
(is (=? [#".*can't exist.*"]
|
||||
(findings agent-message/lint-msg '(msg ["Value: %0$s"] x)))))
|
||||
(testing "a `%<` that repeats the first argument is still accepted"
|
||||
(is (empty? (findings agent-message/lint-msg '(msg ["%s then %<s"] x)))))))
|
||||
|
||||
(deftest ^:parallel msg-specifier-test
|
||||
(testing "GHY-4544: only `%s` with no flags but `<`, `%d` with flags and width, and `%%` are allowed"
|
||||
(doseq [line ["%s" "%d" "%1$s" "%,d" "%05d" "%-8d" "%(d" "%1$,10d"
|
||||
"%s again %<s" "%d again %<d" "%s (100%%)"]]
|
||||
(testing line
|
||||
(is (empty? (findings agent-message/lint-msg (list 'msg [(str "Value: " line)] 'x)))))))
|
||||
(testing "GHY-4544: any other conversion, or a `%s` or `%d` that reshapes its value, is flagged"
|
||||
(doseq [line ["%b" "%h" "%c" "%x" "%X" "%f" "%e" "%o" "%g" "%a" "%tY" "%TY" "%B"
|
||||
"%S" "%.20s" "%10s" "%-5s" "%#s" "%1$.3s" "%-10S" "%.1f" "%.2d" "%1$.2d"]]
|
||||
(testing line
|
||||
(is (=? [#"A `msg` line may only use `%s`, `%d`, or `%%`; .* would alter or hide the value\."]
|
||||
(findings agent-message/lint-msg (list 'msg [(str "Value: " line)] 'x)))))))
|
||||
(testing "GHY-4544: a `%` that doesn't begin a valid format specifier is flagged, since `String/format` rejects it"
|
||||
(doseq [line ["Braces %}" "Dot %." "Trailing %" "Space % " "Unknown %q" "Bad date %tq" "%" "%1$"
|
||||
;; GHY-4544: an argument index too large to be one is malformed, not an arithmetic overflow
|
||||
"%999999999999999999999999$s"]]
|
||||
(testing (pr-str line)
|
||||
(is (=? [#"A `%` in a `msg` line must begin a format specifier.*`%%`.*"]
|
||||
(findings agent-message/lint-msg (list 'msg [line]))))))
|
||||
(testing "an index at the digit cap still parses, and is reported as an argument count"
|
||||
(is (=? [#".*999999999 arguments.*"]
|
||||
(findings agent-message/lint-msg '(msg ["%999999999$s"] x))))))
|
||||
(testing "valid specifiers, including an escaped percent sign, aren't flagged as malformed"
|
||||
(doseq [form ['(msg ["100%% done"])
|
||||
'(msg ["%,d rows"] n)
|
||||
'(msg ["%1$s, then %<s"] x)
|
||||
'(msg [(str "50%" "% of %s")] x)]]
|
||||
(testing (pr-str form)
|
||||
(is (empty? (findings agent-message/lint-msg form)))))))
|
||||
|
||||
(def ^:private stringy-texts
|
||||
"Message arguments built as text rather than with `msg`."
|
||||
['"Query failed."
|
||||
'(str "Query failed: " err)
|
||||
'(format "Table %s not found" (pr-str t))
|
||||
'(cond-> (json/encode body) message (str "\n" message))
|
||||
'(if x "a" (str "b" y))
|
||||
'(if x (msg ["a"]) (str "b" y))
|
||||
'(when-let [e (:error r)] (str/join ", " e))
|
||||
'(or (:message r) (tru "Unknown error"))
|
||||
'(let [s (pr-str x)] (str "Bad value " s))
|
||||
'(cond (:a x) (msg ["a"]) :else "b")
|
||||
'(condp = x :a (str "a" y) (msg ["b"]))
|
||||
'(condp = x :a (msg ["a"]) "b")
|
||||
'(case x :a (msg ["a"]) "b")
|
||||
'(case x :a "a" :b (msg ["b"]))
|
||||
'(->> bad (map name) (str/join ", "))
|
||||
'(-> x (str " suffix"))
|
||||
'(-> x name str)
|
||||
'(some-> e ex-message)
|
||||
'(some->> xs (map name) (clojure.string/join ", "))
|
||||
'(cond->> xs x (map name) y (str/join ", "))
|
||||
'(cond-> "x" y (subs 1))
|
||||
'(as-> x v (assoc v :a 1) (json/encode v))
|
||||
'(doto (str "a" b) println)
|
||||
'(let [s x] (-> s (format y)))])
|
||||
|
||||
(def ^:private message-texts
|
||||
"Message arguments that aren't syntactically text."
|
||||
['(msg ["Table %s not found."] t)
|
||||
'message
|
||||
'(if x (msg ["a"]) (msg ["b"]))
|
||||
'(render-error e)
|
||||
'(cond (str x) (msg ["a"]) :else (msg ["b"]))
|
||||
'(condp = x "a" (msg ["a"]) (msg ["b"]))
|
||||
'(condp get x "a" :>> render-error (msg ["b"]))
|
||||
'(case x "a" (msg ["a"]) ("b" "c") (msg ["b"]))
|
||||
'(-> "x" count)
|
||||
'(->> "x" (map str) set)
|
||||
'(some-> (str "x") render-error)
|
||||
'(-> x)
|
||||
'(-> e :message)
|
||||
'(cond-> {:a 1} x (assoc :b "c"))
|
||||
'(as-> "x" v (count v))
|
||||
'(doto x (println "a"))])
|
||||
|
||||
(deftest ^:parallel teaching-exit-test
|
||||
(doseq [fn-name ["throw-teaching-error" "error-content"]]
|
||||
(testing (str "GHY-4544: text passed to `" fn-name "` is flagged")
|
||||
(doseq [text stringy-texts]
|
||||
(testing (pr-str text)
|
||||
(is (=? [(re-pattern (str ".*passes text to `" fn-name "`.*`msg`.*"))]
|
||||
(findings agent-message/lint-teaching-exit (list (symbol "common" fn-name) text)))))))
|
||||
(testing (str "`" fn-name "` accepts a `msg`, a symbol, and other calls")
|
||||
(doseq [text message-texts]
|
||||
(testing (pr-str text)
|
||||
(is (empty? (findings agent-message/lint-teaching-exit
|
||||
(list (symbol fn-name) text {:status-code 404}))))))))
|
||||
(testing "only the message argument is checked"
|
||||
(is (empty? (findings agent-message/lint-teaching-exit '(error-content (msg ["x"]) (str "code"))))))
|
||||
(testing "nothing is reported when the linter is off"
|
||||
(is (empty? (findings agent-message/lint-teaching-exit '(throw-teaching-error "x") :off)))))
|
||||
|
||||
(def ^:private helper-cases
|
||||
"`[hook fn-name form-fn]`: a message-taking helper's hook, the name its finding cites, and a function of a message
|
||||
returning a call that passes it to the helper."
|
||||
[[agent-message/lint-op-error "op-error!" #(list 'dashboard-ops/op-error! 'idx "add_link" %)]
|
||||
[agent-message/lint-ellipsize "ellipsize" #(list 'common/ellipsize % 300)]
|
||||
[agent-message/lint-list-content "list-content" #(list 'common/list-content 'rows 0 {:offset 0 :empty-hint %})]
|
||||
[agent-message/lint-list-content "paged-list-content"
|
||||
#(list 'paged-list-content 'args 'rows {:empty-hint %} 'identity)]])
|
||||
|
||||
(deftest ^:parallel message-helper-test
|
||||
(doseq [[hook fn-name form-fn] helper-cases]
|
||||
(testing (str "GHY-4544: text passed as `" fn-name "`'s message is flagged")
|
||||
(doseq [text stringy-texts]
|
||||
(testing (pr-str text)
|
||||
(is (=? [(re-pattern (str ".*passes text to `" fn-name "`.*`msg`.*"))]
|
||||
(findings hook (form-fn text)))))))
|
||||
(testing (str "`" fn-name "` accepts a `msg`, a symbol, and other calls as its message")
|
||||
(doseq [text message-texts]
|
||||
(testing (pr-str text)
|
||||
(is (empty? (findings hook (form-fn text))))))))
|
||||
(testing "only the message argument is checked"
|
||||
(is (empty? (findings agent-message/lint-op-error '(op-error! (str "0") (str "op") (msg ["x"])))))
|
||||
(is (empty? (findings agent-message/lint-ellipsize '(ellipsize (msg ["x"]) (str "300")))))
|
||||
(is (empty? (findings agent-message/lint-list-content '(list-content (str "data") 0 {:param (str "x")}))))
|
||||
(is (empty? (findings agent-message/lint-list-content '(list-content data 0 opts))))
|
||||
(is (empty? (findings agent-message/lint-list-content '(list-content data 0))))))
|
||||
|
||||
(deftest ^:parallel jsonrpc-error-test
|
||||
(testing "GHY-4544: text passed as the JSON-RPC error message is flagged"
|
||||
(doseq [text stringy-texts]
|
||||
(testing (pr-str text)
|
||||
(is (=? [#".*passes text to `jsonrpc-error`.*`msg`.*"]
|
||||
(findings agent-message/lint-jsonrpc-error (list 'transport/jsonrpc-error 'id -32602 text)))))))
|
||||
(testing "a `msg`, symbol, or other call as the message is accepted"
|
||||
(doseq [text message-texts]
|
||||
(testing (pr-str text)
|
||||
(is (empty? (findings agent-message/lint-jsonrpc-error (list 'jsonrpc-error 'id -32602 text)))))))
|
||||
(testing "only the third argument is the message"
|
||||
(is (empty? (findings agent-message/lint-jsonrpc-error '(jsonrpc-error "id" (str "code") (msg ["x"])))))))
|
||||
|
||||
(deftest ^:parallel success-content-test
|
||||
(testing "GHY-4544: text passed to `success-content` is flagged"
|
||||
(doseq [text stringy-texts]
|
||||
(testing (pr-str text)
|
||||
(is (=? [#"`success-content` takes data to JSON-encode or a `msg`.*"]
|
||||
(findings agent-message/lint-success-content (list 'common/success-content text)))))))
|
||||
(testing "a payload, a `msg`, or a symbol is accepted"
|
||||
(doseq [text (conj message-texts '{:data rows} '[a b])]
|
||||
(testing (pr-str text)
|
||||
(is (empty? (findings agent-message/lint-success-content (list 'success-content text))))))
|
||||
(is (empty? (findings agent-message/lint-success-content
|
||||
'(success-content {:data rows} (str "structured")))))))
|
||||
|
||||
(deftest ^:parallel ex-info-test
|
||||
(testing "GHY-4544: an ex-info carrying a 4xx status code or an error code is flagged"
|
||||
(doseq [form ["(ex-info \"Card not found\" {:status-code 404})"
|
||||
"(ex-info (str \"Bad \" x) {:status-code 400 :field f})"
|
||||
"(ex-info \"Nope\" {::common/error-code c})"
|
||||
"(ex-info \"Nope\" {:error-code c})"
|
||||
"(ex-info (msg [\"Nope\"]) {:error-code c})"]]
|
||||
(testing form
|
||||
(is (=? [#".*`throw-teaching-error` and a `msg`.*"]
|
||||
(findings agent-message/lint-ex-info form))))))
|
||||
(testing "other ex-info calls aren't flagged"
|
||||
(doseq [form ["(ex-info \"boom\" {:status-code 500})"
|
||||
"(ex-info \"boom\" {:status-code code})"
|
||||
"(ex-info \"boom\" {:foo 1})"
|
||||
"(ex-info \"boom\" data)"
|
||||
"(ex-info \"boom\" (merge {:status-code 400} data))"
|
||||
"(ex-info \"boom\")"]]
|
||||
(testing form
|
||||
(is (empty? (findings agent-message/lint-ex-info form))))))
|
||||
(testing "nothing is reported when the linter is off"
|
||||
(is (empty? (findings agent-message/lint-ex-info "(ex-info \"x\" {:status-code 404})" :off)))))
|
||||
@@ -1,25 +0,0 @@
|
||||
(ns hooks.metabase.app-db.schema-migrations-test.impl-test
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clj-kondo.impl.utils]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.metabase.app-db.schema-migrations-test.impl]))
|
||||
|
||||
(defn- finding-types!
|
||||
[ns-symb]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/validate-mb-app-db-migrations-test {:level :warning}}}
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(hooks.metabase.app-db.schema-migrations-test.impl/test-migrations!
|
||||
{:ns ns-symb
|
||||
:node (hooks/parse-string (pr-str '(impl/test-migrations ["v64.2026-09-01T00:00:00"] [migrate!] (migrate!))))})
|
||||
(mapv :type @(:findings clj-kondo.impl.utils/*ctx*))))
|
||||
|
||||
(deftest ^:synchronized require-app-db-migrations-test-tag-test
|
||||
(testing "a namespace that runs migration tests must be tagged, so CI shards it with the other migration tests"
|
||||
(is (= [:metabase/validate-mb-app-db-migrations-test]
|
||||
(finding-types! 'metabase.some-migrations-test))))
|
||||
(testing "a tagged namespace is fine"
|
||||
(is (= []
|
||||
(finding-types! (with-meta 'metabase.some-migrations-test {:mb/app-db-migrations-test true}))))))
|
||||
@@ -1,335 +0,0 @@
|
||||
(ns hooks.metabase.toucan.db-ns-test
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clj-kondo.impl.utils]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.metabase.toucan.db-ns :as toucan.db-ns]))
|
||||
|
||||
(defn- confinement-findings
|
||||
"Only the `t2-query-namespace` findings, so these tests are not affected by the query-hygiene
|
||||
linter that shares this hook."
|
||||
[findings]
|
||||
(filter #(= :metabase/t2-query-namespace (:type %)) findings))
|
||||
|
||||
(defn- lint-query-call [form ns-sym & [filename modules]]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/t2-query-namespace {:level :warning}
|
||||
:metabase/unsafe-app-db-query {:level :warning}}}
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(let [input {:node (hooks/parse-string (pr-str form))
|
||||
:ns ns-sym
|
||||
:filename (or filename "src/metabase/foo/bar.clj")
|
||||
:config {:metabase/modules modules}}
|
||||
output (toucan.db-ns/lint-query-call input)]
|
||||
(is (identical? (:node input) (:node output))
|
||||
"the hook must return the node unchanged so Kondo's normal analysis still runs")
|
||||
@(:findings clj-kondo.impl.utils/*ctx*))))
|
||||
|
||||
(deftest ^:parallel t2-query-must-live-in-db-namespace-test
|
||||
(testing "a query call outside a db namespace is flagged"
|
||||
(is (=? [{:type :metabase/t2-query-namespace
|
||||
:message #".*`t2/select-one`.*"}]
|
||||
(lint-query-call '(t2/select-one :model/Card :id 1) 'metabase.queries.models.card))))
|
||||
(testing "metabase.<module>.db is allowed"
|
||||
(is (empty? (confinement-findings (lint-query-call '(t2/select-one :model/Card :id 1) 'metabase.queries.db)))))
|
||||
(testing "metabase-enterprise.<module>.db is allowed"
|
||||
(is (empty? (confinement-findings (lint-query-call '(t2/select-one :model/Card :id 1) 'metabase-enterprise.sandbox.db)))))
|
||||
(testing "metabase.driver.<driver>.db is allowed for driver modules"
|
||||
(is (empty? (confinement-findings (lint-query-call '(t2/select-one :model/Database :id 1) 'metabase.driver.bigquery-cloud-sdk.db)))))
|
||||
(testing "a nested db namespace is not a module db namespace"
|
||||
(is (=? [{:type :metabase/t2-query-namespace}]
|
||||
(lint-query-call '(t2/query {:select [:*]}) 'metabase.queries.models.db))))
|
||||
(testing "a namespace merely ending in db is not a module db namespace"
|
||||
(is (=? [{:type :metabase/t2-query-namespace}]
|
||||
(lint-query-call '(t2/delete! :model/Card :id 1) 'metabase.queries.dashboards-db))))
|
||||
(testing "files in a test source tree are exempt even when the namespace name doesn't look like a test"
|
||||
(is (empty? (lint-query-call '(t2/update! :model/User 1 {:sso_source nil}) 'metabase.sso.test-helpers
|
||||
"test/metabase/sso/test_helpers.clj")))
|
||||
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase-enterprise.remote-sync.test-helpers
|
||||
"/repo/enterprise/backend/test/metabase_enterprise/remote_sync/test_helpers.clj"))))
|
||||
(testing "app-db wrappers around Toucan 2 are reported the same way"
|
||||
(is (=? [{:type :metabase/t2-query-namespace
|
||||
:message #".*`mdb/update-or-insert!`.*"}]
|
||||
(lint-query-call '(mdb/update-or-insert! :model/Foo {:id 1}) 'metabase.foo.models.foo))))
|
||||
(testing "fully-qualified calls are reported by their written name"
|
||||
(is (=? [{:type :metabase/t2-query-namespace
|
||||
:message #".*`toucan2.core/insert!`.*"}]
|
||||
(lint-query-call '(toucan2.core/insert! :model/Card {}) 'metabase.queries.models.card)))))
|
||||
|
||||
(deftest ^:parallel t2-query-namespace-follows-the-module-tree-test
|
||||
(let [modules '{queries {}
|
||||
metabot {}
|
||||
metabot.llm {}
|
||||
lib.be {:ns-prefix "metabase.lib-be"}
|
||||
enterprise/sandbox {}}]
|
||||
(testing "a nested module owns its own db namespace"
|
||||
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase.metabot.llm.db nil modules))))
|
||||
(testing "the parent's db namespace stays its own"
|
||||
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase.metabot.db nil modules))))
|
||||
(testing "a module with an explicit :ns-prefix is resolved through it, not its dotted name"
|
||||
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase.lib-be.db nil modules)))
|
||||
(is (=? [{:type :metabase/t2-query-namespace}]
|
||||
(lint-query-call '(t2/select :model/Card) 'metabase.lib.be.db nil modules))))
|
||||
(testing "a db namespace under a directory naming no module is still a finding"
|
||||
(is (=? [{:type :metabase/t2-query-namespace}]
|
||||
(lint-query-call '(t2/query {:select [:*]}) 'metabase.queries.models.db nil modules)))
|
||||
(is (=? [{:type :metabase/t2-query-namespace}]
|
||||
(lint-query-call '(t2/query {:select [:*]}) 'metabase.metabot.llm.models.db nil modules))))
|
||||
(testing "enterprise modules resolve through the metabase-enterprise root"
|
||||
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase-enterprise.sandbox.db nil modules))))))
|
||||
|
||||
(deftest ^:parallel unmarked-value-in-a-db-namespace-test
|
||||
(testing "a symbol reaching a value slot is flagged"
|
||||
(is (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #"`locale` reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call '(t2/select :model/X {:where [:= :locale locale]}) 'metabase.foo.db))))
|
||||
(testing "a marked value is not flagged"
|
||||
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :locale [:auto/param locale]]})
|
||||
'metabase.foo.db))))
|
||||
(testing "a literal cannot carry a request value and is not flagged"
|
||||
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :locale "de"]}) 'metabase.foo.db))))
|
||||
(testing "a column reference is not a value"
|
||||
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :a.id :b.id]}) 'metabase.foo.db))))
|
||||
(testing "values nested under a boolean connective are reached"
|
||||
(is (=? [{:message #"`b`.*"}]
|
||||
(lint-query-call '(t2/select :model/X {:where [:and [:= :x [:auto/param a]] [:= :y b]]})
|
||||
'metabase.foo.db))))
|
||||
(testing "only db namespaces are linted for unmarked values"
|
||||
(is (empty? (filter #(= :metabase/unsafe-app-db-query (:type %))
|
||||
(lint-query-call '(t2/select :model/X {:where [:= :locale locale]})
|
||||
'metabase.foo.models.thing)))))
|
||||
(testing "a test source tree is exempt"
|
||||
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :locale locale]})
|
||||
'metabase.foo.db "test/metabase/foo/db_test.clj")))))
|
||||
|
||||
(deftest ^:parallel kv-arg-style-is-not-flagged-test
|
||||
(testing "call style is not linted: a query written as :column value pairs is left alone"
|
||||
;; Extraction to a .sql file is semantic rather than a transcription of the HoneySQL shape, so a
|
||||
;; query map is no closer to that target than kv-args are. Converting also skips Toucan's type
|
||||
;; transforms, which only run in kv-arg position -- that bug shipped twice.
|
||||
(are [form] (empty? (filter #(re-find #"Pass this query a map" (str (:message %)))
|
||||
(lint-query-call form 'metabase.foo.db)))
|
||||
'(t2/select :model/X :locale [:auto/param locale])
|
||||
'(t2/select :model/X :archived false)
|
||||
'(t2/select-one :model/X :id (long id))
|
||||
'(t2/select-one-fn :value :model/Setting :key [:auto/param k])
|
||||
'(t2/select :model/X {:where [:= :locale [:auto/param locale]]})))
|
||||
(testing "an unmarked value in a kv-arg is still flagged -- only the style check went away"
|
||||
(is (=? [{:type :metabase/unsafe-app-db-query, :message #".*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call '(t2/select :model/X :locale locale) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel conditions-map-values-are-linted-test
|
||||
(testing "a conditions map -- a shape the sweep leaves in place -- is checked"
|
||||
;; `(t2/update! model {:col v} changes)` filters on v, but it is not a `:where` clause, so
|
||||
;; neither the query-map walker nor the kv-arg walker used to see it. A namespace written this
|
||||
;; way passed the lint vacuously.
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #".*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/update! :model/X {:key k} {:v 1})
|
||||
'(t2/delete! :model/X {:key k})
|
||||
'(t2/update! :model/X {:key [:in ks]} {:v 1})))
|
||||
(testing "a marked or coerced conditions value is not flagged"
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/update! :model/X {:key [:auto/param k]} {:v 1})
|
||||
'(t2/update! :model/X {:id (long id)} {:v 1})))
|
||||
(testing "only the map right after the model is conditions -- the changes map is exempt"
|
||||
;; rule 1: a written value must arrive as itself, so it is never flagged.
|
||||
(is (empty? (lint-query-call '(t2/update! :model/X {:id (long id)} {:v written})
|
||||
'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel subquery-values-are-linted-test
|
||||
(testing "a value inside a subquery in a value slot is reached"
|
||||
(is (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #".*`z`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call
|
||||
'(t2/select :model/X {:where [:in :id {:select [:id] :from [:y] :where [:= :z z]}]})
|
||||
'metabase.foo.db))))
|
||||
(testing "the plain token case still works -- descending must not replace it"
|
||||
(is (=? [{:message #"`v`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call '(t2/select :model/X {:where [:= :k v]}) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel conditionally-built-clause-test
|
||||
(testing "a value slot inside a conditional form is still reached"
|
||||
;; `value-nodes` used to stop at a list node, so anything a `when`/`if`/`cond->` built was
|
||||
;; invisible -- a false negative in a security lint. permissions/db.clj and collections/db.clj
|
||||
;; both build clauses this way.
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #".*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X {:where [:and (when k [:= :key k])]})))
|
||||
(testing "both branches of an if are reached"
|
||||
(is (= 2 (count (lint-query-call
|
||||
'(t2/select :model/X {:where [:and (if flag [:= :key k] [:= :key other-k])]})
|
||||
'metabase.foo.db)))))
|
||||
(testing "a marked value inside a conditional form is not flagged"
|
||||
(is (empty? (lint-query-call
|
||||
'(t2/select :model/X {:where [:and (when k [:= :key [:auto/param k]])]})
|
||||
'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel kv-arg-values-are-linted-test
|
||||
(testing "an unmarked value in a kv-arg pair is flagged, so dropping the style check left no gap"
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #".*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X :locale locale)
|
||||
'(t2/select-one :model/X :id id)
|
||||
;; the pairs do not start at a fixed offset -- :value comes before the model here
|
||||
'(t2/select-one-fn :value :model/Setting :key k)
|
||||
;; a value inside an operator form is reached, rather than the form being treated as a value
|
||||
'(t2/select :model/X :id [:in ids])))
|
||||
(testing "GHY-4668: a model written with columns still has its kv-arg values checked"
|
||||
;; The walker found the model by looking for a `:model/...` keyword, so `[:model/X :col]` hid
|
||||
;; every pair after it.
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #".*`prefix`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select [:model/X :id :public_uuid] :public_uuid_prefix prefix :archived false)
|
||||
'(t2/select-one [:model/X :id] :public_uuid_prefix prefix)))
|
||||
(testing "GHY-4668: a marked value after a model written with columns is not flagged"
|
||||
(is (empty? (lint-query-call '(t2/select [:model/X :id] :public_uuid_prefix [:auto/param prefix])
|
||||
'metabase.foo.db))))
|
||||
(testing "a marked or coerced kv-arg value is not flagged"
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X :locale [:auto/param locale])
|
||||
'(t2/select-one :model/X :id (long id))
|
||||
'(t2/select :model/X :id [:in (mapv long ids)])
|
||||
'(t2/select :model/X :id [:in [:auto/param ids]])))
|
||||
(testing "a literal kv-arg value is not flagged"
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X :archived false)
|
||||
'(t2/select :model/X :status "pending")
|
||||
'(t2/select :model/X :engine :appdb)))
|
||||
(testing "one unmarked value per pair is reported"
|
||||
(is (= 2 (count (lint-query-call '(t2/select :model/X :locale locale :msgid msgid)
|
||||
'metabase.foo.db)))))
|
||||
(testing "a call mixing kv-args with a trailing query map reports each value exactly once"
|
||||
;; The two walkers run over the same arguments, so this pins that they do not double-report.
|
||||
(is (= 2 (count (lint-query-call
|
||||
'(t2/select :model/X :locale locale {:where [:= :other other]})
|
||||
'metabase.foo.db))))))
|
||||
|
||||
(deftest ^:parallel write-calls-are-not-linted-for-values-test
|
||||
(testing "an insert's values are written, not filtered on, so they are not flagged"
|
||||
(are [form] (empty? (filter #(= :metabase/unsafe-app-db-query (:type %))
|
||||
(lint-query-call form 'metabase.foo.db)))
|
||||
'(t2/insert! :model/X :key k :value v)
|
||||
'(t2/insert-returning-instances! :model/X :key k :value v)))
|
||||
(testing "a select's values are still flagged"
|
||||
(is (=? [{:type :metabase/unsafe-app-db-query}]
|
||||
(lint-query-call '(t2/select :model/X :key k) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel operator-arity-test
|
||||
(testing "a value operator with an unexpected arity still has its args examined"
|
||||
(is (seq (lint-query-call '(t2/select :model/X {:where [:= :a b c]}) 'metabase.foo.db)))
|
||||
(is (seq (lint-query-call '(t2/select :model/X {:where [:between :a lo hi]}) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel operator-coverage-test
|
||||
(testing "comparison operators HoneySQL accepts as aliases for not= are checked"
|
||||
;; The set used to list only `not=`, so `[:!= :col v]` -- live in collections/db.clj -- was
|
||||
;; silently unchecked even though HoneySQL binds it as `<>`.
|
||||
(are [form] (=? [{:message #"`v`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X {:where [:!= :k v]})
|
||||
'(t2/select :model/X {:where [:<> :k v]})
|
||||
'(t2/select :model/X {:where [:is-distinct-from :k v]})))
|
||||
(testing "a two-value operator reports both values"
|
||||
(is (= 2 (count (lint-query-call '(t2/select :model/X {:where [:not-between :k lo hi]})
|
||||
'metabase.foo.db)))))
|
||||
(testing "a value wrapped in a function-call form is reached"
|
||||
;; `[:lower v]` compiles to `LOWER(?)`, so `v` is a real bind slot.
|
||||
(are [form] (seq (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X {:where [:= :k [:lower v]]})
|
||||
'(t2/select :model/X {:where [:= :k [:cast v :text]]})
|
||||
'(t2/select :model/X :k [:lower v])))
|
||||
(testing "values in a literal collection are reached"
|
||||
;; `[:in :k [a b]]` binds each element -- `IN (?, ?)`.
|
||||
(is (= 2 (count (lint-query-call '(t2/select :model/X {:where [:in :k [a b]]})
|
||||
'metabase.foo.db))))))
|
||||
|
||||
(deftest ^:parallel no-duplicate-findings-test
|
||||
(testing "a multi-arg operator in a kv-arg reports each value exactly once"
|
||||
;; value-nodes misreads `[:between lo hi]` as a clause (lo as column), while
|
||||
;; kv-arg-value-nodes reads it correctly, so the walkers overlap on `hi`. Deduping by source
|
||||
;; position makes that harmless.
|
||||
(is (= 2 (count (lint-query-call '(t2/select :model/X :d [:between lo hi]) 'metabase.foo.db))))
|
||||
(is (= 2 (count (lint-query-call '(t2/delete! :model/X {:d [:between lo hi]})
|
||||
'metabase.foo.db)))))
|
||||
(testing "a query map passed where conditions go reports each value once"
|
||||
;; `{:where ...}` is a query map that value-nodes already walks; treating it as conditions too
|
||||
;; reported everything twice. Live in mcp/db.clj, metabot/db.clj, testing_api/db.clj.
|
||||
(is (= 1 (count (lint-query-call '(t2/delete! :model/X {:where [:= :k k]}) 'metabase.foo.db))))
|
||||
(is (= 2 (count (lint-query-call '(t2/update! :model/X {:where [:and [:= :a a] [:= :b b]]} {:v 1})
|
||||
'metabase.foo.db))))))
|
||||
|
||||
(deftest ^:parallel two-arity-update-changes-map-test
|
||||
(testing "update!'s lone trailing map is CHANGES, not conditions, so its values are not flagged"
|
||||
;; Toucan's arglist ends in the changes map. remote_sync/db.clj's mark-all-rsos-synced! is this
|
||||
;; shape; flagging `written` would ask for a marker on a written value, which the column's
|
||||
;; `:in` transform stores as data.
|
||||
(is (empty? (lint-query-call '(t2/update! :model/X {:v written}) 'metabase.foo.db))))
|
||||
(testing "delete! has no changes map, so its lone map is always conditions"
|
||||
(is (=? [{:message #"`k`.*"}]
|
||||
(lint-query-call '(t2/delete! :model/X {:k k}) 'metabase.foo.db))))
|
||||
(testing "update!'s FIRST map is still conditions when a changes map follows"
|
||||
(is (=? [{:message #"`k`.*"}]
|
||||
(lint-query-call '(t2/update! :model/X {:k k} {:v 1}) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel identifier-clauses-are-not-value-slots-test
|
||||
(testing "a symbol naming a column or a table is not reported"
|
||||
;; `value-guard` refuses a marker in these clauses, so reporting one asks for a fix that throws
|
||||
;; at compile. data_studio/db.clj, task_history/db.clj and permissions/db.clj are written this
|
||||
;; way. `:select` is the exception: index 0 of its entries is an expression, so it has its own
|
||||
;; test in expression-entry-clauses-test.
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query {:select [:*] :from [[union-query :subquery]]})
|
||||
'(t2/query {:select [:*] :from [:t] :order-by [[:sort_key sort-direction]]})
|
||||
'(t2/query {:select [:*] :from [:t] :group-by [group-column]})))
|
||||
(testing "a value nested inside an identifier clause is still reported"
|
||||
(are [form] (=? [{:message #"`v`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
;; a computed projection is a real comparison
|
||||
'(t2/query {:select [[[:= :engine v] :is_match]] :from [:t]})
|
||||
;; so is a CASE sort key
|
||||
'(t2/query {:select [:*] :from [:t] :order-by [[[:case [:= :a v] 1 :else 2] :asc]]})
|
||||
;; a subquery carries its own where clause
|
||||
'(t2/query {:select [:*] :from [[{:select [:*] :from [:u] :where [:= :k v]} :s]]}))))
|
||||
|
||||
(deftest ^:parallel expression-entry-clauses-test
|
||||
(testing "index 0 of an entry is a value slot, because HoneySQL binds a parameter there"
|
||||
;; `{:select [[[:auto/param "a"] :k]]}` compiles to `SELECT ? AS "K"`, so `value-guard` accepts
|
||||
;; a marker there. Treating the whole clause as names would hide a request value.
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #"`v`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query {:select [[v :label]] :from [:t]})
|
||||
'(t2/query {:select-distinct [[v :label]] :from [:t]})
|
||||
'(t2/query {:insert-into [:t] :returning [[v :label]]})))
|
||||
(testing "the alias, a bare entry and a table entry are names"
|
||||
;; `value-guard` refuses a marker in each of these, so asking for one would break the query.
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query {:select [[:key alias-sym]] :from [:t]})
|
||||
'(t2/query {:select [cols] :from [:t]})
|
||||
'(t2/query {:select [:*] :from [[tbl :x]]})))
|
||||
(testing "a computed projection and a subquery entry still reach their values"
|
||||
(are [form] (=? [{:message #"`v`.*"}] (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query {:select [[[:= :engine v] :is_match]] :from [:t]})
|
||||
'(t2/query {:select [:*] :from [[{:select [:*] :from [:u] :where [:= :k v]} :s]]}))))
|
||||
|
||||
(deftest ^:parallel clause-context-survives-assoc-test
|
||||
(testing "a clause key in an assoc call gives its value the clause context back"
|
||||
;; task_history/db.clj:174 and five other namespaces build `:order-by` this way. Without this
|
||||
;; the lint asks for a marker on a column name and on a sort direction, and the fix the message
|
||||
;; names loses the ordering with no error.
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query (assoc q :order-by [[sort-column sort-direction] [:id :desc]]))
|
||||
'(t2/query (cond-> q flag (assoc :order-by [[sort-column sort-direction]])))
|
||||
'(t2/query (assoc q :group-by [group-column]))
|
||||
'(t2/query (assoc q :select [[:key alias-sym]]))))
|
||||
(testing "a value clause built the same way is still reported"
|
||||
(is (=? [{:message #"`v`.*"}]
|
||||
(lint-query-call '(t2/query (assoc q :where [:= :k v])) 'metabase.foo.db))))
|
||||
(testing "an expression entry built the same way is still reported"
|
||||
(is (=? [{:message #"`v`.*"}]
|
||||
(lint-query-call '(t2/query (assoc q :select [[v :label]])) 'metabase.foo.db)))))
|
||||
@@ -1,46 +0,0 @@
|
||||
(ns hooks.metabase.toucan.table-name-test
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clj-kondo.impl.utils]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.metabase.toucan.table-name :as toucan.table-name]))
|
||||
|
||||
;; `hooks.methodical.macros` isn't on the classpath under `.clj-kondo/test`,
|
||||
;; so stub it out — `lint-defmethod` delegates to it via `requiring-resolve`.
|
||||
;; If it ever becomes available, warn so we remember to delete this stub.
|
||||
(if (find-ns 'hooks.methodical.macros)
|
||||
(println (str "WARNING: hooks.methodical.macros is now on the test classpath; "
|
||||
"the stub in " *ns* " is no longer needed and can be removed."))
|
||||
(do (create-ns 'hooks.methodical.macros)
|
||||
(intern 'hooks.methodical.macros 'defmethod (fn [input] input))))
|
||||
|
||||
(defn- lint-defmethod [form filename]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/toucan-model-ns {:level :warning}}}
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(toucan.table-name/lint-defmethod {:node (hooks/parse-string (pr-str form))
|
||||
:filename filename})
|
||||
@(:findings clj-kondo.impl.utils/*ctx*)))
|
||||
|
||||
(deftest ^:parallel table-name-must-live-in-models-namespace-test
|
||||
(testing "defmethod t2/table-name outside a models namespace is flagged"
|
||||
(is (=? [{:type :metabase/toucan-model-ns}]
|
||||
(lint-defmethod '(defmethod t2/table-name :model/Foo [_] :foo)
|
||||
"/repo/src/metabase/foo/core.clj"))))
|
||||
(testing "defmethod t2/table-name inside <module>/models.clj is allowed"
|
||||
(is (empty? (lint-defmethod '(defmethod t2/table-name :model/Foo [_] :foo)
|
||||
"/repo/src/metabase/foo/models.clj"))))
|
||||
(testing "defmethod t2/table-name inside <module>/models/<model>.clj is allowed"
|
||||
(is (empty? (lint-defmethod '(defmethod t2/table-name :model/Foo [_] :foo)
|
||||
"/repo/src/metabase/foo/models/bar.clj"))))
|
||||
(testing "enterprise modules are also allowed"
|
||||
(is (empty? (lint-defmethod '(defmethod t2/table-name :model/Foo [_] :foo)
|
||||
"/repo/enterprise/backend/src/metabase_enterprise/foo/models.clj"))))
|
||||
(testing "toucan2.core/table-name is also recognized"
|
||||
(is (=? [{:type :metabase/toucan-model-ns}]
|
||||
(lint-defmethod '(defmethod toucan2.core/table-name :model/Foo [_] :foo)
|
||||
"/repo/src/metabase/foo/core.clj"))))
|
||||
(testing "unrelated defmethods are ignored"
|
||||
(is (empty? (lint-defmethod '(defmethod t2/primary-keys :model/Foo [_] [:id])
|
||||
"/repo/src/metabase/foo/core.clj")))))
|
||||
@@ -1,59 +0,0 @@
|
||||
(ns hooks.metabase.util.match-test
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clj-kondo.impl.utils]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.metabase.util.match :as sut]))
|
||||
|
||||
(defn- expand [src]
|
||||
(-> (sut/match-one {:node (hooks/parse-string (pr-str src))})
|
||||
:node
|
||||
hooks/sexpr))
|
||||
|
||||
(defn- lint [src]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/match-odd-clauses {:level :warning}}}
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(expand src)
|
||||
@(:findings clj-kondo.impl.utils/*ctx*)))
|
||||
|
||||
(deftest odd-clauses-triggers-warning-test
|
||||
(is (=? [{:type :metabase/match-odd-clauses}]
|
||||
(lint '(metabase.util.match/match-one [] [a b c])))))
|
||||
|
||||
(deftest expansion-test
|
||||
(testing "simple expansion example"
|
||||
(is (= '(let [_ []
|
||||
&match (rand-nth ())
|
||||
&parents (rand-nth ())
|
||||
&recur (rand-nth ())
|
||||
_ &match
|
||||
_ &parents
|
||||
_ &recur
|
||||
a (rand-nth ())
|
||||
b (rand-nth ())
|
||||
c (rand-nth ())]
|
||||
(clojure.core/or (+ a b) (rand-nth ())))
|
||||
(expand '(match-one [] [a b c] (+ a b))))))
|
||||
(testing "complicated expansion example that uses most match-one macro features, and also binding deduplication by the rewriter"
|
||||
(is (= '(let [_ []
|
||||
&match (rand-nth ())
|
||||
&parents (rand-nth ())
|
||||
&recur (rand-nth ())
|
||||
_ &match
|
||||
_ &parents
|
||||
_ &recur
|
||||
val1 (rand-nth ())
|
||||
a (rand-nth ())
|
||||
_ #{maybe important}
|
||||
rst (rand-nth ())
|
||||
x (rand-nth ())
|
||||
_ (= x 3)
|
||||
b (rand-nth ())]
|
||||
(clojure.core/or (+ a val1) (rand-nth ())))
|
||||
(expand '(match-one []
|
||||
(:or [{:key1 val1, :key2 &truthy, :key3 _} _ a #{maybe important} & rst]
|
||||
(:and (x :guard (= x 3))
|
||||
[a b]))
|
||||
(+ a val1)))))))
|
||||
@@ -1,27 +0,0 @@
|
||||
{:aliases
|
||||
{;; defendpoint shape: (api.macros/defendpoint METHOD URL DOCSTRING? [args] body)
|
||||
;; No name slot — synthesize :route [METHOD "path"] from first 2 args.
|
||||
"defendpoint"
|
||||
{:fields {:method (fn [z] (some-> z z/down z/right z/sexpr name str/upper-case))
|
||||
:path (fn [z] (-> z z/down z/right z/right z/sexpr))}}
|
||||
|
||||
;; defenterprise shape:
|
||||
;; (defenterprise NAME DOCSTRING? EE-NAMESPACE [args] body)
|
||||
"defenterprise"
|
||||
{:fields {:name ->defn-name
|
||||
:ee-namespace (fn [z]
|
||||
;; Skip name + optional docstring; the next
|
||||
;; symbol is the EE namespace.
|
||||
(let [name-z (-> z z/down z/right)
|
||||
after-doc (z/right name-z)
|
||||
candidate (if (string? (try (z/sexpr after-doc)
|
||||
(catch Exception _ nil)))
|
||||
(z/right after-doc)
|
||||
after-doc)]
|
||||
(try (let [v (z/sexpr candidate)]
|
||||
(when (symbol? v) v))
|
||||
(catch Exception _ nil))))
|
||||
:arglist ->defn-arg-list}}
|
||||
|
||||
"defsetting"
|
||||
{:fields {:name ->defn-name}}}}
|
||||
88
.cljfmt.edn
88
.cljfmt.edn
@@ -1,10 +1,12 @@
|
||||
;; Cljfmt config. See https://github.com/weavejester/cljfmt#formatting-options
|
||||
{:function-arguments-indentation :community
|
||||
:indent-line-comments? true
|
||||
:normalize-newlines-at-file-end? true
|
||||
:parallel? true
|
||||
:remove-blank-lines-in-forms? true
|
||||
:sort-ns-references? true
|
||||
{:sort-ns-references?
|
||||
true
|
||||
|
||||
:function-arguments-indentation
|
||||
:community
|
||||
|
||||
:parallel?
|
||||
true
|
||||
|
||||
:paths
|
||||
["src"
|
||||
@@ -12,65 +14,12 @@
|
||||
"enterprise/backend"
|
||||
"bin/build"
|
||||
"bin/lint-migrations-file"
|
||||
"bin/load-namespaces"
|
||||
"bin/release-list"
|
||||
#_"mage/src"
|
||||
#_"mage/test"
|
||||
"modules/drivers"
|
||||
".clj-kondo/src"
|
||||
".clj-kondo/test"
|
||||
#_"dev"]
|
||||
|
||||
;; a map of symbols that tell cljfmt which forms are allowed to have blank lines inside of them. The value may be
|
||||
;; either `:all`, which means blank lines are allowed between all elements in the form, e.g. `{cond :all}` to allow
|
||||
;; blank lines between any of the elements inside cond; or it may be a set of element indexes that are allowed to
|
||||
;; have blank lines, e.g. `{let #{0}}`, to allow blank lines in the binding of a `let` form.
|
||||
:extra-blank-line-forms
|
||||
{are :all
|
||||
case :all
|
||||
cond-> :all
|
||||
cond->> :all
|
||||
condp :all
|
||||
clojure.spec.alpha/or :all
|
||||
clojure.test/are :all
|
||||
metabase.driver-api.core/match-many :all
|
||||
metabase.driver-api.core/match-one :all
|
||||
metabase.driver-api.core/replace :all
|
||||
metabase.driver-api.core/replace-in :all
|
||||
metabase.lib.drill-thru.test-util/test-drill-variants-with-merged-args :all
|
||||
metabase.lib.drill-thru.test-util/test-returns-drill :all
|
||||
metabase.test/with-temp #{0}
|
||||
metabase.util.match/match-many :all
|
||||
metabase.util.match/match-one :all
|
||||
metabase.util.match/replace :all
|
||||
metabase.util.match/replace-in :all
|
||||
;; TODO (Cam 2026-05-21) I'm not really sure about `defprotocol`, `deftype`, `defrecord`, `proxy`, `reify`, etc., I
|
||||
;; think idiomatically these should probably not have blank lines but we can worry about that later
|
||||
defprotocol :all
|
||||
defrecord :all
|
||||
deftype :all
|
||||
extend :all
|
||||
extend-protocol :all
|
||||
potemkin.types/def-map-type :all
|
||||
potemkin.types/defprotocol+ :all
|
||||
potemkin.types/defrecord+ :all
|
||||
potemkin.types/deftype+ :all
|
||||
potemkin/def-map-type :all
|
||||
potemkin/defprotocol+ :all
|
||||
potemkin/defrecord+ :all
|
||||
potemkin/deftype+ :all
|
||||
proxy :all
|
||||
reify :all
|
||||
;; TODO (Cam 2026-05-21) -- save these for a follow-on PR
|
||||
defmacro :all
|
||||
defmethod :all
|
||||
defn :all
|
||||
defn- :all
|
||||
fn :all
|
||||
metabase.util.malli/defmethod :all
|
||||
metabase.util.malli/defn :all
|
||||
metabase.util.malli/defn- :all}
|
||||
|
||||
;; See https://github.com/weavejester/cljfmt/blob/master/cljfmt/resources/cljfmt/indents/clojure.clj for the default
|
||||
;; definitions
|
||||
;;
|
||||
@@ -88,12 +37,13 @@
|
||||
;; `:inner` indentation is like `:defn` indentation. `[[:inner 0]]` (default for `defn`) means all arguments at depth
|
||||
;; zero get indented 2 spaces if not on the first line regardless of number of elements on the first line.
|
||||
:extra-indents
|
||||
{ ;; clojure.core stuff
|
||||
{;; clojure.core stuff
|
||||
fn* [[:inner 0]]
|
||||
let* [[:block 1]]
|
||||
with-meta [[:default]]
|
||||
;; library stuff. Libraries that specify their own `:style/indent` specs are included in the section after this
|
||||
;; since it is generated automatically.
|
||||
clojure.core.match/match [[:block 1]]
|
||||
clojure.test.check.properties/for-all [[:block 1]]
|
||||
clojure.test.check.properties/for-all* [[:block 1]]
|
||||
clojure.test.check/quick-check [[:block 1]]
|
||||
@@ -168,9 +118,12 @@
|
||||
metabase.collections.models.collection-test/with-personal-and-impersonal-collections [[:block 1]]
|
||||
metabase.config/build-type-case [[:block 0]]
|
||||
metabase.dashboards.models.dashboard-test/with-dash-in-collection [[:inner 0]]
|
||||
metabase.driver-api.core/match [[:inner 0]]
|
||||
metabase.driver-api.core/match-one [[:inner 0]]
|
||||
metabase.driver-api.core/match-lite [[:inner 0]]
|
||||
metabase.driver-api.core/match-many [[:inner 0]]
|
||||
metabase.driver-api.core/replace [[:inner 0]]
|
||||
metabase.driver-api.core/replace-lite [[:inner 0]]
|
||||
metabase.driver-api.core/replace-in [[:inner 0]]
|
||||
metabase.driver.bigquery-cloud-sdk.query-processor/with-temporal-type [[:default]]
|
||||
metabase.driver.druid.client/DELETE [[:default]]
|
||||
@@ -203,10 +156,13 @@
|
||||
metabase.lib.test-util.macros/$ids [[:inner 0]]
|
||||
metabase.lib.test-util.macros/mbql-5-query [[:inner 0]]
|
||||
metabase.lib.test-util.macros/mbql-query [[:inner 0]]
|
||||
metabase.util.match/match-one [[:inner 0]]
|
||||
metabase.util.match/match-many [[:inner 0]]
|
||||
metabase.util.match/replace [[:inner 0]]
|
||||
metabase.util.match/replace-in [[:inner 0]]
|
||||
metabase.lib.util.match/match [[:inner 0]]
|
||||
metabase.lib.util.match/match-one [[:inner 0]]
|
||||
metabase.lib.util.match/match-lite [[:inner 0]]
|
||||
metabase.lib.util.match/match-many [[:inner 0]]
|
||||
metabase.lib.util.match/replace [[:inner 0]]
|
||||
metabase.lib.util.match/replace-lite [[:inner 0]]
|
||||
metabase.lib.util.match/replace-in [[:inner 0]]
|
||||
metabase.models.interface/define-batched-hydration-method [[:inner 0]]
|
||||
metabase.models.interface/define-simple-hydration-method [[:inner 0]]
|
||||
metabase.parameters.chain-filter-test/with-clean-field-values-for-field [[:block 1]]
|
||||
@@ -232,8 +188,8 @@
|
||||
metabase.query-processor.error-type/deferror [[:block 1]]
|
||||
metabase.query-processor.middleware.cache-backend.interface/with-cached-results [[:block 4]]
|
||||
metabase.query-processor.middleware.process-userland-query-test/with-query-execution [[:block 1]]
|
||||
metabase.query-processor.pipeline-queries-test/mbql5-query [[:block 1]]
|
||||
metabase.query-processor.pipeline-queries-test/run-mbql5-query [[:block 1]]
|
||||
metabase.query-processor.pipeline-queries-test/pmbql-query [[:block 1]]
|
||||
metabase.query-processor.pipeline-queries-test/run-pmbql-query [[:block 1]]
|
||||
metabase.query-processor.store/cached [[:block 1]]
|
||||
metabase.query-processor.store/with-metadata-provider [[:inner 0]]
|
||||
metabase.query-processor.streaming/streaming-response [[:block 1]]
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
high_level_summary: false
|
||||
review_status: false
|
||||
|
||||
auto_review:
|
||||
enabled: false
|
||||
drafts: true
|
||||
labels:
|
||||
- "coderabbit"
|
||||
|
||||
path_filters:
|
||||
- "!**/*.snap"
|
||||
- "!**/*.po"
|
||||
- "!**/bun.lock"
|
||||
- "!**/package-lock.json"
|
||||
|
||||
tools:
|
||||
eslint:
|
||||
enabled: false
|
||||
|
||||
knowledge_base:
|
||||
code_guidelines:
|
||||
filePatterns:
|
||||
- ".claude/skills/clojure-review/SKILL.md"
|
||||
- ".claude/skills/typescript-review/SKILL.md"
|
||||
- ".claude/skills/e2e-test-review/SKILL.md"
|
||||
- ".claude/skills/docs-review/SKILL.md"
|
||||
@@ -16,7 +16,7 @@ skip =
|
||||
frontend/src/metabase/querying/expressions/tokenizer/lezer.js,
|
||||
frontend/src/metabase/query_builder/components/NativeQueryEditor/CodeMirrorEditor/language.ts,
|
||||
frontend/src/metabase/static-viz/constants/char-sizes.ts,
|
||||
frontend/src/metabase/viz-core/lib/mapping_codes.ts,
|
||||
frontend/src/metabase/visualizations/lib/mapping_codes.ts,
|
||||
frontend/*/mocks/*,
|
||||
modules/drivers/mongo/src/metabase/driver/mongo/operators.clj,
|
||||
src/metabase/analyze/classifiers/name.clj,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// dependency-cruiser config for the actual frontend import graph (what's
|
||||
// imported, vs the *allowed* edges in module-boundaries.mjs). Consumed by
|
||||
// .github/scripts/create-test-plan.ts.
|
||||
//
|
||||
// bunx depcruise frontend/src enterprise/frontend/src .storybook/preview.tsx \
|
||||
// --config .dependency-cruiser.cjs --output-type json > dependency-graph.json
|
||||
|
||||
/** @type {import('dependency-cruiser').IConfiguration} */
|
||||
module.exports = {
|
||||
options: {
|
||||
// Keep only edges between our own source files (drops node_modules etc.),
|
||||
// which keeps the JSON small.
|
||||
includeOnly: "^(frontend/src|enterprise/frontend/src|\\.storybook)/",
|
||||
|
||||
doNotFollow: { path: "node_modules" },
|
||||
|
||||
// No rules here, so the cycle and orphan analysis has no consumer and only costs time.
|
||||
skipAnalysisNotInRules: true,
|
||||
|
||||
// Resolve the `"*": ["./frontend/src/*", ...]` aliases so bare imports like
|
||||
// `metabase/lib/foo` map back to a real file.
|
||||
tsConfig: { fileName: "tsconfig.json" },
|
||||
|
||||
// Keep type-only imports: a type-only dependent still breaks on a type change.
|
||||
tsPreCompilationDeps: true,
|
||||
|
||||
moduleSystems: ["es6", "cjs", "tsd"],
|
||||
|
||||
enhancedResolveOptions: {
|
||||
exportsFields: ["exports"],
|
||||
conditionNames: ["import", "require", "node", "default", "types"],
|
||||
extensions: [".js", ".jsx", ".ts", ".tsx", ".json"],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/java:25
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/java:21
|
||||
|
||||
# Set up nodesource, install node, bun, fontconfig for static viz, rlwrap for dev ergonomics
|
||||
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
(eval . (put-clojure-indent 'c/step 1))
|
||||
(eval . (put-clojure-indent 'impl/test-migrations 2))
|
||||
(eval . (put-clojure-indent 'let-404 0))
|
||||
(eval . (put-clojure-indent 'lib.schema.match/match '(:defn)))
|
||||
(eval . (put-clojure-indent 'lib.schema.match/match-one '(:defn)))
|
||||
(eval . (put-clojure-indent 'lib.schema.match/replace '(:defn)))
|
||||
(eval . (put-clojure-indent 'lib.schema.match/replace-in '(:defn)))
|
||||
(eval . (put-clojure-indent 'lib.util.match/replace '(:defn)))
|
||||
(eval . (put-clojure-indent 'macros/case 0))
|
||||
(eval . (put-clojure-indent 'match 1))
|
||||
(eval . (put-clojure-indent 'mt/dataset 1))
|
||||
@@ -82,8 +87,6 @@
|
||||
(cider-default-cljs-repl . shadow-select)
|
||||
(cider-shadow-default-options . "app"))
|
||||
|
||||
(makefile-mode (indent-tabs-mode . t))
|
||||
|
||||
("bin"
|
||||
(clojure-mode
|
||||
(cider-clojure-cli-aliases . "dev:drivers:build:build-dev"))))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user