From b1a252843ea965c2047208245ad535edb6816a3c Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Fri, 12 Dec 2025 13:21:15 -0500 Subject: [PATCH 1/4] feat(upgrade): Sign-in Client Trust status handling --- .../src/__tests__/integration/matcher.test.js | 388 ++++++++++++++++++ packages/upgrade/src/config.js | 3 + packages/upgrade/src/runner.js | 22 +- ...needs-client-trust-sign-in-status-added.md | 29 ++ 4 files changed, 439 insertions(+), 3 deletions(-) create mode 100644 packages/upgrade/src/__tests__/integration/matcher.test.js create mode 100644 packages/upgrade/src/versions/core-3/changes/needs-client-trust-sign-in-status-added.md diff --git a/packages/upgrade/src/__tests__/integration/matcher.test.js b/packages/upgrade/src/__tests__/integration/matcher.test.js new file mode 100644 index 00000000000..1fd4940347f --- /dev/null +++ b/packages/upgrade/src/__tests__/integration/matcher.test.js @@ -0,0 +1,388 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { runScans } from '../../runner.js'; +import { writeFixtureFile } from '../helpers/create-fixture.js'; + +vi.mock('../../render.js', () => ({ + colors: { reset: '', bold: '', yellow: '', gray: '' }, + createSpinner: vi.fn(() => ({ + update: vi.fn(), + stop: vi.fn(), + success: vi.fn(), + error: vi.fn(), + })), + promptText: vi.fn((msg, defaultValue) => defaultValue), + renderCodemodResults: vi.fn(), + renderText: vi.fn(), +})); + +describe('matcher functionality', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-upgrade-matcher-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('single regex matcher', () => { + it('matches single pattern in file', async () => { + writeFixtureFile(tempDir, 'test.tsx', 'const value = hideSlug();'); + + const config = { + changes: [ + { + title: 'Test Change', + matcher: new RegExp('hideSlug', 'g'), + matcherLogic: 'or', + packages: ['*'], + }, + ], + }; + + const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + + expect(results).toHaveLength(1); + expect(results[0].title).toBe('Test Change'); + expect(results[0].instances).toHaveLength(1); + expect(results[0].instances[0].file).toContain('test.tsx'); + }); + + it('does not match when pattern is absent', async () => { + writeFixtureFile(tempDir, 'test.tsx', 'const value = somethingElse();'); + + const config = { + changes: [ + { + title: 'Test Change', + matcher: new RegExp('hideSlug', 'g'), + matcherLogic: 'or', + packages: ['*'], + }, + ], + }; + + const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + + expect(results).toHaveLength(0); + }); + }); + + describe('array matcher with OR logic (default)', () => { + it('matches when any pattern in array matches', async () => { + writeFixtureFile(tempDir, 'test.tsx', 'const value = hideSlug();'); + + const config = { + changes: [ + { + title: 'Test Change', + matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], + matcherLogic: 'or', + packages: ['*'], + }, + ], + }; + + const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + + expect(results).toHaveLength(1); + expect(results[0].instances).toHaveLength(1); + }); + + it('matches multiple patterns when both are present', async () => { + writeFixtureFile(tempDir, 'test.tsx', 'const a = hideSlug(); const b = showSlug();'); + + const config = { + changes: [ + { + title: 'Test Change', + matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], + matcherLogic: 'or', + packages: ['*'], + }, + ], + }; + + const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + + expect(results).toHaveLength(1); + expect(results[0].instances).toHaveLength(2); + }); + + it('does not match when no patterns match', async () => { + writeFixtureFile(tempDir, 'test.tsx', 'const value = somethingElse();'); + + const config = { + changes: [ + { + title: 'Test Change', + matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], + matcherLogic: 'or', + packages: ['*'], + }, + ], + }; + + const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + + expect(results).toHaveLength(0); + }); + }); + + describe('array matcher with AND logic', () => { + it('matches when all patterns are present', async () => { + writeFixtureFile(tempDir, 'test.tsx', 'const a = hideSlug(); const b = showSlug();'); + + const config = { + changes: [ + { + title: 'Test Change', + matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], + matcherLogic: 'and', + packages: ['*'], + }, + ], + }; + + const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + + expect(results).toHaveLength(1); + expect(results[0].instances).toHaveLength(2); + }); + + it('does not match when only one pattern is present', async () => { + writeFixtureFile(tempDir, 'test.tsx', 'const value = hideSlug();'); + + const config = { + changes: [ + { + title: 'Test Change', + matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], + matcherLogic: 'and', + packages: ['*'], + }, + ], + }; + + const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + + expect(results).toHaveLength(0); + }); + + it('does not match when no patterns are present', async () => { + writeFixtureFile(tempDir, 'test.tsx', 'const value = somethingElse();'); + + const config = { + changes: [ + { + title: 'Test Change', + matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], + matcherLogic: 'and', + packages: ['*'], + }, + ], + }; + + const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + + expect(results).toHaveLength(0); + }); + + it('matches across multiple files when all patterns are present', async () => { + writeFixtureFile(tempDir, 'file1.tsx', 'const a = hideSlug();'); + writeFixtureFile(tempDir, 'file2.tsx', 'const b = showSlug();'); + + const config = { + changes: [ + { + title: 'Test Change', + matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], + matcherLogic: 'and', + packages: ['*'], + }, + ], + }; + + const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + + // AND logic checks if all patterns match somewhere in the file + // Since hideSlug is in file1 and showSlug is in file2, each file individually + // doesn't have both, so no matches should be found + expect(results).toHaveLength(0); + }); + + it('matches when all patterns are present in the same file', async () => { + writeFixtureFile(tempDir, 'file1.tsx', 'const a = hideSlug();'); + writeFixtureFile(tempDir, 'file2.tsx', 'const a = hideSlug(); const b = showSlug();'); + + const config = { + changes: [ + { + title: 'Test Change', + matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], + matcherLogic: 'and', + packages: ['*'], + }, + ], + }; + + const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + + // Only file2 has both patterns, so it should match + expect(results).toHaveLength(1); + expect(results[0].instances).toHaveLength(2); // Both patterns match in file2 + }); + }); + + describe('matcherLogic loading from frontmatter', () => { + it('defaults to OR logic when matcherLogic is not specified', async () => { + // Test that matcherLogic defaults to 'or' when not specified + const testConfig = { + id: 'test-version', + changes: [ + { + title: 'Test Change', + matcher: [new RegExp('pattern1', 'g'), new RegExp('pattern2', 'g')], + matcherLogic: 'or', // Should default to 'or' when not specified in frontmatter + packages: ['*'], + }, + ], + }; + + writeFixtureFile(tempDir, 'test.tsx', 'const value = pattern1();'); + + const results = await runScans(testConfig, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + // Should match with OR logic (any pattern matches) + expect(results).toHaveLength(1); + expect(results[0].instances).toHaveLength(1); + }); + + it('loads AND logic from frontmatter', async () => { + const testConfig = { + id: 'test-version', + changes: [ + { + title: 'Test Change', + matcher: [new RegExp('pattern1', 'g'), new RegExp('pattern2', 'g')], + matcherLogic: 'and', + packages: ['*'], + }, + ], + }; + + writeFixtureFile(tempDir, 'test.tsx', 'const a = pattern1(); const b = pattern2();'); + + const results = await runScans(testConfig, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + // Should match with AND logic (all patterns match) + expect(results).toHaveLength(1); + expect(results[0].instances).toHaveLength(2); + }); + + it('loads OR logic explicitly from frontmatter', async () => { + const testConfig = { + id: 'test-version', + changes: [ + { + title: 'Test Change', + matcher: [new RegExp('pattern1', 'g'), new RegExp('pattern2', 'g')], + matcherLogic: 'or', + packages: ['*'], + }, + ], + }; + + writeFixtureFile(tempDir, 'test.tsx', 'const value = pattern1();'); + + const results = await runScans(testConfig, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + // Should match with OR logic (any pattern matches) + expect(results).toHaveLength(1); + expect(results[0].instances).toHaveLength(1); + }); + }); + + describe('edge cases', () => { + it('handles empty array matcher gracefully', async () => { + const config = { + changes: [ + { + title: 'Test Change', + matcher: [], + matcherLogic: 'or', + packages: ['*'], + }, + ], + }; + + writeFixtureFile(tempDir, 'test.tsx', 'const value = something();'); + + const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + + expect(results).toHaveLength(0); + }); + + it('handles multiple matches of the same pattern', async () => { + writeFixtureFile(tempDir, 'test.tsx', 'const a = hideSlug(); const b = hideSlug();'); + + const config = { + changes: [ + { + title: 'Test Change', + matcher: new RegExp('hideSlug', 'g'), + matcherLogic: 'or', + packages: ['*'], + }, + ], + }; + + const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + + expect(results).toHaveLength(1); + expect(results[0].instances).toHaveLength(2); + }); + + it('handles AND logic with three patterns', async () => { + writeFixtureFile(tempDir, 'test.tsx', 'const a = pattern1(); const b = pattern2(); const c = pattern3();'); + + const config = { + changes: [ + { + title: 'Test Change', + matcher: [new RegExp('pattern1', 'g'), new RegExp('pattern2', 'g'), new RegExp('pattern3', 'g')], + matcherLogic: 'and', + packages: ['*'], + }, + ], + }; + + const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + + expect(results).toHaveLength(1); + expect(results[0].instances).toHaveLength(3); + }); + + it('does not match AND logic when one of three patterns is missing', async () => { + writeFixtureFile(tempDir, 'test.tsx', 'const a = pattern1(); const b = pattern2();'); + + const config = { + changes: [ + { + title: 'Test Change', + matcher: [new RegExp('pattern1', 'g'), new RegExp('pattern2', 'g'), new RegExp('pattern3', 'g')], + matcherLogic: 'and', + packages: ['*'], + }, + ], + }; + + const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); + + expect(results).toHaveLength(0); + }); + }); +}); diff --git a/packages/upgrade/src/config.js b/packages/upgrade/src/config.js index 3316b03d400..208de414310 100644 --- a/packages/upgrade/src/config.js +++ b/packages/upgrade/src/config.js @@ -167,9 +167,12 @@ function loadChanges(versionDir, sdk) { : new RegExp(fm.matcher, `g${fm.matcherFlags || ''}`) : null; + const matcherLogic = fm.matcherLogic || 'or'; + changes.push({ title: fm.title, matcher, + matcherLogic, packages, category: fm.category || 'breaking', warning: fm.warning || fm.category === 'warning', diff --git a/packages/upgrade/src/runner.js b/packages/upgrade/src/runner.js index 3bd07a0ebac..9945a88ae2e 100644 --- a/packages/upgrade/src/runner.js +++ b/packages/upgrade/src/runner.js @@ -84,7 +84,7 @@ export async function runScans(config, sdk, options) { const content = await fs.readFile(file, 'utf8'); for (const matcher of matchers) { - const matches = findMatches(content, matcher.matcher); + const matches = findMatches(content, matcher.matcher, matcher.matcherLogic); if (matches.length === 0) { continue; @@ -136,9 +136,25 @@ function loadMatchers(config, sdk) { }); } -function findMatches(content, matcher) { +function findMatches(content, matcher, matcherLogic = 'or') { if (Array.isArray(matcher)) { - return matcher.flatMap(m => Array.from(content.matchAll(m))); + if (matcherLogic === 'and') { + // For AND logic, all patterns must match somewhere in the file + const allMatch = matcher.every(m => { + const matches = Array.from(content.matchAll(m)); + return matches.length > 0; + }); + + if (!allMatch) { + return []; + } + + // If all patterns match, return matches from all patterns + return matcher.flatMap(m => Array.from(content.matchAll(m))); + } else { + // Default OR logic: match if any pattern matches + return matcher.flatMap(m => Array.from(content.matchAll(m))); + } } return Array.from(content.matchAll(matcher)); } diff --git a/packages/upgrade/src/versions/core-3/changes/needs-client-trust-sign-in-status-added.md b/packages/upgrade/src/versions/core-3/changes/needs-client-trust-sign-in-status-added.md new file mode 100644 index 00000000000..a32836b307e --- /dev/null +++ b/packages/upgrade/src/versions/core-3/changes/needs-client-trust-sign-in-status-added.md @@ -0,0 +1,29 @@ +--- +title: 'Sign-in Client Trust status handling' +matcher: + - 'attemptFirstFactor' + - 'password' +matcherLogic: 'and' +category: 'breaking' +--- + +We've add a new Sign-in status of `needs_client_trust` which, given the conditions listed below, will need to be handled in your application. + +Prerequisites: + +- Client Trust is enabled. [TODO: Link] +- You've opted-in to the Client Trust `needs_client_trust` Update. [TODO: Links] +- Sign-in with Email and/or Phone identifiers are enabled. [TODO: Links] +- If Email or SMS sign-in verification aren't enabled. [TODO: Links] + +Example change: + +```diff +- const { signIn } = useSignIn() +- signIn.attemptFirstFactor({ strategy: 'password', password: '...' }) +- if (signIn.status === 'complete') {/* ... */ } ++ const { signIn } = useSignIn() ++ signIn.attemptFirstFactor({ strategy: 'password', password: '...' }) ++ if (signIn.status === 'needs_client_trust') { /* ... */ } ++ else if (signIn.status === 'complete') { /* ... */ } +``` From 1aa5e9c2c768a247e20f84715385dd9abba85f8d Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Fri, 12 Dec 2025 17:15:55 -0500 Subject: [PATCH 2/4] feat: Enable always display updates --- .../src/__tests__/integration/matcher.test.js | 388 ------------------ .../src/__tests__/integration/runner.test.js | 68 +++ packages/upgrade/src/config.js | 3 - packages/upgrade/src/runner.js | 49 +-- ...needs-client-trust-sign-in-status-added.md | 16 +- 5 files changed, 96 insertions(+), 428 deletions(-) delete mode 100644 packages/upgrade/src/__tests__/integration/matcher.test.js diff --git a/packages/upgrade/src/__tests__/integration/matcher.test.js b/packages/upgrade/src/__tests__/integration/matcher.test.js deleted file mode 100644 index 1fd4940347f..00000000000 --- a/packages/upgrade/src/__tests__/integration/matcher.test.js +++ /dev/null @@ -1,388 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { runScans } from '../../runner.js'; -import { writeFixtureFile } from '../helpers/create-fixture.js'; - -vi.mock('../../render.js', () => ({ - colors: { reset: '', bold: '', yellow: '', gray: '' }, - createSpinner: vi.fn(() => ({ - update: vi.fn(), - stop: vi.fn(), - success: vi.fn(), - error: vi.fn(), - })), - promptText: vi.fn((msg, defaultValue) => defaultValue), - renderCodemodResults: vi.fn(), - renderText: vi.fn(), -})); - -describe('matcher functionality', () => { - let tempDir; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-upgrade-matcher-test-')); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - describe('single regex matcher', () => { - it('matches single pattern in file', async () => { - writeFixtureFile(tempDir, 'test.tsx', 'const value = hideSlug();'); - - const config = { - changes: [ - { - title: 'Test Change', - matcher: new RegExp('hideSlug', 'g'), - matcherLogic: 'or', - packages: ['*'], - }, - ], - }; - - const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - - expect(results).toHaveLength(1); - expect(results[0].title).toBe('Test Change'); - expect(results[0].instances).toHaveLength(1); - expect(results[0].instances[0].file).toContain('test.tsx'); - }); - - it('does not match when pattern is absent', async () => { - writeFixtureFile(tempDir, 'test.tsx', 'const value = somethingElse();'); - - const config = { - changes: [ - { - title: 'Test Change', - matcher: new RegExp('hideSlug', 'g'), - matcherLogic: 'or', - packages: ['*'], - }, - ], - }; - - const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - - expect(results).toHaveLength(0); - }); - }); - - describe('array matcher with OR logic (default)', () => { - it('matches when any pattern in array matches', async () => { - writeFixtureFile(tempDir, 'test.tsx', 'const value = hideSlug();'); - - const config = { - changes: [ - { - title: 'Test Change', - matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], - matcherLogic: 'or', - packages: ['*'], - }, - ], - }; - - const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - - expect(results).toHaveLength(1); - expect(results[0].instances).toHaveLength(1); - }); - - it('matches multiple patterns when both are present', async () => { - writeFixtureFile(tempDir, 'test.tsx', 'const a = hideSlug(); const b = showSlug();'); - - const config = { - changes: [ - { - title: 'Test Change', - matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], - matcherLogic: 'or', - packages: ['*'], - }, - ], - }; - - const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - - expect(results).toHaveLength(1); - expect(results[0].instances).toHaveLength(2); - }); - - it('does not match when no patterns match', async () => { - writeFixtureFile(tempDir, 'test.tsx', 'const value = somethingElse();'); - - const config = { - changes: [ - { - title: 'Test Change', - matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], - matcherLogic: 'or', - packages: ['*'], - }, - ], - }; - - const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - - expect(results).toHaveLength(0); - }); - }); - - describe('array matcher with AND logic', () => { - it('matches when all patterns are present', async () => { - writeFixtureFile(tempDir, 'test.tsx', 'const a = hideSlug(); const b = showSlug();'); - - const config = { - changes: [ - { - title: 'Test Change', - matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], - matcherLogic: 'and', - packages: ['*'], - }, - ], - }; - - const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - - expect(results).toHaveLength(1); - expect(results[0].instances).toHaveLength(2); - }); - - it('does not match when only one pattern is present', async () => { - writeFixtureFile(tempDir, 'test.tsx', 'const value = hideSlug();'); - - const config = { - changes: [ - { - title: 'Test Change', - matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], - matcherLogic: 'and', - packages: ['*'], - }, - ], - }; - - const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - - expect(results).toHaveLength(0); - }); - - it('does not match when no patterns are present', async () => { - writeFixtureFile(tempDir, 'test.tsx', 'const value = somethingElse();'); - - const config = { - changes: [ - { - title: 'Test Change', - matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], - matcherLogic: 'and', - packages: ['*'], - }, - ], - }; - - const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - - expect(results).toHaveLength(0); - }); - - it('matches across multiple files when all patterns are present', async () => { - writeFixtureFile(tempDir, 'file1.tsx', 'const a = hideSlug();'); - writeFixtureFile(tempDir, 'file2.tsx', 'const b = showSlug();'); - - const config = { - changes: [ - { - title: 'Test Change', - matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], - matcherLogic: 'and', - packages: ['*'], - }, - ], - }; - - const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - - // AND logic checks if all patterns match somewhere in the file - // Since hideSlug is in file1 and showSlug is in file2, each file individually - // doesn't have both, so no matches should be found - expect(results).toHaveLength(0); - }); - - it('matches when all patterns are present in the same file', async () => { - writeFixtureFile(tempDir, 'file1.tsx', 'const a = hideSlug();'); - writeFixtureFile(tempDir, 'file2.tsx', 'const a = hideSlug(); const b = showSlug();'); - - const config = { - changes: [ - { - title: 'Test Change', - matcher: [new RegExp('hideSlug', 'g'), new RegExp('showSlug', 'g')], - matcherLogic: 'and', - packages: ['*'], - }, - ], - }; - - const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - - // Only file2 has both patterns, so it should match - expect(results).toHaveLength(1); - expect(results[0].instances).toHaveLength(2); // Both patterns match in file2 - }); - }); - - describe('matcherLogic loading from frontmatter', () => { - it('defaults to OR logic when matcherLogic is not specified', async () => { - // Test that matcherLogic defaults to 'or' when not specified - const testConfig = { - id: 'test-version', - changes: [ - { - title: 'Test Change', - matcher: [new RegExp('pattern1', 'g'), new RegExp('pattern2', 'g')], - matcherLogic: 'or', // Should default to 'or' when not specified in frontmatter - packages: ['*'], - }, - ], - }; - - writeFixtureFile(tempDir, 'test.tsx', 'const value = pattern1();'); - - const results = await runScans(testConfig, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - // Should match with OR logic (any pattern matches) - expect(results).toHaveLength(1); - expect(results[0].instances).toHaveLength(1); - }); - - it('loads AND logic from frontmatter', async () => { - const testConfig = { - id: 'test-version', - changes: [ - { - title: 'Test Change', - matcher: [new RegExp('pattern1', 'g'), new RegExp('pattern2', 'g')], - matcherLogic: 'and', - packages: ['*'], - }, - ], - }; - - writeFixtureFile(tempDir, 'test.tsx', 'const a = pattern1(); const b = pattern2();'); - - const results = await runScans(testConfig, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - // Should match with AND logic (all patterns match) - expect(results).toHaveLength(1); - expect(results[0].instances).toHaveLength(2); - }); - - it('loads OR logic explicitly from frontmatter', async () => { - const testConfig = { - id: 'test-version', - changes: [ - { - title: 'Test Change', - matcher: [new RegExp('pattern1', 'g'), new RegExp('pattern2', 'g')], - matcherLogic: 'or', - packages: ['*'], - }, - ], - }; - - writeFixtureFile(tempDir, 'test.tsx', 'const value = pattern1();'); - - const results = await runScans(testConfig, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - // Should match with OR logic (any pattern matches) - expect(results).toHaveLength(1); - expect(results[0].instances).toHaveLength(1); - }); - }); - - describe('edge cases', () => { - it('handles empty array matcher gracefully', async () => { - const config = { - changes: [ - { - title: 'Test Change', - matcher: [], - matcherLogic: 'or', - packages: ['*'], - }, - ], - }; - - writeFixtureFile(tempDir, 'test.tsx', 'const value = something();'); - - const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - - expect(results).toHaveLength(0); - }); - - it('handles multiple matches of the same pattern', async () => { - writeFixtureFile(tempDir, 'test.tsx', 'const a = hideSlug(); const b = hideSlug();'); - - const config = { - changes: [ - { - title: 'Test Change', - matcher: new RegExp('hideSlug', 'g'), - matcherLogic: 'or', - packages: ['*'], - }, - ], - }; - - const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - - expect(results).toHaveLength(1); - expect(results[0].instances).toHaveLength(2); - }); - - it('handles AND logic with three patterns', async () => { - writeFixtureFile(tempDir, 'test.tsx', 'const a = pattern1(); const b = pattern2(); const c = pattern3();'); - - const config = { - changes: [ - { - title: 'Test Change', - matcher: [new RegExp('pattern1', 'g'), new RegExp('pattern2', 'g'), new RegExp('pattern3', 'g')], - matcherLogic: 'and', - packages: ['*'], - }, - ], - }; - - const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - - expect(results).toHaveLength(1); - expect(results[0].instances).toHaveLength(3); - }); - - it('does not match AND logic when one of three patterns is missing', async () => { - writeFixtureFile(tempDir, 'test.tsx', 'const a = pattern1(); const b = pattern2();'); - - const config = { - changes: [ - { - title: 'Test Change', - matcher: [new RegExp('pattern1', 'g'), new RegExp('pattern2', 'g'), new RegExp('pattern3', 'g')], - matcherLogic: 'and', - packages: ['*'], - }, - ], - }; - - const results = await runScans(config, 'nextjs', { dir: tempDir, ignore: ['changes/**'] }); - - expect(results).toHaveLength(0); - }); - }); -}); diff --git a/packages/upgrade/src/__tests__/integration/runner.test.js b/packages/upgrade/src/__tests__/integration/runner.test.js index e3e5b2fcc72..f6a883acc72 100644 --- a/packages/upgrade/src/__tests__/integration/runner.test.js +++ b/packages/upgrade/src/__tests__/integration/runner.test.js @@ -56,6 +56,9 @@ describe('runScans', () => { it('respects ignore patterns', async () => { const config = await loadConfig('nextjs', 6); + // Filter to only changes with matchers for this test + config.changes = config.changes.filter(change => change.matcher); + const options = { dir: fixture.path, ignore: ['**/src/**'], @@ -65,4 +68,69 @@ describe('runScans', () => { expect(results).toEqual([]); }); + + it('always includes changes without matchers', async () => { + const config = await loadConfig('nextjs', 6); + // Add a change without a matcher + config.changes = [ + { + title: 'Test change without matcher', + matcher: null, + packages: ['*'], + category: 'breaking', + warning: false, + docsAnchor: 'test-change', + content: 'This is a test change', + }, + ]; + + const options = { + dir: fixture.path, + ignore: [], + }; + + const results = await runScans(config, 'nextjs', options); + + expect(results).toHaveLength(1); + expect(results[0].title).toBe('Test change without matcher'); + expect(results[0].instances).toEqual([]); + }); + + it('includes both changes with and without matchers', async () => { + const config = await loadConfig('nextjs', 6); + // Add a change without a matcher and one with a matcher + config.changes = [ + { + title: 'Change without matcher', + matcher: null, + packages: ['*'], + category: 'breaking', + warning: false, + docsAnchor: 'change-without-matcher', + content: 'This change has no matcher', + }, + { + title: 'Change with matcher', + matcher: new RegExp('clerkMiddleware', 'g'), + packages: ['*'], + category: 'breaking', + warning: false, + docsAnchor: 'change-with-matcher', + content: 'This change has a matcher', + }, + ]; + + const options = { + dir: fixture.path, + ignore: [], + }; + + const results = await runScans(config, 'nextjs', options); + + // Should include both changes + expect(results.length).toBeGreaterThanOrEqual(1); + const changeWithoutMatcher = results.find(r => r.title === 'Change without matcher'); + expect(changeWithoutMatcher).toBeDefined(); + expect(changeWithoutMatcher.instances).toEqual([]); + }); }); diff --git a/packages/upgrade/src/config.js b/packages/upgrade/src/config.js index 208de414310..3316b03d400 100644 --- a/packages/upgrade/src/config.js +++ b/packages/upgrade/src/config.js @@ -167,12 +167,9 @@ function loadChanges(versionDir, sdk) { : new RegExp(fm.matcher, `g${fm.matcherFlags || ''}`) : null; - const matcherLogic = fm.matcherLogic || 'or'; - changes.push({ title: fm.title, matcher, - matcherLogic, packages, category: fm.category || 'breaking', warning: fm.warning || fm.category === 'warning', diff --git a/packages/upgrade/src/runner.js b/packages/upgrade/src/runner.js index 9945a88ae2e..99f5683a90b 100644 --- a/packages/upgrade/src/runner.js +++ b/packages/upgrade/src/runner.js @@ -59,12 +59,27 @@ export async function runCodemods(config, sdk, options) { } export async function runScans(config, sdk, options) { - const matchers = loadMatchers(config, sdk); + const changes = loadMatchers(config, sdk); - if (matchers.length === 0) { + if (changes.length === 0) { return []; } + const changesWithMatchers = changes.filter(change => change.matcher); + const changesWithoutMatchers = changes.filter(change => !change.matcher); + + const results = {}; + + // Always include changes without matchers + for (const change of changesWithoutMatchers) { + results[change.title] = { instances: [], ...change }; + } + + // Handle scans with matchers + if (changesWithMatchers.length === 0) { + return Object.values(results); + } + const spinner = createSpinner('Scanning files for breaking changes...'); try { @@ -75,16 +90,14 @@ export async function runScans(config, sdk, options) { ignore: [...GLOBBY_IGNORE, ...(options.ignore || [])], }); - const results = {}; - for (let idx = 0; idx < files.length; idx++) { const file = files[idx]; spinner.update(`Scanning ${path.basename(file)} (${idx + 1}/${files.length})`); const content = await fs.readFile(file, 'utf8'); - for (const matcher of matchers) { - const matches = findMatches(content, matcher.matcher, matcher.matcherLogic); + for (const matcher of changesWithMatchers) { + const matches = findMatches(content, matcher.matcher); if (matches.length === 0) { continue; @@ -127,34 +140,14 @@ function loadMatchers(config, sdk) { } return config.changes.filter(change => { - if (!change.matcher) { - return false; - } - const packages = change.packages || ['*']; return packages.includes('*') || packages.includes(sdk); }); } -function findMatches(content, matcher, matcherLogic = 'or') { +function findMatches(content, matcher) { if (Array.isArray(matcher)) { - if (matcherLogic === 'and') { - // For AND logic, all patterns must match somewhere in the file - const allMatch = matcher.every(m => { - const matches = Array.from(content.matchAll(m)); - return matches.length > 0; - }); - - if (!allMatch) { - return []; - } - - // If all patterns match, return matches from all patterns - return matcher.flatMap(m => Array.from(content.matchAll(m))); - } else { - // Default OR logic: match if any pattern matches - return matcher.flatMap(m => Array.from(content.matchAll(m))); - } + return matcher.flatMap(m => Array.from(content.matchAll(m))); } return Array.from(content.matchAll(matcher)); } diff --git a/packages/upgrade/src/versions/core-3/changes/needs-client-trust-sign-in-status-added.md b/packages/upgrade/src/versions/core-3/changes/needs-client-trust-sign-in-status-added.md index a32836b307e..10531871f5e 100644 --- a/packages/upgrade/src/versions/core-3/changes/needs-client-trust-sign-in-status-added.md +++ b/packages/upgrade/src/versions/core-3/changes/needs-client-trust-sign-in-status-added.md @@ -1,20 +1,18 @@ --- title: 'Sign-in Client Trust status handling' -matcher: - - 'attemptFirstFactor' - - 'password' -matcherLogic: 'and' category: 'breaking' --- -We've add a new Sign-in status of `needs_client_trust` which, given the conditions listed below, will need to be handled in your application. +We've added a new Sign-in status of `needs_client_trust` which, given the conditions listed below, will need to be handled in your application. + +[TODO: Documentation Link] Prerequisites: -- Client Trust is enabled. [TODO: Link] -- You've opted-in to the Client Trust `needs_client_trust` Update. [TODO: Links] -- Sign-in with Email and/or Phone identifiers are enabled. [TODO: Links] -- If Email or SMS sign-in verification aren't enabled. [TODO: Links] +- [Passwords and Client Trust](https://dashboard.clerk.com/~/user-authentication/user-and-authentication?user_auth_tab=password) are enabled. +- You've opted-in to the Client Trust `needs_client_trust` [Update](https://dashboard.clerk.com/~/updates). +- Sign-in with [Email](https://dashboard.clerk.com/~/user-authentication/user-and-authentication) and/or [Phone](https://dashboard.clerk.com/~/user-authentication/user-and-authentication?user_auth_tab=phone) identifiers are enabled. +- If [Email](https://dashboard.clerk.com/~/user-authentication/user-and-authentication) or [SMS](https://dashboard.clerk.com/~/user-authentication/user-and-authentication?user_auth_tab=phone) sign-in verification aren't enabled. Example change: From b8a13b64f0049a338e41e93173da13ae2faff4af Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Fri, 12 Dec 2025 17:20:28 -0500 Subject: [PATCH 3/4] chore: Update copy --- .../core-3/changes/needs-client-trust-sign-in-status-added.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/upgrade/src/versions/core-3/changes/needs-client-trust-sign-in-status-added.md b/packages/upgrade/src/versions/core-3/changes/needs-client-trust-sign-in-status-added.md index 10531871f5e..c209bebb0de 100644 --- a/packages/upgrade/src/versions/core-3/changes/needs-client-trust-sign-in-status-added.md +++ b/packages/upgrade/src/versions/core-3/changes/needs-client-trust-sign-in-status-added.md @@ -3,7 +3,7 @@ title: 'Sign-in Client Trust status handling' category: 'breaking' --- -We've added a new Sign-in status of `needs_client_trust` which, given the conditions listed below, will need to be handled in your application. +We've added a new Sign-in status of `needs_client_trust` which, given the conditions listed, will need to be handled in your application. [TODO: Documentation Link] @@ -14,7 +14,7 @@ Prerequisites: - Sign-in with [Email](https://dashboard.clerk.com/~/user-authentication/user-and-authentication) and/or [Phone](https://dashboard.clerk.com/~/user-authentication/user-and-authentication?user_auth_tab=phone) identifiers are enabled. - If [Email](https://dashboard.clerk.com/~/user-authentication/user-and-authentication) or [SMS](https://dashboard.clerk.com/~/user-authentication/user-and-authentication?user_auth_tab=phone) sign-in verification aren't enabled. -Example change: +While your application may differ, we've provided an example change below. Please reach out to [Support](mailto:support@clerk.dev) if you have any questions. ```diff - const { signIn } = useSignIn() From 3a4a7e4f0669e945f7d0d4dbeb935bd796060ef5 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Fri, 12 Dec 2025 17:23:54 -0500 Subject: [PATCH 4/4] chore: Add changeset --- .changeset/small-dots-scream.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/small-dots-scream.md diff --git a/.changeset/small-dots-scream.md b/.changeset/small-dots-scream.md new file mode 100644 index 00000000000..ff44a7842d9 --- /dev/null +++ b/.changeset/small-dots-scream.md @@ -0,0 +1,5 @@ +--- +'@clerk/upgrade': patch +--- + +Add entry for Sign-in Client Trust Status