Compare commits

..

1 Commits

Author SHA1 Message Date
Fraser
64fb24e355 Split api/analytics.ts into search telemetry and the export mutation
The file bundled two unrelated concerns. Search tracking now lives in
api/analytics/search.ts next to its only caller, and the export
mutation in api/analytics/export.ts. The analytics/ directory satisfies
the no-analytics-import-outside-analytics-files rule that pinned the
old filename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 15:23:08 +12:00
10185 changed files with 1526355 additions and 1894129 deletions

View File

@@ -27,27 +27,12 @@ describe("feature name", () => {
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";
import { ORDERS, ORDERS_ID, PRODUCTS } from "e2e/support/cypress_sample_database";
```
**Instance data** (dashboard IDs, user IDs, etc.) — import from `cypress_sample_instance_data`:
@@ -83,7 +68,6 @@ cy.visit("/dashboard/10");
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.
@@ -94,7 +78,6 @@ NEVER use:
`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.
@@ -161,14 +144,11 @@ The `metabase/no-unscoped-text-selectors` lint rule enforces this for the top-le
- 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
@@ -179,7 +159,7 @@ 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).
`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
@@ -405,7 +385,6 @@ Before writing a new `it()` block, look at sibling tests in the same `describe`.
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).

View File

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

View File

@@ -47,16 +47,6 @@ The checklist mirrors the order of the conventions file. Items marked **(lint)**
- [ ] **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`

View File

@@ -14,14 +14,12 @@ 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}"
```
@@ -32,35 +30,21 @@ If tokens are missing, tell the user: "EE tokens are not set. Either export them
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 +52,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 +67,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

View File

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

View File

@@ -17,10 +17,10 @@ Adherence to `typescript-write` is the **highest-priority** review dimension: ra
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.
1. **Violations of [`typescript-write`](../typescript-write/SKILL.md) provisions** — no-`any`, type tightening, type modeling, null/undefined handling, naming, structure, comments. Highest priority; block on the no-`any` rule.
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.
4. Appropriate test coverage.
## Blind spots — act as the missing reviewer
@@ -31,4 +31,3 @@ These rarely surface in team reviews, so this skill should raise them. They are
- **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.

View File

@@ -20,27 +20,20 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
- **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.
- **Use generics to allow TypeScript to infer correct types** when creating functions and components that need to be reusable and type-safe. Don't hesitate to introduce complex generics if they allow to derive types automatically instead of manual narrowing.
- **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";
@@ -52,30 +45,20 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
.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.
- **`readonly` / immutability where mutation isn't intended** — component props, shared constants, exported config. Prefer `readonly T[]` / `ReadonlyArray<T>` for inputs you don't mutate.
- **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.
- **Lists should be filtered** before being used in a map or other iteration.
- **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`).
@@ -92,6 +75,10 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
- **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.
## TypeScript Migration
**When touching existing JavaScript files, propose to convert them to TypeScript first**. Create a separate PR for the conversion, then implement the changes.
## Verify before done
- **Run the project type-check** when finished (see the shared TypeScript commands above).

View File

@@ -65,7 +65,6 @@
: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}
@@ -73,19 +72,12 @@
: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,19 +180,10 @@
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
;; only writes .clj-kondo/ratchets.edn, 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!
@@ -208,8 +191,6 @@
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!
@@ -234,9 +215,7 @@
;; 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
@@ -299,6 +278,10 @@
instaparse.core/transform
(metabase.server.streaming-response/streaming-response)
(metabase.driver.druid.query-processor-test/druid-query-returning-rows)
(metabase.util.match/match-one)
(metabase.util.match/match-many)
(metabase.util.match/replace-in)
(metabase.util.match/replace)
(metabase.driver-api.core/match-one)
(metabase.driver-api.core/match-many)
(metabase.driver-api.core/replace)
@@ -321,12 +304,6 @@
{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/println {:message "Use metabase.util.log instead of clojure.core/println"}
clojure.core/printf {:message "Use metabase.util.log instead of clojure.core/printf"}
@@ -426,7 +403,6 @@
metabase.channel.render.core channel.render
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
@@ -490,7 +466,6 @@
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
@@ -515,13 +490,11 @@
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
@@ -589,11 +562,7 @@
: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
@@ -607,7 +576,6 @@
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
@@ -623,13 +591,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.metabot.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
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.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.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
@@ -659,59 +628,10 @@
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
@@ -808,14 +728,9 @@
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
@@ -863,13 +778,7 @@
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}}}
@@ -890,7 +799,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}
@@ -927,40 +836,16 @@
{: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}
: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`
:discouraged-var
@@ -968,28 +853,12 @@
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}}}
;; .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.

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -58,7 +58,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!

View File

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

View File

@@ -1,84 +1,48 @@
;; 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).
;; Each :ignore-counts value is a non-negative integer budget, or :unlimited for no ceiling.
;; 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.
;; 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.
;; Per-linter budgets for inline `:clj-kondo/ignore` forms.
;; metabase.core.kondo-ratchet-test fails when the budgets drift from the actual counts.
;; `./bin/mage fix-kondo-ratchets` lowers budgets to match the tree; local test runs do it
;; automatically. Raising a budget, or adding one for a new linter (`--seed`), is a hand edit
;; to defend in your PR.
;; :all is the vector-less ignore form, which suppresses every linter on the next form.
{:ignore-counts {:aliased-namespace-symbol 3
:case-symbol-test 1
:clojure-lsp/unused-public-var 9
:consistent-alias 2
:def-fn 1
:deprecated-namespace 95
:deprecated-var 85
:discouraged-java-method 4
:discouraged-namespace 68
:discouraged-var 132
:duplicate-require 1
:equals-true 1
:main-without-gen-class 1
:metabase/disallow-hardcoded-driver-names-in-tests 34
:metabase/discourage-dynamic-vars 395
:metabase/discourage-millis-duration 1
:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests 13
:metabase/modules 24
:metabase/namespace-name 4
:metabase/prefer-with-dynamic-fn-redefs :unlimited
:metabase/test-helpers-use-non-thread-safe-functions 18
:metabase/validate-defendpoint-has-response-schema 427
:metabase/validate-defendpoint-query-params-use-kebab-case 48
:metabase/validate-defendpoint-route-uses-kebab-case 44
:metabase/validate-deftest 9
:missing-docstring 4
:missing-protocol-method 3
:non-arg-vec-return-type-hint 1
:reduce-without-init 3
:redundant-do 1
:syntax 2
:type-mismatch 8
:uninitialized-var 1
:unquote-not-syntax-quoted 1
:unresolved-namespace 3
:unresolved-symbol 11
:unsorted-required-namespaces :unlimited
:unused-alias 2
:unused-binding 9
:unused-excluded-var 2
:unused-namespace 2
:unused-private-var 8
:unused-value 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/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}}
{:ignore-counts {:aliased-namespace-symbol 3
:all 49
:case-symbol-test 1
:clojure-lsp/unused-public-var 9
:def-fn 1
:deprecated-namespace 95
:deprecated-var 79
:discouraged-java-method 3
:discouraged-namespace 79
:discouraged-var 107
:equals-true 1
:main-without-gen-class 1
:metabase/defsetting-namespace 2
:metabase/disallow-hardcoded-driver-names-in-tests 34
:metabase/discourage-millis-duration 1
:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests 13
:metabase/modules 7
:metabase/namespace-name 4
:metabase/prefer-with-dynamic-fn-redefs 28
:metabase/test-helpers-use-non-thread-safe-functions 17
:metabase/validate-defendpoint-has-response-schema 441
:metabase/validate-defendpoint-query-params-use-kebab-case 47
:metabase/validate-defendpoint-route-uses-kebab-case 44
:metabase/validate-deftest 9
:missing-docstring 10
:missing-protocol-method 3
:non-arg-vec-return-type-hint 1
:reduce-without-init 3
:redundant-fn-wrapper 1
:syntax 2
:uninitialized-var 1
:unquote-not-syntax-quoted 1
:unresolved-namespace 3
:unresolved-require 1
:unresolved-symbol 4
:unsorted-required-namespaces 1
:unused-alias 1
:unused-binding 9
:unused-excluded-var 2
:unused-namespace 1
:unused-private-var 8
:unused-value 1}}

View File

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

View File

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

View File

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

View File

@@ -28,11 +28,11 @@
(walk form)))
(defn- deftest-check-parallel
"1. Check if test is marked `^:parallel` / `^:synchronized` correctly.
"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."
3. Disallow ^:parallel tests in namespaces marked ^:synchronous -- 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]
(let [test-metadata (:meta test-name)
metadata-sexprs (map hooks/sexpr test-metadata)
@@ -44,48 +44,28 @@
(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)
ns-synchronous? (boolean (:synchronous (meta ns-sym)))]
(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?)
(when (and parallel? ns-synchronous?)
(hooks/reg-finding!
(assoc (meta test-name)
:message (str "Test should not be marked `^:parallel` in a namespace marked "
"`^:synchronized` -- "
:message (str "Test should not be marked ^:parallel in a namespace marked ^:synchronous -- "
"the ns marker promises no parallel execution. "
"Drop `^:parallel` from the deftest, or drop `^:synchronized` from the ns. "
"Drop ^:parallel from the deftest, or drop ^:synchronous 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?)
;; 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 "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]
@@ -171,10 +151,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"
@@ -194,7 +172,7 @@
(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)
@@ -254,20 +232,18 @@
(defn use-fixtures
"Flag `:parallel/unsafe` forms inside fixtures. Skipped when the surrounding
namespace is marked `^:synchronized` -- the ns marker is the author's
namespace is marked `^:synchronous` -- 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
tests inside a `^:synchronous` ns, so the two checks together form a
coherent opt-in: either the whole ns is synchronous (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)))))
(when-not (:synchronous (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))))

View File

@@ -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$" "")))))))

View File

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

View File

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

View File

@@ -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/`
@@ -286,7 +286,6 @@
(update context :node update-node)))
(comment
(require '[clojure.pprint])
(defn- defsetting* [form]
(hooks/sexpr
(:node
@@ -294,6 +293,7 @@
{:node
(hooks/parse-string
(with-out-str
#_{:clj-kondo/ignore [:unresolved-namespace]}
(clojure.pprint/pprint
form)))}))))

View File

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

View File

@@ -124,25 +124,6 @@
(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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,6 @@
"enterprise/backend"
"bin/build"
"bin/lint-migrations-file"
"bin/load-namespaces"
"bin/release-list"
#_"mage/src"
#_"mage/test"

View File

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

View File

@@ -2,7 +2,7 @@
// 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 \
// bunx depcruise frontend/src enterprise/frontend/src \
// --config .dependency-cruiser.cjs --output-type json > dependency-graph.json
/** @type {import('dependency-cruiser').IConfiguration} */
@@ -10,13 +10,10 @@ 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)/",
includeOnly: "^(frontend/src|enterprise/frontend/src)/",
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" },

View File

@@ -121,9 +121,6 @@ MB_EDITION="ee"
# Frontend dev server port (for hot reload). Must match the port used by `yarn build-hot`.
# MB_FRONTEND_DEV_PORT=8080
# Frontend dev server bind address. Defaults to 127.0.0.1; set 0.0.0.0 to reach it from other devices.
# MB_FRONTEND_DEV_HOST=127.0.0.1
# 3.2 E2E Local Runner Setup
# ------------------------------------------------------------------------------
@@ -164,46 +161,3 @@ TZ="US/Pacific"
# DO NOT USE Docker Desktop. Use Orbstack instead. Metabase will fail to connect
# to the dockerized Mongo QA database otherwise.
EXPERIMENTAL_DOCKER_DESKTOP_FORCE_QEMU=1
# ==============================================================================
# 4. BACKEND DRIVER TESTS
# ==============================================================================
#
# Connection details for running backend tests against a driver, e.g.
# ./bin/test-agent --drivers=snowflake :only '[metabase.foo-test]'
# 4.1 BigQuery
# ------------------------------------------------------------------------------
# Service account with BigQuery access in the project. The test user creates and drops the test-data datasets itself.
# https://cloud.google.com/iam/docs/keys-create-delete
# MB_BIGQUERY_CLOUD_SDK_TEST_PROJECT_ID="<project-id>"
# MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON='{"type": "service_account", ...}'
# Only for database routing tests
# MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING='{"type": "service_account", ...}'
# 4.2 Redshift
# ------------------------------------------------------------------------------
# Test data is loaded into a unique schema per test run.
# MB_REDSHIFT_TEST_HOST="<cluster>.<id>.<region>.redshift.amazonaws.com"
# Comma-separated hosts to pick one at random from, instead of MB_REDSHIFT_TEST_HOST
# MB_REDSHIFT_TEST_HOSTS=""
# MB_REDSHIFT_TEST_PORT=5439
# MB_REDSHIFT_TEST_DB="testdb"
# MB_REDSHIFT_TEST_USER="metabase_ci"
# MB_REDSHIFT_TEST_PASSWORD="<password>"
# Only for database routing tests
# MB_REDSHIFT_TEST_DB_ROUTING="dev"
# 4.3 Snowflake
# ------------------------------------------------------------------------------
# Key-pair authentication. The test user creates and drops the test-data databases itself.
# https://docs.snowflake.com/en/user-guide/key-pair-auth
# MB_SNOWFLAKE_TEST_ACCOUNT="<orgname>-<accountname>"
# MB_SNOWFLAKE_TEST_USER="<username>"
# MB_SNOWFLAKE_TEST_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
# MB_SNOWFLAKE_TEST_WAREHOUSE="<warehouse>"
# MB_SNOWFLAKE_TEST_ADDITIONAL_OPTIONS=""

34
.github/CODEOWNERS vendored
View File

@@ -42,9 +42,6 @@ docs/api.json # auto-generated
docs/configuring-metabase/environment-variables.md # auto-generated
docs/configuring-metabase/config-template.md # auto-generated
docs/installation-and-operation/commands.md # auto-generated
docs/ai/providers.md # auto-generated
docs/embedding/sdk/api # auto-generated
docs/embedding/eajs/snippets # auto-generated
# @metabase/devexp
.github @metabase/devexp
@@ -55,34 +52,3 @@ src/metabase/analytics/prometheus.clj @metabase/cloud-ops
# @metabase/metabot-frontend
enterprise/frontend/src/metabase-enterprise/metabot @metabase/metabot-frontend
frontend/src/metabase/metabot @metabase/metabot-frontend
# @metabase/metabot-backend
enterprise/backend/src/metabase_enterprise/embeddings @metabase/metabot-backend
enterprise/backend/src/metabase_enterprise/entity_retrieval @metabase/metabot-backend
enterprise/backend/src/metabase_enterprise/search @metabase/metabot-backend
enterprise/backend/src/metabase_enterprise/semantic_search @metabase/metabot-backend
enterprise/backend/test/metabase_enterprise/embeddings @metabase/metabot-backend
enterprise/backend/test/metabase_enterprise/entity_retrieval @metabase/metabot-backend
enterprise/backend/test/metabase_enterprise/search @metabase/metabot-backend
enterprise/backend/test/metabase_enterprise/semantic_search @metabase/metabot-backend
frontend/src/metabase-types/api/search.ts @metabase/metabot-backend
frontend/src/metabase/api/search.ts @metabase/metabot-backend
modules/embedder @metabase/metabot-backend
src/metabase/embeddings @metabase/metabot-backend
src/metabase/entity_retrieval @metabase/metabot-backend
src/metabase/search @metabase/metabot-backend
test/metabase/embeddings @metabase/metabot-backend
test/metabase/entity_retrieval @metabase/metabot-backend
test/metabase/search @metabase/metabot-backend
# @metabase/core-frontend-gadget
bin/custom-viz @metabase/core-frontend-gadget
docs/developers-guide/custom-visualizations.md @metabase/core-frontend-gadget
enterprise/frontend/src/custom-viz @metabase/core-frontend-gadget
enterprise/frontend/src/metabase-enterprise/api/custom-viz-plugin.ts @metabase/core-frontend-gadget
enterprise/frontend/src/metabase-enterprise/custom_viz @metabase/core-frontend-gadget
frontend/src/metabase-types/api/custom-viz-plugin.ts @metabase/core-frontend-gadget
frontend/src/metabase/plugins/oss/custom-viz.ts @metabase/core-frontend-gadget
frontend/src/metabase/visualizations/custom-visualizations @metabase/core-frontend-gadget
frontend/test/__support__/custom-viz-fixtures @metabase/core-frontend-gadget
.github/workflows/release-custom-viz.yml @metabase/devexp @metabase/core-frontend-gadget

View File

@@ -8,12 +8,6 @@ paths:
# GitHub added parallel steps (`parallel`, `background`, `wait`, `wait-all`,
# `cancel`) in June 2026, and actionlint 1.7.12 does not know the schema yet:
# https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/
.github/workflows/backend.yml:
ignore:
- 'step must run script with "run" section or run action with "uses" section'
.github/workflows/lint.yml:
ignore:
- 'step must run script with "run" section or run action with "uses" section'
.github/workflows/pr.checks.yml:
ignore:
- 'step must run script with "run" section or run action with "uses" section'

View File

@@ -3,15 +3,17 @@ description: |
Measures app + embedding SDK bundle sizes from the current and base-ref
uberjars (reusing the builds that already ran) plus their bundle-stats
artifacts, and flags gzipped size changes beyond the threshold for the main
app's initial bundle and the chunked SDK's reachable total. The base ref is
read back off the current uberjar so that both sides differ only by the PR.
Skips when the base-ref artifacts aren't available rather than rebuilding from
source (which would compare across pipelines).
app's initial bundle and the chunked SDK's reachable total. Skips when the
base-ref artifacts aren't available rather than rebuilding from source (which
would compare across pipelines).
inputs:
edition:
description: 'Metabase edition (ee or oss)'
required: true
base-ref-commit:
description: 'Commit SHA of the base ref.'
required: true
threshold-percent:
description: 'Percent threshold for bundle size change'
required: true
@@ -39,35 +41,12 @@ runs:
commit: ${{ github.event.pull_request.head.sha || github.sha }}
edition: ${{ inputs.edition }}
extract-path: temp/current-uberjar
- name: Resolve the base ref the current uberjar was built against
id: base-ref
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -uo pipefail
# The uberjar job checks out refs/pull/<n>/merge (the PR head already
# merged into the base branch tip) but names its artifact after head.sha,
# so the comparable base ref is that merge's first parent. Reading it back
# off the jar describes the artifact actually being measured. Using
# pull_request.base.sha instead compares against a stale snapshot of the
# base branch, reporting everything that landed in between as this PR's
# own bundle delta.
unzip -o -j temp/current-uberjar/metabase.jar version.properties -d temp/current-version >/dev/null
built=$(sed -n 's/^hash=//p' temp/current-version/version.properties)
base=$(gh api "/repos/${GITHUB_REPOSITORY}/commits/${built}" \
--jq 'if (.parents | length) == 2 then .parents[0].sha else empty end' 2>/dev/null) || base=""
echo "sha=${base}" >> "$GITHUB_OUTPUT"
echo "The current uberjar was built from ${built}; its base ref is ${base:-unknown}"
- name: Download base-ref uberjar
id: base-jar
if: steps.base-ref.outputs.sha != ''
continue-on-error: true
uses: ./.github/actions/fetch-artifact
with:
commit: ${{ steps.base-ref.outputs.sha }}
commit: ${{ inputs.base-ref-commit }}
edition: ${{ inputs.edition }}
extract-path: temp/base-uberjar
- name: Measure and compare bundle sizes
@@ -77,7 +56,7 @@ runs:
GH_TOKEN: ${{ github.token }}
EDITION: ${{ inputs.edition }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
BASE_SHA: ${{ steps.base-ref.outputs.sha }}
BASE_SHA: ${{ inputs.base-ref-commit }}
THRESHOLD: ${{ inputs.threshold-percent }}
MEASURE_BUNDLES: app,embedding-sdk-legacy,embedding-sdk-chunked
# The gated bundles, as "<bundle>/<kind>". Each one's step outputs are
@@ -100,7 +79,6 @@ runs:
# The base ref needs an already-built uberjar. Rebuilding it from source
# would compare across two build pipelines (the bug this check used to
# have), so skip instead when it — or its stats — isn't available.
[ -n "${BASE_SHA}" ] || skip "no comparable base ref for the current uberjar"
[ -f temp/base-uberjar/metabase.jar ] || skip "base-ref uberjar not found for ${BASE_SHA}"
extract_js() { # $1=jar $2=dest-root

View File

@@ -1,67 +0,0 @@
name: Collect profile
description: >-
Attach a flamegraph and per-test wall times to a job whose tests ran under clj-async-profiler.
Does nothing when the run was not profiled.
inputs:
profiled:
required: true
description: >-
"true" when the test step ran under the profiler. Passed in rather than read from the
environment so the decision stays recorded by the step that made it.
artifact-name:
required: true
description: Name for the uploaded artifact. Must be unique across the jobs of one run.
runs:
using: "composite"
steps:
# Unlike a JUnit failure report, these run on a green build too — a suite that passes slowly is
# exactly what profiling is for.
#
# `awk 'NR<=25'` rather than `head -25`: the runner ignores SIGPIPE, so `head` exiting early
# leaves `sort` with an EPIPE write error and `pipefail` fails the step. `|| true` covers `grep`
# finding no junit files.
#
# `-r --include` rather than a `target/junit/*.xml` glob: a multi-JVM run nests one directory
# per partition under `target/junit`, and a non-recursive glob matches none of it.
- name: Summarize test wall times
if: ${{ inputs.profiled == 'true' }}
continue-on-error: true
shell: bash
run: |
{
echo "### Slowest test namespaces (seconds)"
echo '```'
grep -rh --include='*.xml' '<testsuite' target/junit 2>/dev/null \
| sed -E 's|.*<testsuite name="([^"]*)" time="([^"]*)".*|\2 \1|' \
| sort -rn | awk 'NR<=25' || true
echo '```'
echo "### Slowest tests (seconds)"
echo '```'
grep -rh --include='*.xml' '<testcase' target/junit 2>/dev/null \
| sed -E 's|.*classname="([^"]*)" name="([^"]*)" time="([^"]*)".*|\3 \1/\2|' \
| sort -rn | awk 'NR<=25' || true
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Only the rendered flamegraph is kept: the raw collapsed stacks it was built from run to
# hundreds of MB on a long suite, and are reproducible locally from the same test selection.
#
# The info-level test log rides along on a green profiled run too. The failure-only log artifact
# the test jobs upload is not written when nothing failed, which is exactly the run being profiled.
- name: Upload profile
if: ${{ inputs.profiled == 'true' }}
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.artifact-name }}
# Globs, because a multi-JVM run nests its junit XML one directory per partition and
# names each log `test-log-p<index>.json`. The flamegraphs stay flat, with that same
# `p<index>-` prefix.
path: |
target/clj-async-profiler/results/*flamegraph.html
target/junit/**/*.xml
logs/test-log*.json
retention-days: 14
if-no-files-found: warn

View File

@@ -25,8 +25,6 @@ runs:
COMMIT: ${{ inputs.backport-commit }}
shell: bash
run: | # bash
source "${{ github.action_path }}/kondo-ratchets.sh"
git config --global user.email "metabase-bot@metabase.com"
git config --global user.name "Metabase bot"
@@ -36,7 +34,7 @@ runs:
git checkout ${TARGET_BRANCH}
git fetch --all
git checkout -b ${BACKPORT_BRANCH}
cherry_pick_backport "${COMMIT}"
git cherry-pick ${COMMIT} || true
CONFLICT_LIST=$(git ls-files -u)
@@ -48,7 +46,10 @@ runs:
echo "has-conflicts=true" >> "$GITHUB_OUTPUT"
# Add a shell script for resolving conflicts
write_backport_script "${COMMIT}"
echo "git reset HEAD~1" > ./backport.sh
echo "rm ./backport.sh" >> ./backport.sh
echo "git cherry-pick ${COMMIT}" >> ./backport.sh
echo "echo 'Resolve conflicts and force push this branch.\n\nTo backport translations run: bin/i18n/merge-translations <release-branch>'" >> ./backport.sh
chmod +x ./backport.sh
git add ./backport.sh
git commit -m "Add backport resolution script"
@@ -72,7 +73,7 @@ runs:
git push -u origin ${BACKPORT_BRANCH}
PR_ARGS=(--base "${TARGET_BRANCH}" --head "${BACKPORT_BRANCH}" --label "was-backported" --assignee "${{ github.event.pull_request.user.login }}" --title "🤖 v${TARGET_VERSION} backported \"${ORIGINAL_TITLE}\"" --body "${PR_BODY}")
PR_ARGS=(--base "${TARGET_BRANCH}" --head "${BACKPORT_BRANCH}" --label "was-backported" --assignee "${{ github.event.pull_request.user.login }}" --title "🤖 backported \"${ORIGINAL_TITLE}\"" --body "${PR_BODY}")
if [ "$CONFLICTS" -gt 0 ]; then
PR_ARGS+=(--draft)
fi
@@ -85,20 +86,14 @@ runs:
git checkout ${{ github.sha }}
# Approving takes a different token than the one that opened the PR: GitHub won't let a token
# approve its own pull request.
- name: Approve the backport PR
- name: Auto approve backport PR
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'false' }}
run: gh pr review --approve "$PR_NUMBER"
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token-2 }}
PR_NUMBER: ${{ steps.create_backport_pull_request.outputs.backport_pr_number }}
uses: juliangruber/approve-pull-request-action@68fcc9a5a73b5641cadf757cf99d73720dcb05d0 # v2.1.0
with:
github-token: ${{ inputs.github-token-2 }}
number: ${{ steps.create_backport_pull_request.outputs.backport_pr_number }}
# Merging, though, has to use `github-token` rather than the `GITHUB_TOKEN` that approves: GitHub
# starts no workflow run for a push made with `GITHUB_TOKEN`, so an auto-merge enabled with it
# lands the backport on the release branch with no CI at all.
- name: Merge the backport PR
- name: Enable Pull Request Automerge
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'false' }}
run: gh pr merge --squash --auto "$PR_NUMBER"
shell: bash

View File

@@ -1,375 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
action_dir=$(cd "$(dirname "$0")" && pwd)
cut_release_workflow="$action_dir/../../workflows/cut-release-branch.yml"
# shellcheck source=.github/actions/create-backport/kondo-ratchets.sh
source "$action_dir/kondo-ratchets.sh"
# Any editor launch fails the run, so a continuation that waits on an editor cannot pass.
export GIT_EDITOR=false
test_root=
trap '[ -z "$test_root" ] || rm -rf -- "$test_root"' EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
test_root=$(mktemp -d)
active_ratchets='{:ignore-counts {:example 1}}'
disabled_ratchets=';; Kondo ignore ratchets apply only to master; this release branch opts out.
;; The test and fixer recognize this explicit opt-out.
{:disabled true}'
# Coverage:
#
# Target state Incoming change Other conflict Expected result
# ----------------- -------------------- --------------- ------------------------------------------
# active ratchets unrelated file none leave ratchets unchanged
# active ratchets ratchets none install the release opt-out
# disabled ratchets ratchets none keep the opt-out; finish with an empty commit
# divergent ratchets ratchets none resolve ratchets and finish the cherry-pick
# divergent ratchets ratchets (on a pty) none finish without opening an editor
# active ratchets unrelated file app.txt leave the conflict for manual resolution
# divergent ratchets ratchets + app.txt app.txt resolve only ratchets
# release cut ratchets none cut and backport commit the same opt-out
# n/a cut-release workflow n/a the workflow calls the shared writer
# any invalid commit n/a propagate the cherry-pick failure
# divergent ratchets generated backport.sh app.txt resolve only ratchets through the manual script
#
# Known gaps: an older target with no ratchets file, and an incoming commit that
# deletes the ratchets file.
test_unrelated_backport_preserves_target_ratchets() (
local repo="$test_root/unrelated"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_app)
git -C "$repo" checkout -q release
cd "$repo"
cherry_pick_backport "$commit"
assert_eq feature "$(cat app.txt)" "the unrelated change was not cherry-picked"
assert_ratchets "$active_ratchets"
assert_cherry_pick_completed
assert_worktree_clean
)
test_clean_ratchet_backport_installs_release_opt_out() (
local repo="$test_root/clean-ratchet"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_ratchets)
git -C "$repo" checkout -q release
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets_disabled
assert_cherry_pick_completed
assert_worktree_clean
)
test_disabled_release_absorbs_ratchet_only_backport() (
local repo="$test_root/disabled-release"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_ratchets)
git -C "$repo" checkout -q release
commit_release_change "$repo" disable_release_ratchets "Disable release ratchets"
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets_disabled
assert_cherry_pick_completed
assert_empty_commit
assert_worktree_clean
)
test_ratchet_only_conflict_is_resolved() (
local repo="$test_root/ratchet-conflict"
init_repo "$repo"
local commit
commit=$(conflicting_ratchet_commit "$repo")
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets_disabled
assert_cherry_pick_completed
assert_worktree_clean
)
test_continuation_never_opens_an_editor() (
local repo="$test_root/editor"
init_repo "$repo"
local commit
commit=$(conflicting_ratchet_commit "$repo")
cd "$repo"
# Git only opens an editor for the continuation when stdin is a terminal, so run it on a pty. The
# exported GIT_EDITOR=false turns any editor launch into a failure. The single quotes are deliberate:
# $1 and $2 are for the inner bash, not this shell.
# shellcheck disable=SC2016
python3 -c 'import os, pty, sys; sys.exit(os.waitstatus_to_exitcode(pty.spawn(sys.argv[1:])))' \
bash -c 'source "$1/kondo-ratchets.sh" && cherry_pick_backport "$2"' bash "$action_dir" "$commit" >/dev/null ||
fail "the cherry-pick continuation opened an editor"
assert_ratchets_disabled
assert_cherry_pick_completed
)
test_non_ratchet_conflict_remains_manual() (
local repo="$test_root/non-ratchet-conflict"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_app)
git -C "$repo" checkout -q release
commit_release_change "$repo" change_release_app "Change release app"
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets "$active_ratchets"
assert_only_conflicts app.txt
assert_cherry_pick_pending
)
test_mixed_conflict_resolves_only_ratchets() (
local repo="$test_root/mixed-conflict"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_app_and_ratchets)
git -C "$repo" checkout -q release
commit_release_change "$repo" change_release_app_and_ratchets "Change release app and ratchets"
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets_disabled
assert_only_conflicts app.txt
assert_cherry_pick_pending
)
test_release_cut_and_backport_commit_the_same_opt_out() (
local cut_repo="$test_root/release-cut"
init_repo "$cut_repo"
cd "$cut_repo"
git checkout -qb release-x.99.x
write_disabled_ratchets
git add --sparse .clj-kondo/ratchets.edn
git commit -q --allow-empty --no-verify -m "Cut release-x.99.x branch"
git show HEAD:.clj-kondo/ratchets.edn > "$test_root/release-cut.edn"
local backport_repo="$test_root/release-cut-backport"
init_repo "$backport_repo"
local commit
commit=$(feature_commit "$backport_repo" change_ratchets)
git -C "$backport_repo" checkout -q release
cd "$backport_repo"
cherry_pick_backport "$commit"
git show HEAD:.clj-kondo/ratchets.edn > "$test_root/backport.edn"
cmp -s "$test_root/release-cut.edn" "$test_root/backport.edn" ||
fail "the release cut and the backport committed different opt-out files"
assert_eq "$disabled_ratchets" "$(cat "$test_root/release-cut.edn")" \
"the release cut did not install the expected opt-out"
)
test_cut_release_workflow_uses_the_shared_writer() (
grep -Fq 'source .github/actions/create-backport/kondo-ratchets.sh' "$cut_release_workflow" ||
fail "the cut-release workflow does not source kondo-ratchets.sh"
grep -Fq 'write_disabled_ratchets' "$cut_release_workflow" ||
fail "the cut-release workflow does not call write_disabled_ratchets"
! grep -Fq ':disabled' "$cut_release_workflow" ||
fail "the cut-release workflow carries its own copy of the opt-out"
)
test_invalid_commit_failure_is_propagated() (
local repo="$test_root/invalid"
init_repo "$repo"
cd "$repo"
if cherry_pick_backport deadbeef; then
fail "an invalid commit succeeded"
fi
)
test_generated_script_resolves_ratchets_end_to_end() (
local repo="$test_root/generated-script"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_app_and_ratchets)
git -C "$repo" checkout -q release
commit_release_change "$repo" change_release_app_and_ratchets "Change release app and ratchets"
cd "$repo"
cherry_pick_backport "$commit"
git cherry-pick --abort
local release_commit
release_commit=$(git rev-parse HEAD)
write_backport_script "$commit"
chmod +x backport.sh
git add backport.sh
git commit -qm "Add manual backport script"
bash -n backport.sh
grep -Fq 'write_disabled_ratchets ()' backport.sh || fail "the generated script omitted write_disabled_ratchets"
./backport.sh
assert_ratchets_disabled
assert_only_conflicts app.txt
assert_cherry_pick_pending
assert_eq "$release_commit" "$(git rev-parse HEAD)" "the generated script did not return HEAD to the release commit"
[ ! -e backport.sh ] || fail "the generated script did not remove itself"
! git ls-files --error-unmatch backport.sh >/dev/null 2>&1 || fail "the generated script is still tracked"
! git status --porcelain | grep -q backport.sh || fail "the generated script left a status entry"
)
# Test harness
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_eq() {
local expected=$1
local actual=$2
local message=$3
[ "$expected" = "$actual" ] || fail "$message (expected '$expected', got '$actual')"
}
assert_ratchets() {
assert_eq "$1" "$(cat .clj-kondo/ratchets.edn)" "unexpected ratchet state"
}
assert_ratchets_disabled() {
assert_ratchets "$disabled_ratchets"
}
assert_cherry_pick_completed() {
! git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null || fail "the cherry-pick is still pending"
}
assert_cherry_pick_pending() {
git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null || fail "the cherry-pick state was lost"
}
assert_only_conflicts() {
local expected
local actual
expected=$(printf '%s\n' "$@" | sort)
actual=$(git diff --name-only --diff-filter=U | sort)
assert_eq "$expected" "$actual" "unexpected unresolved files"
}
assert_empty_commit() {
git diff --quiet HEAD^ HEAD || fail "the backport commit was not empty"
}
assert_worktree_clean() {
[ -z "$(git status --porcelain)" ] || fail "the backport left worktree changes"
}
init_repo() {
local repo=$1
git init -q -b release "$repo"
git -C "$repo" config user.email test@example.com
git -C "$repo" config user.name "Backport test"
printf 'base\n' > "$repo/app.txt"
mkdir -p "$repo/.clj-kondo"
printf '%s\n' "$active_ratchets" > "$repo/.clj-kondo/ratchets.edn"
git -C "$repo" add .
git -C "$repo" commit -qm "Base"
}
feature_commit() {
local repo=$1
shift
git -C "$repo" checkout -qb feature
"$@" "$repo"
git -C "$repo" add .
git -C "$repo" commit -qm "Feature"
git -C "$repo" rev-parse HEAD
}
# Sets up a release branch whose ratchets conflict with the feature commit, and prints that commit.
conflicting_ratchet_commit() {
local repo=$1
local commit
commit=$(feature_commit "$repo" change_ratchets)
git -C "$repo" checkout -q release
commit_release_change "$repo" change_release_ratchets "Change release ratchets"
echo "$commit"
}
change_app() {
printf 'feature\n' > "$1/app.txt"
}
change_ratchets() {
printf '{:ignore-counts {:example 2}}\n' > "$1/.clj-kondo/ratchets.edn"
}
change_app_and_ratchets() {
change_app "$1"
change_ratchets "$1"
}
change_release_app() {
printf 'release\n' > "$1/app.txt"
}
change_release_ratchets() {
printf '{:ignore-counts {:release 1}}\n' > "$1/.clj-kondo/ratchets.edn"
}
change_release_app_and_ratchets() {
change_release_app "$1"
change_release_ratchets "$1"
}
# Setup writes the opt-out through the shared writer; the only copy is the expected text at the top.
disable_release_ratchets() {
(cd "$1" && write_disabled_ratchets)
}
commit_release_change() {
local repo=$1
local change=$2
local message=$3
"$change" "$repo"
git -C "$repo" add .
git -C "$repo" commit -qm "$message"
}
run_test() {
local description=$1
local test=${description// /_}
test=${test//-/_}
test="test_$test"
declare -F "$test" >/dev/null || fail "missing test function: $test"
printf 'TEST: %s\n' "$description"
"$test"
printf 'PASS: %s\n' "$description"
}
run_test "unrelated backport preserves target ratchets"
run_test "clean ratchet backport installs release opt-out"
run_test "disabled release absorbs ratchet-only backport"
run_test "ratchet-only conflict is resolved"
run_test "continuation never opens an editor"
run_test "non-ratchet conflict remains manual"
run_test "mixed conflict resolves only ratchets"
run_test "release cut and backport commit the same opt-out"
run_test "cut-release workflow uses the shared writer"
run_test "invalid commit failure is propagated"
run_test "generated script resolves ratchets end-to-end"

View File

@@ -1,74 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# The only place that knows what a release branch's opt-out looks like. The cut-release workflow, the
# backport helper, and the tests all go through this function.
write_disabled_ratchets() {
mkdir -p .clj-kondo
printf '%s\n' \
';; Kondo ignore ratchets apply only to master; this release branch opts out.' \
';; The test and fixer recognize this explicit opt-out.' \
'{:disabled true}' \
> .clj-kondo/ratchets.edn
}
disable_ratchets() {
local commit=$1
local file=.clj-kondo/ratchets.edn
[ -n "$(git diff-tree --root --no-commit-id --name-only -r "$commit" -- "$file")" ] || return 0
echo "$file: disabling kondo ratchets on the release branch"
write_disabled_ratchets
git add -- "$file"
if ! git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null; then
if ! git diff --cached --quiet HEAD; then
git commit --amend --no-edit
fi
return 0
fi
if [ -z "$(git ls-files -u)" ]; then
echo "$file was the only conflict, finishing the cherry-pick"
if git diff --cached --quiet HEAD; then
echo "nothing left to apply, committing the backport empty"
git commit --allow-empty --no-edit
else
# GIT_EDITOR outranks core.editor, VISUAL, and EDITOR, so the continuation can never wait on
# an editor; the repository's own editor configuration is left alone.
GIT_EDITOR=true git cherry-pick --continue
fi
fi
}
cherry_pick_backport() {
local commit=$1
local status=0
git cherry-pick "$commit" || status=$?
if [ "$status" -ne 0 ] && ! git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null; then
echo "cherry-pick failed without entering a resolvable conflict state" >&2
return "$status"
fi
disable_ratchets "$commit"
if [ "$status" -ne 0 ] &&
git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null &&
[ -z "$(git ls-files -u)" ]; then
echo "cherry-pick stopped without a file conflict to resolve" >&2
return "$status"
fi
}
write_backport_script() {
local commit=$1
{
echo "#!/usr/bin/env bash"
echo "set -euo pipefail"
echo "git reset HEAD~1"
echo "rm ./backport.sh"
declare -f write_disabled_ratchets disable_ratchets cherry_pick_backport
printf 'cherry_pick_backport %q\n' "$commit"
printf '%s\n' "printf 'Resolve conflicts and force push this branch.\\n\\nTo backport translations run: bin/i18n/merge-translations <release-branch>\\n'"
} > ./backport.sh
}

View File

@@ -1,5 +1,5 @@
name: Create test plan
description: Compute the affected-tests plan for the changed files.
description: Cruise the frontend import graph and compute the affected-tests plan.
inputs:
changed-files:
@@ -28,15 +28,21 @@ outputs:
stats:
description: FE Affected Tests stats row (JSON).
value: ${{ steps.compute.outputs.stats }}
artifact-id:
description: Artifact containing test-plan.json for this run.
value: ${{ steps.upload.outputs.artifact-id }}
fe_unit_specs_to_run:
description: Unit specs to run (JSON array).
value: ${{ steps.compute.outputs.fe_unit_specs_to_run }}
loki_stories_to_run:
description: Loki stories to run (JSON array).
value: ${{ steps.compute.outputs.loki_stories_to_run }}
e2e_specs_to_run:
description: E2E specs to run (JSON array).
value: ${{ steps.compute.outputs.e2e_specs_to_run }}
runs:
using: composite
steps:
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare Node + Bun
uses: ./.github/actions/prepare-node-bun
- name: Resolve closest-ancestor e2e coverage manifest
id: manifest
uses: actions/github-script@v9
@@ -55,6 +61,16 @@ runs:
headSha: context.payload.pull_request?.head?.sha ?? context.sha,
});
core.setOutput("path", result?.path ?? "");
- name: Build dependency graph
shell: bash
run: |
# Quiet on success; surface output only if install fails.
bun install --frozen-lockfile > /tmp/bun-install.log 2>&1 \
|| { echo "bun install issues (continuing):"; tail -20 /tmp/bun-install.log; }
bunx depcruise frontend/src enterprise/frontend/src \
--config .dependency-cruiser.cjs \
--output-type json > dependency-graph.json || true
- name: Create test plan
id: compute
shell: bash
@@ -66,20 +82,15 @@ runs:
LOKI_INFRA_TOUCHED: ${{ inputs.loki-infra-touched }}
E2E_INFRA_TOUCHED: ${{ inputs.e2e-infra-touched }}
SHARED_SOURCES_TOUCHED: ${{ inputs.shared-sources-touched }}
DEP_GRAPH_JSON: dependency-graph.json
E2E_SPEC_MANIFEST: ${{ steps.manifest.outputs.path }}
run: |
# stdout is the plan JSON; the planner's own info logs go to stderr.
# The planner writes this step's GITHUB_OUTPUT entries.
bun .github/scripts/create-test-plan.ts > test-plan.json
- name: Upload test plan
id: upload
uses: actions/upload-artifact@v7
with:
name: test-plan-${{ github.run_id }}-${{ github.run_attempt }}
path: test-plan.json
if-no-files-found: error
retention-days: 7
echo "stats=$(jq -c '.stats' test-plan.json)" >> "$GITHUB_OUTPUT"
echo "fe_unit_specs_to_run=$(jq -c '.fe_unit_specs_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
echo "loki_stories_to_run=$(jq -c '.loki_stories_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
echo "e2e_specs_to_run=$(jq -c '.e2e_specs_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
- name: Report test plan
shell: bash
@@ -99,10 +110,6 @@ runs:
echo
echo "Changed: $(jq '.stats.fe_files_changed' "$plan") FE / $(jq '.stats.be_files_changed' "$plan") BE file(s) · $(jq '.stats.fe_modules_affected_usage' "$plan") FE module(s) affected"
echo
if [ "$(jq '.stats.loki_preview_touched' "$plan")" = "true" ]; then
echo "**Loki: full run**: a file the Storybook preview imports changed."
echo
fi
e2e_run=$(jq '.stats.e2e_specs_to_run_usage' "$plan")
e2e_all=$(jq '.stats.e2e_specs_all' "$plan")
if [ "$e2e_run" -eq "$e2e_all" ]; then

View File

@@ -14,7 +14,7 @@ runs:
steps:
## First we deploy the base branch
- name: Check out base branch
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
ref: ${{ inputs.base-ref }}
@@ -45,7 +45,7 @@ runs:
## Then we deploy the PR branch to ensure it migrates up successfully
- name: Check out pull request branch
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
ref: ${{ inputs.head-ref }}
@@ -84,7 +84,7 @@ runs:
shell: bash
- name: Re-check out base branch
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
ref: ${{ inputs.base-ref }}

View File

@@ -15,10 +15,6 @@ inputs:
description: OpenLDAP container
required: true
default: false
mocksaml:
description: Mock SAML container
required: true
default: false
postgres:
description: Postgres container
required: true
@@ -77,7 +73,6 @@ runs:
[ "${{ inputs.mongo }}" = true ] && SERVICES+=(mongo-sample)
[ "${{ inputs.maildev }}" = true ] && SERVICES+=(maildev maildev-ssl)
[ "${{ inputs.openldap }}" = true ] && SERVICES+=(ldap)
[ "${{ inputs.mocksaml }}" = true ] && SERVICES+=(saml-idp)
[ "${{ inputs.webhook }}" = true ] && SERVICES+=(webhook-tester)
[ "${{ inputs.snowplow }}" = true ] && SERVICES+=(snowplow-micro)
@@ -98,8 +93,8 @@ runs:
if ${{ inputs.maildev }}; then
echo -e "${Y}checking maildev container..." &&
while ! nc -z localhost 1180; do sleep 1; done &&
while ! nc -z localhost 1125; do sleep 1; done &&
while ! nc -z localhost 1080; do sleep 1; done &&
while ! nc -z localhost 1025; do sleep 1; done &&
echo -e "${G}Maildev is up and running!"
echo -e "${Y}checking maildev-ssl container..." &&
@@ -114,12 +109,6 @@ runs:
echo -e "${G}Openldap is up and running!"
fi
if ${{ inputs.mocksaml }}; then
echo -e "${Y}checking mock saml container..." &&
while ! nc -z localhost 8090; do sleep 1; done &&
echo -e "${G}Mock SAML is up and running!"
fi
if ${{ inputs.postgres }}; then
echo -e "${Y}checking postgres container..." &&
while ! nc -z localhost 5404; do sleep 1; done &&

View File

@@ -20,15 +20,6 @@ inputs:
description: Directory path where the jar should be extracted (defaults to current directory)
required: false
default: "."
download:
description: Default is true. Set to false to only look the artifact up and report `fetch-hit`.
required: false
default: "true"
outputs:
fetch-hit:
description: Boolean indicating if an artifact was successfully found and downloaded.
value: ${{ steps.find_release_artifact.outputs.fetch-hit }}
runs:
using: "composite"
@@ -37,12 +28,11 @@ runs:
id: find_release_artifact
uses: actions/github-script@v9
with:
result-encoding: string
script: | # js
const fs = require('fs');
const isRelease = "${{ inputs.release }}" === "true";
const failOnMiss = "${{ inputs.fail-on-fetch-miss }}" !== "false";
const shouldDownload = "${{ inputs.download }}" !== "false";
const prefix = isRelease ? "metabase-release" : "metabase";
const artifactName = `${prefix}-${{ inputs.edition }}-${{ inputs.commit }}-uberjar`;
console.log(`Looking for artifact: ${artifactName}`);
@@ -54,19 +44,13 @@ runs:
per_page: 1,
});
const artifact_id = artifacts.data?.artifacts?.[0]?.id;
if (!artifact_id) {
core.setOutput('fetch-hit', 'false');
} else {
core.setOutput('fetch-hit', 'true');
}
if (!shouldDownload) {
console.log(`skipping download (download: false)`);
if (!artifacts.data?.artifacts?.[0]?.id) {
core.setFailed(`No artifacts found for ${artifactName}`);
return;
}
const artifact_id = artifacts.data.artifacts[0].id;
let download;
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -90,7 +74,6 @@ runs:
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/mb.zip`, Buffer.from(download.data));
- name: Extract and rename JAR
if: ${{ steps.find_release_artifact.outputs.fetch-hit == 'true' && inputs.download != 'false' }}
shell: bash
run: | # bash
OUTPUT_NAME="${{ inputs.output-name }}"
@@ -136,14 +119,12 @@ runs:
fi
- name: Verify that this is a valid JAR file
if: ${{ steps.find_release_artifact.outputs.fetch-hit == 'true' && inputs.download != 'false' }}
shell: bash
run: |
echo "Verifying JAR at: $JAR_PATH"
file --mime-type "$JAR_PATH"
- name: Reveal its version.properties
if: ${{ steps.find_release_artifact.outputs.fetch-hit == 'true' && inputs.download != 'false' }}
shell: bash
run: |
echo "Extracting version.properties from: $JAR_PATH"

View File

@@ -0,0 +1,45 @@
name: Get cache keys
description: |
Get deterministic cache keys for Maven and Node.js caches based on the centrally controlled cache prefix,
and the suffix based on the runner OS and the current week of the year.
outputs:
m2-cache-key:
description: "The cache key for the Maven cache."
value: ${{ steps.keys-factory.outputs.m2-cache-key }}
m2-restore-key:
description: "The fallback restore key prefix for the Maven cache."
value: ${{ steps.keys-factory.outputs.m2-restore-key }}
node-cache-key:
description: "The cache key for the Node.js cache."
value: ${{ steps.keys-factory.outputs.node-cache-key }}
node-restore-key:
description: "The fallback restore key prefix for the Node.js cache."
value: ${{ steps.keys-factory.outputs.node-restore-key }}
eslint-cache-key:
description: "The cache key for Eslint cache"
value: ${{ steps.keys-factory.outputs.eslint-cache-key }}
cypress-cache-key:
description: "The cache key for Cypress cache"
value: ${{ steps.keys-factory.outputs.cypress-cache-key }}
runs:
using: "composite"
steps:
- name: Generate and output cache keys
id: keys-factory
run: |
M2_CACHE_PREFIX='M2'
NODE_CACHE_PREFIX='node-modules'
ESLINT_CACHE_PREFIX='eslint'
CYPRESS_CACHE_PREFIX='cypress'
WEEK_OF_YEAR=$(/bin/date "+%G-%V")
CACHE_SUFFIX="${{ runner.os }}-$WEEK_OF_YEAR"
echo "m2-cache-key=$M2_CACHE_PREFIX-$CACHE_SUFFIX" >> "$GITHUB_OUTPUT"
echo "m2-restore-key=$M2_CACHE_PREFIX-" >> "$GITHUB_OUTPUT"
echo "node-cache-key=$NODE_CACHE_PREFIX-$CACHE_SUFFIX" >> "$GITHUB_OUTPUT"
echo "node-restore-key=$NODE_CACHE_PREFIX-" >> "$GITHUB_OUTPUT"
echo "eslint-cache-key=$ESLINT_CACHE_PREFIX-$CACHE_SUFFIX" >> "$GITHUB_OUTPUT"
echo "cypress-cache-key=$CYPRESS_CACHE_PREFIX-$CACHE_SUFFIX" >> "$GITHUB_OUTPUT"
shell: bash

View File

@@ -1,19 +1,7 @@
name: Get Docker Info
description: Determines the registry, repository, and tag a container image is pushed to.
description: Determines Docker repository and tag based on branch and edition
inputs:
registry:
description: Registry the image is pushed to, either "docker" or "ecr".
required: false
default: docker
repo:
description: Repository name, e.g. "metabase-enterprise". Overrides a branch derived repo.
required: false
default: ''
tag:
description: Image tag, e.g. v1.87.2. Overrides a branch derived tag.
required: false
default: ''
branch:
description: Branch name (defaults to current branch)
required: false
@@ -22,22 +10,14 @@ inputs:
description: Edition (ee or oss)
required: false
default: ee
aws-account-id:
description: AWS account id hosting the ECR registry. Required for the ecr registry.
owner:
description: Repository owner (defaults to current repo owner)
required: false
default: ''
aws-region:
description: Region of the ECR registry.
required: false
default: us-east-1
dockerhub-owner:
description: DockerHub owner.
required: false
default: metabase
default: ${{ github.repository_owner }}
outputs:
repo:
description: Docker repository, including the registry host (ecr) or the owner (docker)
description: Docker repository name
value: ${{ steps.docker-info.outputs.repo }}
tag:
description: Docker tag
@@ -45,6 +25,9 @@ outputs:
full_image:
description: Full Docker image name (repo:tag)
value: ${{ steps.docker-info.outputs.full_image }}
editions:
description: JSON array of editions to build
value: ${{ steps.docker-info.outputs.editions }}
runs:
using: "composite"
@@ -52,49 +35,40 @@ runs:
- name: Determine Docker info
id: docker-info
uses: actions/github-script@v9
env:
REGISTRY: ${{ inputs.registry }}
REPO: ${{ inputs.repo }}
TAG: ${{ inputs.tag }}
BRANCH: ${{ inputs.branch }}
EDITION: ${{ inputs.edition }}
AWS_ACCOUNT_ID: ${{ inputs.aws-account-id }}
AWS_REGION: ${{ inputs.aws-region }}
DOCKERHUB_OWNER: ${{ inputs.dockerhub-owner }}
with:
script: | # js
const { REGISTRY, OWNER, BRANCH, EDITION, AWS_ACCOUNT_ID, AWS_REGION, DOCKERHUB_OWNER } = process.env;
const branch = '${{ inputs.branch }}';
const edition = '${{ inputs.edition }}';
const owner = '${{ inputs.owner }}';
let tag = process.env.TAG;
if (!tag) {
tag = BRANCH === 'master' ? 'latest' : `${BRANCH.replaceAll('/', '-')}-${EDITION}`;
}
console.log(`Determining Docker info for branch: ${branch}, edition: ${edition}`);
let name = process.env.REPO;
if (!name) {
if (BRANCH === 'master') {
name = EDITION === 'ee' ? 'metabase-enterprise-head' : 'metabase-head';
let repo, tag, editions;
if (branch === 'master') {
// Master builds both editions with specific repos
editions = ['ee', 'oss'];
tag = 'latest';
if (edition === 'ee') {
repo = `${owner}/metabase-enterprise-head`;
} else {
name = 'metabase-dev';
repo = `${owner}/metabase-head`;
}
}
let repo;
if (REGISTRY === 'ecr') {
if (!AWS_ACCOUNT_ID) {
core.setFailed('Pushing to AWS ECR requires aws-account-id to be set.');
return;
}
repo = `${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${name}`;
} else if (REGISTRY === 'docker') {
repo = `${DOCKERHUB_OWNER}/${name}`;
} else {
core.setFailed(`Unknown registry "${REGISTRY}", expected "docker" or "ecr".`);
return;
// All other branches only build EE to metabase-dev
editions = ['ee'];
tag = branch.replaceAll('/', '-'); // slashes make docker sad
repo = `${owner}/metabase-dev`;
}
const fullImage = `${repo}:${tag}`;
// Set outputs
core.setOutput('repo', repo);
core.setOutput('tag', tag);
core.setOutput('full_image', `${repo}:${tag}`);
core.setOutput('full_image', fullImage);
core.setOutput('editions', JSON.stringify(editions));
console.log(`Resolved: registry = ${REGISTRY}, repo = ${repo}, tag = ${tag}`);
console.log(`Docker image: ${fullImage}`);
console.log(`Editions: ${JSON.stringify(editions)}`);

View File

@@ -5,6 +5,9 @@ inputs:
java-version:
required: true
default: "25"
clojure-version:
required: true
default: "1.12.0.1488"
runs:
using: "composite"
@@ -14,40 +17,12 @@ runs:
with:
java-version: ${{ inputs.java-version }}
distribution: "temurin"
- name: Cache spec
id: cache-spec
shell: bash
run: .github/scripts/cache-keys.sh | tee -a "$GITHUB_OUTPUT"
- name: Restore Clojure CLI
uses: actions/cache/restore@v5
with:
path: ${{ steps.cache-spec.outputs.clojure-path }}
key: ${{ steps.cache-spec.outputs.clojure-key }}
- name: Install Clojure CLI
shell: bash
env:
CLOJURE_PREFIX: ${{ steps.cache-spec.outputs.clojure-path }}
CLOJURE_VERSION: ${{ steps.cache-spec.outputs.clojure-version }}
run: |
set -uo pipefail
echo "$CLOJURE_PREFIX/bin" >> "$GITHUB_PATH"
export PATH="$CLOJURE_PREFIX/bin:$PATH"
# Whether the prefix already holds the right install is decided by running it, not by the
# cache-hit flag. The installer bakes the prefix into the clj/clojure shims, so a tree restored
# under a different $HOME points at a directory that is not there; and on a runner that is not
# ephemeral, a previous version's prefix outlives the cache entry that stopped matching it.
if clojure --version | grep -qF "$CLOJURE_VERSION"; then
clojure --version
exit 0
fi
cd "$RUNNER_TEMP"
for attempt in 1 2 3; do
curl --retry 3 --retry-delay 5 --fail -O "https://download.clojure.org/install/linux-install-$CLOJURE_VERSION.sh" \
&& bash "./linux-install-$CLOJURE_VERSION.sh" --prefix "$CLOJURE_PREFIX" \
curl --retry 3 --retry-delay 5 --fail -O https://download.clojure.org/install/linux-install-${{ inputs.clojure-version }}.sh \
&& sudo bash ./linux-install-${{ inputs.clojure-version }}.sh \
&& break
echo "::warning::Clojure install attempt $attempt failed, retrying in 10s..."
sleep 10
@@ -57,6 +32,10 @@ runs:
exit 1
fi
- name: Get M2 cache key
uses: ./.github/actions/get-cache-keys
id: get-cache-keys
- name: Install uv (for Python package installation)
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
@@ -67,6 +46,9 @@ runs:
- name: Restore M2 cache
uses: actions/cache/restore@v5
with:
path: ${{ steps.cache-spec.outputs.m2-path }}
key: ${{ steps.cache-spec.outputs.m2-key }}
restore-keys: ${{ steps.cache-spec.outputs.m2-restore-key }}
path: |
~/.m2
~/.gitlibs
key: ${{ steps.get-cache-keys.outputs.m2-cache-key }}
restore-keys: |
${{ steps.get-cache-keys.outputs.m2-restore-key }}

View File

@@ -27,7 +27,7 @@ runs:
# Anything headed for master runs master's own sources, so a PR can test its own changes.
- name: Fetch ci-conductor sources from master
if: ${{ github.base_ref != 'master' && github.ref_name != 'master' }}
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: metabase/metabase
ref: master

View File

@@ -25,17 +25,15 @@ runs:
chrome-version: 144
id: setup-chrome
- name: Cache spec
id: cache-spec
shell: bash
run: .github/scripts/cache-keys.sh | tee -a "$GITHUB_OUTPUT"
- name: Get cache keys
uses: ./.github/actions/get-cache-keys
id: get-cache-keys
- name: Restore Cypress binary
- name: Get Cypress cache
uses: actions/cache/restore@v5
with:
path: ${{ steps.cache-spec.outputs.cypress-path }}
key: ${{ steps.cache-spec.outputs.cypress-key }}
restore-keys: ${{ steps.cache-spec.outputs.cypress-restore-key }}
path: ~/.cache/Cypress
key: ${{ steps.get-cache-keys.outputs.cypress-cache-key }}
- name: Ensure that Cypress executable is ready
run: |

View File

@@ -16,38 +16,50 @@ runs:
with:
node-version: ${{ inputs.node-version }}
- name: Cache spec
id: cache-spec
shell: bash
run: .github/scripts/cache-keys.sh | tee -a "$GITHUB_OUTPUT"
- name: Get cache keys
uses: ./.github/actions/get-cache-keys
id: get-cache-keys
# The spec names the store path rather than discovering it, so that a job can restore the cache
# before bun exists. Exported job-wide rather than per-step because every later bun invocation has
# to resolve against the same store. Confirm bun agreed: if the variable stopped taking effect, bun
# would fill its default directory and the cache would silently save an empty tree.
- name: Point bun at the cached store
shell: bash
env:
STORE: ${{ steps.cache-spec.outputs.bun-store-path }}
run: |
echo "BUN_INSTALL_CACHE_DIR=$STORE" >> "$GITHUB_ENV"
ACTUAL=$(BUN_INSTALL_CACHE_DIR="$STORE" bun pm cache)
if [ "$ACTUAL" != "$STORE" ]; then
echo "::error::bun reports its store at $ACTUAL, not $STORE; BUN_INSTALL_CACHE_DIR is not taking effect"
exit 1
fi
- name: Restore bun store
- name: Restore M2 cache
uses: actions/cache/restore@v5
with:
path: ${{ steps.cache-spec.outputs.bun-store-path }}
key: ${{ steps.cache-spec.outputs.bun-store-key }}
restore-keys: ${{ steps.cache-spec.outputs.bun-store-restore-key }}
path: |
~/.m2
~/.gitlibs
key: ${{ steps.get-cache-keys.outputs.m2-cache-key }}
restore-keys: |
${{ steps.get-cache-keys.outputs.m2-restore-key }}
- name: Get bun cache directory
id: bun-cache
run: |
BUN_CACHE_DIR=$(bun pm cache)
echo "Bun cache directory: $BUN_CACHE_DIR"
echo "dir=$BUN_CACHE_DIR" >> "$GITHUB_OUTPUT"
shell: bash
- name: Restore node_modules cache
uses: actions/cache/restore@v5
id: node-modules-cache
with:
path: |
${{ steps.bun-cache.outputs.dir }}
node_modules
key: ${{ steps.get-cache-keys.outputs.node-cache-key }}
restore-keys: |
${{ steps.get-cache-keys.outputs.node-restore-key }}
# postinstall lifecycle hook can be dangerous in CI so we only run it locally (see package.json)
# If a job wants to patch packages, it does so explicitly in the CI. This setting is opt-out.
- name: Install front-end dependencies
run: bun install --frozen-lockfile
# For example, we never want to cache node_modules with patches already applied so cache-generator.yml skips that step.
- name: Clean install
# Remove the restored node_modules before installing. The os-week cache can
# carry a stale tree (for example a different React major), and
# `--frozen-lockfile` alone does not prune it. Rebuilding from the cached bun
# store gives an exact, lockfile-correct node_modules with no extra download.
run: |
rm -rf node_modules
bun install --frozen-lockfile
shell: bash
- name: Patch frontend dependencies
if: inputs.apply-patches == 'true'

View File

@@ -14,6 +14,21 @@ inputs:
staging-mb-all-features-token:
description: "Staging MB all features token"
required: true
shoppy-iam-role:
description: "Shoppy IAM role for AWS credentials"
required: true
shoppy-metabase-app-db-s3-bucket:
description: "S3 bucket for Shoppy app DB dump"
required: true
shoppy-metabase-app-db-s3-key:
description: "S3 key for Shoppy app DB dump"
required: true
shoppy-metabase-app-db-user:
description: "Shoppy Metabase app DB user"
required: true
shoppy-metabase-app-db-database:
description: "Shoppy Metabase app DB database"
required: true
metastore-dev-server-url:
description: "Metastore dev server URL"
required: true
@@ -78,6 +93,20 @@ runs:
echo "sample_app_branch_exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Configure AWS credentials
if: ${{ steps.check-sample-app-branch.outputs.sample_app_branch_exists == 'true' }}
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885
with:
role-to-assume: ${{ inputs.shoppy-iam-role }}
role-session-name: GitHub_to_AWS_via_FederatedOIDC
aws-region: us-east-1
- name: Download Shoppy's App DB PostgreSQL dump from S3
if: ${{ steps.check-sample-app-branch.outputs.sample_app_branch_exists == 'true' }}
shell: bash
run: |
aws s3 cp s3://${{ inputs.shoppy-metabase-app-db-s3-bucket }}/${{ inputs.shoppy-metabase-app-db-s3-key }} ./e2e/tmp/db_dumps/shoppy_metabase_app_db_dump.sql
- name: Prepare and launch Shoppy containers
if: ${{ steps.check-sample-app-branch.outputs.sample_app_branch_exists == 'true' }}
shell: bash
@@ -86,6 +115,8 @@ runs:
SAMPLE_APP_ENVIRONMENT: ${{ inputs.sample-app-environment }}
CYPRESS_MB_ALL_FEATURES_TOKEN: ${{ inputs.staging-mb-all-features-token }}
METASTORE_DEV_SERVER_URL: ${{ inputs.metastore-dev-server-url }}
METABASE_APP_DB_USER: ${{ inputs.shoppy-metabase-app-db-user }}
METABASE_APP_DB: ${{ inputs.shoppy-metabase-app-db-database }}
SHOPPY_DATADOG_APPLICATION_ID: ${{ inputs.shoppy-datadog-application-id }}
SHOPPY_DATADOG_CLIENT_TOKEN: ${{ inputs.shoppy-datadog-client-token }}
SHOPPY_DATADOG_SITE: "us5.datadoghq.com"

View File

@@ -1,39 +0,0 @@
name: Prepare test selection
description: Download this run's test plan and write the suite's selected test paths.
inputs:
suite:
description: The suite to select tests for (unit, loki or e2e).
required: true
test-plan-artifact-id:
description: ID of this run's test-plan.json artifact.
required: true
require-download:
description: Fail the step when the test plan download fails, instead of running the full suite.
required: false
default: "false"
outputs:
paths-file:
description: JSON file listing the selected test paths, or '' when the suite runs in full.
value: ${{ steps.selection.outputs.paths-file }}
selection:
description: full, narrowed or empty, or '' when the plan is unavailable and the suite runs in full.
value: ${{ steps.selection.outputs.selection }}
runs:
using: composite
steps:
- name: Download test plan
id: test-plan
continue-on-error: ${{ inputs.require-download != 'true' }}
uses: actions/download-artifact@v8
with:
artifact-ids: ${{ inputs.test-plan-artifact-id }}
path: ${{ runner.temp }}/test-plan
- name: Prepare test selection
id: selection
shell: bash
env:
PLAN_DOWNLOADED: ${{ steps.test-plan.outcome }}
run: bun .github/scripts/prepare-test-selection.ts "${{ inputs.suite }}"

View File

@@ -25,7 +25,6 @@ runs:
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.ref_name }}
CI_CONDUCTOR_TEST_SUITE: ${{ inputs.test-suite }}
ADAPTER: ${{ inputs.adapter }}
IS_RERUN: ${{ github.run_attempt != '1' }}
# Keep the path constrained to known entrypoints.
run: |
case "$ADAPTER" in

View File

@@ -20,30 +20,8 @@ inputs:
required: false
default: "ee"
description: Edition to test (oss or ee)
runner-index:
quarantine-engine:
required: false
description: >-
The index of this runner among those sharing a partitioned suite. See
`be-tests-bigquery-cloud-sdk-ee` in `.github/workflows/drivers.yml` for an example.
runner-count:
required: false
description: >-
How many runners share the suite. Recommended to set to `strategy.job-total` rather
than a literal so that it doesn't need updating if the `runner-index` matrix changes.
jvms-per-runner:
required: false
default: "1"
description: >-
Concurrent JVMs per runner. Recommended to raise above 1 for drivers whose tests
spend a lot of time waiting on a remote warehouse.
heap-per-jvm:
required: false
default: "4g"
description: >-
`-Xmx` per JVM, applied only when `jvms-per-runner` is more than 1 (otherwise the
the `:ci` alias's `-Xmx12g` is used). To size it: reserve ~1G for the OS and ~2G
of non-heap per JVM, then divide what's left. On a 16G runner that's ~4G each for
two JVMs.
granular-rerun:
required: false
default: "true"
@@ -122,60 +100,21 @@ runs:
# Otherwise run exactly as before. Quote the file so the whole `[ns/var ...]`
# vector reaches clojure -X as a single argument.
run: |
ALIASES="dev:ci:${{ inputs.edition }}:${{ inputs.edition }}-dev:drivers:drivers-dev:test"
BASE="clojure -X:dev:ci:${{ inputs.edition }}:${{ inputs.edition }}-dev:drivers:drivers-dev:test"
# `:profile-tests` overrides `:test`'s exec-fn, so it has to come last. See dev.profile-tests.
# The `profiled` output is what the collection steps below key off, so that the decision is
# recorded by the step that acts on it rather than re-derived from the environment.
if [ "$PROFILE_TESTS" = "true" ]; then
echo "Profiling this run; a flamegraph will be attached to the job."
echo "profiled=true" >> "$GITHUB_OUTPUT"
ALIASES="$ALIASES:profile-tests"
BASE="$BASE:profile-tests"
fi
# An explicit selection wins over the rerun narrowing below: it was asked for by hand, on this
# attempt. Names may be separated by commas or spaces; hawk wants them as one vector argument.
if [ -n "$ONLY_TESTS" ]; then
ONLY="[$(echo "$ONLY_TESTS" | tr ',' ' ')]"
echo "Running only: $ONLY"
$BASE :only "$ONLY"
elif [ "${{ inputs.granular-rerun }}" = "true" ] && [ "${{ github.run_attempt }}" != "1" ] && [ -s target/failed-tests ]; then
if [ "${{ inputs.granular-rerun }}" = "true" ] && [ "${{ github.run_attempt }}" != "1" ] && [ -s target/failed-tests ]; then
echo "Granular rerun of previously-failed tests: $(cat target/failed-tests)"
clojure -X:"$ALIASES" :only "$(cat target/failed-tests)"
elif [ -n "$TEST_RUNNER_INDEX" ]; then
CLOJURE_ALIASES="$ALIASES" ./.github/scripts/run-backend-test-jvms.sh ${{ inputs.test-args }}
$BASE :only "$(cat target/failed-tests)"
else
clojure -X:"$ALIASES" ${{ inputs.test-args }}
$BASE ${{ inputs.test-args }}
fi
shell: bash
id: run-test
env:
# Both derived from test.profile-backend.yml, the sole dispatcher; its runs always profile. Inside
# a called workflow `github.event` is the caller's payload, so these resolve here rather than
# in each calling workflow. On a normal CI run there are no dispatch inputs, so these render
# `false` and empty and the job's own selection stands.
#
# ONLY_TESTS replaces the job's own test selection, so every matrix leg of the chosen job
# runs the whole list.
PROFILE_TESTS: ${{ github.event.inputs.suite != '' }}
ONLY_TESTS: ${{ github.event.inputs.tests }}
TEST_RUNNER_INDEX: ${{ inputs.runner-index }}
TEST_RUNNER_COUNT: ${{ inputs.runner-count }}
TEST_JVMS_PER_RUNNER: ${{ inputs.jvms-per-runner }}
TEST_HEAP_PER_JVM: ${{ inputs.heap-per-jvm }}
# Keep this step continue-on-error because the quarantine gate below controls whether it
# ultimately fails.
continue-on-error: true
- name: Collect profile
if: ${{ !cancelled() }}
uses: ./.github/actions/collect-profile
with:
profiled: ${{ steps.run-test.outputs.profiled }}
artifact-name: ${{ format('profile-{0}{1}', inputs.junit-name, inputs.artifact-suffix && format('-{0}', inputs.artifact-suffix) || '') }}
- name: Report failed tests to ci-conductor
if: ${{ !cancelled() && ( steps.run-test.outcome == 'failure' || github.run_attempt != '1' ) }}
if: ${{ !cancelled() && steps.run-test.outcome == 'failure' }}
# This step should NEVER fail the job!
continue-on-error: true
uses: ./.github/actions/report-failures-to-ci-conductor
@@ -226,9 +165,7 @@ runs:
uses: actions/upload-artifact@v7
with:
name: ${{ format('test-logs-{0}{1}', github.job, inputs.artifact-suffix && format('-{0}', inputs.artifact-suffix) || '') }}
# Glob: a single-JVM run writes `logs/test-log.json`; a multi-JVM run merges one
# `logs/test-log-p<index>.json` per JVM.
path: logs/test-log*.json
path: logs/test-log.json
retention-days: 1
# Failing to upload the artifact should never affect the outcome of the whole test run.
continue-on-error: true
@@ -241,4 +178,4 @@ runs:
with:
adapter: backend
test-suite: ${{ inputs.junit-name }}
enforce-quarantine: "true"
enforce-quarantine: ${{ inputs.quarantine-engine == 'ci-conductor' }}

41
.github/actions/update-cache/action.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Update cache
description: |
Update a GitHub Actions cache based on a provided path and key.
If a cache entry already exists for the given key, it is first removed.
This ensures immutability of cache entries, and prevents runtime errors.
inputs:
path:
description: "The path(s) to cache, separated by newlines."
required: true
key:
description: "The cache key to use."
required: true
runs:
using: "composite"
steps:
- name: Check if the cache entry exists
id: cache-lookup
uses: actions/cache/restore@v5
with:
path: ${{ inputs.path }}
key: ${{ inputs.key }}
lookup-only: true # Important bit: the action will not fetch anything, just check for the cache existence.
- name: Delete the cache entry if it exists
if: ${{ steps.cache-lookup.outputs.cache-hit == 'true' }}
run: |
echo "Cache entry found for key '${{ inputs.key }}'. Deleting it to ensure immutability."
gh api \
--method DELETE \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
/repos/${{ github.repository }}/actions/caches?key=${{ inputs.key }}
env:
GH_TOKEN: ${{ github.token }}
shell: bash
- name: Save the new cache entry
uses: actions/cache/save@v5
with:
path: ${{ inputs.path }}
key: ${{ inputs.key }}

View File

@@ -7,8 +7,9 @@ default: &default
- "locales/**"
- "bin/**/!(*.md)"
workflows:
- added|modified: ".github/workflows/*.{yml,yaml}"
actionlint:
- added|modified: ".github/workflows/*.yml"
- added|modified: ".github/workflows/*.yaml"
shared_sources: &shared_sources
- "src/**/*.cljc"
@@ -63,10 +64,9 @@ frontend_specs: &frontend_specs
- "frontend/**/tests/**"
- "frontend/**/*.unit.*"
- "enterprise/frontend/**/*.unit.*"
- ".github/**/*.unit.spec.*"
- "jest.config.js"
- "jest.base.conf.js"
- "jest.esm-packages.js"
- "jest.tz.unit.conf.js"
- "jest.tz.unit.conf.json"
release_source: &release_sources
- "release/**"
@@ -78,7 +78,6 @@ frontend_all: &frontend_all
frontend_loki_ci: &frontend_loki_ci
- ".github/workflows/loki.yml"
- "loki.config.js"
- ".loki/**"
- ".storybook/**"
@@ -86,55 +85,20 @@ frontend_unit_infra: &frontend_unit_infra
- *frontend_configs
- *frontend_ci
- "jest.config.js"
- "jest.base.conf.js"
- "jest.esm-packages.js"
- "jest.tz.unit.conf.js"
- "jest.tz.unit.conf.json"
- "frontend/test/**"
- "frontend/**/__mocks__/**"
- "frontend/src/embedding-sdk-shared/jest/**"
frontend_loki_infra: &frontend_loki_infra
- *frontend_configs
- *shared_ci
- *frontend_loki_ci
- "frontend/build/shared/**"
- "frontend/test/__support__/**"
- "patches/**"
# dependency-cruiser doesn't follow CSS @import, so global styles must run every story.
- "frontend/src/metabase/css/**/*.css"
# Storybook serves resources/frontend_client as static files, and the stories bundle the app's SVGs.
- "resources/frontend_client/**"
- "frontend/**/*.svg"
- "enterprise/frontend/**/*.svg"
frontend_loki_all:
- *frontend_sources
- *frontend_loki_infra
# Everything the ci-scripts jest project runs or depends on.
# test-unit ignores that project, so this filter is the only thing that reruns it.
ci_scripts:
- *frontend_ci
- ".nvmrc"
# file-paths.unit.spec.ts asserts what this file's own patterns match, so a change to it has to
# run the specs that guard it.
- ".github/file-paths.yaml"
- ".github/scripts/**"
- ".github/actions/**"
- ".github/workflows/e2e-{test,tests,matrix-builder}.yml"
- ".github/workflows/loki.yml"
- ".storybook/story-files.cjs"
- "e2e/runner/read-spec-paths.js"
- "e2e/support/cypress.config.js"
- "frontend/lint/module-boundaries.mjs"
- "frontend/lint/shared-tiers.mjs"
- "frontend/test/jest-test-paths-filter.ts"
- ".dependency-cruiser.cjs"
- "jest.config.js"
- "jest.base.conf.js"
- "jest.esm-packages.js"
- "package.json"
- "bun.lock"
# e2e harness/support code (commands, support file, runner). Not in the
# instrumented bundle, so it maps to no covered module — a change here forces a
# full e2e run (see create-test-plan / e2eInfraTouched).
e2e_infra: &e2e_infra
- *shared_e2e
backend_ci: &backend_ci
- *shared_ci
@@ -149,15 +113,11 @@ backend_sources: &backend_sources
- "src/**"
- "modules/drivers/{*,*/{*,!(test)/**}}"
- "resources/**"
# Ratchet files drive project_ratchet_checks alone, so automation PRs that lower budgets
# run the Project tests job by themselves. Patterns are OR-ed, so a separate "!..." entry would match
# every other path instead of excluding this one.
- ".clj-kondo/**/!(ratchets.edn)"
- ".clj-kondo/**"
backend_specs: &backend_specs
- *shared_specs
- "test/**"
- "dev/test/**"
- "enterprise/backend/test/**"
- "test_modules/**"
- "modules/drivers/*/test/**"
@@ -170,48 +130,6 @@ backend_all: &backend_all
- *backend_sources
- *backend_specs
# Project checks have narrower triggers than the backend matrix.
# Changes to their shared CI entry points exercise every suite; otherwise each
# suite follows only the files that can affect its result.
project_tests_infra: &project_tests_infra
- ".github/actions/prepare-backend/**"
- ".github/file-paths.yaml"
- ".github/workflows/backend.yml"
- ".github/workflows/run-tests.yml"
- "bb.edn"
- "bin/mage"
- "bin/mage.bb"
- "mage/src/mage/color.clj"
- "mage/src/mage/project_tests.clj"
- "mage/src/mage/shell.clj"
- "mage/src/mage/util.clj"
project_backend_checks:
- *project_tests_infra
- "{src,test,enterprise,dev,bin,mage}/**/*.{clj,cljc,cljs}"
- "modules/**/*.clj"
- "modules/**/*.cljc"
- "modules/**/*.cljs"
- "**deps.edn"
- ".clj-kondo/{src,test}/**/*.{clj,cljc,cljs}"
- "{resources,test_resources,test_config}/**/*.{clj,cljc,cljs}"
- ".clj-kondo/config.edn"
- ".clj-kondo/config/modules/config.edn"
- ".github/team.json"
- "dev/resources/dev/modules_config_test/**"
# Only the ratchet check reads the budgets, so a diff that touches nothing else runs that suite alone.
project_ratchet_checks:
- ".clj-kondo/ratchets.edn"
- ".clj-kondo/config/modules/ratchets.edn"
project_migration_checks:
- *project_tests_infra
- "bin/lint-migrations-file/**"
- "resources/migrations/**/*.{yaml,yml}"
- "src/metabase/app_db/custom_migrations.clj"
- "src/metabase/app_db/custom_migrations/**"
sources: &sources
- *shared_sources
- *frontend_sources
@@ -263,21 +181,6 @@ snowplow: &snowplow
- "bin/lint-snowplow-schemas.bb"
- "bin/lint-snowplow-schemas-test.bb"
# e2e harness/support code (commands, support file, runner). Not in the
# instrumented bundle, so it maps to no covered module — a change here forces a
# full e2e run (see create-test-plan / e2eInfraTouched).
e2e_infra: &e2e_infra
- *default
- *e2e_ci
- *frontend_configs
- *shared_e2e
- *snowplow
- "e2e/**/!(*.cy.spec.@(js|jsx|ts|tsx))"
- "frontend/build/**"
- "patches/**"
- ".github/scripts/build-e2e-matrix.js"
- ".github/file-paths.yaml"
e2e_all:
- *default
- *e2e_ci
@@ -285,7 +188,6 @@ e2e_all:
- *e2e_specs
- "e2e/**"
- *snowplow
- *e2e_infra
documentation:
- "docs/**"
@@ -320,35 +222,6 @@ cljs:
- ".github/workflows/cljs.yml"
- "shadow-cljs.edn"
build_scripts:
- ".github/actions/prepare-backend/**"
- ".github/file-paths.yaml"
- ".github/workflows/build-scripts.yml"
- "bin/**"
- "deps.edn"
embedder_plugin:
- ".github/actions/prepare-backend/**"
- ".github/file-paths.yaml"
- ".github/workflows/build-scripts.yml"
- "bin/build-embedder-plugin.sh"
- "bin/build/src/build/embedder_model.clj"
- "bin/build/src/build/plugin_uberjar.clj"
- "bin/build/src/build_embedder_plugin.clj"
- "deps.edn"
- "modules/embedder/**"
- "enterprise/backend/src/metabase_enterprise/data_complexity_score/complexity_embedders.clj"
- "enterprise/backend/src/metabase_enterprise/data_complexity_score/settings.clj"
- "enterprise/backend/src/metabase_enterprise/data_complexity_score/synonym_source.clj"
- "enterprise/backend/src/metabase_enterprise/semantic_search/embedding.clj"
- "enterprise/backend/src/metabase_enterprise/semantic_search/settings.clj"
- "enterprise/backend/test/metabase_enterprise/semantic_search/appdb_pgvector_mode_test.clj"
- "enterprise/backend/test/metabase_enterprise/semantic_search/embedding_test.clj"
- "src/metabase/embeddings/provider.clj"
- "src/metabase/embeddings/startup.clj"
- "src/metabase/plugins/**"
- "test/metabase/embeddings/embedder_plugin_artifact_test.clj"
serialization_baseline:
- ".github/workflows/serialization.yml"
- "test_resources/serialization_baseline/**"

View File

@@ -1,12 +0,0 @@
---
applyTo: "e2e/**/*.cy.spec.{js,ts}"
---
# Cypress E2E Spec Review
When reviewing Cypress E2E specs, apply the guidelines in these files:
- `.claude/skills/_shared/cypress-conventions.md`
- `.claude/skills/e2e-test-review/SKILL.md`
These are in addition to the TypeScript/JavaScript instructions, which also match these files.

View File

@@ -105,28 +105,6 @@ export function getAffectedFiles(
return affected;
}
/** The file plus every file it transitively imports. */
export function getTransitiveDependencies(
fileDependencies: FileDependency[],
file: string,
): Set<string> {
const dependenciesOf = new Map(
fileDependencies.map(({ source, dependencies }) => [source, dependencies]),
);
const reached = new Set([file]);
const queue = [file];
while (queue.length > 0) {
const current = queue.shift()!;
for (const dependency of dependenciesOf.get(current) ?? []) {
if (!reached.has(dependency)) {
reached.add(dependency);
queue.push(dependency);
}
}
}
return reached;
}
export type ModuleGraph = {
nodes: ModuleNode[];
/**

View File

@@ -6,7 +6,6 @@ import {
getAffectedFiles,
getAffectedModules,
getChangedModules,
getTransitiveDependencies,
mapFileToModule,
parseCruiseModules,
} from "./affected-modules";
@@ -124,31 +123,6 @@ describe("affected modules", () => {
});
});
describe("getTransitiveDependencies", () => {
it("should return the file and everything it transitively imports", () => {
expect(
[...getTransitiveDependencies(FILE_DEPS, "src/app.js")].sort(),
).toEqual(["src/app.js", "src/foo/foo.tsx", "src/utils/colors.ts"]);
});
it("should return only the file when it imports nothing", () => {
expect([
...getTransitiveDependencies(FILE_DEPS, "src/utils/colors.ts"),
]).toEqual(["src/utils/colors.ts"]);
});
it("should stop at an import cycle", () => {
const cyclic = [
{ source: "src/a.ts", dependencies: ["src/b.ts"] },
{ source: "src/b.ts", dependencies: ["src/a.ts"] },
];
expect([...getTransitiveDependencies(cyclic, "src/a.ts")].sort()).toEqual(
["src/a.ts", "src/b.ts"],
);
});
});
describe("buildUsageModuleGraph + getAffectedModules", () => {
const usageGraph = buildUsageModuleGraph(ELEMENTS, FILE_DEPS);

View File

@@ -11,7 +11,6 @@ import {
buildUsageModuleGraph,
getAffectedModules,
getChangedModules,
getTransitiveDependencies,
mapFileToModule,
} from "./affected-modules";
@@ -22,7 +21,6 @@ export type TestPlanStats = {
be_files_total: number;
unit_infra_touched: boolean;
loki_infra_touched: boolean;
loki_preview_touched: boolean;
shared_sources_touched: boolean;
fe_modules_total: number;
fe_modules_changed: number;
@@ -50,12 +48,9 @@ export type CreateTestPlanInput = {
elements: ModuleDef[];
rules: Rule[];
changedFiles: string[];
// Loads the dependency-cruiser edges, called only when some suite can be narrowed.
// Null falls back to the rules graph.
loadFileDependencies: () => FileDependency[] | null;
// Parsed dependency-cruiser edges, or null to fall back to the rules graph.
fileDependencies: FileDependency[] | null;
testFilesBySuite: { unit: string[]; loki: string[]; e2e: string[] };
// Storybook's preview file, whose imports wrap every story.
lokiPreviewFile: string;
e2eSpecFiles: Record<string, string[]> | null;
unitInfraTouched: boolean;
lokiInfraTouched: boolean;
@@ -68,7 +63,6 @@ export type CreateTestPlanInput = {
};
// Tests whose owning module is affected.
// A spec outside the module tree always runs, because the graph cannot scope it.
export function filterAffectedTests(
nodes: ModuleNode[],
affected: Set<string>,
@@ -76,7 +70,7 @@ export function filterAffectedTests(
): string[] {
return testFiles.filter((file) => {
const module = mapFileToModule(nodes, file);
return module === null || affected.has(module);
return module !== null && affected.has(module);
});
}
@@ -136,9 +130,8 @@ export function createTestPlan({
elements,
rules,
changedFiles,
loadFileDependencies,
fileDependencies,
testFilesBySuite,
lokiPreviewFile,
e2eSpecFiles,
unitInfraTouched,
lokiInfraTouched,
@@ -150,11 +143,16 @@ export function createTestPlan({
beFilesTotal,
}: CreateTestPlanInput): TestPlan {
const rulesGraph = buildModuleGraph(elements, rules);
const usageGraph = fileDependencies
? buildUsageModuleGraph(elements, fileDependencies)
: rulesGraph;
const nodes = rulesGraph.nodes;
// Distinct module types (an element type can span several patterns).
const totalModules = new Set(elements.map((el) => el.type)).size;
const changedModules = getChangedModules(nodes, changedFiles);
const rulesAffected = getAffectedModules(rulesGraph, changedFiles);
const usageAffected = getAffectedModules(usageGraph, changedFiles);
// The coarse "feature" tier is the only set the e2e manifest is ever
// collapsed to (see filterAffectedE2eSpecs).
@@ -163,7 +161,7 @@ export function createTestPlan({
// cljc/cljs compile into the FE bundle, so they force a full run that module
// selection can't narrow — same effect as a suite's own infra changing.
const unitForceAll = unitInfraTouched || sharedSourcesTouched;
const lokiInfraForceAll = lokiInfraTouched || sharedSourcesTouched;
const lokiForceAll = lokiInfraTouched || sharedSourcesTouched;
// e2e is integration-level, so anything the FE-coverage manifest can't see
// forces a full run: cljc/cljs in the bundle (sharedSourcesTouched), a backend
// change that can break the UI (beFilesChanged), an e2e harness/support change
@@ -174,28 +172,6 @@ export function createTestPlan({
beFilesChanged > 0 ||
e2eSpecFiles === null;
// The cruise takes seconds, so it is skipped when every suite runs in full anyway.
const edges =
unitForceAll && lokiInfraForceAll && e2eForceAll
? null
: loadFileDependencies();
const usageGraph = edges
? buildUsageModuleGraph(elements, edges)
: rulesGraph;
const usageAffected = getAffectedModules(usageGraph, changedFiles);
// The preview wraps every story, so a change to a file it imports can change any screenshot.
const previewDependencies = edges
? getTransitiveDependencies(edges, lokiPreviewFile)
: new Set<string>();
const lokiPreviewTouched = changedFiles.some((file) =>
previewDependencies.has(file),
);
// Without the cruise, the rules graph cannot account for the preview's
// global dependencies, so it cannot safely narrow Loki.
const lokiForceAll =
lokiInfraForceAll || lokiPreviewTouched || edges === null;
const select = (forceAll: boolean, affected: Set<string>, files: string[]) =>
forceAll ? files : filterAffectedTests(nodes, affected, files);
@@ -232,7 +208,6 @@ export function createTestPlan({
be_files_total: beFilesTotal,
unit_infra_touched: unitInfraTouched,
loki_infra_touched: lokiInfraTouched,
loki_preview_touched: lokiPreviewTouched,
shared_sources_touched: sharedSourcesTouched,
fe_modules_total: totalModules,
fe_modules_changed: changedModules.size,

View File

@@ -26,9 +26,8 @@ const E2E_FILES = ["e2e/test/scenarios/a.cy.spec.ts"];
const baseInput = {
elements: ELEMENTS,
rules: RULES,
loadFileDependencies: () => null,
fileDependencies: null,
testFilesBySuite: { unit: UNIT_FILES, loki: LOKI_FILES, e2e: E2E_FILES },
lokiPreviewFile: ".storybook/preview.tsx",
e2eSpecFiles: null,
unitInfraTouched: false,
lokiInfraTouched: false,
@@ -42,10 +41,7 @@ const baseInput = {
describe("createTestPlan", () => {
it("runs only specs in affected modules (rules graph)", () => {
const plan = createTestPlan({
...baseInput,
changedFiles: ["src/foo/x.ts"],
});
const plan = createTestPlan({ ...baseInput, changedFiles: ["src/foo/x.ts"] });
expect(plan.stats.fe_modules_changed).toBe(1);
expect(plan.stats.fe_modules_affected_rules).toBe(1);
@@ -54,8 +50,8 @@ describe("createTestPlan", () => {
"src/foo/foo.unit.spec.ts",
]);
expect(plan.stats.unit_specs_to_run_rules).toBe(2);
expect(plan.loki_stories_to_run).toEqual(LOKI_FILES);
// With no file deps, unit usage falls back to rules, but Loki runs in full.
expect(plan.stats.loki_stories_to_run_rules).toBe(1);
// With no file deps, usage falls back to rules.
expect(plan.stats.unit_specs_to_run_usage).toBe(2);
});
@@ -64,7 +60,7 @@ describe("createTestPlan", () => {
...baseInput,
changedFiles: ["src/utils/colors.ts"],
// Only feature/foo actually imports lib/utils.
loadFileDependencies: () => [
fileDependencies: [
{ source: "src/foo/foo.tsx", dependencies: ["src/utils/colors.ts"] },
],
});
@@ -95,69 +91,14 @@ describe("createTestPlan", () => {
...baseInput,
changedFiles: ["docs/readme.md"],
unitInfraTouched: true,
loadFileDependencies: () => [],
});
expect(plan.stats.unit_specs_to_run_rules).toBe(UNIT_FILES.length);
expect(plan.stats.loki_stories_to_run_rules).toBe(0);
});
// The preview imports the theme, which imports the colors.
const PREVIEW_DEPS = [
{ source: ".storybook/preview.tsx", dependencies: ["src/utils/theme.ts"] },
{ source: "src/utils/theme.ts", dependencies: ["src/utils/colors.ts"] },
{ source: "src/foo/foo.tsx", dependencies: ["src/foo/x.ts"] },
];
it("forces a full Loki run when the Storybook preview imports a changed file", () => {
const plan = createTestPlan({
...baseInput,
changedFiles: ["src/utils/colors.ts"],
loadFileDependencies: () => PREVIEW_DEPS,
e2eSpecFiles: { "e2e/test/scenarios/a.cy.spec.ts": ["src/bar/b.ts"] },
});
expect(plan.stats.loki_preview_touched).toBe(true);
expect(plan.stats.loki_stories_to_run_rules).toBe(LOKI_FILES.length);
expect(plan.loki_stories_to_run).toEqual(LOKI_FILES);
// Unit and e2e still narrow to what the usage graph reaches from the change.
expect(plan.fe_unit_specs_to_run).toEqual(["src/utils/utils.unit.spec.ts"]);
expect(plan.e2e_specs_to_run).toEqual([]);
});
it("narrows Loki when the Storybook preview does not import the changed file", () => {
const plan = createTestPlan({
...baseInput,
changedFiles: ["src/foo/x.ts"],
loadFileDependencies: () => PREVIEW_DEPS,
});
expect(plan.stats.loki_preview_touched).toBe(false);
expect(plan.loki_stories_to_run).toEqual(["src/foo/Foo.stories.tsx"]);
});
it("runs all Loki stories when the cruise fails even if rules select fewer", () => {
const plan = createTestPlan({
...baseInput,
changedFiles: ["src/foo/x.ts"],
loadFileDependencies: () => null,
e2eSpecFiles: { "e2e/test/scenarios/a.cy.spec.ts": ["src/bar/b.ts"] },
});
expect(plan.stats.loki_preview_touched).toBe(false);
expect(plan.loki_stories_to_run).toEqual(LOKI_FILES);
expect(plan.stats.loki_stories_to_run_rules).toBe(LOKI_FILES.length);
expect(plan.stats.loki_stories_to_run_usage).toBe(LOKI_FILES.length);
// Unit and E2E keep their rules-graph fallback.
expect(plan.fe_unit_specs_to_run).toEqual(UNIT_FILES.slice(0, 2));
expect(plan.e2e_specs_to_run).toEqual([]);
});
it("runs the full e2e suite when no coverage manifest is available", () => {
const plan = createTestPlan({
...baseInput,
changedFiles: ["src/foo/x.ts"],
});
const plan = createTestPlan({ ...baseInput, changedFiles: ["src/foo/x.ts"] });
expect(plan.stats.e2e_specs_to_run_rules).toBe(E2E_FILES.length);
expect(plan.stats.e2e_specs_to_run_usage).toBe(E2E_FILES.length);
@@ -288,53 +229,21 @@ describe("createTestPlan", () => {
expect(plan.stats.fe_files_changed).toBe(5);
expect(plan.stats.be_files_changed).toBe(3);
});
it("does not build the usage graph when every suite runs in full", () => {
const loadFileDependencies = jest.fn(() => null);
createTestPlan({
...baseInput,
changedFiles: ["src/foo/x.ts"],
sharedSourcesTouched: true,
loadFileDependencies,
});
expect(loadFileDependencies).not.toHaveBeenCalled();
});
it("builds the usage graph when any suite can be narrowed", () => {
const loadFileDependencies = jest.fn(() => null);
createTestPlan({
...baseInput,
changedFiles: ["src/foo/x.ts"],
unitInfraTouched: true, // loki can still be narrowed
loadFileDependencies,
});
expect(loadFileDependencies).toHaveBeenCalledTimes(1);
});
});
describe("filterAffectedTests", () => {
it("keeps tests inside affected modules, drops tests in unaffected ones", () => {
it("keeps only tests inside affected modules", () => {
const nodes = buildNodes(ELEMENTS);
const affected = new Set(["feature/foo", "lib/utils"]);
const tests = [
"src/foo/foo.unit.spec.ts",
"src/utils/colors.unit.spec.ts",
"src/bar/bar.unit.spec.ts",
"docs/unrelated.unit.spec.ts",
];
expect(filterAffectedTests(nodes, affected, tests)).toEqual([
"src/foo/foo.unit.spec.ts",
"src/utils/colors.unit.spec.ts",
]);
});
it("keeps a test that maps to no module (unknown scope)", () => {
const nodes = buildNodes(ELEMENTS);
const affected = new Set(["feature/foo"]);
const tests = ["docs/unrelated.unit.spec.ts", "src/bar/bar.unit.spec.ts"];
expect(filterAffectedTests(nodes, affected, tests)).toEqual([
"docs/unrelated.unit.spec.ts",
]);
});
});

View File

@@ -2,10 +2,9 @@
// its aim is to split the e2e test matrix into multiple jobs
// grouping some specific tests together, other tests are split into chunks
const SPECS_PER_JOB = 5;
const DEFAULT_SPEC_PATTERN = "./e2e/test/scenarios/**/*.cy.spec.*";
const taggedJobs = [
const specialTestConfigs = [
{
name: "oss-subset",
edition: "oss",
@@ -16,38 +15,69 @@ const taggedJobs = [
{ name: "python", tags: "@python", specs: DEFAULT_SPEC_PATTERN },
];
function buildMatrix(options, specFiles, maxJobs) {
/**
*
* @param {*} inputSpecs - specs, which were changed in the PR, separated by comma
* .e.g e2e/test/scenarios/onboarding/command-palette.cy.spec.js,e2e/test/scenarios/question/document-title.cy.spec.js
* or a pattern like DEFAULT_SPEC_PATTERN
*/
function buildMatrix(options, inputSpecs, inputChunks) {
const { java, defaultRunner } = options;
if (!Number.isInteger(maxJobs) || maxJobs <= taggedJobs.length) {
throw new Error(
`E2E job limit must be an integer greater than ${taggedJobs.length} to allow at least one regular job alongside the tagged jobs`,
);
}
if (specFiles?.length === 0) {
return { config: [], regularChunks: 0 };
}
// number of specs per chunk when running specific specs
const SPECS_PER_CHUNK = 5;
const maxRegularJobs = maxJobs - taggedJobs.length;
let regularChunks = maxRegularJobs;
if (specFiles !== null) {
const jobsForSelectedSpecs = Math.ceil(specFiles.length / SPECS_PER_JOB);
regularChunks = Math.min(maxRegularJobs, jobsForSelectedSpecs);
}
const regularJobs = Array.from({ length: regularChunks }, (_, index) => ({
name: `e2e-group-${String(index + 1).padStart(2, "0")}`,
}));
// Regular jobs exclude the OSS, Mongo, and Python tags, so these jobs must also run for selected specs.
const config = [...regularJobs, ...taggedJobs].map((test) => ({
const defaultOptions = {
"java-version": java,
runner: defaultRunner,
edition: "ee",
...test,
};
const isDefaultSpecPattern =
inputSpecs === "" || inputSpecs === DEFAULT_SPEC_PATTERN;
console.log("Processed specs value:", inputSpecs);
console.log("Is default pattern:", isDefaultSpecPattern);
let regularChunks;
if (isDefaultSpecPattern) {
regularChunks = inputChunks - specialTestConfigs.length;
} else {
// when pattern is not default, it means we passed some custom list of the changed specs
// so we need to calculate how many chunks we need to run
const matchingSpecsCount = inputSpecs.split(",").length;
regularChunks = Math.max(
1,
Math.ceil(matchingSpecsCount / SPECS_PER_CHUNK),
);
}
const regularTests = Array.from({ length: regularChunks }, (_, index) => {
const paddedIndex = String(index + 1).padStart(2, "0");
return {
name: `e2e-group-${paddedIndex}`,
// works when specs less than 5, otherwise seems all chunks will contain
// same specs
...(!isDefaultSpecPattern && {
specs: inputSpecs
.split(",")
.slice(SPECS_PER_CHUNK * index, SPECS_PER_CHUNK * (index + 1))
.join(","),
}),
};
});
const testSets = isDefaultSpecPattern
? regularTests.concat(specialTestConfigs)
: regularTests;
const config = testSets.map((options) => ({
...defaultOptions,
...options,
}));
return { config, regularChunks };
return { config, regularChunks, isDefaultSpecPattern };
}
module.exports = { buildMatrix };

View File

@@ -1,74 +1,99 @@
const { buildMatrix } = require("./build-e2e-matrix");
const options = { java: 25, defaultRunner: "runner" };
const specs = (count) =>
Array.from({ length: count }, (_, i) => `e2e/test/scenarios/${i}.cy.spec.ts`);
describe("buildMatrix", () => {
it.each([0, 3, 3.5])("should reject an invalid job budget: %s", (chunks) => {
expect(() => buildMatrix(options, specs(1), chunks)).toThrow();
const options = {
java: "11",
defaultRunner: "${{ vars.DEFAULT_RUNNER_KEY }}",
};
const testBuildMatrix = (entry, chunks) =>
buildMatrix(options, entry, chunks);
it("should calculate correct regular chunks with default parameters", () => {
const result = testBuildMatrix("./e2e/test/scenarios/**/*.cy.spec.*", 30);
expect(result.regularChunks).toBe(27);
});
it("should use the full job budget when there is no plan", () => {
const result = buildMatrix(options, null, 50);
it("should create correct number of configuration objects", () => {
const result = testBuildMatrix("./e2e/test/scenarios/**/*.cy.spec.*", 10);
expect(result.regularChunks).toBe(47);
expect(result.config).toHaveLength(50);
expect(result.config[0]).toMatchObject({
name: "e2e-group-01",
edition: "ee",
runner: "runner",
});
expect(result.config[46].name).toBe("e2e-group-47");
expect(result.config.length).toBe(10);
});
it("should create no jobs for an empty plan", () => {
expect(buildMatrix(options, [], 50)).toEqual({
config: [],
regularChunks: 0,
it("should apply default specs path when empty entry is provided", () => {
const result = testBuildMatrix("", 6);
expect(result.regularChunks).toBe(3);
expect(result.config.length).toBe(6);
});
it("should name regular test groups sequentially", () => {
const result = testBuildMatrix("./e2e/test/scenarios/**/*.cy.spec.*", 10);
const regularTests = result.config.filter((cfg) =>
cfg.name.startsWith("e2e-group-"),
);
regularTests.forEach((test, index) => {
expect(test.name).toBe(
`e2e-group-${String(index + 1).padStart(2, "0")}`,
);
});
});
it.each([
[1, 1],
[5, 1],
[6, 2],
[12, 3],
[424, 47],
])(
"should allocate %i selected specs to %i regular jobs",
(count, regularChunks) => {
const result = buildMatrix(options, specs(count), 50);
it("should handle case when input chunks equals special tests count", () => {
const result = testBuildMatrix("./e2e/test/scenarios/**/*.cy.spec.*", 3);
expect(result.regularChunks).toBe(regularChunks);
expect(result.config).toHaveLength(regularChunks + 3);
},
);
expect(result.regularChunks).toBe(0);
expect(result.config.length).toBe(3);
expect(result.config[0].name).toBe("oss-subset");
expect(result.config[1].name).toBe("mongo");
expect(result.config[2].name).toBe("python");
});
it.each([null, specs(1), specs(424)])(
"should retain every tagged variant",
(plan) => {
const result = buildMatrix(options, plan, 50);
it("should correctly calculate regularChunks for non-default spec pattern", () => {
const entry = "file.cy.spec.js,".repeat(12).slice(0, -1);
const result = testBuildMatrix(entry, 10);
expect(result.config.slice(-3)).toMatchObject([
{ name: "oss-subset", edition: "oss", tags: "@OSS @prerelease+-@EE" },
{ name: "mongo", edition: "ee", tags: "@mongo" },
{ name: "python", edition: "ee", tags: "@python" },
]);
},
);
// For 12 matching specs with SPECS_PER_CHUNK=5, we expect ceil(12/5) = 3 chunks
expect(result.regularChunks).toBe(3);
it("should leave regular jobs to cypress-split", () => {
const result = buildMatrix(options, specs(12), 50);
const firstGroupSpecs = result.config[0].specs.split(",").length;
const secondGroupSpecs = result.config[1].specs.split(",").length;
const thirdGroupSpecs = result.config[2].specs.split(",").length;
const regularJobs = result.config.slice(0, result.regularChunks);
expect(regularJobs.map((job) => job.name)).toEqual([
"e2e-group-01",
"e2e-group-02",
"e2e-group-03",
]);
for (const job of regularJobs) {
expect(job.specs).toBeUndefined();
}
expect(firstGroupSpecs).toBe(5);
expect(secondGroupSpecs).toBe(5);
expect(thirdGroupSpecs).toBe(2);
});
it("should return isDefaultSpecPattern as true when empty or default value is provided", () => {
let result = testBuildMatrix("", 5);
expect(result.isDefaultSpecPattern).toBe(true);
result = testBuildMatrix("./e2e/test/scenarios/**/*.cy.spec.*", 5);
expect(result.isDefaultSpecPattern).toBe(true);
});
it("should return isDefaultSpecPattern as false when non-default spec pattern is provided", () => {
const result = testBuildMatrix("test1.cy.spec.js,test2.cy.spec.js", 1);
expect(result.isDefaultSpecPattern).toBe(false);
});
it("should return special test configs when default spec pattern is provided", () => {
const result = testBuildMatrix("./e2e/test/scenarios/**/*.cy.spec.*", 50);
expect(result.config[47].name).toBe("oss-subset");
expect(result.config[48].name).toBe("mongo");
expect(result.config[49].name).toBe("python");
});
it("should not return special test configs when non-default spec pattern is provided", () => {
const result = testBuildMatrix("test1.cy.spec.js,test2.cy.spec.js", 50);
expect(result.config.length).toBe(1);
expect(result.config[0].name).toBe("e2e-group-01");
});
});

View File

@@ -0,0 +1,152 @@
/* eslint-disable import/no-commonjs */
// Prepares the rows the bundle-size stats logger uploads to stats.metabase.com.
//
// For each measured (bundle, kind) it computes the delta — raw, gzipped and
// brotli (as-served) bytes plus percent — against the previously *plotted* data
// point (restored from a rolling cache), so the deltas can be charted directly.
// It also decides whether this commit is worth recording at all: a point is kept
// when the as-served size moved by at least MIN_DELTA_PERCENT, or when a new
// bundle/kind appears. Only master builds run through here — releases form
// non-linear lines whose deltas are meaningless, so they're not recorded (their
// historical sizes are backfilled separately).
//
// buildStatsRows is pure (data in, data out) so it can be unit-tested; main()
// is the thin I/O wrapper that reads env/files and writes the rows, cache and
// step outputs.
const fs = require("fs");
const path = require("path");
const deltaPercent = (current, base) =>
base ? Math.round(((current - base) * 10000) / base) / 100 : "";
/**
* Turn the current measurements + the last plotted point into upload rows, the
* slim cache rows for the next diff, and the keep/skip decision.
*
* The significance threshold tracks the as-served size: brotli where the build
* ships a precompressed .br, otherwise gzip. Current measurements always carry
* brotliBytes (estimated when no .br shipped), but a cached base point recorded
* before brotli logging may not, so the delta still falls back to gzip per row.
*/
function buildStatsRows({ measurements, previous, threshold, date, commit, commitMessage, version }) {
const previousOf = (bundle, kind) => previous.find(row => row.bundle === bundle && row.kind === kind);
let maxServedDeltaPercent = 0;
let hasNewSeries = false;
const rows = measurements.map(measurement => {
const base = previousOf(measurement.bundle, measurement.kind);
const hasBrotli = measurement.brotliBytes != null && base?.brotliBytes != null;
const gzipDeltaPercent = deltaPercent(measurement.gzipBytes, base?.gzipBytes);
const brotliDeltaPercent = hasBrotli ? deltaPercent(measurement.brotliBytes, base.brotliBytes) : "";
if (!base) {
hasNewSeries = true;
} else {
const servedDeltaPercent = hasBrotli ? brotliDeltaPercent : gzipDeltaPercent;
maxServedDeltaPercent = Math.max(maxServedDeltaPercent, Math.abs(servedDeltaPercent));
}
return {
"Date": date,
"Version": version,
"Commit": commit,
"Bundle": measurement.bundle,
"Kind": measurement.kind, // "initial" or "total"
// The stats table carries a free-text Description column; append-csv
// requires every table column to be present. Populate it with the commit
// subject so the chart's points are self-describing.
"Description": commitMessage,
"Raw bytes": measurement.rawBytes,
"Gzip bytes": measurement.gzipBytes,
"Brotli bytes": measurement.brotliBytes, // as-served (shipped .br, else estimated)
"File count": measurement.fileCount,
"Raw bytes delta": base ? measurement.rawBytes - base.rawBytes : "",
"Gzip bytes delta": base ? measurement.gzipBytes - base.gzipBytes : "",
"Brotli bytes delta": hasBrotli ? measurement.brotliBytes - base.brotliBytes : "",
"Raw delta %": deltaPercent(measurement.rawBytes, base?.rawBytes),
"Gzip delta %": gzipDeltaPercent,
"Brotli delta %": brotliDeltaPercent,
};
});
const firstPoint = previous.length === 0;
const significant = firstPoint || hasNewSeries || maxServedDeltaPercent >= threshold;
// Slim rows the next run diffs against. The workflow only persists this to the
// cache when we actually push, so the cached reference always stays the last
// *plotted* point (cumulative drift is caught).
const cacheRows = measurements.map(({ bundle, kind, rawBytes, gzipBytes, brotliBytes }) => ({
bundle,
kind,
rawBytes,
gzipBytes,
brotliBytes,
}));
const reason = firstPoint
? "first point"
: hasNewSeries
? "new bundle/kind series"
: `max served Δ ${maxServedDeltaPercent.toFixed(2)}% (threshold ${threshold}%)`;
return { rows, cacheRows, significant, reason, maxServedDeltaPercent, firstPoint, hasNewSeries };
}
module.exports = { buildStatsRows };
const readJson = filePath => (filePath && fs.existsSync(filePath) ? JSON.parse(fs.readFileSync(filePath, "utf8")) : null);
const writeJson = (filePath, value) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value));
};
const setOutput = (env, name, value) => {
if (env.GITHUB_OUTPUT) {
fs.appendFileSync(env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
};
// version.properties (extracted from the uberjar) carries the build's tag.
// Non-release (master) builds report tag=vUNKNOWN, so leave Version empty there.
function readVersion(versionPropsPath) {
const raw =
versionPropsPath && fs.existsSync(versionPropsPath)
? (fs.readFileSync(versionPropsPath, "utf8").match(/^tag=(.*)$/m)?.[1]?.trim() ?? "")
: "";
return raw === "vUNKNOWN" ? "" : raw;
}
function main() {
const env = process.env;
const measurements = readJson(env.CURRENT);
if (!Array.isArray(measurements) || measurements.length === 0) {
console.error(`::error::No bundle measurements found at ${env.CURRENT}`);
process.exit(1);
}
const result = buildStatsRows({
measurements,
// The last plotted point, as slim rows: [{ bundle, kind, rawBytes, gzipBytes, brotliBytes }].
previous: readJson(env.LAST) || [],
threshold: Number(env.MIN_DELTA_PERCENT ?? 1),
date: new Date().toISOString().slice(0, 10), // YYYY-MM-DD
commit: (env.HEAD_SHA || "").slice(0, 12),
commitMessage: (env.COMMIT_MESSAGE || "").split("\n")[0], // subject line only
version: readVersion(env.VERSION_PROPS),
});
writeJson(env.ROWS_OUT, result.rows);
writeJson(env.CACHE_OUT, result.cacheRows);
console.log(`${result.significant ? "RECORD" : "SKIP"} — ${result.reason}`);
setOutput(env, "significant", result.significant ? "true" : "false");
setOutput(
env,
"max_delta_percent",
result.firstPoint || result.hasNewSeries ? "" : result.maxServedDeltaPercent.toFixed(2),
);
}
if (require.main === module) {
main();
}

View File

@@ -1,227 +0,0 @@
// Prepares the rows the bundle-size stats logger imports into eng-stats-importer.
//
// For each measured (bundle, kind) it computes the delta — raw, gzipped and
// brotli (as-served) bytes plus percent — against the previously *plotted* data
// point (restored from a rolling cache), so the deltas can be charted directly.
// It also decides whether this commit is worth recording at all: a point is kept
// when the as-served size moved by at least MIN_DELTA_PERCENT, or when a new
// bundle/kind appears. Only master builds run through here — releases form
// non-linear lines whose deltas are meaningless, so they're not recorded (their
// historical sizes are backfilled separately).
//
// buildStatsRows is pure (data in, data out) so it can be unit-tested; main()
// is the thin I/O wrapper that reads env/files and writes the rows, cache and
// step outputs.
import {
appendFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { dirname } from "node:path";
/** One measurement emitted by measure-bundle-sizes. */
export interface Measurement {
bundle: string;
kind: "initial" | "total";
rawBytes: number;
gzipBytes: number;
brotliBytes: number;
fileCount: number;
}
/**
* The slim row cached between runs, holding the last *plotted* point.
* `brotliBytes` is optional because a point cached before brotli logging
* landed does not carry it — see buildStatsRows' per-row gzip fallback.
*/
export type CacheRow = Pick<Measurement, "bundle" | "kind" | "rawBytes" | "gzipBytes"> & {
brotliBytes?: number;
};
export interface BuildStatsRowsInput {
measurements: Measurement[];
previous: CacheRow[];
threshold: number;
date: string;
commit: string;
commitMessage: string;
version: string;
}
/** An upload row, keyed by the importer's column display names. */
export type StatsRow = Record<string, string | number | null>;
export interface BuildStatsRowsResult {
rows: StatsRow[];
cacheRows: CacheRow[];
significant: boolean;
reason: string;
maxServedDeltaPercent: number;
firstPoint: boolean;
hasNewSeries: boolean;
}
// null, not "", for an absent delta: the stats importer coerces values
// strictly and rejects "" for a numeric column.
const deltaPercent = (current: number, base: number | undefined | null): number | null =>
base ? Math.round(((current - base) * 10000) / base) / 100 : null;
/**
* Turn the current measurements + the last plotted point into upload rows, the
* slim cache rows for the next diff, and the keep/skip decision.
*
* The significance threshold tracks the as-served size: brotli where the build
* ships a precompressed .br, otherwise gzip. Current measurements always carry
* brotliBytes (estimated when no .br shipped), but a cached base point recorded
* before brotli logging may not, so the delta still falls back to gzip per row.
*/
export function buildStatsRows({
measurements,
previous,
threshold,
date,
commit,
commitMessage,
version,
}: BuildStatsRowsInput): BuildStatsRowsResult {
const previousOf = (bundle: string, kind: string) =>
previous.find((row) => row.bundle === bundle && row.kind === kind);
let maxServedDeltaPercent = 0;
let hasNewSeries = false;
const rows = measurements.map((measurement): StatsRow => {
const base = previousOf(measurement.bundle, measurement.kind);
// Non-null only when BOTH sides carry brotli: a point cached before brotli
// logging landed has none, and that row falls back to the gzip delta.
const baseBrotli =
measurement.brotliBytes != null && base?.brotliBytes != null ? base.brotliBytes : null;
const gzipDeltaPercent = deltaPercent(measurement.gzipBytes, base?.gzipBytes);
const brotliDeltaPercent =
baseBrotli != null ? deltaPercent(measurement.brotliBytes, baseBrotli) : null;
if (!base) {
hasNewSeries = true;
} else {
const servedDeltaPercent = baseBrotli != null ? brotliDeltaPercent : gzipDeltaPercent;
maxServedDeltaPercent = Math.max(maxServedDeltaPercent, Math.abs(servedDeltaPercent ?? 0));
}
return {
Date: date,
Version: version,
Commit: commit,
Bundle: measurement.bundle,
Kind: measurement.kind, // "initial" or "total"
// The stats table carries a free-text Description column. Populate it
// with the commit subject so the chart's points are self-describing.
Description: commitMessage,
"Raw bytes": measurement.rawBytes,
"Gzip bytes": measurement.gzipBytes,
"Brotli bytes": measurement.brotliBytes, // as-served (shipped .br, else estimated)
"File count": measurement.fileCount,
"Raw bytes delta": base ? measurement.rawBytes - base.rawBytes : null,
"Gzip bytes delta": base ? measurement.gzipBytes - base.gzipBytes : null,
"Brotli bytes delta": baseBrotli != null ? measurement.brotliBytes - baseBrotli : null,
"Raw delta %": deltaPercent(measurement.rawBytes, base?.rawBytes),
"Gzip delta %": gzipDeltaPercent,
"Brotli delta %": brotliDeltaPercent,
};
});
const firstPoint = previous.length === 0;
const significant = firstPoint || hasNewSeries || maxServedDeltaPercent >= threshold;
// Slim rows the next run diffs against. The workflow only persists this to the
// cache when we actually push, so the cached reference always stays the last
// *plotted* point (cumulative drift is caught).
const cacheRows = measurements.map(
({ bundle, kind, rawBytes, gzipBytes, brotliBytes }): CacheRow => ({
bundle,
kind,
rawBytes,
gzipBytes,
brotliBytes,
}),
);
const reason = firstPoint
? "first point"
: hasNewSeries
? "new bundle/kind series"
: `max served Δ ${maxServedDeltaPercent.toFixed(2)}% (threshold ${threshold}%)`;
return {
rows,
cacheRows,
significant,
reason,
maxServedDeltaPercent,
firstPoint,
hasNewSeries,
};
}
const readJson = <T,>(filePath: string | undefined): T | null =>
filePath && existsSync(filePath) ? (JSON.parse(readFileSync(filePath, "utf8")) as T) : null;
const writeJson = (filePath: string, value: unknown) => {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(value));
};
const setOutput = (name: string, value: string) => {
if (process.env.GITHUB_OUTPUT) {
appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
};
// version.properties (extracted from the uberjar) carries the build's tag.
// Non-release (master) builds report tag=vUNKNOWN, so leave Version empty there.
function readVersion(versionPropsPath: string | undefined): string {
const raw =
versionPropsPath && existsSync(versionPropsPath)
? (readFileSync(versionPropsPath, "utf8").match(/^tag=(.*)$/m)?.[1]?.trim() ?? "")
: "";
return raw === "vUNKNOWN" ? "" : raw;
}
export function main() {
const env = process.env;
const measurements = readJson<Measurement[]>(env.CURRENT);
if (!Array.isArray(measurements) || measurements.length === 0) {
console.error(`::error::No bundle measurements found at ${env.CURRENT}`);
process.exit(1);
}
const result = buildStatsRows({
measurements,
// The last plotted point, as slim rows: [{ bundle, kind, rawBytes, gzipBytes, brotliBytes }].
previous: readJson<CacheRow[]>(env.LAST) || [],
threshold: Number(env.MIN_DELTA_PERCENT ?? 1),
// The commit's own time rather than the day it was measured, so the merges
// of one day keep their order on the chart.
date: new Date(env.COMMIT_TIMESTAMP || Date.now()).toISOString(),
commit: (env.HEAD_SHA || "").slice(0, 12),
commitMessage: (env.COMMIT_MESSAGE || "").split("\n")[0], // subject line only
version: readVersion(env.VERSION_PROPS),
});
writeJson(env.ROWS_OUT!, result.rows);
writeJson(env.CACHE_OUT!, result.cacheRows);
console.log(`${result.significant ? "RECORD" : "SKIP"} — ${result.reason}`);
setOutput("significant", result.significant ? "true" : "false");
setOutput(
"max_delta_percent",
result.firstPoint || result.hasNewSeries ? "" : result.maxServedDeltaPercent.toFixed(2),
);
}
// Only run the I/O wrapper when invoked directly, so importing buildStatsRows
// (from the spec) has no side effects.
if ((import.meta as ImportMeta & { main?: boolean }).main) {
main();
}

View File

@@ -0,0 +1,87 @@
const { buildStatsRows } = require("./bundle-size-stats-prepare");
const meta = { date: "2026-06-30", commit: "abc123", commitMessage: "Fix the thing (#123)", version: "" };
const measurement = (over = {}) => ({
bundle: "embedding-sdk-chunked",
kind: "total",
rawBytes: 1000,
gzipBytes: 400,
brotliBytes: 300,
fileCount: 3,
...over,
});
describe("buildStatsRows", () => {
it("records the first point and leaves every delta empty", () => {
const result = buildStatsRows({
...meta,
measurements: [measurement()],
previous: [],
threshold: 1,
});
expect(result.firstPoint).toBe(true);
expect(result.significant).toBe(true);
expect(result.reason).toBe("first point");
expect(result.rows[0]["Gzip bytes delta"]).toBe("");
expect(result.rows[0]["Brotli delta %"]).toBe("");
expect(result.rows[0]["Description"]).toBe("Fix the thing (#123)");
expect(result.cacheRows[0]).toEqual({
bundle: "embedding-sdk-chunked",
kind: "total",
rawBytes: 1000,
gzipBytes: 400,
brotliBytes: 300,
});
});
it("records a never-seen bundle/kind as a new series", () => {
const result = buildStatsRows({
...meta,
measurements: [measurement({ bundle: "app", kind: "initial" })],
previous: [{ bundle: "app", kind: "total", rawBytes: 1, gzipBytes: 1, brotliBytes: 1 }],
threshold: 99,
});
expect(result.hasNewSeries).toBe(true);
expect(result.significant).toBe(true);
expect(result.reason).toBe("new bundle/kind series");
});
it("gates significance on the brotli (as-served) delta when both sides have it", () => {
const previous = [{ bundle: "embedding-sdk-chunked", kind: "total", rawBytes: 1000, gzipBytes: 400, brotliBytes: 300 }];
const big = buildStatsRows({
...meta,
measurements: [measurement({ brotliBytes: 315, gzipBytes: 400 })], // brotli +5%, gzip 0%
previous,
threshold: 1,
});
expect(big.maxServedDeltaPercent).toBeCloseTo(5);
expect(big.significant).toBe(true);
expect(big.rows[0]["Brotli delta %"]).toBeCloseTo(5);
const small = buildStatsRows({
...meta,
measurements: [measurement({ brotliBytes: 301, gzipBytes: 999 })], // brotli +0.33%, gzip ignored
previous,
threshold: 1,
});
expect(small.significant).toBe(false);
expect(small.reason).toContain("threshold 1%");
});
it("falls back to the gzip delta when the cached base predates brotli logging", () => {
const result = buildStatsRows({
...meta,
measurements: [measurement({ gzipBytes: 440, brotliBytes: 300 })], // gzip +10%
previous: [{ bundle: "embedding-sdk-chunked", kind: "total", rawBytes: 1000, gzipBytes: 400 }],
threshold: 1,
});
expect(result.maxServedDeltaPercent).toBeCloseTo(10);
expect(result.significant).toBe(true);
expect(result.rows[0]["Brotli bytes delta"]).toBe(""); // no base brotli to diff against
expect(result.rows[0]["Gzip delta %"]).toBeCloseTo(10);
});
});

View File

@@ -1,105 +0,0 @@
import {
buildStatsRows,
type CacheRow,
type Measurement,
} from "./bundle-size-stats-prepare";
const meta = {
date: "2026-06-30",
commit: "abc123",
commitMessage: "Fix the thing (#123)",
version: "",
};
const measurement = (over: Partial<Measurement> = {}): Measurement => ({
bundle: "embedding-sdk-chunked",
kind: "total",
rawBytes: 1000,
gzipBytes: 400,
brotliBytes: 300,
fileCount: 3,
...over,
});
describe("buildStatsRows", () => {
it("records the first point and leaves every delta empty", () => {
const result = buildStatsRows({
...meta,
measurements: [measurement()],
previous: [],
threshold: 1,
});
expect(result.firstPoint).toBe(true);
expect(result.significant).toBe(true);
expect(result.reason).toBe("first point");
expect(result.rows[0]["Gzip bytes delta"]).toBeNull();
expect(result.rows[0]["Brotli delta %"]).toBeNull();
expect(result.rows[0]["Description"]).toBe("Fix the thing (#123)");
expect(result.cacheRows[0]).toEqual({
bundle: "embedding-sdk-chunked",
kind: "total",
rawBytes: 1000,
gzipBytes: 400,
brotliBytes: 300,
});
});
it("records a never-seen bundle/kind as a new series", () => {
const result = buildStatsRows({
...meta,
measurements: [measurement({ bundle: "app", kind: "initial" })],
previous: [{ bundle: "app", kind: "total", rawBytes: 1, gzipBytes: 1, brotliBytes: 1 }],
threshold: 99,
});
expect(result.hasNewSeries).toBe(true);
expect(result.significant).toBe(true);
expect(result.reason).toBe("new bundle/kind series");
});
it("gates significance on the brotli (as-served) delta when both sides have it", () => {
const previous: CacheRow[] = [
{
bundle: "embedding-sdk-chunked",
kind: "total",
rawBytes: 1000,
gzipBytes: 400,
brotliBytes: 300,
},
];
const big = buildStatsRows({
...meta,
measurements: [measurement({ brotliBytes: 315, gzipBytes: 400 })], // brotli +5%, gzip 0%
previous,
threshold: 1,
});
expect(big.maxServedDeltaPercent).toBeCloseTo(5);
expect(big.significant).toBe(true);
expect(big.rows[0]["Brotli delta %"]).toBeCloseTo(5);
const small = buildStatsRows({
...meta,
measurements: [measurement({ brotliBytes: 301, gzipBytes: 999 })], // brotli +0.33%, gzip ignored
previous,
threshold: 1,
});
expect(small.significant).toBe(false);
expect(small.reason).toContain("threshold 1%");
});
it("falls back to the gzip delta when the cached base predates brotli logging", () => {
const result = buildStatsRows({
...meta,
measurements: [measurement({ gzipBytes: 440, brotliBytes: 300 })], // gzip +10%
// No brotliBytes: this point was cached before brotli logging landed.
previous: [{ bundle: "embedding-sdk-chunked", kind: "total", rawBytes: 1000, gzipBytes: 400 }],
threshold: 1,
});
expect(result.maxServedDeltaPercent).toBeCloseTo(10);
expect(result.significant).toBe(true);
expect(result.rows[0]["Brotli bytes delta"]).toBeNull(); // no base brotli to diff against
expect(result.rows[0]["Gzip delta %"]).toBeCloseTo(10);
});
});

View File

@@ -1,162 +0,0 @@
#!/usr/bin/env bash
# Single definition of every CI dependency cache: what it holds, what identifies it, what it falls back to.
#
# A GitHub cache entry is identified by its key, a hash of the declared path list, and the ref that saved
# it. A step declaring a different path list than the one a cache was saved with computes a different
# identity and can never match it, whatever its key says. So paths and keys are emitted together here and
# every cache step takes both from this script.
#
# Emits `name=value` lines in $GITHUB_OUTPUT format:
#
# .github/scripts/cache-keys.sh >> "$GITHUB_OUTPUT"
#
# Run it with no redirect to see the whole scheme, or pass one name to print a single value:
#
# .github/scripts/cache-keys.sh
# .github/scripts/cache-keys.sh m2-key
#
# Hashes are computed here rather than with GitHub's hashFiles() so that a local run and a CI run of the
# same commit produce the same keys, and so that an empty match can fail loudly - a hashFiles() call that
# matches nothing silently yields "", collapsing the key onto its own restore prefix and pointing every
# dependency set at one entry.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
OS="${RUNNER_OS:-$(uname -s)}"
[ "$OS" = "Darwin" ] && OS="macOS"
sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum | cut -d' ' -f1
else
shasum -a 256 | cut -d' ' -f1
fi
}
# Digest of a set of files: hash each one, then hash the concatenation, so the result depends on every
# file's contents and on which files exist.
digest() {
local f
if [ "$#" -eq 0 ]; then
echo "::error::cache-keys.sh: no files matched; refusing to emit a key that would collide across dependency sets" >&2
exit 1
fi
for f in "$@"; do
[ -f "$f" ] || { echo "::error::cache-keys.sh: $f does not exist" >&2; exit 1; }
sha256 < "$f"
done | sha256
}
shopt -s nullglob
DEPS_FILES=(deps.edn
bin/lint-migrations-file/deps.edn
bin/load-namespaces/deps.edn
modules/drivers/deps.edn
modules/drivers/*/deps.edn)
shopt -u nullglob
DEPS_HASH="$(digest "${DEPS_FILES[@]}")"
LOCK_HASH="$(digest bun.lock)"
# The Cypress binary depends on the Cypress version alone, so keying it on the whole lockfile would
# throw away a 200MB download every time any frontend dependency moved.
#
# Reading a version out of a lockfile with a regex is only safe while exactly one line matches. Insist
# on that rather than taking the first of several: a second match means the format moved under us, and
# picking one at random would key the binary under a version it is not.
CYPRESS_VERSIONS="$(sed -n 's/.*"cypress": \["cypress@\([^"]*\)".*/\1/p' bun.lock)"
CYPRESS_MATCHES="$(printf '%s\n' "$CYPRESS_VERSIONS" | grep -c . || true)"
if [ "$CYPRESS_MATCHES" != "1" ]; then
echo "::error::cache-keys.sh: expected exactly one resolved cypress version in bun.lock, found $CYPRESS_MATCHES" >&2
exit 1
fi
CYPRESS_VERSION="$CYPRESS_VERSIONS"
# The Clojure CLI version CI installs. It lives here rather than in prepare-backend so that the version
# and the key that identifies its cache entry cannot drift apart.
CLOJURE_VERSION="1.12.0.1488"
# An incremental lint cache is only ever an accelerator, so it keys on the commit and always falls back
# to the newest entry on the prefix. The exact key never pre-exists, which keeps the entry current.
ESLINT_SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
emit_multiline() {
local name="$1"; shift
printf '%s<<CACHE_KEYS_EOF\n' "$name"
printf '%s\n' "$@"
printf 'CACHE_KEYS_EOF\n'
}
# Paths under the home directory are emitted absolute rather than as `~/...`. actions/cache would expand
# a tilde, but a shell assigning one of these to a variable would not, and would go on to create a
# directory named `~`. Paths inside the checkout stay relative; actions/cache resolves those against the
# workspace.
spec() {
emit_multiline m2-path "$HOME/.m2" "$HOME/.gitlibs" "$HOME/.deps.clj" 'bin/bb'
echo "m2-key=M2-$OS-$DEPS_HASH"
echo "m2-restore-key=M2-$OS-"
# Pinned rather than discovered via `bun pm cache` so that the path is known before bun is installed;
# prepare-frontend exports BUN_INSTALL_CACHE_DIR to match and fails if bun disagrees.
echo "bun-store-path=$HOME/.bun/install/cache"
echo "bun-store-key=bun-store-$OS-$LOCK_HASH"
echo "bun-store-restore-key=bun-store-$OS-"
# No restore-key: an entry from another version installs a working `clojure` of the wrong version, and
# the freshness check in prepare-backend - running the binary - would accept it.
#
# The installer bakes this prefix into the clj/clojure shims, so the tree only works when restored to
# the same absolute path it was built at; prepare-backend reinstalls when it is not.
echo "clojure-path=$HOME/.clojure-cli"
echo "clojure-key=clojure-cli-$OS-$CLOJURE_VERSION"
echo "clojure-version=$CLOJURE_VERSION"
echo "cypress-path=$HOME/.cache/Cypress"
echo "cypress-key=cypress-$OS-$CYPRESS_VERSION"
echo "cypress-restore-key=cypress-$OS-"
echo "eslint-path=.eslintcache"
echo "eslint-key=eslint-$OS-$ESLINT_SHA"
echo "eslint-restore-key=eslint-$OS-"
}
# Print one output's value, including the multiline form, and fail on a name that does not exist. A
# caller that silently received "" would go on to build a key with a hole in it.
query() {
local want="$1" line delim found=0 in_block=0
while IFS= read -r line; do
if [ "$in_block" = 1 ]; then
if [ "$line" = "$delim" ]; then
in_block=0
else
printf '%s\n' "$line"
fi
continue
fi
case "$line" in
"$want="*)
printf '%s\n' "${line#*=}"
found=1
;;
"$want<<"*)
delim="${line#*<<}"
in_block=1
found=1
;;
esac
done <<EOF
$(spec)
EOF
if [ "$found" = 0 ]; then
echo "::error::cache-keys.sh: no output named '$want'" >&2
exit 1
fi
}
if [ "$#" -eq 0 ]; then
spec
else
query "$1"
fi

View File

@@ -1,80 +0,0 @@
import { execFileSync } from "child_process";
import { join } from "path";
const SCRIPT = join(__dirname, "cache-keys.sh");
const CACHES = ["m2", "bun-store", "cypress", "eslint"];
// stderr is inherited by default, so the deliberate-failure case below would print a literal
// `::error::` line - which GitHub Actions reads as an annotation directive, making a passing run
// annotate itself with an error.
const run = (args: string[] = [], env: NodeJS.ProcessEnv = {}, quiet = false) =>
execFileSync(SCRIPT, args, {
encoding: "utf8",
stdio: quiet ? ["ignore", "pipe", "ignore"] : "pipe",
env: { ...process.env, RUNNER_OS: "Linux", GITHUB_SHA: "abc123", ...env },
});
const get = (name: string, env: NodeJS.ProcessEnv = {}) =>
run([name], env).trimEnd();
describe("cache-keys.sh", () => {
it("is deterministic for a given tree", () => {
expect(run()).toEqual(run());
});
it("emits every cache's path, key and restore key", () => {
for (const cache of CACHES) {
expect(get(`${cache}-path`)).not.toEqual("");
expect(get(`${cache}-key`)).not.toEqual("");
expect(get(`${cache}-restore-key`)).not.toEqual("");
}
});
// Silently returning "" for a name that does not exist is how a caller ends up building a key with a
// hole in it.
it("fails on an unknown output name", () => {
expect(() => run(["no-such-output"], {}, true)).toThrow();
});
// A key equal to its own restore prefix points every dependency set at one entry, and nothing reports
// it. This is the failure the script exists to prevent.
it("never emits a key equal to its own restore prefix", () => {
for (const cache of CACHES) {
const key = get(`${cache}-key`);
const prefix = get(`${cache}-restore-key`);
expect(key.startsWith(prefix)).toBe(true);
expect(key.length).toBeGreaterThan(prefix.length);
}
});
it("scopes every key to the runner OS", () => {
for (const cache of CACHES) {
expect(get(`${cache}-key`, { RUNNER_OS: "Linux" })).not.toEqual(
get(`${cache}-key`, { RUNNER_OS: "macOS" }),
);
}
});
// Home paths must be absolute: actions/cache expands a tilde but a shell assigning one to a variable
// does not, and would create a directory literally named `~`.
it("emits absolute paths for home directory locations", () => {
expect(get("bun-store-path").startsWith("/")).toBe(true);
expect(get("cypress-path").startsWith("/")).toBe(true);
for (const line of get("m2-path").split("\n")) {
expect(line.startsWith("~")).toBe(false);
}
});
// The Cypress binary depends on the Cypress version alone, so an unrelated dependency bump must not
// invalidate a 200MB download.
it("keys the Cypress binary on the resolved version, not the lockfile", () => {
expect(get("cypress-key")).toMatch(/^cypress-Linux-\d+\.\d+\.\d+/);
});
it("keys the ESLint cache on the commit", () => {
expect(get("eslint-key", { GITHUB_SHA: "deadbeef" })).toEqual(
"eslint-Linux-deadbeef",
);
});
});

View File

@@ -1,71 +0,0 @@
#!/usr/bin/env bash
# Fails when a job in .github/workflows/ does not set `timeout-minutes`.
#
# Without it a job inherits GitHub's 6-hour default, so a hung job burns six hours of runner time -
# and holds up everything queued behind it - before anyone notices. actionlint cannot check this: its
# rule set is compiled in and .github/actionlint.yaml only takes `self-hosted-runner`,
# `config-variables`, and `paths` (which suppresses findings rather than adding them). Hence this
# script.
#
# The rule is per job: a `timeout-minutes` on a step does not satisfy it, and steps are not required
# to carry one of their own.
#
# Jobs that call a reusable workflow (`uses:` at the job level) are skipped - GitHub rejects
# `timeout-minutes` there. The called workflow's own jobs are checked when it lives in this repo.
#
# .github/scripts/check-job-timeouts.sh [workflow-file ...]
#
# With no arguments it checks every workflow; CI passes the files that changed, like actionlint does.
set -euo pipefail
# Arguments are used as given, so the paths in the annotations match what the caller passed. Without
# them the full scan has to run from the repo root.
if [ $# -gt 0 ]; then
workflows=("$@")
else
cd "$(git rev-parse --show-toplevel)"
shopt -s nullglob
workflows=(.github/workflows/*.yml .github/workflows/*.yaml)
if [ ${#workflows[@]} -eq 0 ]; then
echo "::error::check-job-timeouts.sh: no workflow files found under .github/workflows/" >&2
exit 1
fi
fi
status=0
for file in "${workflows[@]}"; do
# `awk NF` drops the blank line yq emits when a file has nothing to report.
missing="$(
# --header-preprocess=false: yq otherwise slurps any leading comment block before parsing, which
# shifts every line number it reports by the size of that block.
yq --header-preprocess=false '
.jobs
| to_entries
| .[]
| select(((.value | has("timeout-minutes")) or (.value | has("uses"))) | not)
| [(.key | line), .key]
| @tsv
' "$file" | awk NF
)"
[ -n "$missing" ] || continue
while IFS=$'\t' read -r line job; do
echo "::error file=${file},line=${line}::Job \"${job}\" does not set a job-level timeout-minutes" >&2
done <<< "$missing"
status=1
done
if [ "$status" -ne 0 ]; then
echo >&2
echo "Every job must set an explicit timeout-minutes; without one it inherits GitHub's 6-hour default." >&2
echo "Pick a value with headroom over the job's normal runtime." >&2
exit 1
fi
echo "All jobs in the ${#workflows[@]} workflow file(s) checked set timeout-minutes."

View File

@@ -1,109 +0,0 @@
#!/usr/bin/env bash
# Fails when a Clojure classpath used by CI is not pre-resolved by cache-warm.
#
# tools.deps selects dependency versions globally per classpath, so two alias combinations can resolve
# the same transitive library to different versions. A combination the warm job never resolved is
# therefore not merely absent from the cache - the cache holds *a* version of those libraries, just not
# the one this classpath wants, and the job fetches its delta straight from Maven Central on every run.
# That is the intermittent-403 failure of DEV-2094, and it is invisible: the build still passes.
#
# Nothing about a missing combination shows up at runtime, so this check is the only thing standing
# between a newly added alias combination and a permanently half-cold cache.
#
# .github/scripts/check-preresolve-aliases.sh
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
WARM=.github/workflows/cache-warm.yml
# Invocations deliberately left out of the warm job. Each needs a reason, because the default answer is
# to add the combination to the list instead.
WAIVED="$(
cat <<'EOF'
:deps
:outdated
EOF
)"
# :deps - tools.deps built-in used by write-poms.sh; contributes no dependencies of its own.
# :outdated - scheduled dependency-bump workflow only; a cold resolve there costs nothing anyone waits on.
# Dependency roots and alias combinations the warm job pre-resolves.
declared="$({
sed -n '/^[[:space:]]*combos=(/,/^ *)/p' "$WARM" \
| grep -oE '":[^"]*"' \
| tr -d '"' \
| sed 's#^#.|#'
sed -n '/^[[:space:]]*nested_combos=(/,/^ *)/p' "$WARM" \
| grep -oE '"[^"]+\|:[^"]+"' \
| tr -d '"'
} | sort -u)"
if [ -z "$declared" ]; then
echo "::error::check-preresolve-aliases.sh: found no pre-resolved classpaths in $WARM" >&2
exit 1
fi
# Expressions we know how to expand. An unrecognised one has to stop the check rather than be skipped:
# silently ignoring it is how a whole matrix of combinations would go unchecked.
unknown="$(grep -rhoE '\$\{\{[^}]*\}\}' --include='*' \
--exclude='*.png' --exclude='*.jpg' .github/ 2>/dev/null \
| sort -u | grep -v 'edition' || true)"
scan() { # scan <edition>: dependency root and aliases used by .github Clojure invocations
grep -rhE 'clojure[[:space:]]+(-P[[:space:]]+)?-[XMATP]?:' \
--include='*' --exclude='*.png' --exclude='*.jpg' .github/ 2>/dev/null \
| sed "s/\\\${{[^}]*edition[^}]*}}/$1/g" \
| grep -oE 'clojure[[:space:]]+(-P[[:space:]]+)?-[XMATP]?:[A-Za-z0-9:_./-]+' \
| grep -oE ':[A-Za-z0-9:_./-]+' \
| sed -e 's/:$//' -e 's#^#.|#'
}
scan_project_tests() { # scan_project_tests <function> <dependency-root>
# Mage shell commands are vectors split across lines, so flatten each function body before
# extracting its alias.
sed -n "/(defn- $1 /,/^(defn/p" mage/src/mage/project_tests.clj \
| tr '\n' ' ' \
| grep -oE '"clojure"[[:space:]]+("-P"[[:space:]]+)?"-[XMATP]?:[A-Za-z0-9:_./-]+' \
| grep -oE ':[A-Za-z0-9:_./-]+' \
| sed -e 's/:$//' -e "s#^#$2|#"
}
# Check each scan separately. One scan still finding commands would otherwise conceal that another has
# stopped matching, which silently drops everything it used to cover.
require_scan() { # require_scan <what> <result>
[ -n "$2" ] && return
echo "::error::check-preresolve-aliases.sh: found no Clojure invocation in $1; the scan pattern no longer matches" >&2
exit 1
}
workflow_checks=$( { scan ee; scan oss; } || true)
clojure_checks=$(scan_project_tests run-clojure-checks! . || true)
migration_checks=$(scan_project_tests run-migration-checks! bin/lint-migrations-file || true)
require_scan ".github/" "$workflow_checks"
require_scan "run-clojure-checks! (mage/src/mage/project_tests.clj)" "$clojure_checks"
require_scan "run-migration-checks! (mage/src/mage/project_tests.clj)" "$migration_checks"
scanned="$(printf '%s\n' "$workflow_checks" "$clojure_checks" "$migration_checks" | sort -u)"
found="$( {
printf '%s\n' "$scanned"
# build-scripts.yml runs this classpath from its own dependency root.
printf '%s\n' 'bin/load-namespaces|:test'
} | sort -u )"
covered="$( {
printf '%s\n' "$declared"
printf '%s\n' "$WAIVED" | sed 's#^#.|#'
} | sort -u)"
missing="$(comm -23 <(printf '%s\n' "$found") <(printf '%s\n' "$covered") || true)"
if [ -n "$missing" ]; then
echo "::error::Clojure classpaths used by CI that cache-warm does not pre-resolve:" >&2
printf '%s\n' "$missing" | sed 's/^/ /' >&2
echo >&2
echo "Add each to the matching root or nested combo list in $WARM, or waive it here with a reason." >&2
echo "Leaving it uncovered means those jobs re-fetch their version deltas from Maven on every run." >&2
exit 1
fi
echo "All $(printf '%s\n' "$found" | grep -c .) Clojure classpaths used by CI are pre-resolved."

View File

@@ -1,182 +0,0 @@
import { spawnSync } from "node:child_process";
import { readFileSync, statSync } from "node:fs";
import { join } from "node:path";
const TEST_UNIT_COMMAND = ["bun", "run", "test-unit-keep-cljs"];
type SuiteResult = {
name: string;
status: string;
assertionResults: unknown[];
};
type JestResults = {
wasInterrupted: boolean;
testResults: SuiteResult[];
};
function isSuiteResult(value: unknown): value is SuiteResult {
return (
typeof value === "object" &&
value !== null &&
"name" in value &&
typeof value.name === "string" &&
"status" in value &&
typeof value.status === "string" &&
"assertionResults" in value &&
Array.isArray(value.assertionResults)
);
}
function readResults(file: string): JestResults | null {
const value = readJson(file);
if (
typeof value === "object" &&
value !== null &&
"wasInterrupted" in value &&
typeof value.wasInterrupted === "boolean" &&
"testResults" in value &&
Array.isArray(value.testResults) &&
value.testResults.every(isSuiteResult)
) {
return {
wasInterrupted: value.wasInterrupted,
testResults: value.testResults,
};
}
return null;
}
function parseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function readJson(file: string): unknown {
try {
return parseJson(readFileSync(file, "utf8"));
} catch {
return null;
}
}
function isStringArray(value: unknown): value is string[] {
return (
Array.isArray(value) && value.every((item) => typeof item === "string")
);
}
function hasContent(file: string): boolean {
try {
return statSync(file).size > 0;
} catch {
return false;
}
}
export function findRunnerFailure(env: NodeJS.ProcessEnv): string | null {
const {
UNIT_TEST_RESULTS_FILE,
JEST_JUNIT_OUTPUT_DIR,
JEST_JUNIT_OUTPUT_NAME,
} = env;
if (
!UNIT_TEST_RESULTS_FILE ||
!JEST_JUNIT_OUTPUT_DIR ||
!JEST_JUNIT_OUTPUT_NAME
) {
throw new Error(
"UNIT_TEST_RESULTS_FILE, JEST_JUNIT_OUTPUT_DIR and JEST_JUNIT_OUTPUT_NAME are required",
);
}
const results = readResults(UNIT_TEST_RESULTS_FILE);
if (results === null || results.wasInterrupted) {
return "Jest did not finish the run.";
}
if (!hasContent(join(JEST_JUNIT_OUTPUT_DIR, JEST_JUNIT_OUTPUT_NAME))) {
return "Jest did not write a JUnit report.";
}
const failedSuites = results.testResults.filter(
(suite) => suite.status === "failed",
);
// jest-junit leaves out suites with no test results, so the quarantine gate never sees them.
const suitesWithoutResults = failedSuites.filter(
(suite) => suite.assertionResults.length === 0,
);
if (suitesWithoutResults.length > 0) {
const names = suitesWithoutResults.map((suite) => suite.name).join(", ");
return `Test suites failed to run: ${names}`;
}
if (failedSuites.length === 0) {
return "Jest failed without a failing test suite.";
}
return null;
}
function listTests(
command: readonly string[],
env: NodeJS.ProcessEnv,
): string[] | null {
const [program, ...args] = command;
const result = spawnSync(program, [...args, "--listTests", "--json"], {
env,
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
});
if (result.status !== 0) {
return null;
}
const listed = parseJson(result.stdout);
return isStringArray(listed) ? listed : null;
}
// --passWithNoTests lets every shard pass when the selection matches nothing Jest discovers.
// A single empty shard is normal, so only an unsharded listing that is also empty warns.
export function findEmptySelection(
env: NodeJS.ProcessEnv,
command: readonly string[] = TEST_UNIT_COMMAND,
): string | null {
const { UNIT_TEST_RESULTS_FILE, JEST_TEST_PATHS_FILE } = env;
if (!UNIT_TEST_RESULTS_FILE || !JEST_TEST_PATHS_FILE) {
return null;
}
const results = readResults(UNIT_TEST_RESULTS_FILE);
const selection = readJson(JEST_TEST_PATHS_FILE);
if (
results === null ||
results.testResults.length > 0 ||
!isStringArray(selection) ||
selection.length === 0
) {
return null;
}
const listed = listTests(command, env);
if (listed === null) {
return "Could not list Jest's unit tests to check the selection.";
}
if (listed.length > 0) {
return null;
}
return `None of the ${selection.length} selected unit specs match a test Jest discovers, so no unit tests ran.`;
}
if (require.main === module) {
if (process.env.TEST_OUTCOME === "failure") {
const failure = findRunnerFailure(process.env);
if (failure !== null) {
console.error(`::error::${failure}`);
process.exitCode = 1;
}
} else {
const warning = findEmptySelection(process.env);
if (warning !== null) {
console.warn(`::warning::${warning}`);
}
}
}

View File

@@ -1,45 +1,37 @@
// I/O entrypoint: gather inputs (env vars, the test-file lists, the cruise graph on request),
// hand them to createTestPlan, which does the computing, and publish its small stats output.
// I/O entrypoint: gather inputs (env vars, the cruise graph, the test-file
// lists) and hand them to createTestPlan, which does the computing.
import { execFileSync, spawnSync } from "node:child_process";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import micromatch from "micromatch";
import { MAIN_APP_STORY_GLOBS } from "../../.storybook/story-files.cjs";
import { elements, rules } from "../../frontend/lint/module-boundaries.mjs";
import { type FileDependency, parseCruiseModules } from "./affected-modules";
import { createTestPlan } from "./affected-tests";
import { listSpecFiles } from "./e2e-spec-globs.mjs";
// The specs `bun run test-unit` runs.
const UNIT_ROOTS = ["."];
const UNIT_ROOTS = ["frontend/src", "enterprise/frontend/src"];
const UNIT_GLOBS = [
"**/*.unit.spec.{js,jsx,ts,tsx}",
"!.github/**", // the ci-scripts project, which test-unit ignores
"!release/**", // has its own jest config
"!enterprise/frontend/src/custom-viz/**", // modulePathIgnorePatterns in jest.config.js
"!**/*.leak.unit.spec.{js,jsx,ts,tsx}", // run only by jest.memory.conf.js
"frontend/src/**/*.unit.spec.{js,jsx,ts,tsx}",
"enterprise/frontend/src/**/*.unit.spec.{js,jsx,ts,tsx}",
];
const FRONTEND_ROOTS = ["frontend", "enterprise/frontend"];
const LOKI_PREVIEW_FILE = ".storybook/preview.tsx";
// `git ls-files -- frontend enterprise/frontend` already prints just over a
// megabyte of paths, and node's default maxBuffer is exactly 1 MiB: past that
// the spawn dies with ENOBUFS. Give the listings room to grow.
const LS_FILES_MAX_BUFFER = 64 * 1024 * 1024;
const STORY_ROOTS = ["frontend", "enterprise/frontend"];
const STORY_GLOBS = [
"frontend/**/*.stories.{js,jsx,ts,tsx}",
"enterprise/frontend/**/*.stories.{js,jsx,ts,tsx}",
];
// Returns the tracked files under `roots` that match `globs`. The `dot: true`
// option means files inside dot-directories such as `.storybook` are included.
function listFiles(roots: string[], globs: string[]): string[] {
const tracked = execFileSync("git", ["ls-files", "-z", "--", ...roots], {
const tracked = execFileSync("git", ["ls-files", "--", ...roots], {
encoding: "utf8",
maxBuffer: LS_FILES_MAX_BUFFER,
})
.split("\0")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return micromatch(tracked, globs, { dot: true });
}
@@ -51,43 +43,26 @@ const csvToList = (csv: string | undefined) =>
.map((s) => s.trim())
.filter(Boolean);
// Runs dependency-cruiser over the frontend sources and the Storybook preview and parses its edges.
// Null falls back to the rules graph, so a failed cruise never breaks the plan.
function loadFileDependencies(): FileDependency[] | null {
const output = "dependency-graph.json";
process.stderr.write("Building usage graph with dependency-cruiser.\n");
try {
// stdout is the plan JSON, so the cruise's own output goes to stderr.
const result = spawnSync(
"bunx",
[
"depcruise",
"frontend/src",
"enterprise/frontend/src",
LOKI_PREVIEW_FILE,
"--config",
".dependency-cruiser.cjs",
"--output-type",
"json",
"--output-to",
output,
],
{ stdio: ["ignore", 2, 2] },
);
if (result.error) {
throw result.error;
// Reads the dependency-cruiser graph (DEP_GRAPH_JSON). Null falls back to the
// rules graph, so a missing or unparseable file never breaks the plan.
function readFileDependencies(): FileDependency[] | null {
const path = process.env.DEP_GRAPH_JSON;
if (path && existsSync(path)) {
try {
const { modules } = JSON.parse(readFileSync(path, "utf8"));
process.stderr.write(`Using usage graph from ${path}.\n`);
return parseCruiseModules(modules);
} catch (error) {
process.stderr.write(
`Failed to read ${path}; falling back to rules graph: ${error}\n`,
);
}
if (result.status !== 0) {
throw new Error(`depcruise exited with status ${result.status}`);
}
const { modules } = JSON.parse(readFileSync(output, "utf8"));
return parseCruiseModules(modules);
} catch (error) {
} else {
process.stderr.write(
`Failed to build usage graph; falling back to rules graph: ${error}\n`,
"No DEP_GRAPH_JSON found; falling back to rules graph.\n",
);
return null;
}
return null;
}
// Reads the nightly coverage manifest (E2E_SPEC_MANIFEST): { builtAt, specs:
@@ -117,13 +92,12 @@ const testPlan = createTestPlan({
elements,
rules,
changedFiles: csvToList(process.env.CHANGED_FILES),
loadFileDependencies,
fileDependencies: readFileDependencies(),
testFilesBySuite: {
unit: listFiles(UNIT_ROOTS, UNIT_GLOBS),
loki: listFiles(FRONTEND_ROOTS, MAIN_APP_STORY_GLOBS),
loki: listFiles(STORY_ROOTS, STORY_GLOBS),
e2e: listSpecFiles(),
},
lokiPreviewFile: LOKI_PREVIEW_FILE,
e2eSpecFiles: readE2eSpecFiles(),
unitInfraTouched: process.env.UNIT_INFRA_TOUCHED === "true",
lokiInfraTouched: process.env.LOKI_INFRA_TOUCHED === "true",
@@ -131,15 +105,8 @@ const testPlan = createTestPlan({
sharedSourcesTouched: process.env.SHARED_SOURCES_TOUCHED === "true",
feFilesChanged: csvToList(process.env.FE_CHANGED_FILES).length,
beFilesChanged: csvToList(process.env.BE_CHANGED_FILES).length,
feFilesTotal: listFiles(FRONTEND_ROOTS, ["**"]).length,
feFilesTotal: listFiles(["frontend", "enterprise/frontend"], ["**"]).length,
beFilesTotal: listFiles(["src", "enterprise/backend"], ["**"]).length,
});
process.stdout.write(JSON.stringify(testPlan) + "\n");
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
`stats=${JSON.stringify(testPlan.stats)}\n`,
);
}

91
.github/scripts/csv-to-mb.js vendored Normal file
View File

@@ -0,0 +1,91 @@
/**
* jsonData must be in the format:
* [
* { column1: value1, column2: value2, ... },
* { column1: value3, column2: value4, ... },
* ...
* ]
*/
function jsonToCsvFormData(jsonData) {
const formData = new FormData();
const csvHeader = Object.keys(jsonData[0]).join(',') + '\n';
const csvContent = jsonData.map(row =>
Object.values(row).map(value => `"${String(value).replace(/"/g, '""')}"`).join(',')
).join('\n');
const blob = new Blob([csvHeader + csvContent], { type: 'text/csv' });
formData.append('file', blob, 'data.csv');
return formData;
}
// Transient failures worth retrying: throttling and server-side errors. A 4xx
// (bad request, auth) won't succeed on retry, so those fail fast.
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
class UploadError extends Error {
constructor(message, { retryable }) {
super(message);
this.retryable = retryable;
}
}
async function attemptUpload(url, jsonData, timeoutMs) {
let response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
"x-api-key": process.env.API_KEY
},
// Rebuilt per attempt: a consumed request body can't be reused.
body: jsonToCsvFormData(jsonData),
// Without this the request can hang indefinitely on a stalled server,
// eating the whole job timeout. An abort surfaces below as a retryable
// network error, so it retries with backoff instead.
signal: AbortSignal.timeout(timeoutMs)
});
} catch (networkError) {
throw new UploadError(networkError.message, { retryable: true });
}
if (response.ok) {
return;
}
const body = await response.text().catch(() => '');
throw new UploadError(
`Upload failed: ${response.status} ${response.statusText} ${body}`.trim(),
{ retryable: RETRYABLE_STATUS.has(response.status) }
);
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
/**
* Uploads jsonData as CSV, retrying transient API errors with exponential
* backoff. `retries` is the number of extra attempts after the first (so the
* default 2 means up to 3 tries). `timeoutMs` is a per-attempt total deadline
* (default 60s) that aborts a stalled request. Throws the last error once
* attempts are exhausted or the error is non-retryable.
*/
async function uploadCsvToMb({ baseUrl, tableId, jsonData, mode = 'append', retries = 2, retryDelayMs = 1000, timeoutMs = 60_000 }) {
const operation = mode === 'replace' ? 'replace-csv' : 'append-csv';
const url = `${baseUrl}/api/table/${tableId}/${operation}`;
for (let attempt = 0; ; attempt++) {
try {
await attemptUpload(url, jsonData, timeoutMs);
return { success: true };
} catch (error) {
if (!error.retryable || attempt >= retries) {
throw error;
}
const delay = retryDelayMs * 2 ** attempt;
console.warn(`Upload attempt ${attempt + 1} failed (${error.message}); retrying in ${delay}ms`);
await sleep(delay);
}
}
}
module.exports = { uploadCsvToMb };

50
.github/scripts/csv-to-mb.unit.spec.js vendored Normal file
View File

@@ -0,0 +1,50 @@
const { uploadCsvToMb } = require("./csv-to-mb");
const ok = () => ({ ok: true });
const fail = status => ({ ok: false, status, statusText: "err", text: async () => "" });
const upload = (over = {}) =>
uploadCsvToMb({
baseUrl: "https://stats.example.com",
tableId: 1,
jsonData: [{ a: 1 }],
retryDelayMs: 1,
...over,
});
describe("uploadCsvToMb retries", () => {
afterEach(() => {
delete global.fetch;
});
it("retries transient server errors then succeeds", async () => {
global.fetch = jest.fn().mockResolvedValueOnce(fail(503)).mockResolvedValueOnce(fail(500)).mockResolvedValueOnce(ok());
await expect(upload({ retries: 2 })).resolves.toEqual({ success: true });
expect(global.fetch).toHaveBeenCalledTimes(3);
});
it("retries network errors", async () => {
global.fetch = jest
.fn()
.mockRejectedValueOnce(new Error("ECONNRESET"))
.mockResolvedValueOnce(ok());
await expect(upload({ retries: 2 })).resolves.toEqual({ success: true });
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it("gives up after exhausting retries and throws the last error", async () => {
global.fetch = jest.fn().mockResolvedValue(fail(500));
await expect(upload({ retries: 2 })).rejects.toThrow("Upload failed: 500");
expect(global.fetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
});
it("does not retry a non-transient 4xx", async () => {
global.fetch = jest.fn().mockResolvedValue(fail(400));
await expect(upload({ retries: 2 })).rejects.toThrow("Upload failed: 400");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,181 +0,0 @@
import { readFileSync } from "fs";
import { join } from "path";
import { load } from "js-yaml";
import { isMatch } from "micromatch";
const FILTERS = join(__dirname, "..", "file-paths.yaml");
type Filter = string | Filter[] | { [changeType: string]: Filter };
// A filter is a list of patterns, and an entry may name the change types it applies to as
// { "added|modified": pattern }. Either way the patterns are what matters here.
const patterns = (value: Filter): string[] => {
if (typeof value === "string") {
return [value];
}
if (Array.isArray(value)) {
return value.flatMap(patterns);
}
return Object.values(value).flatMap(patterns);
};
describe("file-paths.yaml", () => {
// js-yaml returns an untyped value, and this repository file defines named path filters.
const filters = load(readFileSync(FILTERS, "utf8")) as Record<string, Filter>;
// dorny/paths-filter matches with picomatch, which micromatch wraps, so this asks the same
// question CI asks of a changed path.
const matches = (name: string, path: string) =>
patterns(filters[name]).some((pattern) =>
isMatch(path, pattern, { dot: true }),
);
// dorny/paths-filter ORs the patterns in a filter, so a "!..." entry does not subtract from the
// other entries - it matches every path they don't, turning the filter permanently on. Excluding
// a path means writing the exclusion inside one pattern, the way backend_sources keeps
// ratchet files out with ".clj-kondo/**/!(ratchets.edn)".
it("never excludes a path with a standalone negation", () => {
const negated = Object.entries(filters).flatMap(([name, filter]) =>
patterns(filter)
.filter((pattern) => pattern.startsWith("!"))
.map((pattern) => `${name}: ${pattern}`),
);
expect(negated).toEqual([]);
});
it.each(["backend_sources", "backend_all"])(
"%s skips a ratchets-only change and still follows the rest of .clj-kondo",
(name) => {
expect(matches(name, ".clj-kondo/ratchets.edn")).toBe(false);
expect(matches(name, ".clj-kondo/config/modules/ratchets.edn")).toBe(
false,
);
expect(matches(name, ".clj-kondo/config.edn")).toBe(true);
expect(matches(name, ".clj-kondo/config/modules/config.edn")).toBe(true);
},
);
it("runs CI script tests when the Node version changes", () => {
expect(matches("ci_scripts", ".nvmrc")).toBe(true);
});
it.each([
"jest.config.js",
"jest.base.conf.js",
"jest.esm-packages.js",
"jest.tz.unit.conf.js",
])("runs every frontend unit test when %s changes", (path) => {
expect(matches("frontend_specs", path)).toBe(true);
expect(matches("frontend_unit_infra", path)).toBe(true);
});
it("runs every frontend unit test when an SDK jest setup file changes", () => {
expect(
matches(
"frontend_unit_infra",
"frontend/src/embedding-sdk-shared/jest/setup-env.ts",
),
).toBe(true);
});
it.each([
"jest.config.js",
"jest.base.conf.js",
"jest.esm-packages.js",
".storybook/story-files.cjs",
".github/workflows/loki.yml",
])("runs CI script tests when %s changes", (path) => {
expect(matches("ci_scripts", path)).toBe(true);
});
it("runs the ratchet check on the ratchets file", () => {
expect(matches("project_ratchet_checks", ".clj-kondo/ratchets.edn")).toBe(
true,
);
expect(
matches(
"project_ratchet_checks",
".clj-kondo/config/modules/ratchets.edn",
),
).toBe(true);
});
it.each([
"loki.config.js",
".storybook/story-files.cjs",
".storybook/preview.tsx",
".loki/reference/chrome.laptop-Button.png",
".github/workflows/loki.yml",
".github/workflows/run-tests.yml",
".github/actions/prepare-frontend/action.yml",
"frontend/build/shared/rspack/css-config.js",
"frontend/test/__support__/custom-viz-fixtures/calendar-heatmap/index.js",
"patches/@loki+browser+0.35.0.patch",
"frontend/src/metabase/css/index.module.css",
"frontend/src/metabase/css/core/base.module.css",
"resources/frontend_client/app/fonts/Lato/lato-v16-latin-regular.woff2",
"resources/frontend_client/app/assets/img/no_results.svg",
"frontend/src/metabase/ui/components/icons/Icon/icons/warning.svg",
"enterprise/frontend/src/metabase-enterprise/google_drive/database-error.svg",
])("runs all Loki stories when %s changes", (file) => {
expect(matches("frontend_loki_all", file)).toBe(true);
expect(matches("frontend_loki_infra", file)).toBe(true);
});
it.each([
"frontend/src/metabase/ui/Button.stories.tsx",
"frontend/src/metabase/ui/components/feedback/Alert/Alert.module.css",
"frontend/build/embedding-sdk/rspack.config.js",
])("allows a change to %s to narrow the Loki run", (file) => {
expect(matches("frontend_loki_all", file)).toBe(true);
expect(matches("frontend_loki_infra", file)).toBe(false);
});
it.each([
".github/workflows/frontend.yml",
"frontend/test/metabase/scenarios/Button.unit.spec.tsx",
])("does not run Loki stories when %s changes", (file) => {
expect(matches("frontend_loki_all", file)).toBe(false);
});
it.each([
"e2e/support/cypress.config.js",
"e2e/test/scenarios/shared-helper.js",
"e2e/runner/read-spec-paths.js",
".github/workflows/e2e-test.yml",
".github/workflows/run-tests.yml",
".github/actions/prepare-cypress/action.yml",
".github/scripts/build-e2e-matrix.js",
".github/file-paths.yaml",
"frontend/build/shared/esbuild/side-effect-free-modules-plugin.js",
"rspack.main.config.js",
"bun.lock",
"locales/fr.po",
"snowplow/events/event.yaml",
"patches/@cypress+grep+6.0.0.patch",
])("runs all E2E specs when %s changes", (file) => {
expect(matches("e2e_all", file)).toBe(true);
expect(matches("e2e_infra", file)).toBe(true);
});
it.each(["js", "jsx", "ts", "tsx"])(
"allows changes to .cy.spec.%s files to narrow E2E",
(extension) => {
expect(
matches("e2e_infra", `e2e/test/scenarios/a.cy.spec.${extension}`),
).toBe(false);
},
);
it.each([
".github/workflows/e2e-test.yml",
".github/workflows/e2e-tests.yml",
".github/workflows/e2e-matrix-builder.yml",
"e2e/runner/read-spec-paths.js",
"e2e/support/cypress.config.js",
])("runs CI-script tests when %s changes", (file) => {
expect(matches("ci_scripts", file)).toBe(true);
});
});

40
.github/scripts/find-linear-issue.js vendored Normal file
View File

@@ -0,0 +1,40 @@
async function findLinearIssue({ githubIssueUrl, linearApiKey }) {
const response = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: linearApiKey,
},
body: JSON.stringify({
query: `
query {
attachmentsForURL(url: "${githubIssueUrl}") {
nodes {
issue { identifier }
}
}
}
`,
}),
});
const data = await response.json();
if (!response.ok) {
return { success: false, message: `Linear API error: ${response.status}` };
}
if (!data.data?.attachmentsForURL?.nodes?.length) {
return {
success: false,
message: `No Linear issue linked to ${githubIssueUrl}`,
};
}
return {
success: true,
issue: data.data.attachmentsForURL.nodes[0].issue.identifier,
};
}
module.exports = { findLinearIssue };

View File

@@ -1,237 +0,0 @@
import { spawnSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { load } from "js-yaml";
import {
findEmptySelection,
findRunnerFailure,
} from "./check-unit-test-results";
import { prepareTestSelection } from "./prepare-test-selection";
type Step = { name: string; run?: string; "continue-on-error"?: boolean };
// js-yaml returns an untyped value, and this repository workflow defines the job's steps.
const workflow = load(
readFileSync(resolve(__dirname, "../workflows/frontend.yml"), "utf8"),
) as { jobs: { "fe-tests-unit": { steps: Step[] } } };
const steps = workflow.jobs["fe-tests-unit"].steps;
const checkScript = resolve(__dirname, "check-unit-test-results.ts");
const filter = resolve(
__dirname,
"../../frontend/test/jest-test-paths-filter.ts",
);
describe("frontend test plan handoff", () => {
let dir: string;
let env: NodeJS.ProcessEnv;
beforeEach(() => {
jest.spyOn(console, "warn").mockImplementation(() => {});
// Jest reports real paths, and macOS's temporary directory is behind a symlink.
dir = realpathSync(mkdtempSync(join(tmpdir(), "frontend-test-plan-")));
mkdirSync(join(dir, "test-plan"));
writeFileSync(join(dir, "output"), "");
env = {
...process.env,
NODE_OPTIONS: "",
JEST_TEST_PATHS_FILE: "",
RUNNER_TEMP: dir,
GITHUB_OUTPUT: join(dir, "output"),
PLAN_DOWNLOADED: "success",
JEST_JUNIT_OUTPUT_DIR: dir,
JEST_JUNIT_OUTPUT_NAME: "junit.xml",
UNIT_TEST_RESULTS_FILE: join(dir, "unit-test-results.json"),
};
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});
function plan(files: string[], total = 2) {
return {
fe_unit_specs_to_run: files,
stats: { unit_specs_all: total, unit_specs_to_run_usage: files.length },
};
}
function writePlan(value: unknown) {
writeFileSync(join(dir, "test-plan/test-plan.json"), JSON.stringify(value));
}
function jestCommand(useFilter: boolean) {
return [
process.execPath,
require.resolve("jest/bin/jest"),
"--config",
JSON.stringify({
rootDir: dir,
testMatch: ["**/*.spec.cjs"],
reporters: ["default", require.resolve("jest-junit")],
...(useFilter && { filter }),
}),
];
}
function runJest(
files: Record<string, string>,
useFilter = false,
args: string[] = [],
) {
for (const [name, source] of Object.entries(files)) {
writeFileSync(join(dir, name), source);
}
const [program, ...commandArgs] = jestCommand(useFilter);
return spawnSync(
program,
[
...commandArgs,
"--runInBand",
"--watch=false",
"--json",
`--outputFile=${env.UNIT_TEST_RESULTS_FILE}`,
...(useFilter ? ["--passWithNoTests"] : []),
...args,
],
{ cwd: dir, env, encoding: "utf8" },
);
}
function readResults() {
return JSON.parse(
readFileSync(join(dir, "unit-test-results.json"), "utf8"),
);
}
function writeSelection(files: string[]) {
env.JEST_TEST_PATHS_FILE = join(dir, "unit-specs.json");
writeFileSync(env.JEST_TEST_PATHS_FILE, JSON.stringify(files));
}
it("lets ordinary assertion failures reach quarantine", () => {
expect(
runJest({ "fail.spec.cjs": "test('fails', () => expect(1).toBe(2));" })
.status,
).toBe(1);
expect(findRunnerFailure(env)).toBeNull();
});
it("lets suite hook failures reach quarantine", () => {
expect(
runJest({
"hook.spec.cjs":
"test('passes', () => {}); afterAll(() => { throw new Error('hook'); });",
}).status,
).toBe(1);
expect(readFileSync(join(dir, "junit.xml"), "utf8")).toContain(
"Test execution failure",
);
expect(findRunnerFailure(env)).toBeNull();
});
it("fails a filter crash even though the test step permits errors", () => {
env.JEST_TEST_PATHS_FILE = join(dir, "broken.json");
writeFileSync(env.JEST_TEST_PATHS_FILE, "[");
expect(
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true).status,
).toBe(1);
env.TEST_OUTCOME = "failure";
const result = spawnSync("bun", [checkScript], { env, encoding: "utf8" });
expect(result.status).toBe(1);
expect(result.stderr).toContain("::error::");
const step = steps.find((step) =>
step.run?.includes("check-unit-test-results.ts"),
);
expect(step?.["continue-on-error"]).toBeUndefined();
});
it("fails suite startup errors even alongside ordinary assertion failures", () => {
expect(
runJest({
"crash.spec.cjs": "throw new Error('startup crash');",
"fail.spec.cjs": "test('fails', () => expect(1).toBe(2));",
}).status,
).toBe(1);
expect(readFileSync(join(dir, "junit.xml"), "utf8")).not.toContain(
"crash.spec.cjs",
);
expect(findRunnerFailure(env)).toContain("crash.spec.cjs");
});
it("fails when JUnit results are missing", () => {
runJest({ "fail.spec.cjs": "test('fails', () => expect(1).toBe(2));" });
rmSync(join(dir, "junit.xml"));
expect(findRunnerFailure(env)).not.toBeNull();
});
it.each([true, false])(
"loads the TypeScript filter natively with an empty selection: %s",
(empty) => {
writePlan(plan(empty ? [] : [join(dir, "pass.spec.cjs")]));
prepareTestSelection("unit", env);
env.JEST_TEST_PATHS_FILE = join(dir, "unit-specs.json");
const result = runJest(
{
"pass.spec.cjs": "test('passes', () => {});",
"fail.spec.cjs":
"test('excluded', () => { throw new Error('must not run'); });",
},
true,
);
expect(result.status === 0 ? "" : result.stderr).toBe("");
expect(result.status).toBe(0);
expect(readResults()).toMatchObject({
numTotalTestSuites: empty ? 0 : 1,
});
},
);
it("warns when a narrowed run matches none of Jest's test paths", () => {
writeSelection([join(dir, "renamed.spec.cjs")]);
expect(
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true).status,
).toBe(0);
expect(findEmptySelection(env, jestCommand(true))).toContain(
"None of the 1 selected unit specs",
);
});
it("warns through Bun when the unit test step passed", () => {
writeSelection([join(dir, "renamed.spec.cjs")]);
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true);
env.TEST_OUTCOME = "success";
const result = spawnSync("bun", [checkScript], { env, encoding: "utf8" });
expect(result.status).toBe(0);
expect(result.stderr).toContain(
"::warning::None of the 1 selected unit specs",
);
});
it("does not warn for a shard the selection leaves empty", () => {
writeSelection([join(dir, "pass.spec.cjs")]);
const result = runJest(
{ "pass.spec.cjs": "test('passes', () => {});" },
true,
["--shard=2/2"],
);
expect(result.status).toBe(0);
expect(readResults()).toMatchObject({ numTotalTestSuites: 0 });
expect(findEmptySelection(env, jestCommand(true))).toBeNull();
});
it("does not list tests for an empty selection", () => {
writeSelection([]);
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true);
expect(findEmptySelection(env, ["false"])).toBeNull();
});
});

View File

@@ -1,44 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// eslint-disable-next-line @typescript-eslint/no-require-imports -- Match Jest's direct CommonJS loading contract.
const filter = require("../../frontend/test/jest-test-paths-filter");
describe("jest-test-paths-filter", () => {
const originalPathsFile = process.env.JEST_TEST_PATHS_FILE;
let tempDir: string;
let pathsFile: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "jest-test-paths-"));
pathsFile = path.join(tempDir, "paths.json");
process.env.JEST_TEST_PATHS_FILE = pathsFile;
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
if (originalPathsFile === undefined) {
delete process.env.JEST_TEST_PATHS_FILE;
} else {
process.env.JEST_TEST_PATHS_FILE = originalPathsFile;
}
});
it("should keep the listed paths and drop the rest", () => {
const one = path.resolve("frontend/one.unit.spec.ts");
const two = path.resolve("frontend/two.unit.spec.ts");
const three = path.resolve("frontend/three.unit.spec.ts");
fs.writeFileSync(pathsFile, JSON.stringify([one, two]));
expect(filter([one, three, two])).toEqual({ filtered: [one, two] });
});
it("should resolve relative paths against the working directory", () => {
fs.writeFileSync(pathsFile, JSON.stringify(["frontend/one.unit.spec.ts"]));
const one = path.resolve("frontend/one.unit.spec.ts");
const two = path.resolve("frontend/two.unit.spec.ts");
expect(filter([one, two])).toEqual({ filtered: [one] });
});
});

View File

@@ -1,109 +0,0 @@
async function findLinearIssue({ githubIssueUrl, linearApiKey }) {
const response = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: linearApiKey,
},
body: JSON.stringify({
query: `
query {
attachmentsForURL(url: "${githubIssueUrl}") {
nodes {
issue { identifier }
}
}
}
`,
}),
});
const data = await response.json();
if (!response.ok) {
return { success: false, message: `Linear API error: ${response.status}` };
}
if (!data.data?.attachmentsForURL?.nodes?.length) {
return {
success: false,
message: `No Linear issue linked to ${githubIssueUrl}`,
};
}
return {
success: true,
issue: data.data.attachmentsForURL.nodes[0].issue.identifier,
};
}
async function createLinearIssue({
teamId,
title,
description,
projectId,
labelIds,
linearApiKey,
}) {
const response = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: linearApiKey,
},
body: JSON.stringify({
query: `
mutation CreateIssue($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
identifier
title
url
}
}
}
`,
variables: {
input: {
teamId,
title,
...(description ? { description } : {}),
...(projectId ? { projectId } : {}),
...(labelIds?.length ? { labelIds } : {}),
},
},
}),
});
if (!response.ok) {
return { success: false, message: `Linear API error: ${response.status}` };
}
const data = await response.json();
if (data.errors?.length) {
return {
success: false,
message: `Linear API error: ${data.errors.map((e) => e.message).join(", ")}`,
};
}
if (!data.data?.issueCreate?.success) {
return {
success: false,
message: `Failed to create Linear issue "${title}"`,
};
}
return {
success: true,
issue: data.data.issueCreate.issue,
};
}
module.exports = {
findLinearIssue,
createLinearIssue,
};

View File

@@ -24,15 +24,6 @@ const SDK_ROOT = "resources/frontend_client/app/embedding-sdk";
const SDK_RUNTIME_RE = /embedding-sdk-chunk-runtime\./;
const notRuntime = name => !SDK_RUNTIME_RE.test(name);
// One locale catalogue per chunk, named by the `webpackChunkName: "locale-[request]"`
// comment in localization.ts. A session loads exactly one and an English session
// loads none, so counting all 36 in a reachable total overstates it by 35x: 8.5 MB
// gzip against the SDK's 3.1 MB. Their size also tracks the translation process
// rather than any code change, so counting them makes every catalogue update look
// like a bundle regression.
const LOCALE_CHUNK_RE = /(^|\/)locale-/;
const notLocale = name => !LOCALE_CHUNK_RE.test(name);
const assetFiles = (root, names) => names.map(name => path.join(root, name));
const unique = files => [...new Set(files)];
@@ -125,8 +116,7 @@ function selectAppAssets(stats) {
if (!initialAssets) {
throw new Error('app: stats-main.json / "app-main" entrypoint missing');
}
const reachableAssets = entrypointJsAssets(stats, "app-main", "reachableAssets");
return { initialAssets, reachableAssets: reachableAssets?.filter(notLocale) ?? null };
return { initialAssets, reachableAssets: entrypointJsAssets(stats, "app-main", "reachableAssets") };
}
/**
@@ -146,9 +136,7 @@ function selectLegacySdkAssets(stats, { sdkAsyncChunksLoadable }) {
const initial = unique(initialNames).filter(notRuntime);
return {
initialNames: initial,
reachableNames: sdkAsyncChunksLoadable
? unique(reachableNames).filter(notRuntime).filter(notLocale)
: initial,
reachableNames: sdkAsyncChunksLoadable ? unique(reachableNames).filter(notRuntime) : initial,
};
}
@@ -173,9 +161,7 @@ function selectChunkedSdkAssets(stats, { sdkAsyncChunksLoadable }) {
const bootstrapReachable = entrypointJsAssets(stats, "embedding-sdk-bootstrap", "reachableAssets") || bootstrapInitial;
return {
initialNames: unique([...bootstrapInitial, ...chunkedInitial]).filter(notRuntime),
reachableNames: unique([...bootstrapReachable, ...chunkedReachable])
.filter(notRuntime)
.filter(notLocale),
reachableNames: unique([...bootstrapReachable, ...chunkedReachable]).filter(notRuntime),
includesAsyncChunks: chunkedReachableAssets != null && sdkAsyncChunksLoadable,
};
}

View File

@@ -6,7 +6,6 @@ const {
} = require("./measure-bundle-sizes");
const RUNTIME = "embedding-sdk-chunk-runtime.deadbeef.js";
const LOCALE = "chunks/locale-fr-json.deadbeef.js";
const stats = entrypoints => ({ entrypoints });
@@ -31,13 +30,6 @@ describe("selectAppAssets", () => {
it("throws when the app-main entrypoint is missing", () => {
expect(() => selectAppAssets(stats({}))).toThrow("app-main");
});
it("leaves the locale catalogues out of the reachable total", () => {
const s = stats({
"app-main": { assets: ["main.js"], reachableAssets: ["main.js", "locale-fr-json.abc.js"] },
});
expect(selectAppAssets(s).reachableAssets).toEqual(["main.js"]);
});
});
describe("selectChunkedSdkAssets", () => {
@@ -58,15 +50,6 @@ describe("selectChunkedSdkAssets", () => {
expect(result.includesAsyncChunks).toBe(false);
});
it("leaves the locale catalogues out of the reachable total", () => {
const withLocale = {
...base,
"embedding-sdk-chunked": { assets: ["a.js", RUNTIME], reachableAssets: ["a.js", "b.js", LOCALE, RUNTIME] },
};
const result = selectChunkedSdkAssets(stats(withLocale), { sdkAsyncChunksLoadable: true });
expect(result.reachableNames).toEqual(["boot.js", "a.js", "b.js"]);
});
it("collapses total to initial when the stats carry no reachable graph", () => {
const noGraph = { "embedding-sdk-chunked": { assets: ["a.js"] } };
const result = selectChunkedSdkAssets(stats(noGraph), { sdkAsyncChunksLoadable: true });

View File

@@ -1,122 +0,0 @@
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { TestPlan, TestPlanStats } from "./affected-tests";
type NumberStat = {
[K in keyof TestPlanStats]: TestPlanStats[K] extends number ? K : never;
}[keyof TestPlanStats];
type Suite = {
name: string;
filesKey: Exclude<keyof TestPlan, "stats">;
totalKey: NumberStat;
selectedKey: NumberStat;
pathsFile: string;
};
export const SUITES = {
unit: {
name: "unit",
filesKey: "fe_unit_specs_to_run",
totalKey: "unit_specs_all",
selectedKey: "unit_specs_to_run_usage",
pathsFile: "unit-specs.json",
},
loki: {
name: "Loki",
filesKey: "loki_stories_to_run",
totalKey: "loki_stories_all",
selectedKey: "loki_stories_to_run_usage",
pathsFile: "loki-stories.json",
},
e2e: {
name: "E2E",
filesKey: "e2e_specs_to_run",
totalKey: "e2e_specs_all",
selectedKey: "e2e_specs_to_run_usage",
pathsFile: "e2e-specs.json",
},
} satisfies Record<string, Suite>;
function isSuiteName(name: string): name is keyof typeof SUITES {
return Object.hasOwn(SUITES, name);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isStringArray(value: unknown): value is string[] {
return (
Array.isArray(value) && value.every((item) => typeof item === "string")
);
}
function isCount(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 0;
}
// null means a full run, and [] is an explicit selection of no tests.
function readSelection(planFile: string, suite: Suite): string[] | null {
const plan: unknown = JSON.parse(readFileSync(planFile, "utf8"));
const files = isRecord(plan) ? plan[suite.filesKey] : undefined;
const stats = isRecord(plan) && isRecord(plan.stats) ? plan.stats : {};
const total = stats[suite.totalKey];
if (
!isStringArray(files) ||
files.some((file) => file.length === 0) ||
!isCount(total) ||
files.length !== stats[suite.selectedKey] ||
files.length > total
) {
throw new Error(`Invalid ${suite.name} test selection`);
}
return files.length === total ? null : files;
}
export function prepareTestSelection(
suiteName: keyof typeof SUITES,
env: NodeJS.ProcessEnv = process.env,
) {
const suite: Suite = SUITES[suiteName];
const { RUNNER_TEMP, GITHUB_OUTPUT, PLAN_DOWNLOADED } = env;
if (!RUNNER_TEMP || !GITHUB_OUTPUT) {
throw new Error("RUNNER_TEMP and GITHUB_OUTPUT are required");
}
let files: string[] | null;
try {
if (PLAN_DOWNLOADED !== "success") {
throw new Error("Test plan download failed");
}
files = readSelection(join(RUNNER_TEMP, "test-plan/test-plan.json"), suite);
} catch (error) {
console.warn(
`::warning::Test plan unavailable or invalid, running the full ${suite.name} suite: ${error}`,
);
return;
}
if (files === null) {
appendFileSync(GITHUB_OUTPUT, "selection=full\n");
return;
}
const pathsFile = join(RUNNER_TEMP, suite.pathsFile);
writeFileSync(pathsFile, JSON.stringify(files));
appendFileSync(
GITHUB_OUTPUT,
`paths-file=${pathsFile}\nselection=${files.length === 0 ? "empty" : "narrowed"}\n`,
);
}
if (require.main === module) {
const suiteName = process.argv[2] ?? "";
if (!isSuiteName(suiteName)) {
throw new Error(
`Unknown test suite "${suiteName}", expected one of: ${Object.keys(SUITES).join(", ")}`,
);
}
prepareTestSelection(suiteName);
}

View File

@@ -1,274 +0,0 @@
import { spawnSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { load } from "js-yaml";
import { SUITES, prepareTestSelection } from "./prepare-test-selection";
type Suite = (typeof SUITES)[keyof typeof SUITES];
type Step = {
id?: string;
if?: string;
uses?: string;
run?: string;
env?: Record<string, string>;
with?: Record<string, string | boolean>;
};
const SCRIPT = resolve(__dirname, "prepare-test-selection.ts");
const SUITE_NAMES = ["unit", "loki", "e2e"] satisfies (keyof typeof SUITES)[];
function loadWorkflow(file: string) {
// js-yaml returns an untyped value, and these repository workflows define jobs with steps.
return load(
readFileSync(resolve(__dirname, "../workflows", file), "utf8"),
) as {
jobs: Record<
string,
{ if?: string; outputs?: Record<string, string>; steps?: Step[] }
>;
};
}
function loadAction() {
// js-yaml returns an untyped value, and this repository action defines outputs and steps.
return load(
readFileSync(
resolve(__dirname, "../actions/prepare-test-selection/action.yml"),
"utf8",
),
) as {
outputs: Record<string, { value: string }>;
runs: { steps: Step[] };
};
}
describe("prepareTestSelection", () => {
const { loki } = SUITES;
let dir: string;
let env: NodeJS.ProcessEnv;
beforeEach(() => {
jest.spyOn(console, "warn").mockImplementation(() => {});
dir = mkdtempSync(join(tmpdir(), "test-selection-"));
mkdirSync(join(dir, "test-plan"));
writeFileSync(join(dir, "output"), "");
env = {
...process.env,
RUNNER_TEMP: dir,
GITHUB_OUTPUT: join(dir, "output"),
PLAN_DOWNLOADED: "success",
};
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});
function plan(files: unknown, total = 2, suite: Suite = loki) {
return {
[suite.filesKey]: files,
stats: {
[suite.totalKey]: total,
[suite.selectedKey]: Array.isArray(files) ? files.length : 0,
},
};
}
function writePlan(value: unknown) {
writeFileSync(join(dir, "test-plan/test-plan.json"), JSON.stringify(value));
}
const output = () => readFileSync(join(dir, "output"), "utf8");
it.each([{ files: ["one.spec.cjs"] }, { files: [] }])(
"preserves an explicit selection: $files",
({ files }) => {
writePlan(plan(files));
prepareTestSelection("loki", env);
expect(output()).toBe(
`paths-file=${join(dir, loki.pathsFile)}\nselection=${files.length > 0 ? "narrowed" : "empty"}\n`,
);
expect(
JSON.parse(readFileSync(join(dir, loki.pathsFile), "utf8")),
).toEqual(files);
},
);
it("writes the selection when invoked through Bun in CI", () => {
writePlan(plan(["one.spec.cjs"]));
const result = spawnSync("bun", [SCRIPT, "loki"], {
env,
encoding: "utf8",
});
expect(result.status === 0 ? "" : result.stderr).toBe("");
expect(result.status).toBe(0);
expect(output()).toBe(
`paths-file=${join(dir, loki.pathsFile)}\nselection=narrowed\n`,
);
});
it("writes no paths file for a full selection", () => {
writePlan(plan(["one.spec.cjs", "two.spec.cjs"]));
prepareTestSelection("loki", env);
expect(output()).toBe("selection=full\n");
});
it.each([
null,
{},
plan("[]"),
plan([null]),
{ ...plan([]), [loki.filesKey]: ["one.spec.cjs"] },
plan([""]),
plan(["one.spec.cjs"], 0),
])("runs in full for an invalid plan: %j", (value) => {
writePlan(value);
prepareTestSelection("loki", env);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("::warning::"),
);
expect(output()).toBe("");
});
it.each([
{ scenario: "the test plan is missing", setup: () => {} },
{
scenario: "the test plan is truncated",
setup: () => {
writeFileSync(
join(dir, "test-plan/test-plan.json"),
`{"${loki.filesKey}":[`,
);
},
},
{
scenario: "the download failed",
setup: () => {
writePlan(plan([])); // Even a partial download must not be trusted.
env.PLAN_DOWNLOADED = "failure";
},
},
])("runs in full when $scenario", ({ setup }) => {
setup();
prepareTestSelection("loki", env);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("::warning::"),
);
expect(output()).toBe("");
});
it.each(SUITE_NAMES)(
"reads the %s selection from its own plan keys",
(suiteName) => {
const suite = SUITES[suiteName];
writePlan(plan(["one.spec.cjs"], 2, suite));
prepareTestSelection(suiteName, env);
expect(output()).toBe(
`paths-file=${join(dir, suite.pathsFile)}\nselection=narrowed\n`,
);
expect(
JSON.parse(readFileSync(join(dir, suite.pathsFile), "utf8")),
).toEqual(["one.spec.cjs"]);
},
);
it("ignores the other suites' selections", () => {
writePlan({
...plan(["one.spec.cjs", "two.spec.cjs"]),
[SUITES.unit.filesKey]: [],
[SUITES.e2e.filesKey]: [],
});
prepareTestSelection("loki", env);
expect(console.warn).not.toHaveBeenCalled();
expect(output()).toBe("selection=full\n");
});
it("rejects an unknown suite", () => {
const result = spawnSync("bun", [SCRIPT, "toString"], {
env,
encoding: "utf8",
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('Unknown test suite "toString"');
});
});
describe("test selection workflow steps", () => {
const ACTION = "./.github/actions/prepare-test-selection";
const CONSUMERS: Record<
string,
{ suite: keyof typeof SUITES; "require-download"?: boolean }
> = {
"frontend.yml": { suite: "unit" },
"loki.yml": { suite: "loki" },
"e2e-matrix-builder.yml": { suite: "e2e" },
// The matrix was sized for the planned specs, so a shard fails when the plan download fails.
"e2e-test.yml": { suite: "e2e", "require-download": true },
};
it("skips Loki visual tests only for an empty story selection", () => {
const { jobs } = loadWorkflow("loki.yml");
expect(jobs["story-selection"].outputs?.selection).toBe(
"${{ steps.loki-selection.outputs.selection }}",
);
expect(jobs["visual-test"].if).toContain(
"needs.story-selection.outputs.selection != 'empty'",
);
expect(jobs["visual-test"].if).not.toMatch(/outputs\.[\w-]+ *[!=]= *'\d+'/);
});
it.each(Object.entries(CONSUMERS))(
"selects tests through the composite action in %s",
(file, { suite, ...inputs }) => {
const steps = Object.values(loadWorkflow(file).jobs).flatMap(
({ steps = [] }) => steps.filter((step) => step.uses === ACTION),
);
expect(steps.length).toBeGreaterThan(0);
for (const step of steps) {
expect(step.with).toEqual({
suite,
"test-plan-artifact-id": expect.stringMatching(/^\$\{\{ .+ \}\}$/),
...inputs,
});
}
},
);
it("downloads the test plan before preparing the selection", () => {
const { outputs, runs } = loadAction();
const [download, prepare] = runs.steps;
expect(download).toMatchObject({
uses: expect.stringMatching(/^actions\/download-artifact@/),
"continue-on-error": "${{ inputs.require-download != 'true' }}",
with: {
"artifact-ids": "${{ inputs.test-plan-artifact-id }}",
path: "${{ runner.temp }}/test-plan",
},
});
expect(prepare).toMatchObject({
env: { PLAN_DOWNLOADED: `\${{ steps.${download.id}.outcome }}` },
run: expect.stringContaining(
'prepare-test-selection.ts "${{ inputs.suite }}"',
),
});
expect(outputs).toMatchObject({
"paths-file": {
value: `\${{ steps.${prepare.id}.outputs.paths-file }}`,
},
selection: { value: `\${{ steps.${prepare.id}.outputs.selection }}` },
});
});
});

View File

@@ -1,34 +0,0 @@
const { readFileSync } = require("node:fs");
const { readSpecPaths } = require("../../e2e/runner/read-spec-paths");
jest.mock("node:fs", () => ({ readFileSync: jest.fn() }));
describe("readSpecPaths", () => {
it("should use the full suite when no paths file is provided", () => {
expect(readSpecPaths("")).toBeNull();
expect(readSpecPaths(undefined)).toBeNull();
});
it("should preserve an empty selection", () => {
readFileSync.mockReturnValue("[]");
expect(readSpecPaths("specs.json")).toEqual([]);
});
it("should preserve selected paths, including spaces and commas", () => {
const files = ["e2e/test/a.cy.spec.js", "e2e/test/a folder/b,c.cy.spec.ts"];
readFileSync.mockReturnValue(JSON.stringify(files));
expect(readSpecPaths("specs.json")).toEqual(files);
});
it.each(["{", "null", '""', "{}", "[null]", '[""]'])(
"should reject invalid spec paths: %s",
(contents) => {
readFileSync.mockReturnValue(contents);
expect(() => readSpecPaths("specs.json")).toThrow();
},
);
});

View File

@@ -1,169 +0,0 @@
#!/usr/bin/env bash
#
# Run this runner's share of a partitioned backend test suite as one or more concurrent
# JVMs. The caller says how many runners share the suite and how many JVMs each gets.
#
# WHY MULTIPLE JVMS PER RUNNER
# ----------------------------
# Driver tests spend most of their wall time waiting on the remote warehouse, and they
# can't use the idle CPU by using more threads because some tests mutate global state.
# A second JVM gets that isolation for free.
#
# Two paths are NOT safe to share:
# * `target/junit` — hawk deletes the whole directory at `:begin-test-run`, so a JVM
# starting late would wipe an earlier one's results.
# * `logs/test-log.json` — the log4j2 File appender truncates it on open.
# So each JVM runs in its own hardlinked copy of the checkout (inodes, not disk) and
# outputs are merged back into the workspace afterwards.
#
# INPUTS (environment)
# --------------------
# CLOJURE_ALIASES alias string for `clojure -X`, e.g. dev:ci:ee:ee-dev:drivers:drivers-dev:test
# TEST_RUNNER_INDEX index of this runner among those sharing the suite
# TEST_RUNNER_COUNT how many runners share the suite
# TEST_JVMS_PER_RUNNER concurrent JVMs per runner (default 1)
# TEST_HEAP_PER_JVM -Xmx per JVM, applied only when running more than one
#
# Exits non-zero if any JVM fails.
set -uo pipefail
: "${CLOJURE_ALIASES:?CLOJURE_ALIASES is required}"
: "${TEST_RUNNER_INDEX:?TEST_RUNNER_INDEX is required}"
: "${TEST_RUNNER_COUNT:?TEST_RUNNER_COUNT is required}"
TEST_ARGS=("$@")
TEST_JVMS_PER_RUNNER="${TEST_JVMS_PER_RUNNER:-1}"
TEST_HEAP_PER_JVM="${TEST_HEAP_PER_JVM:-4g}"
die() {
echo "::error::run-backend-test-jvms.sh: $1"
exit 1
}
[[ "$TEST_RUNNER_INDEX" =~ ^[0-9]+$ ]] || die "TEST_RUNNER_INDEX must be a non-negative integer, got '$TEST_RUNNER_INDEX'"
[[ "$TEST_RUNNER_COUNT" =~ ^[0-9]+$ ]] || die "TEST_RUNNER_COUNT must be a positive integer, got '$TEST_RUNNER_COUNT'"
[[ "$TEST_JVMS_PER_RUNNER" =~ ^[0-9]+$ ]] || die "TEST_JVMS_PER_RUNNER must be a positive integer, got '$TEST_JVMS_PER_RUNNER'"
[ "$TEST_RUNNER_COUNT" -ge 1 ] || die "TEST_RUNNER_COUNT must be at least 1"
[ "$TEST_JVMS_PER_RUNNER" -ge 1 ] || die "TEST_JVMS_PER_RUNNER must be at least 1"
[ "$TEST_RUNNER_INDEX" -lt "$TEST_RUNNER_COUNT" ] ||
die "runner index $TEST_RUNNER_INDEX is out of range for $TEST_RUNNER_COUNT runner(s) — a matrix entry was added without raising runner-count"
# Partition indexes are split across runners rather than handed out in contiguous
# blocks. E.g. with 2 runners and 2 JVMs the first runner gets partition 0 and 2, and
# the second runner gets partitions 1 and 3. Hawk partitions in namespace order, so
# neighbouring partitions have similar runtimes — contiguous blocks would group the slow
# ones together onto one runner.
PARTITION_TOTAL=$((TEST_RUNNER_COUNT * TEST_JVMS_PER_RUNNER))
INDEXES=()
for ((k = 0; k < TEST_JVMS_PER_RUNNER; k++)); do
INDEXES+=($((TEST_RUNNER_INDEX + k * TEST_RUNNER_COUNT)))
done
# One JVM: run directly in the workspace with no heap override, exactly as a
# partitioned run behaved before this script existed.
if [ "$TEST_JVMS_PER_RUNNER" -eq 1 ]; then
echo "Running partition ${INDEXES[0]} of $PARTITION_TOTAL"
exec clojure -X:"$CLOJURE_ALIASES" "${TEST_ARGS[@]}" \
:partition/total "$PARTITION_TOTAL" \
:partition/index "${INDEXES[0]}"
fi
WORKSPACE="$PWD"
# Sibling of the checkout: same filesystem (so `cp -al` can hardlink) but outside the
# repo, so one JVM's directory never gets copied into another's.
JVM_DIRS_ROOT="$(dirname "$WORKSPACE")/backend-test-jvms"
STATUS_DIR="$JVM_DIRS_ROOT/status"
# Merge every JVM's results into the workspace the rest of the job reads from.
#
# Each partition's junit XML goes into its own subdirectory: `mb_hawk_var_less_errors.xml`
# has a fixed name, so a flat copy would silently overwrite it. Nesting is safe because
# both consumers (dorny/test-reporter and ci-conductor's backend adapter) walk
# `target/junit` recursively and match on filename suffix.
#
# Runs from an EXIT trap so a cancelled or timed-out job still publishes whatever
# finished.
# shellcheck disable=SC2329 # invoked by the EXIT trap below, which shellcheck can't see.
merge_outputs() {
mkdir -p "$WORKSPACE/target/junit" "$WORKSPACE/logs"
local idx jvm_dir
for idx in "${INDEXES[@]}"; do
jvm_dir="$JVM_DIRS_ROOT/partition-$idx"
if [ -d "$jvm_dir/target/junit" ]; then
mkdir -p "$WORKSPACE/target/junit/p$idx"
cp -a "$jvm_dir/target/junit/." "$WORKSPACE/target/junit/p$idx/" || true
fi
# `logs/test-log*.json` is what the action uploads on failure.
if [ -f "$jvm_dir/logs/test-log.json" ]; then
cp -a "$jvm_dir/logs/test-log.json" "$WORKSPACE/logs/test-log-p$idx.json" || true
fi
# Flamegraphs, when the run was profiled. Flat with a `p$idx-` prefix, not nested: the
# collect-profile action globs `results/*flamegraph.html` without descending.
if [ -d "$jvm_dir/target/clj-async-profiler/results" ]; then
mkdir -p "$WORKSPACE/target/clj-async-profiler/results"
for f in "$jvm_dir"/target/clj-async-profiler/results/*flamegraph.html; do
[ -e "$f" ] || continue
cp -a "$f" "$WORKSPACE/target/clj-async-profiler/results/p$idx-$(basename "$f")" || true
done
fi
done
}
trap merge_outputs EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
# Hardlinked copy of the checkout. Skips the paths a run writes to (`target`, `logs`, `.cpcache`)
# and the two most expensive trees nothing on the test classpath reads (`.git`, `node_modules`).
prepare_jvm_dir() {
local jvm_dir="$1" entry
rm -rf "$jvm_dir"
mkdir -p "$jvm_dir"
for entry in "$WORKSPACE"/* "$WORKSPACE"/.[!.]*; do
[ -e "$entry" ] || continue
case "$(basename "$entry")" in
.git | node_modules | target | logs | .cpcache) continue ;;
esac
# Fall back to a real copy if the filesystem refuses the hardlink.
cp -al "$entry" "$jvm_dir/" 2>/dev/null || cp -a "$entry" "$jvm_dir/"
done
}
# Prefix output with the partition index so interleaved JVM logs stay readable.
run_partition() {
local idx="$1" jvm_dir="$2" rc line
(
cd "$jvm_dir" || exit 1
# `-J` options land after the aliases' `:jvm-opts`, overriding the `:ci` alias's
# `-Xms12g -Xmx12g`. Xms stays small so JVMs sharing a runner grow into memory on
# demand rather than each committing its ceiling up front.
exec clojure -J-Xms512m -J-Xmx"$TEST_HEAP_PER_JVM" -X:"$CLOJURE_ALIASES" \
"${TEST_ARGS[@]}" \
:partition/total "$PARTITION_TOTAL" \
:partition/index "$idx"
) 2>&1 | while IFS= read -r line || [ -n "$line" ]; do printf '[p%s] %s\n' "$idx" "$line"; done
rc="${PIPESTATUS[0]}"
echo "$rc" > "$STATUS_DIR/p$idx"
}
rm -rf "$JVM_DIRS_ROOT"
mkdir -p "$STATUS_DIR"
echo "Runner $TEST_RUNNER_INDEX of $TEST_RUNNER_COUNT: running $TEST_JVMS_PER_RUNNER concurrent JVMs for partition(s) ${INDEXES[*]} of $PARTITION_TOTAL (-Xmx$TEST_HEAP_PER_JVM each)"
for idx in "${INDEXES[@]}"; do
prepare_jvm_dir "$JVM_DIRS_ROOT/partition-$idx"
done
for idx in "${INDEXES[@]}"; do
run_partition "$idx" "$JVM_DIRS_ROOT/partition-$idx" &
done
wait
overall=0
for idx in "${INDEXES[@]}"; do
rc="$(cat "$STATUS_DIR/p$idx" 2>/dev/null || echo 1)"
echo "partition $idx exited $rc"
[ "$rc" -eq 0 ] || overall=1
done
exit "$overall"

View File

@@ -1,140 +0,0 @@
// Pushes stats rows to eng-stats-importer, which writes them to Postgres.
//
// The importer's origin comes from ENG_STATS_URL and its key from API_KEY, both
// repository secrets — this repo is public, so neither is hardcoded here. Visit
// the importer to see the tables and mint keys.
// Transient failures worth retrying: throttling and server-side errors. A 4xx
// (bad request, auth, unknown table) won't succeed on retry, so those fail fast.
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
/**
* One row, keyed by column. Values are `unknown` rather than a scalar union
* because a `json` column takes arbitrary nested data.
*/
export type StatsRow = Record<string, unknown>;
export interface ImportStatsOptions {
/** Table name as it appears in `/api/import/{table}`, e.g. `emotion_files`. */
table: string;
rows: StatsRow[];
/** Importer origin. Defaults to ENG_STATS_URL. */
baseUrl?: string;
/** Extra attempts after the first, so the default 2 means up to 3 tries. */
retries?: number;
retryDelayMs?: number;
/** Per-attempt total deadline; aborts a stalled request. */
timeoutMs?: number;
}
export interface ImportStatsResult {
success: true;
inserted?: number;
/** Payload keys with no matching column, when `ignoreUnknown` is in play. */
ignoredKeys?: string[];
}
class ImportError extends Error {
retryable: boolean;
constructor(message: string, { retryable }: { retryable: boolean }) {
super(message);
this.retryable = retryable;
}
}
async function attemptImport(
url: string,
rows: StatsRow[],
timeoutMs: number,
): Promise<ImportStatsResult> {
let response: Response;
try {
response = await fetch(url, {
method: "PUT",
headers: {
"x-api-key": process.env.API_KEY ?? "",
"content-type": "application/json",
},
body: JSON.stringify(rows),
// Without this the request can hang indefinitely on a stalled server,
// eating the whole job timeout. An abort surfaces below as a retryable
// network error, so it retries with backoff instead.
signal: AbortSignal.timeout(timeoutMs),
});
} catch (networkError) {
const message =
networkError instanceof Error ? networkError.message : String(networkError);
throw new ImportError(message, { retryable: true });
}
if (!response.ok) {
// Failures carry an `{ error }` body naming the offending row and column,
// which is the only way to tell a type mismatch from a typo'd key.
const body = await response.text().catch(() => "");
throw new ImportError(
`Import failed: ${response.status} ${response.statusText} ${body}`.trim(),
{ retryable: RETRYABLE_STATUS.has(response.status) },
);
}
// Best-effort: the row count is for logging, so a body that doesn't parse
// shouldn't turn a successful import into a failure.
const result = (await response.json().catch(() => ({}))) as {
inserted?: number;
ignoredKeys?: string[];
};
return { success: true, inserted: result.inserted, ignoredKeys: result.ignoredKeys };
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Imports `rows` into the `table` table, retrying transient API errors with
* exponential backoff.
*
* Keys are matched leniently against the table's columns -- `Emotion Files`,
* `emotionFiles` and `emotion_files` all land in `emotion_files` -- but values
* are matched strictly, and a key matching no column is an error rather than a
* silent drop. Rows are appended; the importer has no replace mode.
*
* Throws the last error once attempts are exhausted or the error is
* non-retryable.
*/
export async function importStats({
table,
rows,
baseUrl = process.env.ENG_STATS_URL,
retries = 2,
retryDelayMs = 1000,
timeoutMs = 60_000,
}: ImportStatsOptions): Promise<ImportStatsResult> {
// The importer accepts an empty payload, but there's no point spending a
// request (and a rate-limit slot) to insert nothing.
if (rows.length === 0) {
return { success: true, inserted: 0 };
}
if (!baseUrl) {
throw new Error("ENG_STATS_URL is not set, so there is no importer to push to");
}
// Tolerate a trailing slash: the origin comes from a hand-entered secret.
const url = `${baseUrl.replace(/\/+$/, "")}/api/import/${table}`;
for (let attempt = 0; ; attempt++) {
try {
return await attemptImport(url, rows, timeoutMs);
} catch (error) {
const importError = error as ImportError;
if (!importError.retryable || attempt >= retries) {
throw error;
}
const delay = retryDelayMs * 2 ** attempt;
console.warn(
`Import attempt ${attempt + 1} failed (${importError.message}); retrying in ${delay}ms`,
);
await sleep(delay);
}
}
}

View File

@@ -1,160 +0,0 @@
import { importStats } from "./stats-import";
const ok = (body: unknown = { inserted: 1 }) => ({ ok: true, json: async () => body });
const fail = (status: number) => ({
ok: false,
status,
statusText: "err",
text: async () => "",
});
const load = (over: Record<string, unknown> = {}) =>
importStats({
baseUrl: "https://stats.example.com",
table: "emotion_files",
rows: [{ a: 1 }],
retryDelayMs: 1,
...over,
});
describe("importStats requests", () => {
afterEach(() => {
// @ts-expect-error -- removing the stub the tests installed
delete global.fetch;
delete process.env.API_KEY;
delete process.env.ENG_STATS_URL;
});
it("defaults the origin to ENG_STATS_URL", async () => {
const fetchMock = jest.fn().mockResolvedValue(ok());
global.fetch = fetchMock;
process.env.ENG_STATS_URL = "https://importer.example.com";
await load({ baseUrl: undefined });
expect(fetchMock.mock.calls[0][0]).toBe(
"https://importer.example.com/api/import/emotion_files",
);
});
it("tolerates a trailing slash on the origin", async () => {
const fetchMock = jest.fn().mockResolvedValue(ok());
global.fetch = fetchMock;
await load({ baseUrl: "https://importer.example.com/" });
expect(fetchMock.mock.calls[0][0]).toBe(
"https://importer.example.com/api/import/emotion_files",
);
});
it("fails loudly when no origin is configured", async () => {
const fetchMock = jest.fn();
global.fetch = fetchMock;
await expect(load({ baseUrl: undefined })).rejects.toThrow("ENG_STATS_URL is not set");
expect(fetchMock).not.toHaveBeenCalled();
});
it("PUTs the rows as JSON to the table's import endpoint", async () => {
const fetchMock = jest.fn().mockResolvedValue(ok());
global.fetch = fetchMock;
process.env.API_KEY = "secret";
await expect(load({ rows: [{ a: 1 }, { a: 2 }] })).resolves.toEqual({
success: true,
inserted: 1,
ignoredKeys: undefined,
});
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://stats.example.com/api/import/emotion_files");
expect(options.method).toBe("PUT");
expect(options.headers).toMatchObject({
"x-api-key": "secret",
"content-type": "application/json",
});
expect(JSON.parse(options.body)).toEqual([{ a: 1 }, { a: 2 }]);
});
it("skips the request entirely when there are no rows", async () => {
const fetchMock = jest.fn();
global.fetch = fetchMock;
await expect(load({ rows: [] })).resolves.toEqual({ success: true, inserted: 0 });
expect(fetchMock).not.toHaveBeenCalled();
});
it("succeeds even if the response body does not parse", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => {
throw new Error("not json");
},
});
await expect(load()).resolves.toMatchObject({ success: true });
});
});
describe("importStats retries", () => {
afterEach(() => {
// @ts-expect-error -- removing the stub the tests installed
delete global.fetch;
});
it("retries transient server errors then succeeds", async () => {
const fetchMock = jest
.fn()
.mockResolvedValueOnce(fail(503))
.mockResolvedValueOnce(fail(500))
.mockResolvedValueOnce(ok());
global.fetch = fetchMock;
await expect(load({ retries: 2 })).resolves.toMatchObject({ success: true });
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("retries throttling", async () => {
const fetchMock = jest.fn().mockResolvedValueOnce(fail(429)).mockResolvedValueOnce(ok());
global.fetch = fetchMock;
await expect(load({ retries: 2 })).resolves.toMatchObject({ success: true });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("retries network errors", async () => {
const fetchMock = jest
.fn()
.mockRejectedValueOnce(new Error("ECONNRESET"))
.mockResolvedValueOnce(ok());
global.fetch = fetchMock;
await expect(load({ retries: 2 })).resolves.toMatchObject({ success: true });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("gives up after exhausting retries and throws the last error", async () => {
const fetchMock = jest.fn().mockResolvedValue(fail(500));
global.fetch = fetchMock;
await expect(load({ retries: 2 })).rejects.toThrow("Import failed: 500");
expect(fetchMock).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
});
it("does not retry a non-transient 4xx", async () => {
const fetchMock = jest.fn().mockResolvedValue(fail(400));
global.fetch = fetchMock;
await expect(load({ retries: 2 })).rejects.toThrow("Import failed: 400");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does not retry an unknown table", async () => {
const fetchMock = jest.fn().mockResolvedValue(fail(404));
global.fetch = fetchMock;
await expect(load({ retries: 2 })).rejects.toThrow("Import failed: 404");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,226 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import micromatch, { isMatch } from "micromatch";
import { normalizeStories } from "storybook/internal/common";
import {
MAIN_APP_STORY_GLOBS,
getStories,
hasExplicitTitle,
} from "../../.storybook/story-files.cjs";
const ROOT = resolve(__dirname, "../..");
const tracked = execFileSync(
"git",
["ls-files", "-z", "--", "frontend", "enterprise/frontend"],
{ cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
)
.split("\0")
.filter(Boolean);
const planned = micromatch(tracked, MAIN_APP_STORY_GLOBS, { dot: true }).sort();
describe("getStories", () => {
let tempDir: string;
let pathsFile: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "story-files-"));
pathsFile = join(tempDir, "stories.json");
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("loads the main app stories and docs when there is no plan", () => {
const patterns = getStories({ pathsFile: "", filter: "" });
expect(
isMatch("../frontend/src/metabase/ui/Button.stories.tsx", patterns),
).toBe(true);
expect(isMatch("../frontend/src/metabase/ui/Button.mdx", patterns)).toBe(
true,
);
expect(
isMatch(
"../frontend/src/embedding-sdk-shared/Button.stories.tsx",
patterns,
),
).toBe(false);
});
it("builds only the files in the JSON plan", () => {
const files = planned.slice(0, 2);
writeFileSync(pathsFile, JSON.stringify(files));
expect(getStories({ pathsFile })).toEqual(
files.map((file) => `../${file}`),
);
});
it("builds a planned story with an explicit title", () => {
const story = join(tempDir, "Titled.stories.tsx");
writeFileSync(
story,
'const meta = { title: "Components/Titled" };\nexport default meta;\n',
);
writeFileSync(pathsFile, JSON.stringify([story]));
expect(getStories({ pathsFile })).toEqual([`../${story}`]);
});
it("rejects a planned story without an explicit title", () => {
const story = join(tempDir, "Untitled.stories.tsx");
writeFileSync(story, "export default { component: Untitled };\n");
writeFileSync(pathsFile, JSON.stringify([story]));
expect(() => getStories({ pathsFile })).toThrow(story);
});
it("rejects an untitled Alert story even though its args have a title", () => {
const source = readFileSync(
join(
ROOT,
"frontend/src/metabase/ui/components/feedback/Alert/Alert.stories.tsx",
),
"utf8",
).replace(' title: "Components/Feedback/Alert",\n', "");
const story = join(tempDir, "Alert.stories.tsx");
writeFileSync(story, source);
writeFileSync(pathsFile, JSON.stringify([story]));
expect(() => getStories({ pathsFile })).toThrow(story);
});
it("rejects an empty plan even when the CSV filter is set", () => {
writeFileSync(pathsFile, "[]");
expect(() =>
getStories({ pathsFile, filter: "frontend/src/Unselected.stories.tsx" }),
).toThrow("No stories selected");
});
it("preserves the stress test's CSV filter", () => {
expect(
getStories({
filter: "frontend/One.stories.tsx,frontend/Two.stories.tsx",
}),
).toEqual(["../frontend/One.stories.tsx", "../frontend/Two.stories.tsx"]);
});
it.each(["{", "null", '""', "{}", "[null]"])(
"rejects an invalid plan: %s",
(contents) => {
writeFileSync(pathsFile, contents);
expect(() => getStories({ pathsFile })).toThrow();
},
);
});
describe("hasExplicitTitle", () => {
it.each([
{
form: "double quotes",
source: 'export default {\n title: "Components/Button",\n};\n',
},
{
form: "single quotes",
source: "export default {\n title: 'Components/Button',\n};\n",
},
{
form: "a title constant",
source: 'const title = "Components/Button"; export default { title };',
},
{
form: "a meta constant",
source:
'const meta: Meta<typeof Button> = {\n title: "Components/Button",\n};\n\nexport default meta;\n',
},
{
form: "a satisfies expression",
source:
'const meta = { title: "Components/Button" } satisfies Meta<typeof Button>; export default meta;',
},
])("accepts a title in $form", ({ source }) => {
expect(hasExplicitTitle(source)).toBe(true);
});
it.each([
{
form: "no title",
source: "export default {\n component: Button,\n};\n",
},
{
form: "only a title control",
source:
'const argTypes = {\n title: {\n control: { type: "text" },\n },\n};\n\nexport default { component: Button, argTypes };\n',
},
{
form: "only a title variable",
source:
"export default {\n component: Button,\n args: {\n title: defaultTitle,\n },\n};\n",
},
{
form: "only a string-valued args title",
source: 'export default { args: { title: "Button label" } };',
},
{
form: "only another object's title",
source:
'const other = { title: "Other" }; export default { component: Button };',
},
{
form: "only a commented title",
source:
'export default {\n // title: "Components/Button",\n component: Button };',
},
{
form: "an empty title",
source: 'export default { title: "" };',
},
{
form: "a template literal unsupported by Storybook",
source: "export default { title: `Components/Button` };",
},
])("rejects $form", ({ source }) => {
expect(hasExplicitTitle(source)).toBe(false);
});
});
describe("MAIN_APP_STORY_GLOBS", () => {
it("matches the same tracked files as Storybook's own story matcher", () => {
const specifiers = normalizeStories(getStories(), {
configDir: join(ROOT, ".storybook"),
workingDir: ROOT,
});
const built = tracked
.filter(
(file) =>
!file.endsWith(".mdx") &&
specifiers.some((specifier) =>
specifier.importPathMatcher.test(`./${file}`),
),
)
.sort();
expect(planned.length).toBeGreaterThan(0);
expect(planned).toEqual(built);
});
it("matches no docs entries", () => {
expect(planned.filter((file) => file.endsWith(".mdx"))).toEqual([]);
});
it("matches only stories with an explicit title", () => {
const untitled = planned.filter(
(file) => !hasExplicitTitle(readFileSync(join(ROOT, file), "utf8")),
);
expect(untitled).toEqual([]);
});
});

View File

@@ -0,0 +1,64 @@
// Appends the test-plan stats row produced by create-test-plan.ts to the
// "FE Affected Tests" table on stats.metabase.com.
// Reads STATS_JSON, TRIGGERED_BY, PR_NUMBER, HEAD_SHA, BASE_SHA, API_KEY from env.
const { uploadCsvToMb } = require("./csv-to-mb.js");
// "FE Affected Tests" uploaded table on stats.metabase.com.
const TABLE_ID = 81818;
async function main() {
// create-test-plan doesn't always produce stats (e.g. on release branches
// running an older planner), leaving STATS_JSON empty. Nothing to upload.
const raw = (process.env.STATS_JSON || "").trim();
if (!raw || raw === "null") {
console.log("No STATS_JSON to upload; skipping.");
return;
}
const s = JSON.parse(raw);
console.log("stats", s);
const row = {
Date: new Date().toISOString(),
// "pr_update" or "merge_to_master". ("Trigger" is a reserved SQL word.)
"Triggered By": process.env.TRIGGERED_BY,
PR: Number(process.env.PR_NUMBER),
"Head SHA": process.env.HEAD_SHA,
"Base SHA": process.env.BASE_SHA,
"FE Files Changed": s.fe_files_changed,
"FE Files Total": s.fe_files_total,
"BE Files Changed": s.be_files_changed,
"BE Files Total": s.be_files_total,
"Unit Infra Touched": s.unit_infra_touched,
"Loki Infra Touched": s.loki_infra_touched,
"Shared Sources Touched": s.shared_sources_touched,
"FE Modules Total": s.fe_modules_total,
"FE Modules Changed": s.fe_modules_changed,
"FE Modules Affected (Rules)": s.fe_modules_affected_rules,
"FE Modules Affected (Usage)": s.fe_modules_affected_usage,
"Unit Specs All": s.unit_specs_all,
"Unit Specs To Run (Rules)": s.unit_specs_to_run_rules,
"Unit Specs To Run (Usage)": s.unit_specs_to_run_usage,
"Loki Stories All": s.loki_stories_all,
"Loki Stories To Run (Rules)": s.loki_stories_to_run_rules,
"Loki Stories To Run (Usage)": s.loki_stories_to_run_usage,
"E2E Specs All": s.e2e_specs_all,
"E2E Specs To Run (Rules)": s.e2e_specs_to_run_rules,
"E2E Specs To Run (Usage)": s.e2e_specs_to_run_usage,
};
await uploadCsvToMb({
baseUrl: "https://stats.metabase.com",
tableId: TABLE_ID,
jsonData: [row],
mode: "append",
});
console.log("Data uploaded successfully");
}
// Best-effort: never let a telemetry failure turn the job red.
main().catch((error) => {
console.error("Skipping stats upload after error:", error);
process.exit(0);
});

View File

@@ -1,62 +0,0 @@
// Appends the test-plan stats row produced by create-test-plan.ts to the
// "FE Affected Tests" table in eng-stats-importer.
// Reads STATS_JSON, PR_NUMBER, HEAD_SHA, BASE_SHA, API_KEY from env.
import type { TestPlanStats } from "./affected-tests";
import { importStats } from "./stats-import";
const TABLE = "fe_affected_tests";
async function main() {
// create-test-plan doesn't always produce stats (e.g. on release branches
// running an older planner), leaving STATS_JSON empty. Nothing to upload.
const raw = (process.env.STATS_JSON || "").trim();
if (!raw || raw === "null") {
console.log("No STATS_JSON to upload; skipping.");
return;
}
const s = JSON.parse(raw) as TestPlanStats;
console.log("stats", s);
const row = {
Date: new Date().toISOString(),
// ("Trigger" is a reserved SQL word.)
"Triggered By": "pr_update",
PR: Number(process.env.PR_NUMBER),
"Head SHA": process.env.HEAD_SHA,
"Base SHA": process.env.BASE_SHA,
"FE Files Changed": s.fe_files_changed,
"FE Files Total": s.fe_files_total,
"BE Files Changed": s.be_files_changed,
"BE Files Total": s.be_files_total,
"Unit Infra Touched": s.unit_infra_touched,
"Loki Infra Touched": s.loki_infra_touched,
"Shared Sources Touched": s.shared_sources_touched,
"FE Modules Total": s.fe_modules_total,
"FE Modules Changed": s.fe_modules_changed,
"FE Modules Affected (Rules)": s.fe_modules_affected_rules,
"FE Modules Affected (Usage)": s.fe_modules_affected_usage,
"Unit Specs All": s.unit_specs_all,
"Unit Specs To Run (Rules)": s.unit_specs_to_run_rules,
"Unit Specs To Run (Usage)": s.unit_specs_to_run_usage,
"Loki Stories All": s.loki_stories_all,
"Loki Stories To Run (Rules)": s.loki_stories_to_run_rules,
"Loki Stories To Run (Usage)": s.loki_stories_to_run_usage,
// Spelled as the column name rather than a display name: the importer
// splits a digit-then-capital run, so "E2E Specs All" would normalize to
// `e2_e_specs_all` instead of `e2e_specs_all`.
e2e_specs_all: s.e2e_specs_all,
e2e_specs_to_run_rules: s.e2e_specs_to_run_rules,
e2e_specs_to_run_usage: s.e2e_specs_to_run_usage,
};
await importStats({ table: TABLE, rows: [row] });
console.log("Data uploaded successfully");
}
// Best-effort: never let a telemetry failure turn the job red.
main().catch((error) => {
console.error("Skipping stats upload after error:", error);
process.exit(0);
});

View File

@@ -1,114 +0,0 @@
// Appends the measured load times to the "Bundle Load Times" table in
// eng-stats-importer. Reads ROWS (the JSON matrix.js prints) and API_KEY from
// env, and stamps each row with the commit it came from.
import { readFileSync } from "node:fs";
import { importStats } from "./stats-import";
/** One condition, as `frontend/test/bench/matrix.ts` reports it. */
interface Condition {
network: string;
networkMbps: number;
latencyMs: number;
cpu: string;
cpuThrottle: number;
coldMs: number;
warmMs: number;
steadyMs: number;
coldSpreadPercent: number;
coldTtfbMs: number;
coldFirstPaintMs: number;
coldAppMountedMs: number;
coldLargestPaintMs: number;
coldPageReadyMs: number;
warmPageReadyMs: number;
warmLargestPaintMs: number;
steadyLargestPaintMs: number;
steadyPageReadyMs: number;
locale: string;
scripts: number;
scriptKb: number;
cssKb: number;
totalKb: number;
runs: number;
}
interface CommitStamp {
sha: string;
subject: string;
/** ISO 8601, in UTC. */
timestamp: string;
}
type LoadTimeRow = Record<string, string | number>;
function buildRows(
conditions: Condition[],
{ sha, subject, timestamp }: CommitStamp,
): LoadTimeRow[] {
return conditions.map((condition) => ({
// The commit's own time rather than the day it was measured, so the merges
// of one day keep their order and a backfilled row lands on its commit.
Date: timestamp,
// Truncated the same way the bundle-size table does, so the two join.
Commit: sha.slice(0, 12),
// The stats table carries a free-text Description column. Populate it with
// the commit subject so the chart's points are self-describing.
Description: subject.split("\n")[0],
Network: condition.network,
"Network mbps": condition.networkMbps,
"Latency ms": condition.latencyMs,
CPU: condition.cpu,
"CPU throttle": condition.cpuThrottle,
"Cold ms": condition.coldMs,
"Warm ms": condition.warmMs,
"Steady ms": condition.steadyMs,
"Cold spread %": condition.coldSpreadPercent,
// The cold load broken up. A zero means the browser or the route never
// reported that one, rather than that it happened at time zero.
"Cold ttfb ms": condition.coldTtfbMs,
"Cold first paint ms": condition.coldFirstPaintMs,
"Cold app mounted ms": condition.coldAppMountedMs,
"Cold largest paint ms": condition.coldLargestPaintMs,
"Cold page ready ms": condition.coldPageReadyMs,
"Warm page ready ms": condition.warmPageReadyMs,
"Warm largest paint ms": condition.warmLargestPaintMs,
"Steady largest paint ms": condition.steadyLargestPaintMs,
"Steady page ready ms": condition.steadyPageReadyMs,
Locale: condition.locale,
Scripts: condition.scripts,
"Script kb": condition.scriptKb,
"Css kb": condition.cssKb,
"Total kb": condition.totalKb,
Runs: condition.runs,
}));
}
async function main() {
const path = process.env.ROWS || "artifacts/load-times.json";
// matrix.js wrote this file, and Condition is the shape it prints.
const conditions = JSON.parse(readFileSync(path, "utf8")) as Condition[];
const rows = buildRows(conditions, {
sha: process.env.HEAD_SHA || "",
subject: process.env.COMMIT_MESSAGE || "",
timestamp: new Date(process.env.COMMIT_TIMESTAMP || Date.now()).toISOString(),
});
console.table(rows);
try {
await importStats({ table: "bundle_load_times", rows });
console.log("Load times uploaded successfully");
} catch (error) {
// Stats logging is best-effort. A point lost to an unreachable importer is
// worth less than a red build on master, and the next commit plots another.
const message = error instanceof Error ? error.message : String(error);
console.log(
`::warning::Load-time upload failed after retries; leaving run green: ${message}`,
);
}
}
main();

View File

@@ -1,38 +0,0 @@
// Appends the measured bundle sizes (with deltas) to the "Bundle Sizes" table
// in eng-stats-importer. Reads ROWS (a JSON file of rows) and API_KEY from env,
// and reports whether the import landed via the `uploaded` step output.
import { appendFileSync, readFileSync } from "node:fs";
import { importStats, type StatsRow } from "./stats-import";
/** `core.setOutput` without the github-script runtime. */
function setOutput(name: string, value: string) {
const file = process.env.GITHUB_OUTPUT;
if (file) {
appendFileSync(file, `${name}=${value}\n`);
}
}
async function main() {
const rowsPath = process.env.ROWS || "artifacts/upload-rows.json";
const rows = JSON.parse(readFileSync(rowsPath, "utf8")) as StatsRow[];
console.table(rows);
try {
await importStats({ table: "bundle_sizes", rows });
console.log("Bundle sizes uploaded successfully");
setOutput("uploaded", "true");
} catch (error) {
// Stats logging is best-effort: if the importer is still unreachable after
// the client's retries, keep the run green. The last-plotted cache is only
// saved on a successful upload, so this point is re-attempted on the next
// master build and nothing is silently dropped.
const message = error instanceof Error ? error.message : String(error);
console.log(`::warning::Bundle-size upload failed after retries; leaving run green: ${message}`);
setOutput("uploaded", "false");
}
}
main();

View File

@@ -1,73 +0,0 @@
/**
* Collect some stats about our e2e tests:
* - total number of spec files
* - total time to run all specs
* - average spec runtime
* - max spec runtime
*
*/
import { readFileSync } from "node:fs";
import { importStats, StatsRow } from "./stats-import";
const TABLE = "e2e_test_timings";
export interface Timings {
durations?: { spec?: string; duration?: unknown }[];
}
export const buildTestTimingsRow = (
timestamp: string,
timings: Timings,
): StatsRow => {
const durations = timings.durations ?? [];
const specCount = durations.length;
if (specCount === 0) {
throw new Error("No durations found.");
}
let totalRunTime = 0;
let maxSpecRuntime = 0;
for (const { duration } of durations) {
if (typeof duration !== "number") {
continue;
}
totalRunTime += duration;
maxSpecRuntime = duration > maxSpecRuntime ? duration : maxSpecRuntime;
}
const averageSpecRuntime = Math.round(totalRunTime / specCount);
return {
date: timestamp,
spec_count: specCount,
total_runtime: totalRunTime,
avg_spec_runtime: averageSpecRuntime,
max_spec_runtime: maxSpecRuntime,
};
};
export async function main() {
const path = process.env.TIMINGS_PATH || "e2e/support/timings.json";
try {
const timings = JSON.parse(readFileSync(path, "utf-8")) as Timings;
const row = buildTestTimingsRow(new Date().toISOString(), timings);
console.log(row);
await importStats({ table: TABLE, rows: [row] });
console.log(`Successfully uploaded e2e test timings to ${TABLE}.`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(
`::warning::Uploading e2e test timings from ${path} failed; leaving run green: ${message}`,
);
}
}
if ((import.meta as ImportMeta & { main?: boolean }).main) {
main();
}

View File

@@ -1,61 +0,0 @@
import { buildTestTimingsRow } from "./upload-e2e-test-timings";
const timestamp = "2026-06-30T00:00:00.000Z";
describe("buildTestTimingsRow", () => {
it("summarizes spec durations", () => {
const row = buildTestTimingsRow(timestamp, {
durations: [
{ spec: "a.cy.spec.js", duration: 1000 },
{ spec: "b.cy.spec.js", duration: 3000 },
{ spec: "c.cy.spec.js", duration: 2000 },
],
});
expect(row).toEqual({
date: timestamp,
spec_count: 3,
total_runtime: 6000,
avg_spec_runtime: 2000,
max_spec_runtime: 3000,
});
});
it("rounds the average runtime", () => {
const row = buildTestTimingsRow(timestamp, {
durations: [
{ spec: "a.cy.spec.js", duration: 1000 },
{ spec: "b.cy.spec.js", duration: 1001 },
],
});
expect(row.avg_spec_runtime).toBe(1001);
});
it("counts specs with a non-numeric duration but ignores their value", () => {
const row = buildTestTimingsRow(timestamp, {
durations: [
{ spec: "a.cy.spec.js", duration: 1000 },
{ spec: "b.cy.spec.js", duration: null },
{ spec: "c.cy.spec.js" },
],
});
expect(row).toEqual({
date: timestamp,
spec_count: 3,
total_runtime: 1000,
avg_spec_runtime: 333,
max_spec_runtime: 1000,
});
});
it("throws when there are no durations", () => {
expect(() => buildTestTimingsRow(timestamp, { durations: [] })).toThrow(
"No durations found.",
);
expect(() => buildTestTimingsRow(timestamp, {})).toThrow(
"No durations found.",
);
});
});

View File

@@ -1,25 +0,0 @@
// Appends the weekly @emotion/* importer count to the "Emotion Files" table in
// eng-stats-importer. Reads EMOTION_FILES and API_KEY from env.
import { importStats } from "./stats-import";
async function main() {
console.log("EMOTION_FILES", Number(process.env.EMOTION_FILES));
// Keys must match the target table's columns (the importer normalizes both
// sides, so casing/spacing is forgiving).
const rows = [
{
Date: new Date().toISOString().slice(0, 10), // YYYY-MM-DD
"Emotion Files": Number(process.env.EMOTION_FILES),
},
];
await importStats({ table: "emotion_stats", rows });
console.log("Data uploaded successfully");
}
main().catch((error) => {
console.error("Error uploading data:", error);
process.exit(1);
});

View File

@@ -1,30 +0,0 @@
// Appends the weekly module-boundary counts to the "Module Boundaries" table in
// eng-stats-importer. Reads NAMED_MODULES, ENFORCED_MODULES, UNMODULED,
// VIOLATIONS and API_KEY from env.
import { importStats } from "./stats-import";
async function main() {
console.log("NAMED_MODULES", Number(process.env.NAMED_MODULES));
console.log("ENFORCED_MODULES", Number(process.env.ENFORCED_MODULES));
console.log("UNMODULED", Number(process.env.UNMODULED));
console.log("VIOLATIONS", Number(process.env.VIOLATIONS));
const rows = [
{
Date: new Date().toISOString().slice(0, 10), // YYYY-MM-DD
Modules: Number(process.env.NAMED_MODULES),
"Enforced modules": Number(process.env.ENFORCED_MODULES),
"Unmoduled folders": Number(process.env.UNMODULED),
Violations: Number(process.env.VIOLATIONS),
},
];
await importStats({ table: "module_boundaries", rows });
console.log("Data uploaded successfully");
}
main().catch((error) => {
console.error("Error uploading data:", error);
process.exit(1);
});

Some files were not shown because too many files have changed in this diff Show More