From 04d931a75a2d044030b3c73d2899cc7332d86b2f Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 4 Nov 2026 11:55:27 -0700 Subject: [PATCH 1/8] docs(mosaic): make agents read the legacy component and run the parity audit for ports Ports of @clerk/react and @clerk/ui components are migrations. A PreToolUse hook blocks writing a new feature model until its legacy namesake has been read, and a Stop hook blocks finishing once until the parity audit subagent has run. AGENTS.md, migration.md and parity-audit.md say the same for agents that don't run Claude hooks. --- .changeset/mosaic-parity-gates.md | 2 + .claude/hooks/mosaic-hook-utils.mjs | 41 ++++++++++++++++++ .claude/hooks/require-mosaic-legacy-read.mjs | 41 ++++++++++++++++++ .claude/hooks/require-mosaic-parity-audit.mjs | 43 +++++++++++++++++++ .claude/settings.json | 17 ++++++++ packages/mosaic/AGENTS.md | 9 ++++ packages/mosaic/docs/migration.md | 6 ++- packages/mosaic/docs/parity-audit.md | 5 +++ 8 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 .changeset/mosaic-parity-gates.md create mode 100644 .claude/hooks/mosaic-hook-utils.mjs create mode 100644 .claude/hooks/require-mosaic-legacy-read.mjs create mode 100644 .claude/hooks/require-mosaic-parity-audit.mjs diff --git a/.changeset/mosaic-parity-gates.md b/.changeset/mosaic-parity-gates.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-parity-gates.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/hooks/mosaic-hook-utils.mjs b/.claude/hooks/mosaic-hook-utils.mjs new file mode 100644 index 00000000000..0005625964d --- /dev/null +++ b/.claude/hooks/mosaic-hook-utils.mjs @@ -0,0 +1,41 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export function read(path) { + try { + return readFileSync(path, 'utf8'); + } catch { + return ''; + } +} + +function parse(text) { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +export function readInput() { + return parse(read(0)) ?? {}; +} + +export function toolCalls(transcript) { + return transcript.split('\n').flatMap(line => { + const entry = parse(line); + const content = entry?.type === 'assistant' ? entry.message?.content : undefined; + return Array.isArray(content) ? content.filter(part => part?.type === 'tool_use') : []; + }); +} + +function pascalCase(kebab) { + return kebab.replace(/(^|-)([a-z0-9])/g, (_, __, char) => char.toUpperCase()); +} + +export function legacyCounterparts(repoRoot, feature) { + const name = pascalCase(feature); + return [join('packages/react/src/components', `${name}.tsx`), join('packages/ui/src/components', name)].filter(path => + existsSync(join(repoRoot, path)), + ); +} diff --git a/.claude/hooks/require-mosaic-legacy-read.mjs b/.claude/hooks/require-mosaic-legacy-read.mjs new file mode 100644 index 00000000000..9c8e27b32ac --- /dev/null +++ b/.claude/hooks/require-mosaic-legacy-read.mjs @@ -0,0 +1,41 @@ +import { existsSync } from 'node:fs'; +import { basename } from 'node:path'; + +import { legacyCounterparts, read, readInput, toolCalls } from './mosaic-hook-utils.mjs'; + +const input = readInput(); +const filePath = input.tool_input?.file_path ?? ''; +const newModel = filePath.match(/^(.*)\/packages\/mosaic\/src\/features\/([a-z0-9-]+)\/\2\.model\.tsx?$/); + +if (input.tool_name !== 'Write' || !newModel || existsSync(filePath)) { + process.exit(0); +} + +const transcript = read(input.agent_transcript_path || input.transcript_path || ''); + +if (!transcript) { + process.exit(0); +} + +const touched = toolCalls(transcript) + .filter(call => call.name !== 'Agent') + .map(call => JSON.stringify(call.input ?? {})); +const unread = legacyCounterparts(newModel[1], newModel[2]).filter( + path => !touched.some(input => input.includes(path)), +); + +if (unread.length > 0) { + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: [ + `\`${basename(filePath)}\` has a legacy counterpart: ${unread.join(', ')}.`, + 'This is a migration. Read the counterpart and write the Phase 1 behavior inventory from packages/mosaic/docs/migration.md', + '(one row per prop, default, guard, error path, loading state and click behavior) before writing the model, then retry.', + ].join(' '), + }, + }), + ); +} diff --git a/.claude/hooks/require-mosaic-parity-audit.mjs b/.claude/hooks/require-mosaic-parity-audit.mjs new file mode 100644 index 00000000000..5de327b6406 --- /dev/null +++ b/.claude/hooks/require-mosaic-parity-audit.mjs @@ -0,0 +1,43 @@ +import { legacyCounterparts, read, readInput, toolCalls } from './mosaic-hook-utils.mjs'; + +const input = readInput(); + +if (input.stop_hook_active) { + process.exit(0); +} + +const calls = toolCalls(read(input.transcript_path || '')); +const features = new Map(); + +for (const call of calls) { + if (!['Write', 'Edit', 'MultiEdit'].includes(call.name)) { + continue; + } + const match = String(call.input?.file_path ?? '').match(/^(.*)\/packages\/mosaic\/src\/features\/([a-z0-9-]+)\//); + if (match) { + features.set(match[2], match[1]); + } +} + +const migrations = [...features].flatMap(([feature, repoRoot]) => + legacyCounterparts(repoRoot, feature).map(legacy => ({ feature, legacy })), +); + +const audited = calls.some(call => call.name === 'Agent' && /parity/i.test(JSON.stringify(call.input ?? {}))); + +if (migrations.length === 0 || audited) { + process.exit(0); +} + +const list = migrations.map(({ feature, legacy }) => `${feature} (legacy: ${legacy})`).join(', '); + +process.stdout.write( + JSON.stringify({ + decision: 'block', + reason: [ + `This session changed Mosaic features with a legacy counterpart: ${list}.`, + 'Before finishing, launch the Explore subagent with the prompt in packages/mosaic/docs/parity-audit.md.', + 'Fix or raise with the user every row it reports as breaking a Mosaic rule, and list the remaining differences in the PR description.', + ].join(' '), + }), +); diff --git a/.claude/settings.json b/.claude/settings.json index eafc2715b1c..4fa276d2f99 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -35,6 +35,18 @@ ] } ], + "PreToolUse": [ + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/require-mosaic-legacy-read.mjs\"", + "timeout": 10 + } + ] + } + ], "Stop": [ { "hooks": [ @@ -47,6 +59,11 @@ "type": "command", "command": "cd \"$(git rev-parse --show-toplevel)/packages/swingset\" || exit 2; files=\"$(git diff --name-only --diff-filter=d HEAD -- ':(glob)src/**/*.ts' ':(glob)src/**/*.tsx' && git ls-files --others --exclude-standard -- ':(glob)src/**/*.ts' ':(glob)src/**/*.tsx')\" || exit 2; if [ -n \"$files\" ]; then pnpm lint >&2 || exit 2; fi", "timeout": 180 + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/require-mosaic-parity-audit.mjs\"", + "timeout": 10 } ] } diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 04fbe3de9e7..d5363c93081 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -58,6 +58,15 @@ Before you change a file that matches a pattern below, read its guide, even for - `src/**/*.view.tsx` → [Views](docs/views.md) - `src/**/__tests__/**`, `src/**/*.test.ts(x)`, `src/**/*.test-d.ts` → [Testing](docs/testing.md) +## Ports of legacy components are migrations + +Before you write a new feature, search `packages/react/src/components/` and `packages/ui/src/components/` for its counterpart. If one exists, the work is a [migration](docs/migration.md), including a headless port of a `@clerk/react` button framed as a new feature: + +1. Read the counterpart and write the Phase 1 inventory before the model. +2. Before you call the work done, review it, or open a PR, launch the Explore subagent with the prompt in [Parity audit](docs/parity-audit.md). Reading the doc is not running the audit. +3. List every **Deliberately changed** and **Deferred** row in the PR description. A difference that is not listed is a regression. +4. Listing a difference does not make it acceptable. If one breaks a rule in this file or in `docs/`, such as a failure that nobody sees, fix it or ask the user before you finish. Do not label it deliberate. + ## Layers in one paragraph A flow lives in `src/features//`. The **model** (`*.model.ts(x)`) is the only file that reads Clerk hooks or calls Clerk resources, and answers with plain data, plain callbacks and a `status`. The **controller** (`*.controller.ts(x)`) holds local state and wraps the model's callbacks for pending, errors and closing. The **view** (`*.view.tsx`) renders plain props. No Clerk resource reaches the controller and no machine snapshot reaches the view. The file shape and how a wrapper composes the layers are in [Features](docs/features.md). diff --git a/packages/mosaic/docs/migration.md b/packages/mosaic/docs/migration.md index a426c526c54..629adf77a4c 100644 --- a/packages/mosaic/docs/migration.md +++ b/packages/mosaic/docs/migration.md @@ -15,7 +15,11 @@ Worked example: `src/features/reverification/`. ## 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//`, the `@clerk/react` component under +`packages/react/src/components/`, or both. A headless port of a `@clerk/react` +button is a migration too. Then grep them for the primitives that carry hidden logic. Every +hit is a row you must consciously place or drop later: | Primitive | The behavior it usually hides | | --------------------------------- | -------------------------------------------------- | diff --git a/packages/mosaic/docs/parity-audit.md b/packages/mosaic/docs/parity-audit.md index 40dc17d5df0..299a4d29169 100644 --- a/packages/mosaic/docs/parity-audit.md +++ b/packages/mosaic/docs/parity-audit.md @@ -36,6 +36,11 @@ 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). + +Then check every row that is not Migrated against packages/mosaic/AGENTS.md and +packages/mosaic/docs/. List each one that breaks a rule there, with the rule it +breaks, such as a failure that is never shown or logged. These must be fixed or +raised with the user; they cannot ship as Deliberately changed. ``` To find the legacy behaviors, start from the search in [Migration → Phase 1](migration.md#phase-1-inventory-the-legacy-behavior). From f0210462bd6abe8b2bc88f7c31ebf55d84ba65d5 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 4 Nov 2026 12:37:56 -0700 Subject: [PATCH 2/8] docs(mosaic): count only real reads and require an audit per ported feature The legacy-read hook only counts Read, Grep, Glob and Bash calls, so a write that mentions the legacy path no longer passes. The Stop hook requires a parity audit that names each ported feature. The Phase 1 search command in migration.md and parity-audit.md now includes the @clerk/react component. --- .claude/hooks/require-mosaic-legacy-read.mjs | 15 +++++++++++---- .claude/hooks/require-mosaic-parity-audit.mjs | 12 +++++++++--- packages/mosaic/docs/migration.md | 2 +- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.claude/hooks/require-mosaic-legacy-read.mjs b/.claude/hooks/require-mosaic-legacy-read.mjs index 9c8e27b32ac..0f6ec630b33 100644 --- a/.claude/hooks/require-mosaic-legacy-read.mjs +++ b/.claude/hooks/require-mosaic-legacy-read.mjs @@ -17,11 +17,18 @@ if (!transcript) { process.exit(0); } -const touched = toolCalls(transcript) - .filter(call => call.name !== 'Agent') - .map(call => JSON.stringify(call.input ?? {})); +const READ_TARGETS = { + Read: input => input.file_path, + Grep: input => input.path, + Glob: input => `${input.path ?? ''}/${input.pattern ?? ''}`, + Bash: input => input.command, +}; + +const reads = toolCalls(transcript) + .filter(call => call.name in READ_TARGETS) + .map(call => String(READ_TARGETS[call.name](call.input ?? {}) ?? '')); const unread = legacyCounterparts(newModel[1], newModel[2]).filter( - path => !touched.some(input => input.includes(path)), + path => !reads.some(target => target.includes(path)), ); if (unread.length > 0) { diff --git a/.claude/hooks/require-mosaic-parity-audit.mjs b/.claude/hooks/require-mosaic-parity-audit.mjs index 5de327b6406..61f8910eda4 100644 --- a/.claude/hooks/require-mosaic-parity-audit.mjs +++ b/.claude/hooks/require-mosaic-parity-audit.mjs @@ -23,13 +23,19 @@ const migrations = [...features].flatMap(([feature, repoRoot]) => legacyCounterparts(repoRoot, feature).map(legacy => ({ feature, legacy })), ); -const audited = calls.some(call => call.name === 'Agent' && /parity/i.test(JSON.stringify(call.input ?? {}))); +const audits = calls + .filter(call => call.name === 'Agent') + .map(call => JSON.stringify(call.input ?? {})) + .filter(input => /parity/i.test(input)); +const unaudited = migrations.filter( + ({ feature, legacy }) => !audits.some(input => input.includes(legacy) || input.includes(`/features/${feature}/`)), +); -if (migrations.length === 0 || audited) { +if (unaudited.length === 0) { process.exit(0); } -const list = migrations.map(({ feature, legacy }) => `${feature} (legacy: ${legacy})`).join(', '); +const list = unaudited.map(({ feature, legacy }) => `${feature} (legacy: ${legacy})`).join(', '); process.stdout.write( JSON.stringify({ diff --git a/packages/mosaic/docs/migration.md b/packages/mosaic/docs/migration.md index 629adf77a4c..c53a74f880d 100644 --- a/packages/mosaic/docs/migration.md +++ b/packages/mosaic/docs/migration.md @@ -34,7 +34,7 @@ hit is a row you must consciously place or drop later: ```bash rg -n 'useEffect|handleError|card\.setError|useReverification|revalidate|/ + packages/ui/src/components// packages/react/src/components/.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). From 19d0b007f0603bf2c2dae4c59b1580442f74fd6b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 4 Nov 2026 14:28:52 -0700 Subject: [PATCH 3/8] docs(mosaic): check parity with a proof file that Claude and Codex hooks can both read --- .claude/hooks/mosaic-hook-utils.mjs | 41 ---- .claude/hooks/require-mosaic-legacy-read.mjs | 48 ----- .claude/hooks/require-mosaic-parity-audit.mjs | 49 ----- .claude/settings.json | 6 +- .codex/hooks.json | 17 ++ packages/mosaic/AGENTS.md | 6 +- packages/mosaic/docs/migration.md | 15 ++ packages/mosaic/docs/parity-audit.md | 2 +- scripts/mosaic-parity-gate.mjs | 184 ++++++++++++++++++ scripts/mosaic-parity-gate.test.mjs | 56 ++++++ 10 files changed, 280 insertions(+), 144 deletions(-) delete mode 100644 .claude/hooks/mosaic-hook-utils.mjs delete mode 100644 .claude/hooks/require-mosaic-legacy-read.mjs delete mode 100644 .claude/hooks/require-mosaic-parity-audit.mjs create mode 100644 scripts/mosaic-parity-gate.mjs create mode 100644 scripts/mosaic-parity-gate.test.mjs diff --git a/.claude/hooks/mosaic-hook-utils.mjs b/.claude/hooks/mosaic-hook-utils.mjs deleted file mode 100644 index 0005625964d..00000000000 --- a/.claude/hooks/mosaic-hook-utils.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -export function read(path) { - try { - return readFileSync(path, 'utf8'); - } catch { - return ''; - } -} - -function parse(text) { - try { - return JSON.parse(text); - } catch { - return undefined; - } -} - -export function readInput() { - return parse(read(0)) ?? {}; -} - -export function toolCalls(transcript) { - return transcript.split('\n').flatMap(line => { - const entry = parse(line); - const content = entry?.type === 'assistant' ? entry.message?.content : undefined; - return Array.isArray(content) ? content.filter(part => part?.type === 'tool_use') : []; - }); -} - -function pascalCase(kebab) { - return kebab.replace(/(^|-)([a-z0-9])/g, (_, __, char) => char.toUpperCase()); -} - -export function legacyCounterparts(repoRoot, feature) { - const name = pascalCase(feature); - return [join('packages/react/src/components', `${name}.tsx`), join('packages/ui/src/components', name)].filter(path => - existsSync(join(repoRoot, path)), - ); -} diff --git a/.claude/hooks/require-mosaic-legacy-read.mjs b/.claude/hooks/require-mosaic-legacy-read.mjs deleted file mode 100644 index 0f6ec630b33..00000000000 --- a/.claude/hooks/require-mosaic-legacy-read.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import { existsSync } from 'node:fs'; -import { basename } from 'node:path'; - -import { legacyCounterparts, read, readInput, toolCalls } from './mosaic-hook-utils.mjs'; - -const input = readInput(); -const filePath = input.tool_input?.file_path ?? ''; -const newModel = filePath.match(/^(.*)\/packages\/mosaic\/src\/features\/([a-z0-9-]+)\/\2\.model\.tsx?$/); - -if (input.tool_name !== 'Write' || !newModel || existsSync(filePath)) { - process.exit(0); -} - -const transcript = read(input.agent_transcript_path || input.transcript_path || ''); - -if (!transcript) { - process.exit(0); -} - -const READ_TARGETS = { - Read: input => input.file_path, - Grep: input => input.path, - Glob: input => `${input.path ?? ''}/${input.pattern ?? ''}`, - Bash: input => input.command, -}; - -const reads = toolCalls(transcript) - .filter(call => call.name in READ_TARGETS) - .map(call => String(READ_TARGETS[call.name](call.input ?? {}) ?? '')); -const unread = legacyCounterparts(newModel[1], newModel[2]).filter( - path => !reads.some(target => target.includes(path)), -); - -if (unread.length > 0) { - process.stdout.write( - JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: [ - `\`${basename(filePath)}\` has a legacy counterpart: ${unread.join(', ')}.`, - 'This is a migration. Read the counterpart and write the Phase 1 behavior inventory from packages/mosaic/docs/migration.md', - '(one row per prop, default, guard, error path, loading state and click behavior) before writing the model, then retry.', - ].join(' '), - }, - }), - ); -} diff --git a/.claude/hooks/require-mosaic-parity-audit.mjs b/.claude/hooks/require-mosaic-parity-audit.mjs deleted file mode 100644 index 61f8910eda4..00000000000 --- a/.claude/hooks/require-mosaic-parity-audit.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import { legacyCounterparts, read, readInput, toolCalls } from './mosaic-hook-utils.mjs'; - -const input = readInput(); - -if (input.stop_hook_active) { - process.exit(0); -} - -const calls = toolCalls(read(input.transcript_path || '')); -const features = new Map(); - -for (const call of calls) { - if (!['Write', 'Edit', 'MultiEdit'].includes(call.name)) { - continue; - } - const match = String(call.input?.file_path ?? '').match(/^(.*)\/packages\/mosaic\/src\/features\/([a-z0-9-]+)\//); - if (match) { - features.set(match[2], match[1]); - } -} - -const migrations = [...features].flatMap(([feature, repoRoot]) => - legacyCounterparts(repoRoot, feature).map(legacy => ({ feature, legacy })), -); - -const audits = calls - .filter(call => call.name === 'Agent') - .map(call => JSON.stringify(call.input ?? {})) - .filter(input => /parity/i.test(input)); -const unaudited = migrations.filter( - ({ feature, legacy }) => !audits.some(input => input.includes(legacy) || input.includes(`/features/${feature}/`)), -); - -if (unaudited.length === 0) { - process.exit(0); -} - -const list = unaudited.map(({ feature, legacy }) => `${feature} (legacy: ${legacy})`).join(', '); - -process.stdout.write( - JSON.stringify({ - decision: 'block', - reason: [ - `This session changed Mosaic features with a legacy counterpart: ${list}.`, - 'Before finishing, launch the Explore subagent with the prompt in packages/mosaic/docs/parity-audit.md.', - 'Fix or raise with the user every row it reports as breaking a Mosaic rule, and list the remaining differences in the PR description.', - ].join(' '), - }), -); diff --git a/.claude/settings.json b/.claude/settings.json index 4fa276d2f99..79296baadff 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -41,7 +41,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/require-mosaic-legacy-read.mjs\"", + "command": "node \"$(git rev-parse --show-toplevel)/scripts/mosaic-parity-gate.mjs\" write", "timeout": 10 } ] @@ -62,8 +62,8 @@ }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/require-mosaic-parity-audit.mjs\"", - "timeout": 10 + "command": "node \"$(git rev-parse --show-toplevel)/scripts/mosaic-parity-gate.mjs\" stop", + "timeout": 30 } ] } diff --git a/.codex/hooks.json b/.codex/hooks.json index 9c06bbed754..472f974ed9b 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -12,6 +12,23 @@ "type": "command", "command": "cd \"$(git rev-parse --show-toplevel)/packages/swingset\" || exit 2; files=\"$(git diff --name-only --diff-filter=d HEAD -- ':(glob)src/**/*.ts' ':(glob)src/**/*.tsx' && git ls-files --others --exclude-standard -- ':(glob)src/**/*.ts' ':(glob)src/**/*.tsx')\" || exit 2; if [ -n \"$files\" ]; then pnpm lint >&2 || exit 2; fi", "timeout": 180 + }, + { + "type": "command", + "command": "node \"$(git rev-parse --show-toplevel)/scripts/mosaic-parity-gate.mjs\" stop", + "timeout": 30 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "apply_patch|Edit|Write", + "hooks": [ + { + "type": "command", + "command": "node \"$(git rev-parse --show-toplevel)/scripts/mosaic-parity-gate.mjs\" write", + "timeout": 10 } ] } diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index d5363c93081..ff6e669f8a2 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -62,11 +62,13 @@ Before you change a file that matches a pattern below, read its guide, even for Before you write a new feature, search `packages/react/src/components/` and `packages/ui/src/components/` for its counterpart. If one exists, the work is a [migration](docs/migration.md), including a headless port of a `@clerk/react` button framed as a new feature: -1. Read the counterpart and write the Phase 1 inventory before the model. -2. Before you call the work done, review it, or open a PR, launch the Explore subagent with the prompt in [Parity audit](docs/parity-audit.md). Reading the doc is not running the audit. +1. Read the counterpart and write the Phase 1 inventory to `tmp/mosaic-parity/.md` before the model. The file names the legacy path and has an `## Inventory` section. +2. Before you call the work done, review it, or open a PR, run the [parity audit](docs/parity-audit.md) in a read-only subagent and put its table under `## Parity audit` in the same file. Reading the doc is not running the audit. 3. List every **Deliberately changed** and **Deferred** row in the PR description. A difference that is not listed is a regression. 4. Listing a difference does not make it acceptable. If one breaks a rule in this file or in `docs/`, such as a failure that nobody sees, fix it or ask the user before you finish. Do not label it deliberate. +`tmp/` is ignored by git, so the file stays out of the PR. Hooks in `.claude/settings.json` and `.codex/hooks.json` (`scripts/mosaic-parity-gate.mjs`) check it: one blocks writing a new feature's model until the inventory exists, and one blocks finishing until the audit is in. + ## Layers in one paragraph A flow lives in `src/features//`. The **model** (`*.model.ts(x)`) is the only file that reads Clerk hooks or calls Clerk resources, and answers with plain data, plain callbacks and a `status`. The **controller** (`*.controller.ts(x)`) holds local state and wraps the model's callbacks for pending, errors and closing. The **view** (`*.view.tsx`) renders plain props. No Clerk resource reaches the controller and no machine snapshot reaches the view. The file shape and how a wrapper composes the layers are in [Features](docs/features.md). diff --git a/packages/mosaic/docs/migration.md b/packages/mosaic/docs/migration.md index c53a74f880d..4dea9c03250 100644 --- a/packages/mosaic/docs/migration.md +++ b/packages/mosaic/docs/migration.md @@ -39,6 +39,19 @@ rg -n 'useEffect|handleError|card\.setError|useReverification|revalidate|.md` (ignored by git) before the model: + +```md +# sign-out-button parity + +Legacy: packages/react/src/components/SignOutButton.tsx + +## Inventory + +- `redirectUrl` defaults to `/` +- … +``` + ## 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)). @@ -62,6 +75,8 @@ Test with one `*.feature.test.tsx` that uses the feature the way a user would ([ 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. +Launch a read-only subagent with the prompt in `parity-audit.md`. Give it the legacy file paths and the new model/controller/view paths. Put its table under `## Parity audit` in `tmp/mosaic-parity/.md`, next to the Phase 1 inventory. The table guides the work and the PR discussion, and is never committed. + ## Phase 5: ship Tests green, then a changeset and a conventional commit with scope `mosaic` (the `clerk-monorepo` skill has the dev loop and changeset rules). If the migration removes or changes legacy code in `packages/ui`, keep its public surface backwards compatible: non-major `packages/ui` releases load into older SDKs in the wild. diff --git a/packages/mosaic/docs/parity-audit.md b/packages/mosaic/docs/parity-audit.md index 299a4d29169..b2a8aaa33d9 100644 --- a/packages/mosaic/docs/parity-audit.md +++ b/packages/mosaic/docs/parity-audit.md @@ -7,7 +7,7 @@ Read this when you review whether a migrated feature kept every legacy behavior, - 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. - 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 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. +- Run it as a read-only subagent that returns a conclusion, not file dumps. Put its table under `## Parity audit` in `tmp/mosaic-parity/.md`, next to the Phase 1 inventory. The table guides the work and the PR discussion; nothing is committed. ## The subagent prompt diff --git a/scripts/mosaic-parity-gate.mjs b/scripts/mosaic-parity-gate.mjs new file mode 100644 index 00000000000..2da84a798aa --- /dev/null +++ b/scripts/mosaic-parity-gate.mjs @@ -0,0 +1,184 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const PROOF_DIR = 'tmp/mosaic-parity'; +export const MAX_BLOCKS = 3; + +const FEATURE_MODEL = /^packages\/mosaic\/src\/features\/([a-z0-9-]+)\/\1\.model\.tsx?$/; +const FEATURE_FILE = /^packages\/mosaic\/src\/features\/([a-z0-9-]+)\//; + +function pascalCase(kebab) { + return kebab.replace(/(^|-)([a-z0-9])/g, (_, __, char) => char.toUpperCase()); +} + +export function legacyCounterparts(root, feature) { + const name = pascalCase(feature); + return [`packages/react/src/components/${name}.tsx`, `packages/ui/src/components/${name}`].filter(path => + existsSync(join(root, path)), + ); +} + +export function proofPath(feature) { + return `${PROOF_DIR}/${feature}.md`; +} + +function section(text, heading) { + const match = text.match(new RegExp(`^##\\s+${heading}\\s*$([\\s\\S]*?)(?=^##\\s|(?![\\s\\S]))`, 'im')); + return match ? match[1].trim() : ''; +} + +export function inventoryProblem(proof, legacy) { + if (!proof) { + return 'missing'; + } + const missing = legacy.filter(path => !proof.includes(path)); + if (missing.length > 0) { + return `does not name ${missing.join(', ')}`; + } + return section(proof, 'Inventory') ? undefined : 'has no `## Inventory` section'; +} + +export function auditProblem(proof) { + if (!proof) { + return 'missing'; + } + return section(proof, 'Parity audit') ? undefined : 'has no `## Parity audit` section'; +} + +export function createdFiles(input, root) { + const tool = input.tool_input ?? {}; + const cwd = input.cwd ?? root; + const toRepo = path => relative(root, isAbsolute(path) ? path : join(cwd, path)); + + if (typeof tool.file_path === 'string') { + return existsSync(isAbsolute(tool.file_path) ? tool.file_path : join(cwd, tool.file_path)) + ? [] + : [toRepo(tool.file_path)]; + } + if (typeof tool.command === 'string') { + return [...tool.command.matchAll(/^\*\*\* Add File: (.+)$/gm)].map(match => toRepo(match[1].trim())); + } + return []; +} + +function run(args, cwd) { + return spawnSync('git', args, { cwd, encoding: 'utf8' }).stdout ?? ''; +} + +function readText(path) { + try { + return readFileSync(path, 'utf8'); + } catch { + return ''; + } +} + +function readInput() { + try { + return JSON.parse(readFileSync(0, 'utf8')); + } catch { + return {}; + } +} + +function newFeatures(root) { + const base = run(['merge-base', 'HEAD', 'origin/main'], root).trim() || 'HEAD'; + const changed = `${run(['diff', '--name-only', '--diff-filter=d', base, '--', 'packages/mosaic/src/features'], root)}\n${run(['ls-files', '--others', '--exclude-standard', '--', 'packages/mosaic/src/features'], root)}`; + const features = new Set( + changed + .split('\n') + .map(file => file.match(FEATURE_FILE)?.[1]) + .filter(Boolean), + ); + return [...features].filter( + feature => !run(['ls-tree', '--name-only', base, `packages/mosaic/src/features/${feature}`], root).trim(), + ); +} + +function beforeWrite(input, root) { + const blocked = createdFiles(input, root).flatMap(file => { + const feature = file.match(FEATURE_MODEL)?.[1]; + const legacy = feature ? legacyCounterparts(root, feature) : []; + const problem = legacy.length > 0 ? inventoryProblem(readText(join(root, proofPath(feature))), legacy) : undefined; + return problem ? [{ feature, legacy, problem }] : []; + }); + + if (blocked.length === 0) { + return; + } + + const reason = blocked + .map( + ({ feature, legacy, problem }) => + `\`${feature}\` ports ${legacy.join(' and ')}, and ${proofPath(feature)} ${problem}. Read the legacy component, then write ${proofPath(feature)} with the legacy path and an \`## Inventory\` section (one row per prop, default, guard, error path, loading state and click behavior), as packages/mosaic/docs/migration.md describes. Then retry this edit.`, + ) + .join(' '); + + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason }, + }), + ); +} + +function beforeStop(input, root) { + const unaudited = newFeatures(root).flatMap(feature => { + const legacy = legacyCounterparts(root, feature); + const problem = legacy.length > 0 ? auditProblem(readText(join(root, proofPath(feature)))) : undefined; + return problem ? [{ feature, legacy, problem }] : []; + }); + + const stateDir = join(tmpdir(), 'clerk-mosaic-parity-gate'); + const statePath = join(stateDir, `${String(input.session_id ?? 'unknown').replace(/[^\w-]/g, '_')}.json`); + + if (unaudited.length === 0) { + return; + } + + let state; + try { + state = JSON.parse(readFileSync(statePath, 'utf8')); + } catch { + state = { blocks: 0 }; + } + if (state.blocks >= MAX_BLOCKS) { + return; + } + mkdirSync(stateDir, { recursive: true }); + writeFileSync(statePath, JSON.stringify({ blocks: state.blocks + 1 })); + + const list = unaudited.map( + ({ feature, legacy, problem }) => `${feature} (legacy: ${legacy.join(', ')}; ${proofPath(feature)} ${problem})`, + ); + + process.stdout.write( + JSON.stringify({ + decision: 'block', + reason: [ + `This branch adds Mosaic features that port a legacy component: ${list.join('; ')}.`, + 'Before finishing, run the parity audit in packages/mosaic/docs/parity-audit.md and put its table under `## Parity audit` in that file.', + 'Fix or raise with the user every row that breaks a Mosaic rule, and list the remaining differences in the PR description.', + ].join(' '), + }), + ); +} + +function main() { + const input = readInput(); + const root = run(['rev-parse', '--show-toplevel'], input.cwd ?? process.cwd()).trim(); + if (!root) { + return; + } + if (process.argv[2] === 'write') { + beforeWrite(input, root); + } else if (process.argv[2] === 'stop') { + beforeStop(input, root); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + main(); +} diff --git a/scripts/mosaic-parity-gate.test.mjs b/scripts/mosaic-parity-gate.test.mjs new file mode 100644 index 00000000000..dcc5d23bbaf --- /dev/null +++ b/scripts/mosaic-parity-gate.test.mjs @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'vitest'; + +import { auditProblem, createdFiles, inventoryProblem } from './mosaic-parity-gate.mjs'; + +const root = '/repo'; +const legacy = ['packages/react/src/components/SignOutButton.tsx']; + +describe('createdFiles', () => { + test('reads a Claude Write of a new file', () => { + const input = { tool_input: { file_path: '/repo/packages/mosaic/src/features/x/x.model.ts' } }; + expect(createdFiles(input, root)).toEqual(['packages/mosaic/src/features/x/x.model.ts']); + }); + + test('reads every added file in a Codex apply_patch', () => { + const command = [ + '*** Begin Patch', + '*** Add File: packages/mosaic/src/features/x/x.model.ts', + '+export {};', + '*** Update File: packages/mosaic/src/localization/registry.ts', + '*** Add File: /repo/packages/mosaic/src/features/x/x.view.tsx', + '*** End Patch', + ].join('\n'); + expect(createdFiles({ cwd: root, tool_input: { command } }, root)).toEqual([ + 'packages/mosaic/src/features/x/x.model.ts', + 'packages/mosaic/src/features/x/x.view.tsx', + ]); + }); +}); + +describe('inventoryProblem', () => { + test('requires the proof file', () => { + expect(inventoryProblem('', legacy)).toBe('missing'); + }); + + test('requires the legacy path', () => { + expect(inventoryProblem('## Inventory\n- redirectUrl defaults to /', legacy)).toMatch(/does not name/); + }); + + test('requires a non-empty inventory', () => { + expect(inventoryProblem(`Legacy: ${legacy[0]}\n## Inventory\n\n## Parity audit\n`, legacy)).toMatch(/Inventory/); + }); + + test('passes a filled inventory', () => { + expect(inventoryProblem(`Legacy: ${legacy[0]}\n## Inventory\n- redirectUrl defaults to /`, legacy)).toBeUndefined(); + }); +}); + +describe('auditProblem', () => { + test('requires a non-empty audit section', () => { + expect(auditProblem('## Inventory\n- a\n## Parity audit\n')).toMatch(/Parity audit/); + }); + + test('passes a filled audit section', () => { + expect(auditProblem('## Inventory\n- a\n## Parity audit\n| row | Migrated |')).toBeUndefined(); + }); +}); From 77c4f085d96755f9a884a63e656c36b287257956 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 4 Nov 2026 14:41:09 -0700 Subject: [PATCH 4/8] docs(mosaic): only start the Claude write check for Mosaic feature files --- .claude/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index 79296baadff..1807baad0dd 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -42,7 +42,8 @@ { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/scripts/mosaic-parity-gate.mjs\" write", - "timeout": 10 + "timeout": 10, + "if": "Write(/packages/mosaic/src/features/**)" } ] } From ebe9ddb9dbef842edb6b691ca3936a19b2349dfd Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 5 Nov 2026 11:04:39 -0700 Subject: [PATCH 5/8] fix(repo): scope Mosaic parity reminders to current edits --- .claude/settings.json | 10 +- .codex/hooks.json | 5 - packages/mosaic/AGENTS.md | 11 +- packages/mosaic/docs/migration.md | 14 +- packages/mosaic/docs/parity-audit.md | 181 +++++++++++++---- scripts/mosaic-parity-gate.mjs | 228 +++++++--------------- scripts/mosaic-parity-gate.test.mjs | 278 +++++++++++++++++++++++---- 7 files changed, 472 insertions(+), 255 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 1807baad0dd..2d8562d83c0 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -37,13 +37,12 @@ ], "PreToolUse": [ { - "matcher": "Write", + "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/scripts/mosaic-parity-gate.mjs\" write", - "timeout": 10, - "if": "Write(/packages/mosaic/src/features/**)" + "timeout": 10 } ] } @@ -60,11 +59,6 @@ "type": "command", "command": "cd \"$(git rev-parse --show-toplevel)/packages/swingset\" || exit 2; files=\"$(git diff --name-only --diff-filter=d HEAD -- ':(glob)src/**/*.ts' ':(glob)src/**/*.tsx' && git ls-files --others --exclude-standard -- ':(glob)src/**/*.ts' ':(glob)src/**/*.tsx')\" || exit 2; if [ -n \"$files\" ]; then pnpm lint >&2 || exit 2; fi", "timeout": 180 - }, - { - "type": "command", - "command": "node \"$(git rev-parse --show-toplevel)/scripts/mosaic-parity-gate.mjs\" stop", - "timeout": 30 } ] } diff --git a/.codex/hooks.json b/.codex/hooks.json index 472f974ed9b..fe1494fe991 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -12,11 +12,6 @@ "type": "command", "command": "cd \"$(git rev-parse --show-toplevel)/packages/swingset\" || exit 2; files=\"$(git diff --name-only --diff-filter=d HEAD -- ':(glob)src/**/*.ts' ':(glob)src/**/*.tsx' && git ls-files --others --exclude-standard -- ':(glob)src/**/*.ts' ':(glob)src/**/*.tsx')\" || exit 2; if [ -n \"$files\" ]; then pnpm lint >&2 || exit 2; fi", "timeout": 180 - }, - { - "type": "command", - "command": "node \"$(git rev-parse --show-toplevel)/scripts/mosaic-parity-gate.mjs\" stop", - "timeout": 30 } ] } diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index ff6e669f8a2..35c99ab3d7c 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -60,14 +60,15 @@ Before you change a file that matches a pattern below, read its guide, even for ## Ports of legacy components are migrations -Before you write a new feature, search `packages/react/src/components/` and `packages/ui/src/components/` for its counterpart. If one exists, the work is a [migration](docs/migration.md), including a headless port of a `@clerk/react` button framed as a new feature: +For a current task that implements or reviews a Mosaic migration, or changes its migrated behavior, search `packages/react/src/components/` and `packages/ui/src/components/` for the counterpart. A headless port framed as a new feature is a [migration](docs/migration.md) too. Unrelated work does not acquire this workflow merely because the branch already contains Mosaic changes. -1. Read the counterpart and write the Phase 1 inventory to `tmp/mosaic-parity/.md` before the model. The file names the legacy path and has an `## Inventory` section. +1. Read the counterpart, shared prop types, and relevant helper/runtime calls. Write the Phase 1 inventory to `tmp/mosaic-parity/.md` before the model. Record the task scope, exact revisions, accepted differences, and exclusions alongside the legacy path and `## Inventory` section. 2. Before you call the work done, review it, or open a PR, run the [parity audit](docs/parity-audit.md) in a read-only subagent and put its table under `## Parity audit` in the same file. Reading the doc is not running the audit. -3. List every **Deliberately changed** and **Deferred** row in the PR description. A difference that is not listed is a regression. -4. Listing a difference does not make it acceptable. If one breaks a rule in this file or in `docs/`, such as a failure that nobody sees, fix it or ask the user before you finish. Do not label it deliberate. +3. Use the audit's classifications to separate parity gaps, intentional differences needing disclosure, missing verification, and optional improvements. List intentional differences and approved deferrals in the PR description. Do not require inherited improvements or refactors as parity fixes. +4. Listing a difference does not make it acceptable. Cite any conflicting rule in this file or `docs/`, and fix the gap or raise it with the user. Do not infer intentional behavior from the implementation. Preserve decisions the user already made, and leave unresolved product choices open. +5. Inspect the actual results of focused type and behavior checks. For relevant async actions, include a representative failure through the existing fake FAPI or supported browser setup. A populated note is process bookkeeping, not proof of parity. -`tmp/` is ignored by git, so the file stays out of the PR. Hooks in `.claude/settings.json` and `.codex/hooks.json` (`scripts/mosaic-parity-gate.mjs`) check it: one blocks writing a new feature's model until the inventory exists, and one blocks finishing until the audit is in. +`tmp/` is ignored by git, so the file stays out of the PR. The hooks in `.claude/settings.json` and `.codex/hooks.json` (`scripts/mosaic-parity-gate.mjs`) give conditional guidance for the feature paths in the current edit. They do not inspect branch history or notes, deny edits, or block completion. For a read-only parity review, run the explicit scoped command in [Parity audit](docs/parity-audit.md). These reminders do not run the audit or verify its evidence. ## Layers in one paragraph diff --git a/packages/mosaic/docs/migration.md b/packages/mosaic/docs/migration.md index 4dea9c03250..966154286b0 100644 --- a/packages/mosaic/docs/migration.md +++ b/packages/mosaic/docs/migration.md @@ -4,6 +4,8 @@ Read this when you move a legacy component (usually under `packages/ui/src/compo Worked example: `src/features/reverification/`. +Apply this workflow to the current requested Mosaic work, not every Mosaic change already on the branch. Record the feature scope, original revisions, accepted differences, and exclusions. When reviewing earlier feedback, use the commit the reviewer saw, not a later implementation that may already have addressed it. + ## 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. @@ -39,6 +41,12 @@ rg -n 'useEffect|handleError|card\.setError|useReverification|revalidate|.md` (ignored by git) before the model: ```md @@ -73,9 +81,11 @@ 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. +For each affected async action, exercise a representative rejection as well as success and pending work. Verify error delivery and the final rendered/resource state, including recovery where promised. Do not replace a Clerk mutation with a method mock when the existing fake FAPI can drive it. Check mode-dependent prop contracts with focused type checks and verify forwarding in the relevant modes. + +Tests only cover branches you remembered to write. Launch a read-only subagent with the prompt in [Parity audit](parity-audit.md). Give it the legacy and new revisions, file paths, shared types, and accepted decisions. Use the classifications and evidence columns there. Distinguish parity gaps, intentional differences needing disclosure, missing verification, and optional improvements. Leave unresolved intent as a decision for the user. -Launch a read-only subagent with the prompt in `parity-audit.md`. Give it the legacy file paths and the new model/controller/view paths. Put its table under `## Parity audit` in `tmp/mosaic-parity/.md`, next to the Phase 1 inventory. The table guides the work and the PR discussion, and is never committed. +Every inventory row from Phase 1 must have a conclusion and evidence. Put the table under `## Parity audit` in `tmp/mosaic-parity/.md`, next to the Phase 1 inventory. The table guides the work and the PR discussion, and is never committed. Read the results of its referenced checks before claiming parity. A filled table, a tracked deferral, or a hook reminder is not proof that the behavior works. ## Phase 5: ship diff --git a/packages/mosaic/docs/parity-audit.md b/packages/mosaic/docs/parity-audit.md index b2a8aaa33d9..27eafe3d83c 100644 --- a/packages/mosaic/docs/parity-audit.md +++ b/packages/mosaic/docs/parity-audit.md @@ -1,60 +1,167 @@ -# Parity audit +# Parity audit — Phase 4 reference -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). +The parity audit is the confidence step of a Mosaic migration. It diffs the +legacy implementation and shared API contract against the new behavior, +including the helpers and Clerk runtime they call. -## Rules +Use this workflow when the current task implements or reviews a Mosaic +migration or changes its migrated behavior. Do not start it for unrelated work +because the branch already contains Mosaic changes. Record the task's component +scope and exact legacy/new revisions before comparing. A renamed counterpart +still counts; a same-name match is only a discovery shortcut. +A shared-library task does not trigger this full workflow solely because Mosaic +is a downstream consumer. -- 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. -- 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 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. Put its table under `## Parity audit` in `tmp/mosaic-parity/.md`, next to the Phase 1 inventory. The table guides the work and the PR discussion; nothing is committed. +For a requested read-only parity review, name the current task's feature folders +explicitly from the repository root: + +```bash +node scripts/mosaic-parity-gate.mjs review sign-out-button sign-up-button +``` + +This command prints scoped guidance without changing files or registering scope +for later requests. It does not run an audit. Use feature names from the request, +not every changed feature on the branch. A renamed or historical feature can be +named even when its folder is absent from the current checkout. + +Edit hooks provide the same guidance for current feature paths. A style-only edit +does not require a behavioral audit. The reminder is conditional on changing +migrated behavior, and emits at most once per tool action. Shell-only edits and +read-only reviews without the explicit command have no automatic reminder. +There is no parity Stop hook or completion gate. Follow this workflow whenever +the current task calls for it, whether a reminder appeared or not. + +Run it as a read-only subagent (Explore in Claude Code) that returns a +conclusion, not file dumps. Put its table under `## Parity audit` in +`tmp/mosaic-parity/.md`, next to the Phase 1 inventory. The table guides +the work and the PR discussion. `tmp/` is ignored by git, so nothing is committed. +A populated note proves neither parity nor execution of the checks. Inspect the +source citations and actual test results, and distinguish measured outcomes from +source-only conclusions and checks that could not run. ## The subagent prompt -Fill in the two file lists and hand this to a read-only agent: +Fill in the scope, revisions, and file lists and hand this to an Explore agent: ``` In the repo at , audit a Mosaic migration for behavioral parity. +CURRENT TASK: +REVISIONS: +ACCEPTED DIFFERENCES: + LEGACY component files (the spec — behavior must be preserved or consciously changed): NEW Mosaic files (model / controller / view / wrapper): -Enumerate EVERY behavior in the legacy files — each: effect, guard, error path, +Read the shared public prop types, relevant callers, and Mosaic rules as well. +Follow calls into shared helpers and Clerk runtime until you can establish the +observable outcome. Do not stop at matching method names or argument objects. + +Enumerate EVERY relevant behavior — each: prop and mode-dependent option, default, +callback order/awaiting, 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` +the new implementation and classify it: + + - Preserved: same observable contract, with source and verification evidence. + - Parity gap: introduced loss or incompatible contract; identify the failing condition. + - Intentional difference: cite the user decision or Mosaic rule, and required disclosure. + If intent is not established, mark Decision needed rather than inventing approval. + - Verification gap: relevant outcome is untested or blocked; name the missing probe. + - Optional improvement: inherited defect or refactor outside the migration contract. + Keep it separate; do not require a fix to complete parity work. + +For each in-scope action check success, pending, and a representative rejection. +Use the existing real-Clerk/fake-FAPI feature harness where it can exercise the +behavior. Assert final DOM, error delivery, resource state, and navigation as +relevant. A mock of the Clerk method cannot prove that method's runtime effects. +Use a controlled boundary failure for a wallet or external modal when the harness +cannot model it, and state what that leaves unverified. + +Use focused type checks for mode-dependent contracts, including options accepted +but silently ignored. Verify runtime forwarding separately. Check loading both +before Clerk loads and after a failed mutation, since runtime state changes may +unmount the controller that owns the pending state or error. + +For callbacks, record internal/consumer order, awaiting, and throw/rejection +behavior. Compare them with headless.md before demanding legacy ordering. +For redirects, exercise explicit URLs and omitted props with a non-default +provider setting; follow runtime defaults through to the navigation destination. 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). - -Then check every row that is not Migrated against packages/mosaic/AGENTS.md and -packages/mosaic/docs/. List each one that breaks a rule there, with the rule it -breaks, such as a failure that is never shown or logged. These must be fixed or -raised with the user; they cannot ship as Deliberately changed. +guard). Never dump whole files. Return the table below, gaps first. Include the +test command/case, expected and observed outcome, and any limitation. An existing +test that asserts only re-enablement does not verify error delivery or recovery. + +Check differences against packages/mosaic/AGENTS.md and the relevant docs. +Use models.md for public contracts, controllers.md for error ownership, +headless.md for callback composition, and testing.md for test boundaries. +Quote the applicable rule and distinguish its requirement from your suggested +implementation. Lost errors require an observable error path; that does not +automatically require a new onError API or a particular message. +Raise unresolved rule conflicts and product choices with the user. A ticket, +TODO, or difference label does not resolve a parity gap or verification gap. ``` -To find the legacy behaviors, start from the search in [Migration → Phase 1](migration.md#phase-1-inventory-the-legacy-behavior). - -## Output - -| Legacy behavior | Layer it should live in | Status | Evidence (legacy → new) | -| ------------------------------------------------------- | ----------------------- | --------------------------- | -------------------------------------------------------------------------- | -| Per-field error mapping via `handleError(err, [field])` | controller/view | Deferred (only a `// TODO`) | `AddDomainForm.tsx` handleError → `*.controller.tsx` single `errorMessage` | - -## What it catches - -An audit of the organization domains section, migrated before Mosaic was its own package (that code is not in `packages/mosaic`), found three dropped behaviors, each tracked only by a `// TODO` and none failing a test or typecheck: +## Output table format + +| Contract / condition | Classification | Source evidence (legacy → new, types/rules/runtime) | Verification (expected → observed; command/case) | Disclosure / decision / next step | +| ----------------------------------- | -------------- | ------------------------------------------------------ | ------------------------------------------------ | ---------------------------------------------- | +| Rejected mutation delivers an error | Parity gap | Legacy rejection → controller captures and drops error | Fake FAPI rejects; no observable error | Restore an error path; API choice remains open | + +List intentional differences in the PR description, including their consumer +impact. Keep optional improvements separate from required fixes. Record deferred +work with its approval and ticket without changing its underlying classification. + +## Calibration: headless auth buttons (#10588) + +At the originally reviewed commit `ecb1a5508614aad8614e74ba53554864afdfd824`: + +- Trace rejected actions through `usePendingAction` to their observable error + destination. Capturing an error and returning only `isPending` loses it. +- Compare `SignUpButtonProps` in shared types, not just `SignUpProps`. Check that + `unsafeMetadata` is accepted only in modal mode and forwarded there. +- Exercise sign-out through fake FAPI, including rejection. Follow `clerk.signOut` + state emission and `useUser` loading state; a method mock hides this path. +- Disclose async `onClick` behavior under `mergeProps(internal, consumer)`, initial + fallback rendering, and the changed sign-out redirect default. The original + review requested documentation for these, not restoration of legacy behavior. +- Inspect modal `appearance` support as an API difference. Establish the intended + Mosaic contract before removing or restoring it. +- Do not require missing-MetaMask message mapping or sign-up continuation as + parity fixes. Both limitations existed in the legacy button. Sharing the four + buttons, deduplicating option lists, and the requested pending presentation are + optional improvements unless a separately established contract requires them. + +## Grep list (shared with Phase 1) + +```bash +rg -n 'useEffect|handleError|card\.setError|useReverification|revalidate|/ packages/react/src/components/.tsx +``` -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. +## Worked example — `OrganizationProfileDomainsSection` + +A historical example: the domains section was migrated before Mosaic became +its own package, and that code is not in `packages/mosaic` today. The audit +run against the finished migration surfaced three candidates for investigation, +each tracked only by a buried `// TODO`: + +1. **Per-field error mapping.** Legacy `handleError(err, [field])` mapped errors + to the specific field; the new add/verify flow collapses every failure to one + `errorMessage` string in the controller's machine context. +2. **Step-up reverification on domain delete.** Legacy `RemoveDomainForm` left a + TODO to wrap `domain.delete()` in `useReverification`; the new remove flow + omits the step-up entirely. +3. **Pending-invitations callout.** Legacy `useCalloutLabel` computed a count + from `totalPendingInvitations + totalPendingSuggestions`; the new view + receives the counts but never renders the callout. + +None of these failed a test or a typecheck. Establish the baseline contract before +classifying them. In particular, an unimplemented legacy reverification TODO +does not establish a regression. Require a fix only when the migration loses +behavior or violates an applicable rule. Record inherited improvements separately +and keep any approved deferral and ticket attached to its underlying finding. diff --git a/scripts/mosaic-parity-gate.mjs b/scripts/mosaic-parity-gate.mjs index 2da84a798aa..1235f7abdcb 100644 --- a/scripts/mosaic-parity-gate.mjs +++ b/scripts/mosaic-parity-gate.mjs @@ -1,184 +1,88 @@ import { spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { isAbsolute, join, relative } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -export const PROOF_DIR = 'tmp/mosaic-parity'; -export const MAX_BLOCKS = 3; - -const FEATURE_MODEL = /^packages\/mosaic\/src\/features\/([a-z0-9-]+)\/\1\.model\.tsx?$/; -const FEATURE_FILE = /^packages\/mosaic\/src\/features\/([a-z0-9-]+)\//; - -function pascalCase(kebab) { - return kebab.replace(/(^|-)([a-z0-9])/g, (_, __, char) => char.toUpperCase()); -} - -export function legacyCounterparts(root, feature) { - const name = pascalCase(feature); - return [`packages/react/src/components/${name}.tsx`, `packages/ui/src/components/${name}`].filter(path => - existsSync(join(root, path)), - ); -} - -export function proofPath(feature) { - return `${PROOF_DIR}/${feature}.md`; -} - -function section(text, heading) { - const match = text.match(new RegExp(`^##\\s+${heading}\\s*$([\\s\\S]*?)(?=^##\\s|(?![\\s\\S]))`, 'im')); - return match ? match[1].trim() : ''; -} - -export function inventoryProblem(proof, legacy) { - if (!proof) { - return 'missing'; - } - const missing = legacy.filter(path => !proof.includes(path)); - if (missing.length > 0) { - return `does not name ${missing.join(', ')}`; - } - return section(proof, 'Inventory') ? undefined : 'has no `## Inventory` section'; -} - -export function auditProblem(proof) { - if (!proof) { - return 'missing'; - } - return section(proof, 'Parity audit') ? undefined : 'has no `## Parity audit` section'; +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(' '); } -export function createdFiles(input, root) { - const tool = input.tool_input ?? {}; - const cwd = input.cwd ?? root; - const toRepo = path => relative(root, isAbsolute(path) ? path : join(cwd, path)); - - if (typeof tool.file_path === 'string') { - return existsSync(isAbsolute(tool.file_path) ? tool.file_path : join(cwd, tool.file_path)) - ? [] - : [toRepo(tool.file_path)]; - } - if (typeof tool.command === 'string') { - return [...tool.command.matchAll(/^\*\*\* Add File: (.+)$/gm)].map(match => toRepo(match[1].trim())); +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 ...]\n'); + process.exitCode = 1; + return; + } + process.stdout.write(`${reminder([...new Set(args)].sort(), true)}\n`); + return; } - return []; -} - -function run(args, cwd) { - return spawnSync('git', args, { cwd, encoding: 'utf8' }).stdout ?? ''; -} - -function readText(path) { - try { - return readFileSync(path, 'utf8'); - } catch { - return ''; + if (mode !== 'write' || args.length > 0) { + return; } -} -function readInput() { + let input; try { - return JSON.parse(readFileSync(0, 'utf8')); + input = JSON.parse(readFileSync(0, 'utf8')); } catch { - return {}; - } -} - -function newFeatures(root) { - const base = run(['merge-base', 'HEAD', 'origin/main'], root).trim() || 'HEAD'; - const changed = `${run(['diff', '--name-only', '--diff-filter=d', base, '--', 'packages/mosaic/src/features'], root)}\n${run(['ls-files', '--others', '--exclude-standard', '--', 'packages/mosaic/src/features'], root)}`; - const features = new Set( - changed - .split('\n') - .map(file => file.match(FEATURE_FILE)?.[1]) - .filter(Boolean), - ); - return [...features].filter( - feature => !run(['ls-tree', '--name-only', base, `packages/mosaic/src/features/${feature}`], root).trim(), - ); -} - -function beforeWrite(input, root) { - const blocked = createdFiles(input, root).flatMap(file => { - const feature = file.match(FEATURE_MODEL)?.[1]; - const legacy = feature ? legacyCounterparts(root, feature) : []; - const problem = legacy.length > 0 ? inventoryProblem(readText(join(root, proofPath(feature))), legacy) : undefined; - return problem ? [{ feature, legacy, problem }] : []; - }); - - if (blocked.length === 0) { return; } - - const reason = blocked - .map( - ({ feature, legacy, problem }) => - `\`${feature}\` ports ${legacy.join(' and ')}, and ${proofPath(feature)} ${problem}. Read the legacy component, then write ${proofPath(feature)} with the legacy path and an \`## Inventory\` section (one row per prop, default, guard, error path, loading state and click behavior), as packages/mosaic/docs/migration.md describes. Then retry this edit.`, - ) - .join(' '); - - process.stdout.write( - JSON.stringify({ - hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason }, - }), - ); -} - -function beforeStop(input, root) { - const unaudited = newFeatures(root).flatMap(feature => { - const legacy = legacyCounterparts(root, feature); - const problem = legacy.length > 0 ? auditProblem(readText(join(root, proofPath(feature)))) : undefined; - return problem ? [{ feature, legacy, problem }] : []; - }); - - const stateDir = join(tmpdir(), 'clerk-mosaic-parity-gate'); - const statePath = join(stateDir, `${String(input.session_id ?? 'unknown').replace(/[^\w-]/g, '_')}.json`); - - if (unaudited.length === 0) { + if ( + !input || + typeof input.cwd !== 'string' || + !isAbsolute(input.cwd) || + input.cwd.includes('\0') || + !input.tool_input + ) { return; } - let state; - try { - state = JSON.parse(readFileSync(statePath, 'utf8')); - } catch { - state = { blocks: 0 }; + 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 (state.blocks >= MAX_BLOCKS) { + if (paths.length === 0 || paths.some(path => path.includes('\0'))) { return; } - mkdirSync(stateDir, { recursive: true }); - writeFileSync(statePath, JSON.stringify({ blocks: state.blocks + 1 })); - - const list = unaudited.map( - ({ feature, legacy, problem }) => `${feature} (legacy: ${legacy.join(', ')}; ${proofPath(feature)} ${problem})`, - ); - - process.stdout.write( - JSON.stringify({ - decision: 'block', - reason: [ - `This branch adds Mosaic features that port a legacy component: ${list.join('; ')}.`, - 'Before finishing, run the parity audit in packages/mosaic/docs/parity-audit.md and put its table under `## Parity audit` in that file.', - 'Fix or raise with the user every row that breaks a Mosaic rule, and list the remaining differences in the PR description.', - ].join(' '), - }), - ); -} -function main() { - const input = readInput(); - const root = run(['rev-parse', '--show-toplevel'], input.cwd ?? process.cwd()).trim(); - if (!root) { + const git = spawnSync('git', ['rev-parse', '--show-cdup'], { cwd: input.cwd, encoding: 'utf8' }); + if (git.status !== 0) { return; } - if (process.argv[2] === 'write') { - beforeWrite(input, root); - } else if (process.argv[2] === 'stop') { - beforeStop(input, root); + 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) }, + }), + ); } } -if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { - main(); -} +main(); diff --git a/scripts/mosaic-parity-gate.test.mjs b/scripts/mosaic-parity-gate.test.mjs index dcc5d23bbaf..521aeabe874 100644 --- a/scripts/mosaic-parity-gate.test.mjs +++ b/scripts/mosaic-parity-gate.test.mjs @@ -1,56 +1,262 @@ -import { describe, expect, test } from 'vitest'; +import { execFile, spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; -import { auditProblem, createdFiles, inventoryProblem } from './mosaic-parity-gate.mjs'; +const script = fileURLToPath(new URL('./mosaic-parity-gate.mjs', import.meta.url)); +const features = 'packages/mosaic/src/features'; +let root; -const root = '/repo'; -const legacy = ['packages/react/src/components/SignOutButton.tsx']; +function write(path, text = 'export {};') { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), text); +} -describe('createdFiles', () => { - test('reads a Claude Write of a new file', () => { - const input = { tool_input: { file_path: '/repo/packages/mosaic/src/features/x/x.model.ts' } }; - expect(createdFiles(input, root)).toEqual(['packages/mosaic/src/features/x/x.model.ts']); - }); +function git(...args) { + const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' }); + expect(result.status, result.stderr).toBe(0); + return result.stdout; +} - test('reads every added file in a Codex apply_patch', () => { - const command = [ - '*** Begin Patch', - '*** Add File: packages/mosaic/src/features/x/x.model.ts', - '+export {};', - '*** Update File: packages/mosaic/src/localization/registry.ts', - '*** Add File: /repo/packages/mosaic/src/features/x/x.view.tsx', - '*** End Patch', - ].join('\n'); - expect(createdFiles({ cwd: root, tool_input: { command } }, root)).toEqual([ - 'packages/mosaic/src/features/x/x.model.ts', - 'packages/mosaic/src/features/x/x.view.tsx', - ]); +function invoke(input, args = ['write']) { + return spawnSync(process.execPath, [script, ...args], { + cwd: root, + input: typeof input === 'string' ? input : JSON.stringify(input), + encoding: 'utf8', + env: { ...process.env, TMPDIR: root }, }); +} + +function edit(path, tool_name = 'Edit', cwd = root) { + return { cwd, session_id: 'same-session', tool_name, tool_input: { file_path: path } }; +} + +function patch(lines, cwd = root) { + return { + cwd, + tool_name: 'apply_patch', + tool_input: { command: ['*** Begin Patch', ...lines, '*** End Patch'].join('\n') }, + }; +} + +function advisory(result, names) { + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(''); + const output = JSON.parse(result.stdout); + expect(Object.keys(output)).toEqual(['hookSpecificOutput']); + expect(Object.keys(output.hookSpecificOutput).sort()).toEqual(['additionalContext', 'hookEventName']); + expect(output.hookSpecificOutput.hookEventName).toBe('PreToolUse'); + const text = output.hookSpecificOutput.additionalContext; + expect(text).toContain(names.join(', ')); + expect(text).toMatch(/if.*migrated behavior/i); + expect(text).toContain('packages/mosaic/docs/migration.md'); + expect(text).toContain('packages/mosaic/docs/parity-audit.md'); + expect(text).not.toMatch(/passed|parity verified|retry|must.*audit/i); + return text; +} + +function silent(result) { + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'mosaic-reminder-test-')); + git('init', '-q'); + git('config', 'user.email', 'test@example.com'); + git('config', 'user.name', 'Test'); + write('README.md', 'fixture'); + write('packages/react/src/components/SignOutButton.tsx'); + git('add', '.'); + git('commit', '-qm', 'base'); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + write(`${features}/sign-out-button/sign-out-button.model.ts`); + git('add', '.'); + git('commit', '-qm', 'prior Mosaic work'); + write(`${features}/sign-up-button/sign-up-button.model.ts`); }); -describe('inventoryProblem', () => { - test('requires the proof file', () => { - expect(inventoryProblem('', legacy)).toBe('missing'); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +describe('current action scope', () => { + test('ignores prior committed and untracked Mosaic work during unrelated edits and Stop', () => { + silent(invoke(edit('packages/shared/src/utils.ts'))); + silent(invoke({ cwd: root, session_id: 'same-session' }, ['stop'])); + }); + + test.each(['Edit', 'Write'])('advises on existing feature %s without blocking', tool => { + advisory(invoke(edit(`${features}/sign-out-button/sign-out-button.model.ts`, tool)), ['sign-out-button']); }); - test('requires the legacy path', () => { - expect(inventoryProblem('## Inventory\n- redirectUrl defaults to /', legacy)).toMatch(/does not name/); + test('advises on new models without requiring notes or counterpart matches', () => { + advisory(invoke(edit(`${features}/renamed-feature/renamed-feature.model.ts`, 'Write')), ['renamed-feature']); }); - test('requires a non-empty inventory', () => { - expect(inventoryProblem(`Legacy: ${legacy[0]}\n## Inventory\n\n## Parity audit\n`, legacy)).toMatch(/Inventory/); + test.each(['view.tsx', 'styles.ts', 'test.ts'])('keeps guidance conditional for feature %s', file => { + advisory(invoke(edit(`${features}/sign-out-button/${file}`)), ['sign-out-button']); }); - test('passes a filled inventory', () => { - expect(inventoryProblem(`Legacy: ${legacy[0]}\n## Inventory\n- redirectUrl defaults to /`, legacy)).toBeUndefined(); + test('does not carry scope into later unrelated actions in the same session', () => { + advisory(invoke(edit(`${features}/sign-out-button/view.tsx`)), ['sign-out-button']); + silent(invoke(edit('README.md'))); + silent(invoke(edit('packages/mosaic/src/primitives/button.tsx'))); + }); + + test('normalizes absolute, relative, and nested-cwd paths', () => { + const path = `${features}/sign-out-button/view.tsx`; + const first = advisory(invoke(edit(path)), ['sign-out-button']); + expect(advisory(invoke(edit(join(root, path))), ['sign-out-button'])).toBe(first); + expect( + advisory(invoke(edit('src/features/sign-out-button/view.tsx', 'Edit', join(root, 'packages/mosaic'))), [ + 'sign-out-button', + ]), + ).toBe(first); + }); + + test('collects add, update, delete and move scopes in one sorted unique advisory', () => { + const text = advisory( + invoke( + patch([ + `*** Add File: ${features}/new-feature/model.ts`, + '+export {};', + `*** Update File: ${join(root, features)}/sign-out-button/view.tsx`, + `*** Move to: ${features}/renamed-feature/view.tsx`, + '@@', + '-old', + '+new', + `*** Delete File: ${features}/deleted-feature/model.ts`, + '*** Add File: README.md', + '+unrelated', + `*** Update File: ${features}/new-feature/view.tsx`, + '@@', + '-old', + '+new', + '*** Delete File: /elsewhere/packages/mosaic/src/features/outside/view.tsx', + ]), + ), + ['deleted-feature', 'new-feature', 'renamed-feature', 'sign-out-button'], + ); + expect(text).not.toContain('outside'); + }); + + test.each([ + '/elsewhere/packages/mosaic/src/features/outside/view.tsx', + '../packages/mosaic/src/features/outside/view.tsx', + 'packages/mosaic/src/features/../../shared.ts', + 'packages/mosaic/src/features-extra/outside/view.tsx', + ])('ignores paths outside feature scope: %s', path => silent(invoke(edit(path)))); + + test('populated notes neither suppress guidance nor prove success', () => { + write( + 'tmp/mosaic-parity/sign-out-button.md', + 'packages/react/src/components/SignOutButton.tsx\n## Inventory\nComplete\n## Parity audit\nPassed', + ); + advisory(invoke(edit(`${features}/sign-out-button/new.model.ts`, 'Write')), ['sign-out-button']); }); }); -describe('auditProblem', () => { - test('requires a non-empty audit section', () => { - expect(auditProblem('## Inventory\n- a\n## Parity audit\n')).toMatch(/Parity audit/); +describe('invalid hook input', () => { + test.each(['{', 'null', '[]', '"text"', '{}'])('silently ignores %s', input => silent(invoke(input))); + test.each([ + { tool_name: 'Edit', tool_input: null }, + { tool_name: 'Edit', tool_input: { file_path: 1 } }, + { tool_name: 'Edit', tool_input: { file_path: `${features}/x/view\0.ts` } }, + { + tool_name: 'apply_patch', + tool_input: { command: `*** Begin Patch\n*** Add File: ${features}/x/view\0.ts\n+text\n*** End Patch` }, + }, + { tool_name: 'Bash', tool_input: { file_path: `${features}/x/x.ts` } }, + { tool_name: 'Bash', tool_input: { command: `*** Begin Patch\n*** Add File: ${features}/x/x.ts\n*** End Patch` } }, + { tool_name: 'apply_patch', tool_input: { command: `echo hi\n*** Add File: ${features}/x/x.ts` } }, + { tool_name: 'apply_patch', tool_input: { command: `*** Begin Patch\n*** Add File: ${features}/x/x.ts` } }, + ])('ignores malformed or unsupported payload %#', input => silent(invoke({ cwd: root, ...input }))); + + test('requires a valid cwd and Git root', () => { + const input = edit(`${features}/x/x.ts`); + delete input.cwd; + silent(invoke(input)); + silent(invoke({ ...input, cwd: 42 })); + silent(invoke({ ...input, cwd: `${root}\0` })); + silent(invoke({ ...input, cwd: tmpdir() })); + silent(invoke({ ...input, cwd: join(root, 'absent') })); }); +}); - test('passes a filled audit section', () => { - expect(auditProblem('## Inventory\n- a\n## Parity audit\n| row | Migrated |')).toBeUndefined(); +describe('explicit read-only review', () => { + test('prints sorted scope without modifying files or registering future scope', () => { + const before = readdirSync(root, { recursive: true }).sort(); + const status = git('status', '--porcelain', '--untracked-files=all'); + const result = invoke('', ['review', 'sign-up-button', 'historical-feature', 'sign-up-button']); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain('historical-feature, sign-up-button'); + expect(result.stdout).toContain('packages/mosaic/docs/parity-audit.md'); + expect(result.stdout).not.toMatch(/if.*migrated behavior|passed|verified/i); + expect(readdirSync(root, { recursive: true }).sort()).toEqual(before); + expect(git('status', '--porcelain', '--untracked-files=all')).toBe(status); + silent(invoke(edit('README.md'))); }); + + test.each([[], ['../x'], ['X'], ['x/y'], ['--help'], ['x', ''], ['-x'], ['x--y']])( + 'rejects invalid review arguments %j', + (...args) => { + const result = invoke('', ['review', ...args]); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Usage:'); + expect(result.stdout).toBe(''); + }, + ); + + test('repeated and concurrent invocations stay independent and create no state', async () => { + const before = readdirSync(root, { recursive: true }).sort(); + const inputs = ['alpha', 'beta', 'alpha']; + const results = await Promise.all( + inputs.map(name => { + const run = promisify(execFile)(process.execPath, [script, 'review', name], { + cwd: root, + env: { ...process.env, TMPDIR: root }, + timeout: 2000, + }); + run.child.stdin.end(); + return run; + }), + ); + results.forEach((result, index) => { + expect(result.stdout).toContain(inputs[index]); + expect(result.stdout).not.toContain(inputs[index] === 'alpha' ? 'beta' : 'alpha'); + }); + for (let count = 0; count < 4; count++) { + advisory(invoke(edit(`${features}/sign-out-button/view.tsx`)), ['sign-out-button']); + } + expect(readdirSync(root, { recursive: true }).sort()).toEqual(before); + }); +}); + +describe('hook registration', () => { + test.each(['.claude/settings.json', '.codex/hooks.json'])( + '%s retains lint hooks and uses only current-action parity advice', + path => { + const config = JSON.parse(readFileSync(new URL(`../${path}`, import.meta.url), 'utf8')); + const stop = config.hooks.Stop.flatMap(entry => entry.hooks); + expect(stop).toHaveLength(2); + expect(stop.every(hook => hook.timeout === 180 && hook.command.includes('pnpm exec eslint'))).toBe(true); + expect(stop.some(hook => hook.command.includes('packages/mosaic'))).toBe(true); + expect(stop.some(hook => hook.command.includes('packages/swingset/src'))).toBe(true); + expect(config.hooks.UserPromptSubmit).toBeUndefined(); + expect(config.hooks.PostToolUse).toBeUndefined(); + const pre = config.hooks.PreToolUse; + expect(pre).toHaveLength(1); + expect(pre[0].matcher).toBe(path.startsWith('.claude') ? 'Write|Edit' : 'apply_patch|Edit|Write'); + expect(pre[0].hooks[0].if).toBeUndefined(); + expect(pre[0].hooks[0].command).toContain('mosaic-parity-gate.mjs'); + if (path.startsWith('.claude')) { + expect(config.disableBypassPermissionsMode).toBe(true); + expect(config.permissions.deny).toHaveLength(20); + expect(config.hooks.SessionStart).toHaveLength(1); + } + }, + ); }); From 34a6033b1c683f25fc55f0ac71af551278d55e63 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 5 Nov 2026 11:17:17 -0700 Subject: [PATCH 6/8] test(repo): run Mosaic parity checks in CI --- .github/workflows/ci.yml | 2 +- scripts/mosaic-parity-gate.test.mjs | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8f50d993e5..d16c7f4d7e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/scripts/mosaic-parity-gate.test.mjs b/scripts/mosaic-parity-gate.test.mjs index 521aeabe874..2bdf207d9fa 100644 --- a/scripts/mosaic-parity-gate.test.mjs +++ b/scripts/mosaic-parity-gate.test.mjs @@ -242,16 +242,22 @@ describe('hook registration', () => { const config = JSON.parse(readFileSync(new URL(`../${path}`, import.meta.url), 'utf8')); const stop = config.hooks.Stop.flatMap(entry => entry.hooks); expect(stop).toHaveLength(2); - expect(stop.every(hook => hook.timeout === 180 && hook.command.includes('pnpm exec eslint'))).toBe(true); + expect(stop.every(hook => hook.timeout === 180 && hook.command.includes('pnpm lint'))).toBe(true); expect(stop.some(hook => hook.command.includes('packages/mosaic'))).toBe(true); - expect(stop.some(hook => hook.command.includes('packages/swingset/src'))).toBe(true); + expect(stop.some(hook => hook.command.includes('packages/swingset'))).toBe(true); expect(config.hooks.UserPromptSubmit).toBeUndefined(); expect(config.hooks.PostToolUse).toBeUndefined(); const pre = config.hooks.PreToolUse; expect(pre).toHaveLength(1); expect(pre[0].matcher).toBe(path.startsWith('.claude') ? 'Write|Edit' : 'apply_patch|Edit|Write'); expect(pre[0].hooks[0].if).toBeUndefined(); - expect(pre[0].hooks[0].command).toContain('mosaic-parity-gate.mjs'); + expect(pre[0].hooks).toEqual([ + { + type: 'command', + command: 'node "$(git rev-parse --show-toplevel)/scripts/mosaic-parity-gate.mjs" write', + timeout: 10, + }, + ]); if (path.startsWith('.claude')) { expect(config.disableBypassPermissionsMode).toBe(true); expect(config.permissions.deny).toHaveLength(20); From a0c7408035793b9476fa21ac08bf6912b9672f1a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 9 Nov 2026 11:47:57 -0700 Subject: [PATCH 7/8] docs(mosaic): keep parity rules in the migration and audit guides Move the port workflow out of AGENTS.md into the Rules of migration.md and parity-audit.md, following the one-home layout from #10691. Keep the Migrated, Deliberately changed and Deferred buckets, and add the evidence, decision and scope requirements as rules on them. Route packages/react ports to Migration from AGENTS.md and features.md. --- packages/mosaic/AGENTS.md | 16 +-- packages/mosaic/docs/features.md | 2 +- packages/mosaic/docs/migration.md | 26 +--- packages/mosaic/docs/parity-audit.md | 191 +++++++-------------------- 4 files changed, 57 insertions(+), 178 deletions(-) diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 35c99ab3d7c..dd5d0e4e581 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -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) @@ -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) @@ -58,18 +58,6 @@ Before you change a file that matches a pattern below, read its guide, even for - `src/**/*.view.tsx` → [Views](docs/views.md) - `src/**/__tests__/**`, `src/**/*.test.ts(x)`, `src/**/*.test-d.ts` → [Testing](docs/testing.md) -## Ports of legacy components are migrations - -For a current task that implements or reviews a Mosaic migration, or changes its migrated behavior, search `packages/react/src/components/` and `packages/ui/src/components/` for the counterpart. A headless port framed as a new feature is a [migration](docs/migration.md) too. Unrelated work does not acquire this workflow merely because the branch already contains Mosaic changes. - -1. Read the counterpart, shared prop types, and relevant helper/runtime calls. Write the Phase 1 inventory to `tmp/mosaic-parity/.md` before the model. Record the task scope, exact revisions, accepted differences, and exclusions alongside the legacy path and `## Inventory` section. -2. Before you call the work done, review it, or open a PR, run the [parity audit](docs/parity-audit.md) in a read-only subagent and put its table under `## Parity audit` in the same file. Reading the doc is not running the audit. -3. Use the audit's classifications to separate parity gaps, intentional differences needing disclosure, missing verification, and optional improvements. List intentional differences and approved deferrals in the PR description. Do not require inherited improvements or refactors as parity fixes. -4. Listing a difference does not make it acceptable. Cite any conflicting rule in this file or `docs/`, and fix the gap or raise it with the user. Do not infer intentional behavior from the implementation. Preserve decisions the user already made, and leave unresolved product choices open. -5. Inspect the actual results of focused type and behavior checks. For relevant async actions, include a representative failure through the existing fake FAPI or supported browser setup. A populated note is process bookkeeping, not proof of parity. - -`tmp/` is ignored by git, so the file stays out of the PR. The hooks in `.claude/settings.json` and `.codex/hooks.json` (`scripts/mosaic-parity-gate.mjs`) give conditional guidance for the feature paths in the current edit. They do not inspect branch history or notes, deny edits, or block completion. For a read-only parity review, run the explicit scoped command in [Parity audit](docs/parity-audit.md). These reminders do not run the audit or verify its evidence. - ## Layers in one paragraph A flow lives in `src/features//`. The **model** (`*.model.ts(x)`) is the only file that reads Clerk hooks or calls Clerk resources, and answers with plain data, plain callbacks and a `status`. The **controller** (`*.controller.ts(x)`) holds local state and wraps the model's callbacks for pending, errors and closing. The **view** (`*.view.tsx`) renders plain props. No Clerk resource reaches the controller and no machine snapshot reaches the view. The file shape and how a wrapper composes the layers are in [Features](docs/features.md). diff --git a/packages/mosaic/docs/features.md b/packages/mosaic/docs/features.md index 019b598af6a..56ab559f205 100644 --- a/packages/mosaic/docs/features.md +++ b/packages/mosaic/docs/features.md @@ -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`. diff --git a/packages/mosaic/docs/migration.md b/packages/mosaic/docs/migration.md index 966154286b0..1b535113128 100644 --- a/packages/mosaic/docs/migration.md +++ b/packages/mosaic/docs/migration.md @@ -1,15 +1,15 @@ # Migration -Read this when you move a legacy component (usually under `packages/ui/src/components//`) 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//` 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/`. -Apply this workflow to the current requested Mosaic work, not every Mosaic change already on the branch. Record the feature scope, original revisions, accepted differences, and exclusions. When reviewing earlier feedback, use the commit the reviewer saw, not a later implementation that may already have addressed it. - ## 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/.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. @@ -17,11 +17,7 @@ Apply this workflow to the current requested Mosaic work, not every Mosaic chang ## Phase 1: inventory the legacy behavior -Locate the legacy files: the prebuilt component under -`packages/ui/src/components//`, the `@clerk/react` component under -`packages/react/src/components/`, or both. A headless port of a `@clerk/react` -button is a migration too. Then grep them for the primitives that carry hidden logic. Every -hit is a row you must consciously place or drop later: +Locate the legacy files: the prebuilt component under `packages/ui/src/components//`, 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 | | --------------------------------- | -------------------------------------------------- | @@ -41,13 +37,7 @@ rg -n 'useEffect|handleError|card\.setError|useReverification|revalidate|.md` (ignored by git) before the model: +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/.md`: ```md # sign-out-button parity @@ -81,11 +71,7 @@ Test with one `*.feature.test.tsx` that uses the feature the way a user would ([ ## Phase 4: verify parity -For each affected async action, exercise a representative rejection as well as success and pending work. Verify error delivery and the final rendered/resource state, including recovery where promised. Do not replace a Clerk mutation with a method mock when the existing fake FAPI can drive it. Check mode-dependent prop contracts with focused type checks and verify forwarding in the relevant modes. - -Tests only cover branches you remembered to write. Launch a read-only subagent with the prompt in [Parity audit](parity-audit.md). Give it the legacy and new revisions, file paths, shared types, and accepted decisions. Use the classifications and evidence columns there. Distinguish parity gaps, intentional differences needing disclosure, missing verification, and optional improvements. Leave unresolved intent as a decision for the user. - -Every inventory row from Phase 1 must have a conclusion and evidence. Put the table under `## Parity audit` in `tmp/mosaic-parity/.md`, next to the Phase 1 inventory. The table guides the work and the PR discussion, and is never committed. Read the results of its referenced checks before claiming parity. A filled table, a tracked deferral, or a hook reminder is not proof that the behavior works. +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/.md`. ## Phase 5: ship diff --git a/packages/mosaic/docs/parity-audit.md b/packages/mosaic/docs/parity-audit.md index 27eafe3d83c..e7849e35790 100644 --- a/packages/mosaic/docs/parity-audit.md +++ b/packages/mosaic/docs/parity-audit.md @@ -1,53 +1,33 @@ -# Parity audit — Phase 4 reference +# Parity audit -The parity audit is the confidence step of a Mosaic migration. It diffs the -legacy implementation and shared API contract against the new behavior, -including the helpers and Clerk runtime they call. +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). -Use this workflow when the current task implements or reviews a Mosaic -migration or changes its migrated behavior. Do not start it for unrelated work -because the branch already contains Mosaic changes. Record the task's component -scope and exact legacy/new revisions before comparing. A renamed counterpart -still counts; a same-name match is only a discovery shortcut. -A shared-library task does not trigger this full workflow solely because Mosaic -is a downstream consumer. +Worked example: [What it catches](#what-it-catches). -For a requested read-only parity review, name the current task's feature folders -explicitly from the repository root: +## Rules -```bash -node scripts/mosaic-parity-gate.mjs review sign-out-button sign-up-button -``` - -This command prints scoped guidance without changing files or registering scope -for later requests. It does not run an audit. Use feature names from the request, -not every changed feature on the branch. A renamed or historical feature can be -named even when its folder is absent from the current checkout. - -Edit hooks provide the same guidance for current feature paths. A style-only edit -does not require a behavioral audit. The reminder is conditional on changing -migrated behavior, and emits at most once per tool action. Shell-only edits and -read-only reviews without the explicit command have no automatic reminder. -There is no parity Stop hook or completion gate. Follow this workflow whenever -the current task calls for it, whether a reminder appeared or not. - -Run it as a read-only subagent (Explore in Claude Code) that returns a -conclusion, not file dumps. Put its table under `## Parity audit` in -`tmp/mosaic-parity/.md`, next to the Phase 1 inventory. The table guides -the work and the PR discussion. `tmp/` is ignored by git, so nothing is committed. -A populated note proves neither parity nor execution of the checks. Inspect the -source citations and actual test results, and distinguish measured outcomes from -source-only conclusions and checks that could not run. +- 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. +- 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. +- 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/.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 scope, revisions, and file lists and hand this to an Explore agent: +Fill in the blanks and hand this to a read-only agent: ``` In the repo at , audit a Mosaic migration for behavioral parity. -CURRENT TASK: -REVISIONS: +CURRENT TASK: +REVISIONS: ACCEPTED DIFFERENCES: LEGACY component files (the spec — behavior must be preserved or consciously changed): @@ -56,112 +36,37 @@ LEGACY component files (the spec — behavior must be preserved or consciously c NEW Mosaic files (model / controller / view / wrapper): -Read the shared public prop types, relevant callers, and Mosaic rules as well. -Follow calls into shared helpers and Clerk runtime until you can establish the -observable outcome. Do not stop at matching method names or argument objects. - -Enumerate EVERY relevant behavior — each: prop and mode-dependent option, default, -callback order/awaiting, 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 implementation and classify it: - - - Preserved: same observable contract, with source and verification evidence. - - Parity gap: introduced loss or incompatible contract; identify the failing condition. - - Intentional difference: cite the user decision or Mosaic rule, and required disclosure. - If intent is not established, mark Decision needed rather than inventing approval. - - Verification gap: relevant outcome is untested or blocked; name the missing probe. - - Optional improvement: inherited defect or refactor outside the migration contract. - Keep it separate; do not require a fix to complete parity work. - -For each in-scope action check success, pending, and a representative rejection. -Use the existing real-Clerk/fake-FAPI feature harness where it can exercise the -behavior. Assert final DOM, error delivery, resource state, and navigation as -relevant. A mock of the Clerk method cannot prove that method's runtime effects. -Use a controlled boundary failure for a wallet or external modal when the harness -cannot model it, and state what that leaves unverified. - -Use focused type checks for mode-dependent contracts, including options accepted -but silently ignored. Verify runtime forwarding separately. Check loading both -before Clerk loads and after a failed mutation, since runtime state changes may -unmount the controller that owns the pending state or error. - -For callbacks, record internal/consumer order, awaiting, and throw/rejection -behavior. Compare them with headless.md before demanding legacy ordering. -For redirects, exercise explicit URLs and omitted props with a non-default -provider setting; follow runtime defaults through to the navigation destination. +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 table below, gaps first. Include the -test command/case, expected and observed outcome, and any limitation. An existing -test that asserts only re-enablement does not verify error delivery or recovery. - -Check differences against packages/mosaic/AGENTS.md and the relevant docs. -Use models.md for public contracts, controllers.md for error ownership, -headless.md for callback composition, and testing.md for test boundaries. -Quote the applicable rule and distinguish its requirement from your suggested -implementation. Lost errors require an observable error path; that does not -automatically require a new onError API or a particular message. -Raise unresolved rule conflicts and product choices with the user. A ticket, -TODO, or difference label does not resolve a parity gap or verification gap. +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. ``` -## Output table format - -| Contract / condition | Classification | Source evidence (legacy → new, types/rules/runtime) | Verification (expected → observed; command/case) | Disclosure / decision / next step | -| ----------------------------------- | -------------- | ------------------------------------------------------ | ------------------------------------------------ | ---------------------------------------------- | -| Rejected mutation delivers an error | Parity gap | Legacy rejection → controller captures and drops error | Fake FAPI rejects; no observable error | Restore an error path; API choice remains open | - -List intentional differences in the PR description, including their consumer -impact. Keep optional improvements separate from required fixes. Record deferred -work with its approval and ticket without changing its underlying classification. - -## Calibration: headless auth buttons (#10588) - -At the originally reviewed commit `ecb1a5508614aad8614e74ba53554864afdfd824`: - -- Trace rejected actions through `usePendingAction` to their observable error - destination. Capturing an error and returning only `isPending` loses it. -- Compare `SignUpButtonProps` in shared types, not just `SignUpProps`. Check that - `unsafeMetadata` is accepted only in modal mode and forwarded there. -- Exercise sign-out through fake FAPI, including rejection. Follow `clerk.signOut` - state emission and `useUser` loading state; a method mock hides this path. -- Disclose async `onClick` behavior under `mergeProps(internal, consumer)`, initial - fallback rendering, and the changed sign-out redirect default. The original - review requested documentation for these, not restoration of legacy behavior. -- Inspect modal `appearance` support as an API difference. Establish the intended - Mosaic contract before removing or restoring it. -- Do not require missing-MetaMask message mapping or sign-up continuation as - parity fixes. Both limitations existed in the legacy button. Sharing the four - buttons, deduplicating option lists, and the requested pending presentation are - optional improvements unless a separately established contract requires them. - -## Grep list (shared with Phase 1) - -```bash -rg -n 'useEffect|handleError|card\.setError|useReverification|revalidate|/ packages/react/src/components/.tsx -``` +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 ...]` 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) | +| ------------------------------------------------------- | ----------------------- | --------------------------- | -------------------------------------------------------------------------- | +| Per-field error mapping via `handleError(err, [field])` | controller/view | Deferred (only a `// TODO`) | `AddDomainForm.tsx` handleError → `*.controller.tsx` single `errorMessage` | + +## What it catches + +An audit of the organization domains section, migrated before Mosaic was its own package (that code is not in `packages/mosaic`), found three dropped behaviors, each tracked only by a `// TODO` and none failing a test or typecheck: + +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. -## Worked example — `OrganizationProfileDomainsSection` - -A historical example: the domains section was migrated before Mosaic became -its own package, and that code is not in `packages/mosaic` today. The audit -run against the finished migration surfaced three candidates for investigation, -each tracked only by a buried `// TODO`: - -1. **Per-field error mapping.** Legacy `handleError(err, [field])` mapped errors - to the specific field; the new add/verify flow collapses every failure to one - `errorMessage` string in the controller's machine context. -2. **Step-up reverification on domain delete.** Legacy `RemoveDomainForm` left a - TODO to wrap `domain.delete()` in `useReverification`; the new remove flow - omits the step-up entirely. -3. **Pending-invitations callout.** Legacy `useCalloutLabel` computed a count - from `totalPendingInvitations + totalPendingSuggestions`; the new view - receives the counts but never renders the callout. - -None of these failed a test or a typecheck. Establish the baseline contract before -classifying them. In particular, an unimplemented legacy reverification TODO -does not establish a regression. Require a fix only when the migration loses -behavior or violates an applicable rule. Record inherited improvements separately -and keep any approved deferral and ticket attached to its underlying finding. +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. From 6f8cd2fe3663db70a62e4cc7d46e8e9f290e2577 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 10 Nov 2026 09:51:42 -0700 Subject: [PATCH 8/8] docs(mosaic): keep lost errors observable in parity audits --- packages/mosaic/docs/migration.md | 6 +++--- packages/mosaic/docs/parity-audit.md | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/docs/migration.md b/packages/mosaic/docs/migration.md index 1b535113128..21ffced7cf8 100644 --- a/packages/mosaic/docs/migration.md +++ b/packages/mosaic/docs/migration.md @@ -40,13 +40,13 @@ Turn the hits into a behavior inventory, one row per effect, guard, error path, 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/.md`: ```md -# sign-out-button parity +# checkout-button parity -Legacy: packages/react/src/components/SignOutButton.tsx +Legacy: packages/react/src/components/CheckoutButton.tsx ## Inventory -- `redirectUrl` defaults to `/` +- `for="organization"` throws without an active organization - … ``` diff --git a/packages/mosaic/docs/parity-audit.md b/packages/mosaic/docs/parity-audit.md index e7849e35790..9b9f1da7968 100644 --- a/packages/mosaic/docs/parity-audit.md +++ b/packages/mosaic/docs/parity-audit.md @@ -14,6 +14,7 @@ Worked example: [What it catches](#what-it-catches). - A Deferred item tracked only by a `// TODO` is a regression at risk of shipping. Surface it as a tracked item. - 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/.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.