Skip to content
Open
2 changes: 2 additions & 0 deletions .changeset/mosaic-parity-gates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
12 changes: 12 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@
]
}
],
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "node \"$(git rev-parse --show-toplevel)/scripts/mosaic-parity-gate.mjs\" write",
"timeout": 10
}
]
}
],
"Stop": [
{
"hooks": [
Expand Down
12 changes: 12 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@
}
]
}
],
"PreToolUse": [
{
"matcher": "apply_patch|Edit|Write",
"hooks": [
{
"type": "command",
"command": "node \"$(git rev-parse --show-toplevel)/scripts/mosaic-parity-gate.mjs\" write",
"timeout": 10
}
]
}
]
}
}
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ jobs:

- name: Run verify script tests
if: matrix.node-version == '24.15.0'
run: pnpm vitest run --root scripts verify-attach verify-flaky
run: pnpm vitest run --root scripts verify-attach verify-flaky mosaic-parity-gate

- name: Upload Turbo Summary
uses: actions/upload-artifact@v4.6.2
Expand Down
4 changes: 2 additions & 2 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The docs describe the Mosaic we are building toward. Code that differs from them

Read every line that matches your task. Paths below are relative to this package.

- Add a feature, or a file to one → [Features](docs/features.md), then the guide for each layer below. If the repo's `packages/ui` has a legacy version, [Migration](docs/migration.md) first
- Add a feature, or a file to one → [Features](docs/features.md), then the guide for each layer below. If `packages/ui` or `packages/react` has a legacy version, [Migration](docs/migration.md) first, even for a headless port
- Write or change a model, or add a Clerk read, mutation or permission check → [Models](docs/models.md)
- Build a feature from a design before Clerk's API can back it → [Models → Before the API exists](docs/models.md#before-the-api-exists)
- Write or change a controller, or how a feature handles pending state, errors, dialogs or removal focus → [Controllers](docs/controllers.md)
Expand All @@ -41,7 +41,7 @@ Read every line that matches your task. Paths below are relative to this package
- Write, move or review a test → [Testing](docs/testing.md)
- Fix or silence a lint error → [Lint](docs/lint.md)
- Move a legacy component's logic or styles into Mosaic → [Migration](docs/migration.md)
- Check that a migration kept every legacy behavior → [Parity audit](docs/parity-audit.md)
- Implement or review a migration, or check that it kept every legacy behavior → [Parity audit](docs/parity-audit.md)
- Add or change a component's Swingset page → [Documenting components](docs/documenting-components.md)
- Prove a change in a browser → [verify-mosaic](.claude/skills/verify-mosaic/SKILL.md)
- Remove or rename a public export, prop, `--cl-*` token or styling hook, or change a public type or default → [Breaking changes](docs/breaking-changes.md)
Expand Down
2 changes: 1 addition & 1 deletion packages/mosaic/docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Worked example: `src/features/reverification/` (model, controller, view, types,

## Rules

- Before you add a feature, check `packages/ui/src/components/` for a legacy version. If one exists, this is a migration: read [Migration](migration.md) first.
- Before you add a feature, check `packages/ui/src/components/` and `packages/react/src/components/` for a legacy version, including for a headless port. If one exists, this is a migration: read [Migration](migration.md) first.
- Only a `*.model.ts(x)`, or a shared Clerk-reading hook in `src/hooks/` (`useMosaicEnvironment`, `useMosaicRouter`, `useMosaicSupportEmail`), imports Clerk hooks or calls Clerk resource methods.
- What crosses each boundary is plain data: no Clerk resource reaches the controller, and no machine snapshot reaches the view.
- A feature's machine lives in its `*.controller.ts(x)`, not in `src/machine/` and not in a `*.machine.ts`.
Expand Down
23 changes: 19 additions & 4 deletions packages/mosaic/docs/migration.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
# Migration

Read this when you move a legacy component (usually under `packages/ui/src/components/<Feature>/`) into Mosaic, whether its flow logic or only its styles. It covers the five phases from inventory to ship; the finished shape is in [Features](features.md), and the parity check is in [Parity audit](parity-audit.md).
Read this when you move a legacy component (under `packages/ui/src/components/<Feature>/` or `packages/react/src/components/`) into Mosaic, including a headless port framed as a new feature, whether its flow logic or only its styles. It covers the five phases from inventory to ship; the finished shape is in [Features](features.md), and the parity check is in [Parity audit](parity-audit.md).

Worked example: `src/features/reverification/`.

## Rules

- The legacy component is the spec. A legacy file fuses rendering, data fetching and flow logic, and splitting it three ways drops behavior that was only implicit (a per-field error map, a step-up reverification, an empty-state gate, a derived callout) without failing a test or a type check.
- Inventory the legacy behavior first, then make each layer account for a row. Do not write the layers first and ask what you missed.
- Apply this workflow to the current task, not every Mosaic change already on the branch.
- Write the inventory to `tmp/mosaic-parity/<feature>.md` before the model, with the legacy path and the features in scope. Run the [Parity audit](parity-audit.md) into the same file before you call the work done, review it or open a PR.
- Every inventory row lands in exactly one layer, and later in exactly one parity bucket. A row with no home is a behavior you are about to drop.
- A deferred behavior gets a tracked ticket, never a buried `// TODO`.
- Share a business-rule helper when legacy and Mosaic must behave the same. Do not extract similar presentation code only to remove duplication, since the designs may diverge.
- A component with no data or flow logic only needs its styles moved ([Styles](#styles)).

## Phase 1: inventory the legacy behavior

Grep the legacy files for the primitives that carry hidden logic. Every hit is a row you must place or consciously drop:
Locate the legacy files: the prebuilt component under `packages/ui/src/components/<Feature>/`, the `@clerk/react` component under `packages/react/src/components/`, or both. Grep them for the primitives that carry hidden logic. Every hit is a row you must place or consciously drop:

| Primitive | The behavior it usually hides |
| --------------------------------- | -------------------------------------------------- |
Expand All @@ -30,11 +32,24 @@ Grep the legacy files for the primitives that carry hidden logic. Every hit is a

```bash
rg -n 'useEffect|handleError|card\.setError|useReverification|revalidate|<Protect|checkAuthorization|useCalloutLabel|useFetch|useOrganization|\.reload\(|useInView' \
packages/ui/src/components/<Feature>/
packages/ui/src/components/<Feature>/ packages/react/src/components/<Feature>.tsx
```

Turn the hits into a behavior inventory, one row per effect, guard, error path, empty or loading state, permission gate, revalidate, reset-on-close and derived label. The table does not cover every behavior; read the files too. For shared answers (`save`, `useReverificationFlow`), use [Reuse before you write](../AGENTS.md#reuse-before-you-write).

Include shared prop types and mode-dependent options, defaults, callback order and error delivery; the full list is in [Parity audit → Rules](parity-audit.md#rules). Write the inventory to `tmp/mosaic-parity/<feature>.md`:

```md
# checkout-button parity

Legacy: packages/react/src/components/CheckoutButton.tsx

## Inventory

- `for="organization"` throws without an active organization
- …
```

## Phase 2: map every row to a layer

- Clerk reads, mutations, permission gates, revalidate timing, first-load empty states and capability flags go in the **model** ([Models](models.md)).
Expand All @@ -56,7 +71,7 @@ Test with one `*.feature.test.tsx` that uses the feature the way a user would ([

## Phase 4: verify parity

Tests cover only the branches you remembered. Run the [Parity audit](parity-audit.md) against the legacy files and the new layers, and place every Phase 1 row as Migrated, Deliberately changed (such as infinite scroll becoming a "Load more" button) or Deferred.
Tests cover only the branches you remembered. Run the [Parity audit](parity-audit.md) against the legacy files and the new layers, and place every Phase 1 row as Migrated, Deliberately changed (such as infinite scroll becoming a "Load more" button) or Deferred, under `## Parity audit` in `tmp/mosaic-parity/<feature>.md`.

## Phase 5: ship

Expand Down
50 changes: 34 additions & 16 deletions packages/mosaic/docs/parity-audit.md
Original file line number Diff line number Diff line change
@@ -1,45 +1,61 @@
# Parity audit

Read this when you review whether a migrated feature kept every legacy behavior, or before you open a migration PR. It covers how to run the audit and how to report it; the migration itself is in [Migration](migration.md).
Read this when you implement or review a migration of a `packages/ui` or `packages/react` component, change behavior a migration moved, or open a migration PR. It covers how to run the audit, how to classify what it finds and how to report it; the migration itself is in [Migration](migration.md).

Worked example: [What it catches](#what-it-catches).

## Rules

- Classify every legacy behavior: each effect, guard, error path, empty or loading state, permission gate, revalidate or reload call, reset-on-close, derived label or count, and pagination trigger.
- Audit the current task, not every Mosaic change on the branch. Record the features in scope and the legacy and new revisions before you compare. When you review earlier feedback, compare against the commit the reviewer saw.
- Classify every legacy behavior: each prop and mode-dependent option, default, callback order, effect, guard, error path, empty or loading state, permission gate, revalidate or reload call, reset-on-close, derived label or count, and pagination trigger. Follow shared prop types, helpers and Clerk runtime calls until the observable outcome is clear; matching method names is not parity.
- Each one is **Migrated** (points at a state, transition, context field or prop in the new code), **Deliberately changed** (names the new behavior and why) or **Deferred** (not implemented; say whether a ticket tracks it).
- A Migrated row cites the test or check that shows the behavior, not only the code that should produce it. A row with no such evidence is not Migrated yet; name the missing check.
- A Deliberately changed row cites the user's decision or the Mosaic rule behind it. If no one decided, mark it "decision needed"; do not infer intent from the code.
- A Deferred item tracked only by a `// TODO` is a regression at risk of shipping. Surface it as a tracked item.
- Run it as a read-only subagent that returns a conclusion, not file dumps. The table guides the work and the PR discussion; nothing is committed.
- A defect the legacy component already had is not a parity row. List it below the table as an optional improvement.
- A difference that breaks a rule in [AGENTS.md](../AGENTS.md) or `docs/` is fixed or raised with the user, not shipped as intentional.
- An error the legacy component let reach the app, by a throw, a rejection or the console, stays observable. Losing it is a gap to fix, not a decision to ask about. Check that whatever owns the error still renders after the action fails: a failed sign-out can unmount its own button.
- For each async action in scope, check success, pending and a representative rejection through the feature's fake FAPI harness ([Testing](testing.md)). A mock of the Clerk method cannot prove that method's runtime effect.
- Run it as a read-only subagent that returns a conclusion, not file dumps. Put its table under `## Parity audit` in `tmp/mosaic-parity/<feature>.md`, beside the [Phase 1 inventory](migration.md#phase-1-inventory-the-legacy-behavior). `tmp/` is ignored by git, so nothing is committed.
- A filled table is not proof. Read the results of the checks it cites before you claim parity.
- List every Deliberately changed and Deferred row in the PR description, with its effect on consumers.

## The subagent prompt

Fill in the two file lists and hand this to a read-only agent:
Fill in the blanks and hand this to a read-only agent:

```
In the repo at <repo-root>, audit a Mosaic migration for behavioral parity.

CURRENT TASK: <requested features or behavior; exclude unrelated branch changes>
REVISIONS: <legacy revision and new revision, including working-tree changes>
ACCEPTED DIFFERENCES: <user decisions and explicit exclusions, or none>

LEGACY component files (the spec — behavior must be preserved or consciously changed):
<absolute paths to the legacy component + its sub-forms/flows>

NEW Mosaic files (model / controller / view / wrapper):
<absolute paths to the *.model.tsx, *.controller.tsx, *.view.tsx, *.tsx>

Enumerate EVERY behavior in the legacy files — each: effect, guard, error path,
empty/loading state, permission gate, revalidate/reload call, reset-on-close,
derived label/count, pagination trigger. For each one, find where it lives in
the new files and classify it:

- Migrated → points at a specific new state / transition / context field / prop
- Deliberately changed → the new behavior differs on purpose; name the new behavior and why
- Deferred → not implemented in the new code; note whether a tracked ticket exists
or it is only a buried `// TODO`
Follow packages/mosaic/docs/parity-audit.md → Rules. Enumerate every behavior
it lists, find where each lives in the new files, and classify it. Read the
shared prop types and follow helpers and Clerk runtime calls to the observable
outcome. For callbacks, compare order and awaiting with docs/headless.md before
demanding legacy order. For redirects, check explicit URLs and omitted props
with a non-default provider setting.

Quote ONLY the relevant branch from each file (the conditional, the effect, the
guard). Never dump whole files. Return the result as the table below, most
severe gaps first, then a short list of any Deferred items that are tracked only
by a `// TODO` (these are the regressions at risk of shipping).
guard). Never dump whole files. Return the table below, most severe gaps first,
with the test command or case behind each Migrated row. Then list Deferred
items tracked only by a `// TODO`, legacy defects as optional improvements, and
every row that breaks a rule in packages/mosaic/AGENTS.md or
packages/mosaic/docs/, quoting the rule.
```

To find the legacy behaviors, start from the search in [Migration → Phase 1](migration.md#phase-1-inventory-the-legacy-behavior).

For a read-only review, `node scripts/mosaic-parity-gate.mjs review <feature> [feature ...]` prints the same reminder the edit hooks give. It does not run the audit.

## Output

| Legacy behavior | Layer it should live in | Status | Evidence (legacy → new) |
Expand All @@ -53,3 +69,5 @@ An audit of the organization domains section, migrated before Mosaic was its own
1. Per-field error mapping: every add or verify failure collapsed to one `errorMessage`.
2. Step-up reverification on domain delete was omitted.
3. The pending-invitations callout count reached the view but was never rendered.

An audit of the headless auth buttons (#10588) found a rejected action whose error `usePendingAction` captured but nothing showed, an `unsafeMetadata` option the shared props type allows only in modal mode, and a sign-out path a method mock hid from the test. It also found missing MetaMask error mapping, which the legacy button lacked too; that is an optional improvement, not a parity row.
88 changes: 88 additions & 0 deletions scripts/mosaic-parity-gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { isAbsolute, relative, resolve } from 'node:path';

const FEATURE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const FEATURE_FILE = /^packages\/mosaic\/src\/features\/([a-z0-9]+(?:-[a-z0-9]+)*)\/.+/;

function reminder(features, review) {
return [
`Mosaic parity scope: ${features.join(', ')}.`,
review ? 'For this requested review, follow' : 'If this work changes migrated behavior, follow',
'packages/mosaic/docs/migration.md and packages/mosaic/docs/parity-audit.md.',
'Use actual focused type and runtime results, and preserve the accepted decisions and classifications in those guides.',
'This reminder is not parity evidence.',
].join(' ');
}

function main() {
const [mode, ...args] = process.argv.slice(2);
if (mode === 'review') {
if (args.length === 0 || args.some(name => !FEATURE_NAME.test(name))) {
process.stderr.write('Usage: node scripts/mosaic-parity-gate.mjs review <feature-name> [feature-name ...]\n');
process.exitCode = 1;
return;
}
process.stdout.write(`${reminder([...new Set(args)].sort(), true)}\n`);
return;
}
if (mode !== 'write' || args.length > 0) {
return;
}

let input;
try {
input = JSON.parse(readFileSync(0, 'utf8'));
} catch {
return;
}
if (
!input ||
typeof input.cwd !== 'string' ||
!isAbsolute(input.cwd) ||
input.cwd.includes('\0') ||
!input.tool_input
) {
return;
}

const tool = input.tool_input;
let paths;
if (input.tool_name === 'Write' || input.tool_name === 'Edit') {
paths = typeof tool.file_path === 'string' ? [tool.file_path] : [];
} else if (input.tool_name === 'apply_patch' && typeof tool.command === 'string') {
const patch = tool.command.trim();
if (!patch.startsWith('*** Begin Patch\n') || !patch.endsWith('\n*** End Patch')) {
return;
}
paths = [...patch.matchAll(/^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/gm)].map(match => match[1]);
} else {
return;
}
if (paths.length === 0 || paths.some(path => path.includes('\0'))) {
return;
}

const git = spawnSync('git', ['rev-parse', '--show-cdup'], { cwd: input.cwd, encoding: 'utf8' });
if (git.status !== 0) {
return;
}
const root = resolve(input.cwd, git.stdout.trim());
const features = [
...new Set(
paths.flatMap(path => {
const feature = relative(root, resolve(input.cwd, path)).match(FEATURE_FILE)?.[1];
return feature ? [feature] : [];
}),
),
].sort();
if (features.length > 0) {
process.stdout.write(
JSON.stringify({
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: reminder(features, false) },
}),
);
}
}

main();
Loading
Loading