Repository navigation
docs(mosaic): make agents compare ports with the legacy component before and after building #10656
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ce3afe1
9a8ac75
e4ed58d
c5bfad8
9fc5b1f
c1cd382
d703838
7317272
d93517f
1bfbcf5
30ed600
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,17 @@ 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: | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| 1. Read the counterpart and write the Phase 1 inventory to `tmp/mosaic-parity/<feature>.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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Document the stop-hook retry limit. The parity gate stops blocking after three stop attempts. An agent can therefore finish without a Line 50 says the hook blocks finishing until the audit exists. State the retry limit and keep the manual requirement, or remove the escape from the gate.
|
||
|
|
||
| ## Layers in one paragraph | ||
|
|
||
| A flow lives in `src/features/<feature>/`. 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. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| 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'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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(); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: clerk/javascript
Length of output: 6733
Add a test for the Claude hook configuration.
The gate tests in
scripts/mosaic-parity-gate.test.mjscover onlycreatedFiles,inventoryProblem, andauditProblem. No test reads.claude/settings.json. A change to theWritematcher, theiffilter, thewriteorstopcommand, or a timeout can pass the test suite unnoticed. The project requires unit tests for new functionality, and this PR adds the hook entries at lines 45-46 and 66-67.Add a test that parses
.claude/settings.jsonand asserts theWritematcher, theifpath filter, thewritecommand, thestopcommand, and their timeouts.