From fe109edd136e141339e282e4cb279de600a0e8f8 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 4 Nov 2026 11:55:27 -0700 Subject: [PATCH 1/6] 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 c12febda46cd7949fe0ea4353fe3b84eabac0bf9 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 4 Nov 2026 12:37:56 -0700 Subject: [PATCH 2/6] 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 609a68e562d42f97d012cd014409fa68f7ee929d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 4 Nov 2026 14:28:52 -0700 Subject: [PATCH 3/6] 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 7450659b77db73efdf9fc402a3f14e17b4eba4fc Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 4 Nov 2026 14:41:09 -0700 Subject: [PATCH 4/6] 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 a33c4a75f226dfe4d817796964b010bc209e4408 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 5 Nov 2026 11:04:39 -0700 Subject: [PATCH 5/6] 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 6c72d4311b657e0429122d05d8a7e373885110cb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 5 Nov 2026 11:17:17 -0700 Subject: [PATCH 6/6] 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);