diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 20217c32..b610ca32 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -17,6 +17,7 @@ /domains/coding/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /domains/general/ @MetaMask/extension-platform @MetaMask/mobile-platform /domains/performance/ @MetaMask/extension-platform @MetaMask/mobile-platform +/domains/platform/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /domains/perps/ @MetaMask/perps /domains/pr-workflow/ @MetaMask/extension-platform @MetaMask/mobile-platform /domains/swaps/ @MetaMask/swaps-engineers diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f4e618f..ffb60f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `feature-flags` skill with a repo-agnostic base and a MetaMask Mobile overlay for version-gated remote flags. Marked `base: true` so it installs even when its domain is filtered out. ([#147](https://github.com/MetaMask/skills/pull/147)) +- Add `analytics` skill (`platform/analytics`, moved from `coding`) with a repo-agnostic base and a MetaMask Mobile overlay for the canonical tracking API. Marked `base: true` so it installs even when its domain is filtered out. ([#140](https://github.com/MetaMask/skills/pull/140)) + ## [0.3.1] ### Fixed diff --git a/README.md b/README.md index c1608837..1e82a148 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ tools/ | -------------- | ----------------- | ------------------------------------------- | | `web3-tools` | dApp builders | `gator-cli`, `smart-accounts-kit`, `oh-my-opencode` | | `coding` | MM product eng | Coding guidelines, controller patterns | +| `platform` | MM product eng | Product analytics and other platform skills | | `agentic` | MM product eng | Experimental recipe workflows and runtime proof tools | | `assets` | MM product eng | Assets domain skills | | `general` | All agents | `codex`, `gemini` CLI usage guides | @@ -386,6 +387,12 @@ Extra metadata blocks (e.g. OpenClaw-style `metadata:` with emoji and homepage) are preserved through install — only `name`, `description`, `maturity`, `base`, and `scope` are read by the CLI. +`base: true` installs the skill even when its domain is filtered out. +`--exclude` / `SKILLS_EXCLUDE` still wins. The maturity filter runs before the +base bypass, so `--maturity stable` drops a `base: true` experimental skill. +A skill with a `repos/` directory and no overlay for `--repo` is skipped +(this `analytics` skill installs for Mobile and is skipped for Extension). + The 1,536-character ceiling is a repo budget rather than an operator limit — the description is always-on context for every installed skill, so it is capped deliberately. It is enforced by `yarn audit:skills` from diff --git a/domains/platform/skills/analytics/repos/metamask-mobile.md b/domains/platform/skills/analytics/repos/metamask-mobile.md new file mode 100644 index 00000000..68462864 --- /dev/null +++ b/domains/platform/skills/analytics/repos/metamask-mobile.md @@ -0,0 +1,194 @@ +--- +repo: metamask-mobile +parent: analytics +--- + +# Analytics — MetaMask Mobile + +Human-facing file map: `app/core/Analytics/README.md`. A/B enrichment SSOT: `docs/ab-testing.md`. + +## Canonical API + +| Role | Path | +|------|------| +| Helper (non-React) | `app/util/analytics/analytics.ts` → `analytics.trackEvent` | +| Helper (UI) | `app/components/hooks/useAnalytics/useAnalytics.ts` → `useAnalytics` | +| Engine (controllers) | `app/core/Engine/utils/analytics.ts` → `trackEvent`, `buildAndTrackEvent` | +| Event builder | `app/util/analytics/AnalyticsEventBuilder.ts` → `AnalyticsEventBuilder.createEventBuilder` | +| Catalog | `app/core/Analytics/` → `MetaMetricsEvents` at call sites; `EVENT_NAME` in catalog modules | +| Typed helpers | `app/util/analytics/actionButtonTracking.ts` (sibling files matching `*Tracking.ts`) | +| A/B registry | `app/util/analytics/abTestAnalyticsRegistry.ts` (feature-local `abTestConfig.ts`, e.g. `app/components/Views/Homepage/abTestConfig.ts`) | +| Test factory | `app/util/test/analyticsMock.ts` → `createMockUseAnalyticsHook` (default); `createMockEventBuilder` (optional standalone double) | + +`useAnalytics()` returns `trackEvent`, `createEventBuilder`, `identify`, `enable`, +`isEnabled`, `getAnalyticsId`, and data-deletion helpers. + +Controllers that already talk to Engine import `trackEvent` / `buildAndTrackEvent` +from `app/core/Engine/utils/analytics.ts`. Those helpers always wrap the +messenger call in try/catch. `enrichWithABTests` runs only when the event name +is registered in `app/util/analytics/abTestAnalyticsRegistry.ts` (fed by +feature-local `abTestConfig.ts`). New experiment events: follow +`docs/ab-testing.md`. Do not copy Engine-util internals. + +`createMockEventBuilder()` default `build()` is +`{ name: 'mock-event', properties: {}, sensitiveProperties: {} }`. Use it only +as a standalone builder double, wrapped in `jest.fn(() => createMockEventBuilder())`. + +## Requirements + +- UI: platform `useAnalytics` from `app/components/hooks/useAnalytics/useAnalytics.ts` +- Non-React: `analytics.trackEvent` +- Controllers: `trackEvent` / `buildAndTrackEvent` from `app/core/Engine/utils/analytics.ts` +- When a typed helper exists in `app/util/analytics/` (files matching `*Tracking.ts`) for this event, call it +- Call sites (new and existing) import `MetaMetricsEvents.*`. Register new names as `EVENT_NAME` + `generateOpt` in catalog modules, then emit via `MetaMetricsEvents`. Reuse a catalog name only when this control is the same interaction as existing call sites (same event, same product meaning). +- Properties via `.addProperties(...).build()` +- UI tests: `createMockUseAnalyticsHook` wrapping `useAnalytics`, including when the file already mocks the hook. Default: `createMockUseAnalyticsHook({ trackEvent: mockTrackEvent })`. Tests that assert `addProperties` keep `AnalyticsEventBuilder.createEventBuilder` +- Non-React tests: assert `AnalyticsEventBuilder.createEventBuilder` and `analytics.trackEvent` or Engine `trackEvent` / `buildAndTrackEvent` + +Generic UI (`app/components/UI/BalanceEmptyState/BalanceEmptyState.tsx`): + +```ts +import React from 'react'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; + +const BalanceEmptyState: React.FC = ({ + testID = 'balance-empty-state', + ...props +}) => { + const { trackEvent, createEventBuilder } = useAnalytics(); + + const handleAction = () => { + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_BUTTON_CLICKED) + .addProperties({ + button_text: 'Add funds', + location: 'BalanceEmptyState', + ramp_type: 'UNIFIED_BUY_2', + }) + .build(), + ); + }; +``` + +Typed helper (`app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SendButton.tsx`): + +```ts +import React, { useCallback } from 'react'; +import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; +import { + ActionButtonType, + ActionLocation, + trackActionButtonClick, +} from '../../../../../../util/analytics/actionButtonTracking'; + +const SendButton = ({ + actionPosition, + allowTwoLineLabel, + onSend, +}: SendButtonProps) => { + const { trackEvent, createEventBuilder } = useAnalytics(); + + const handlePress = useCallback(() => { + trackActionButtonClick(trackEvent, createEventBuilder, { + action_name: ActionButtonType.SEND, + action_position: actionPosition, + button_label: label, + location: ActionLocation.HOME, + }); + onSend(); + }, [actionPosition, createEventBuilder, label, onSend, trackEvent]); +``` + +Non-React (`app/util/analytics/accountAccessTracking.ts`): + +```ts +import { MetaMetricsEvents } from '../../core/Analytics/MetaMetrics.events'; +import { analytics } from './analytics'; +import { AnalyticsEventBuilder } from './AnalyticsEventBuilder'; + +analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.APP_UNLOCKED_FAILED, + ) + .addProperties({ + unlock_error_type: unlockErrorType, + forced_reset: forcedReset, + }) + .build(), +); +``` + +Controllers: + +```ts +import { buildAndTrackEvent } from '../../core/Engine/utils/analytics'; +import { MetaMetricsEvents } from '../../core/Analytics'; + +buildAndTrackEvent( + initMessenger, + MetaMetricsEvents.PROFILE_ACTIVITY_UPDATED.category, + { + profile_id: profileId, + feature_name: 'Contacts Sync', + action: 'Contacts Sync Contact Updated', + }, +); +``` + +`createEventBuilder` copies only `category` from `IMetaMetricsEvent`. When +migrating a wrapper that used `generateOpt(name, action, description)`, re-apply +`properties.action` and `properties.name` with `addProperties`. + +`generateOpt` belongs in catalog modules: `app/core/Analytics/MetaMetrics.events.ts`, +`app/core/Analytics/events/`, and feature-local `/analytics/events.ts` +(see SampleFeature). Component files import catalog entries; they do not call +`generateOpt` themselves. + +Tests mock the hook with the factory, not a hand-built object. +Call `createMockUseAnalyticsHook` again in `beforeEach` after +`jest.resetAllMocks()` — that wipes mock implementations. `jest.clearAllMocks()` +does not. + +```ts +import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; +import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock'; + +jest.mock('../../hooks/useAnalytics/useAnalytics'); + +beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + }), + ); +}); +``` + +Standalone builder double (only when the test needs one): + +```ts +createEventBuilder: jest.fn(() => createMockEventBuilder()), +``` + +## Reject + +- `addSensitiveProperties` on new tracking. Existing call sites: drop those + fields only. Moving the last sensitive field into `addProperties` flips + `isAnonymous` (true iff `sensitiveProperties` is nonempty). Do not relocate + without human sign-off. +- A new feature-local tracker that is not a file matching `*Tracking.ts` under + `app/util/analytics/`, a feature-local `abTestConfig.ts`, or a catalog + `generateOpt` module (`app/core/Analytics/MetaMetrics.events.ts`, + `app/core/Analytics/events/`, `/analytics/events.ts`) +- Replacing `MetaMetricsEvents.*` at a call site with `EVENT_NAME.*` +- Reintroducing `useMetrics` (removed) or MetaMetrics internals at call sites +- Dropping `generateOpt` `action` / `name` when migrating `IMetaMetricsEvent` call sites (until the catalog migration lands) +- Hand-built `useAnalytics` mock objects — use `createMockUseAnalyticsHook` +- Raw `initMessenger.call('AnalyticsController:trackEvent', …)` when Engine + `trackEvent` / `buildAndTrackEvent` is available +- Defaulting UI tests to `createEventBuilder: jest.fn(() => createMockEventBuilder())` + when `createMockUseAnalyticsHook()` already stubs the builder +- Attaching a new control to a catalog event whose live call sites are a different product (example: `VIEW_ALL_ASSETS_CLICKED` is wallet tokens/NFTs `asset_type`, not a homepage section) +- Firing an existing catalog event at a new lifecycle (example: `TOKEN_DETECTED` on controller init). Add a catalog name for that lifecycle. diff --git a/domains/platform/skills/analytics/skill.md b/domains/platform/skills/analytics/skill.md new file mode 100644 index 00000000..b1149b43 --- /dev/null +++ b/domains/platform/skills/analytics/skill.md @@ -0,0 +1,25 @@ +--- +name: analytics +description: >- + Product analytics and event tracking. Use when adding, migrating, or + reviewing tracked events, or when writing tests for analytics call sites. +maturity: stable +base: true +--- + +# Analytics + +Use this skill for product event tracking. + +## When to use + +- Adding or migrating event tracking in UI or non-UI code +- Writing or updating tests for analytics call sites +- Reviewing a PR that introduces or changes tracked events + +## Workflow + +1. Register this interaction in the catalog (`EVENT_NAME` + `generateOpt` in catalog modules). Reuse an existing catalog name only when this control is another instance of that same interaction (same dashboard event, same owners). +2. Attach properties on the event builder. +3. Send the built event through the tracking entry point. +4. In UI tests, wrap `useAnalytics` with the test factory (including files that already mock the hook). In non-React tests, assert the builder and the helper or Engine tracking util. diff --git a/domains/platform/skills/feature-flags/repos/metamask-extension.md b/domains/platform/skills/feature-flags/repos/metamask-extension.md new file mode 100644 index 00000000..b137978f --- /dev/null +++ b/domains/platform/skills/feature-flags/repos/metamask-extension.md @@ -0,0 +1,187 @@ +--- +repo: metamask-extension +parent: feature-flags +--- + +# Feature flags — MetaMask Extension + +There is no human-facing doc for version gating. `docs/ab-testing.md` covers A/B flags only. +`AGENTS.md` "Working with Feature Flags" covers build flags and local overrides, not version +gating. + +## Canonical API + +| Role | Path | +|------|------| +| Helper | `shared/lib/remote-feature-flag-utils.ts` → `getBooleanFeatureFlag`, `validatedVersionGatedFeatureFlag`, `hasMinimumRequiredVersion` | +| Raw flags | `shared/lib/selectors/remote-feature-flags.ts` → `getRemoteFeatureFlags`, `getFeatureFlagThresholdGroups` | +| Selectors | A colocated `feature-flags.ts` per domain, under `ui/selectors/**` or `shared/lib/**` | +| Names | Exported string constants beside the selector that reads them | +| E2E registry | `test/e2e/feature-flags/feature-flag-registry.ts` → `FEATURE_FLAG_REGISTRY` | + +`shared/lib/remote-feature-flag-utils.ts` is a port of mobile's +`app/util/remoteFeatureFlag/index.ts` and its own header says so, so the exported names match +mobile. The evaluation differs in one respect that matters: `hasMinimumRequiredVersion` +compares against `packageJson.version`, read from the repo's `package.json` at build time. +There is no native binary version and no `react-native-device-info` equivalent. A flag change +reaches only installs running a build whose code reads the flag, so it cannot change behavior +on an older build. + +`getBooleanFeatureFlag(flagValue, defaultValue)` is the usual entry point, at 34 non-test call +sites against 11 for `validatedVersionGatedFeatureFlag`. It takes the fallback as a required +second argument rather than mobile's trailing `?? localFlag`, and it returns a plain boolean +flag unchanged, so one call covers both a boolean and a version-gated object. + +`validatedVersionGatedFeatureFlag` returns `boolean | undefined`. Reach for it when the caller +must tell an invalid or absent flag apart from a disabled one. + +Progressive rollout wrappers shaped `{ name?, value: { enabled, minimumVersion } }` are +unwrapped by `unwrapVersionGatedFeatureFlag` inside both helpers. + +### Two modules export the same three names + +`shared/lib/feature-flags/version-gating.ts` also exports `hasMinimumRequiredVersion`, +`validatedVersionGatedFeatureFlag` and the type `VersionGatedFeatureFlag`. An import of either +name compiles against either module, so read the import path before trusting the behavior. + +| | `shared/lib/remote-feature-flag-utils.ts` | `shared/lib/feature-flags/version-gating.ts` | +|---|---|---| +| Non-test importers | 14 | 4 | +| `minimumVersion` type | `string` | `string \| null` | +| Wrapper unwrapping | Yes | No | +| Also exports | `getBooleanFeatureFlag`, `isVersionGatedFeatureFlag` | `getBaseSemVerVersion` | + +Prefer `shared/lib/remote-feature-flag-utils.ts` for new code. The other module is live and its +four importers are not defects, but it does not unwrap rollout wrappers. + +Two further local reimplementations of the same version compare exist and take no new callers: +`isMultichainFeatureEnabled` in `shared/lib/multichain-feature-flags.ts`, and +`isPerpsRemoteConfigSatisfied` in `shared/lib/perps-feature-flags.ts`. + +## Requirements + +- Evaluate in a selector or a shared predicate. UI and hooks only call + `useSelector(selectXEnabled)`. +- Map a non-standard remote shape to `{ enabled, minimumVersion }` before calling the helper. +- Register every new remote flag in `FEATURE_FLAG_REGISTRY` with its production default. + +A shared predicate over the raw flag bag, when the background needs the same answer: + +```ts +import { validatedVersionGatedFeatureFlag } from '../remote-feature-flag-utils'; + +export const MY_FEATURE_FLAG_NAME = 'myFeature'; + +export function isMyFeatureEnabled( + remoteFeatureFlags: Record | undefined, +): boolean { + return ( + validatedVersionGatedFeatureFlag(remoteFeatureFlags?.[MY_FEATURE_FLAG_NAME]) ?? false + ); +} +``` + +Background code reads the bag through the `RemoteFeatureFlagController:getState` messenger +action. Until this session's fetch completes, that state holds the flags persisted from the +previous fetch. + +The selector then composes that predicate over `getRemoteFeatureFlags`, which keeps one +version-gate interpretation shared between the UI and the background: + +```ts +import { createSelector } from 'reselect'; +import { getRemoteFeatureFlags } from '../../../shared/lib/selectors/remote-feature-flags'; +import { isMyFeatureEnabled } from '../../../shared/lib/my-domain/feature-flags'; + +export const selectMyFeatureEnabled = createSelector( + getRemoteFeatureFlags, + isMyFeatureEnabled, +); +``` + +Where no background caller needs the predicate, `getBooleanFeatureFlag` inline is the shorter +and more common form: + +```ts +export const selectMyFeatureEnabled = createSelector( + getRemoteFeatureFlags, + ({ myFeature }) => getBooleanFeatureFlag(myFeature, false), +); +``` + +UI: + +```ts +const isEnabled = useSelector(selectMyFeatureEnabled); +``` + +## Adding a flag + +1. Export the flag name as a constant beside the selector that reads it. +2. Write the predicate or selector against `getRemoteFeatureFlags`. +3. Add a `FEATURE_FLAG_REGISTRY` entry: `name`, `type`, `inProd`, `productionDefault` and + `status`. The registry is what `mock-e2e.js` serves, so an unregistered flag reads as + absent in E2E. +4. Cover the selector with a collocated test. + +`getRemoteFeatureFlags` merges manifest flags over controller state, manifest winning, so a +local override goes in `.manifest-overrides.json` under `_flags.remoteFeatureFlags` with +`MANIFEST_OVERRIDES` set in `.metamaskrc`. There is no `OVERRIDE_REMOTE_FEATURE_FLAGS` switch. + +## Testing + +Collocated unit tests import `packageJson` and build flag values around the real current +version rather than hardcoding one, since the gate reads `package.json`: + +```ts +import packageJson from '../../../package.json'; + +const CURRENT_VERSION = packageJson.version; +``` + +Cover enabled, disabled, below-minimum, rollout-wrapped, and invalid or absent. +`RemoteFeatureFlagController` is disabled until onboarding completes and while basic +functionality (`useExternalServices`) is off, so those users never fetch and read absent or +last-persisted flags. A failed fetch is only logged, in +`app/scripts/lib/update-remote-feature-flags.ts`. + +For E2E, seed state with `withRemoteFeatureFlagController(...)` from +`test/e2e/fixtures/fixture-builder-v2.ts`, or override at runtime with +`manifestFlags.remoteFeatureFlags`. Seeded state never passes through fetching and validating +a flag response. To test that path, serve the response from `testSpecificMock`, which +`test/e2e/mock-e2e.js` registers ahead of its registry default for the `client-config` flags +route. + +## Not present in the extension + +State these as gaps rather than substituting a near neighbor: + +- No `useRemoteFeatureFlag` hook. Consumption is `useSelector` over a selector. The only + flag-reading hook is `useABTest` in `ui/hooks/useABTest.ts`, which is for A/B assignment and + exposure events, not version gating. +- No central flag-name registry equivalent to mobile's `FeatureFlagNames`. An enum of that + name exists at `shared/lib/feature-flags.ts` holding a single member, and it is not where + flag names live. +- No handling for the multi-version shape `{ versions: { "7.53.0": value } }`. Neither helper + reads a `versions` key. + +## Extension only + +- `FEATURE_FLAG_REGISTRY` is the production-default source of truth for E2E. Mobile has no + counterpart. `.github/workflows/check-feature-flag-registry-drift.yml` checks it against + production on a weekly schedule (`cron: '0 1 * * 2'`) and not on pull requests, so a new + flag's `productionDefault` can merge unchecked. +- `getRemoteFeatureFlags` folds manifest overrides in at the selector, so override precedence + is a property of the read rather than of a build switch. +- Threshold and A/B flags carry a separate `featureFlagThresholdGroups` map, read through + `getFeatureFlagThresholdGroups`. + +## Reject + +- New local copies of `hasMinimumRequiredVersion`, or a `semver.gte` against + `packageJson.version` for flag gating, outside `shared/lib/remote-feature-flag-utils.ts` +- Version checks in hooks or components +- A new remote flag with no `FEATURE_FLAG_REGISTRY` entry +- Importing `validatedVersionGatedFeatureFlag` without checking which of the two modules it + resolves to + diff --git a/domains/platform/skills/feature-flags/repos/metamask-mobile.md b/domains/platform/skills/feature-flags/repos/metamask-mobile.md new file mode 100644 index 00000000..2fe7f377 --- /dev/null +++ b/domains/platform/skills/feature-flags/repos/metamask-mobile.md @@ -0,0 +1,71 @@ +--- +repo: metamask-mobile +parent: feature-flags +--- + +# Feature flags — MetaMask Mobile + +Human-facing file map: `docs/readme/version-gated-feature-flags.md`. + +## Canonical API + +| Role | Path | +|------|------| +| Helper | `app/util/remoteFeatureFlag/index.ts` → `validatedVersionGatedFeatureFlag`, `hasMinimumRequiredVersion` | +| Raw flags | `RemoteFeatureFlagController.remoteFeatureFlags` (no client version gating) | +| Selectors | `app/selectors/featureFlagController/` or `**/selectors/featureFlags/` | +| Names (when the flag is overrideable in dev tools) | `app/constants/featureFlags.ts` → `FeatureFlagNames` | + +`validatedVersionGatedFeatureFlag` compares against the native binary version from `react-native-device-info` `getVersion()`, not `package.json`. Progressive-rollout wrappers `{ name, value: { enabled, minimumVersion } }` are unwrapped in the helper. + +`hasMinimumRequiredVersion` is the lower-level compare used inside the helper. Import it from the util only for a standalone version check. + +Multi-version flags shaped `{ versions: { "7.53.0": value } }` are resolved at fetch time by the controller. Consumers read the processed value — no helper call for that shape. + +## Requirements + +- Selectors call `validatedVersionGatedFeatureFlag`. UI and hooks only `useSelector(selectXEnabled)`. +- Non-standard flag shapes (e.g. `active` instead of `enabled`) map to `{ enabled, minimumVersion }` before the helper. +- Remote flag wins when valid. Use `?? env/local` when the helper returns `undefined` (invalid shape, or `OVERRIDE_REMOTE_FEATURE_FLAGS=true`). + +```ts +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '../index'; +import { + validatedVersionGatedFeatureFlag, + type VersionGatedFeatureFlag, +} from '../../../util/remoteFeatureFlag'; + +export const selectMyFeatureEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const localFlag = process.env.MM_MY_FEATURE_ENABLED === 'true'; + const remoteFlag = + remoteFeatureFlags?.myFeature as unknown as VersionGatedFeatureFlag; + + return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; + }, +); +``` + +Non-standard shape: + +```ts +validatedVersionGatedFeatureFlag({ + enabled: depositConfig.active ?? false, + minimumVersion: depositConfig.minimumVersion ?? '', +}) ?? false; +``` + +UI: + +```ts +const isEnabled = useSelector(selectMyFeatureEnabled); +``` + +## Reject + +- Local copies of `hasMinimumRequiredVersion` or `validatedVersionGatedFeatureFlag` +- Inline `compare-versions` + `getVersion` for feature-flag gating outside `app/util/remoteFeatureFlag` +- Version checks in hooks or components (`getVersion()`, `compare-versions`, or a local helper) +- Duplicate util files under `app/components/UI/**/utils/` or `app/core/redux/slices/**/` diff --git a/domains/platform/skills/feature-flags/skill.md b/domains/platform/skills/feature-flags/skill.md new file mode 100644 index 00000000..6f230054 --- /dev/null +++ b/domains/platform/skills/feature-flags/skill.md @@ -0,0 +1,27 @@ +--- +name: feature-flags +description: >- + Version-gated remote feature flags. Use when adding, migrating, or + reviewing a remote or version-gated feature flag, writing a flag + selector, or gating UI on a flag boolean. +maturity: stable +base: true +--- + +# Feature flags + +Use this skill for remote and version-gated feature flags. + +## When to use + +- Adding or migrating a remote / version-gated feature flag +- Writing or updating a flag selector +- Gating UI or hooks on a flag boolean +- Reviewing a PR that introduces or changes flag evaluation + +## Workflow + +1. Evaluate the flag in a selector. Return a boolean. +2. Map a non-standard remote shape to `{ enabled, minimumVersion }` before the shared helper. +3. Consume that boolean in UI or hooks. Do not re-run version math there. +4. Cover the selector with collocated tests (enabled, disabled, invalid, fallback).