diff --git a/packages/sv-utils/api-surface.md b/packages/sv-utils/api-surface.md index 92975e35c..e79fd55d3 100644 --- a/packages/sv-utils/api-surface.md +++ b/packages/sv-utils/api-surface.md @@ -779,6 +779,7 @@ type Printer = (content: string, alt?: string) => string; declare function createPrinter(...conditions: boolean[]): Printer[]; declare function sanitizeName(name: string, style: 'package' | 'wrangler'): string; + declare function minimizeDiff(old: string, updated: string): string; declare const downloadJson: (url: string) => Promise; type Package = { diff --git a/packages/sv-utils/src/sanitize.ts b/packages/sv-utils/src/sanitize.ts index 28b8454c6..8557f8e68 100644 --- a/packages/sv-utils/src/sanitize.ts +++ b/packages/sv-utils/src/sanitize.ts @@ -28,75 +28,143 @@ export function sanitizeName(name: string, style: 'package' | 'wrangler'): strin return sanitized || 'undefined-sv-name'; } -function normalizeWhitespace(value: string): string { - // Ignore formatting-only characters that commonly differ between source code and generated - // output: whitespace, semicolons, commas, and parentheses. - return value.replace(/[\s;,()]+/g, ''); +// `diffLines` is O(N·D); past this size we keep the printer output rather than hanging. +const MAX_DIFF_INPUT_BYTES = 512 * 1024; + +/** + * Minimizes formatting churn in reprinted output: formatting-only hunks are restored verbatim from + * the original, real changes keep the updated content but reuse the original blank-line layout. + */ +export function minimizeDiff(old: string, updated: string): string { + if (isBlank(old)) return updated; + if (old.length > MAX_DIFF_INPUT_BYTES || updated.length > MAX_DIFF_INPUT_BYTES) return updated; + + const original = normalizeLineEndings(old); + const replacement = normalizeLineEndings(updated); + + const formattingMinimized = restoreFormattingOnlyChanges(original, replacement); + return restoreBlankLineLayout(original, formattingMinimized); +} + +type LineChange = ReturnType[number]; + +function restoreFormattingOnlyChanges(original: string, replacement: string): string { + // exact matching keeps reformatted punctuation inside the hunk instead of becoming a false anchor + const changes = diffLines(original, replacement); + let result = ''; + + for (let i = 0; i < changes.length; i += 1) { + const change = changes[i]; + const next = changes[i + 1]; + + if (change.removed && next?.added) { + result += + normalizeForComparison(change.value) === normalizeForComparison(next.value) + ? change.value + : next.value; + i += 1; + } else if (!change.removed) { + result += change.value; + } + } + + return result; } -function isOnlyWhitespace(value: string): boolean { +function restoreBlankLineLayout(original: string, replacement: string): string { + // ignoring whitespace keeps reindented lines as anchors so only blank-line moves show up + const changes = diffLines(original, replacement, { ignoreWhitespace: true }); + let result = ''; + + for (let i = 0; i < changes.length; ) { + const change = changes[i]; + const next = changes[i + 1]; + const afterNext = changes[i + 2]; + + if (isChange(change) && next && isOppositeChange(change, next)) { + result += resolveReplacement([change, next]); + i += 2; + continue; + } + + // a blank line can anchor between the removed and added sides; treat it as part of the hunk + if ( + isChange(change) && + next && + !isChange(next) && + isBlank(next.value) && + afterNext && + isOppositeChange(change, afterNext) + ) { + result += resolveReplacement([change, next, afterNext]); + i += 3; + continue; + } + + if (!isChange(change)) { + result += change.value; + } else if (isBlank(change.value)) { + if (change.removed) result += change.value; + } else if (change.added) { + result += change.value; + } + + i += 1; + } + + return result; +} + +function normalizeLineEndings(value: string): string { + return value.replace(/\r\n?/g, '\n'); +} + +function isChange(change: LineChange): boolean { + return change.added === true || change.removed === true; +} + +function isOppositeChange(a: LineChange, b: LineChange): boolean { + return (a.added === true && b.removed === true) || (a.removed === true && b.added === true); +} + +function isBlank(value: string): boolean { return value.trim() === ''; } -/** Splits content into lines, each keeping its trailing newline (matches the diff line tokenizer). */ -function toLines(value: string): string[] { - return value.match(/[^\n]*\n|[^\n]+$/g) ?? []; +function resolveReplacement(changes: LineChange[]): string { + let original = ''; + let replacement = ''; + + for (const change of changes) { + if (!change.added) original += change.value; + if (!change.removed) replacement += change.value; + } + + if (normalizeForComparison(original) === normalizeForComparison(replacement)) return original; + + return preserveBlankLineLayout(original, replacement); } -/** - * Minimizes formatting churn in generated output. - * - * A line whose content is unchanged - ignoring whitespace, semicolons, commas and parentheses - is - * restored verbatim from the original. This reverts everything a printer reformats but doesn't - * meaningfully change: re-indentation, added semicolons, rewrapped punctuation and, crucially, the - * blank lines printers like to insert between statements. Only lines with real content changes keep - * the printer's output (printer-inserted blank lines among them are dropped). - * - * Brand-new files (no original to diff against) are kept verbatim: their blank lines are authored - * layout, not printer churn, so stripping them would mangle generated markdown, env files, etc. - */ -// `diffLines` is O(N·D) in the number of differing lines, which explodes on large, heavily -// reformatted files (e.g. a minified bundle reprinted by a pretty-printer). Past this size we skip -// minimization and keep the printer's output verbatim rather than hanging. -const MAX_DIFF_INPUT_BYTES = 512 * 1024; +function normalizeForComparison(value: string): string { + return value + .replace(/\s/g, '') + .replace(/;/g, '') + .replace(/,([}\])])/g, '$1') + .replace(/,$/, ''); +} -export function minimizeDiff(old: string, updated: string): string { - if (isOnlyWhitespace(old)) return updated; - if (old.length > MAX_DIFF_INPUT_BYTES || updated.length > MAX_DIFF_INPUT_BYTES) return updated; +function preserveBlankLineLayout(original: string, replacement: string): string { + const originalLines = original.split('\n'); + const replacementLines = replacement.split('\n'); + const replacementContent = replacementLines.filter((line) => line.trim() !== ''); - // Normalize line endings first: on Windows the original is often CRLF while the printer emits LF, - // which would make every line differ and collapse the diff, defeating the restoration below. - const diff = diffLines(old.replace(/\r\n/g, '\n'), updated.replace(/\r\n/g, '\n')); - - // blank lines are layout, not content: keep the original's, drop the ones printers insert - const realContent = (value: string) => - toLines(value) - .filter((l) => !isOnlyWhitespace(l)) - .join(''); - const blankLines = (value: string) => toLines(value).filter(isOnlyWhitespace).join(''); - - let out = ''; - for (let i = 0; i < diff.length; i += 1) { - const part = diff[i]; - const next = diff[i + 1]; - - if (part.removed && next?.added) { - // a changed range. If it differs only by formatting (whitespace, semicolons, rewrapping) - // restore the original verbatim; otherwise keep the new content, minus inserted blanks but - // keeping the original's blank lines. - out += - normalizeWhitespace(part.value) === normalizeWhitespace(next.value) - ? part.value - : blankLines(part.value) + realContent(next.value); - i += 1; - } else if (part.removed) { - out += blankLines(part.value); // keep original blank lines, drop removed content - } else if (part.added) { - out += realContent(part.value); // real new content, minus printer-inserted blanks - } else { - out += part.value; // unchanged - } + // positional pairing is only safe when content line counts match + if (originalLines.filter((line) => line.trim() !== '').length !== replacementContent.length) { + return replacement; } - return out; + let contentIndex = 0; + return originalLines + .map((line) => (line.trim() === '' ? line : replacementContent[contentIndex++])) + .join('\n'); } diff --git a/packages/sv-utils/src/tests/sanitize.ts b/packages/sv-utils/src/tests/sanitize.ts index 38bc503a2..948931a03 100644 --- a/packages/sv-utils/src/tests/sanitize.ts +++ b/packages/sv-utils/src/tests/sanitize.ts @@ -93,6 +93,12 @@ describe('minimizeDiff', () => { expect(minimizeDiff('', updated)).toBe(updated); }); + it('treats a whitespace-only original as a brand-new file', () => { + const updated = 'first\n\nsecond\n'; + + expect(minimizeDiff(' \n\t\n', updated)).toBe(updated); + }); + it('preserves blank lines from the original content', () => { const old = dedent` console.log('line1'); @@ -165,6 +171,148 @@ describe('minimizeDiff', () => { expect(minimizeDiff(old, updated)).toBe(old); }); + it('restores a blank line removed between unchanged statements', () => { + const old = dedent` + const cache = getCache(); + const host_key = get_host_key(); + + const sha = await canonicalize_commit_sha(); + `; + + const updated = dedent` + const cache = getCache(); + const host_key = get_host_key(); + const sha = await canonicalize_commit_sha(); + `; + + expect(minimizeDiff(old, updated)).toBe(old); + }); + + it('restores original layout when blank lines move within a function', () => { + // The reindentation is what makes diffLines misalign the otherwise unchanged statements. The + // behavior under test is that the blank lines must still stay in their original positions. + const old = dedent` + function parse(value: string) { + let parsed: URL; + try { + parsed = new URL(value, 'http://safe-next.invalid'); + } catch { + return null; + } + if (parsed.origin !== 'http://safe-next.invalid') return null; + + for (const prefix of BLOCKLIST) { + if (parsed.pathname === prefix) return null; + } + + return parsed.pathname; + } + `; + + const updated = dedent` + function parse(value: string) { + let parsed: URL; + + try { + parsed = new URL(value, 'http://safe-next.invalid'); + } catch { + return null; + } + + if (parsed.origin !== 'http://safe-next.invalid') return null; + for (const prefix of BLOCKLIST) { + if (parsed.pathname === prefix) return null; + } + return parsed.pathname; + } + `; + + const blankLineLayout = (value: string) => + value + .split('\n') + .map((line) => (line.trim() ? 'content' : '')) + .join('\n'); + expect(blankLineLayout(minimizeDiff(old, updated))).toBe(blankLineLayout(old)); + }); + + it('restores blank lines around a rewrapped statement', () => { + const old = dedent` + const cache = getCache(); + const host_key = get_host_key(); + + const sha = await canonicalize_commit_sha({ + owner: opts.owner, + repo: opts.repo, + commit: opts.commit + }); + + // validate sha + const immutable = true; + `; + + const updated = dedent` + const cache = getCache(); + const host_key = get_host_key(); + const sha = await canonicalize_commit_sha({ owner: opts.owner, repo: opts.repo, commit: opts.commit }); + + // validate sha + + const immutable = true; + `; + + const result = minimizeDiff(old, updated); + expect(result).toContain('const host_key = get_host_key();\n\nconst sha'); + expect(result).toContain('});\n\n// validate sha\nconst immutable = true;'); + }); + + it('restores blank lines shifted within a formatting-only hunk', () => { + const old = dedent` + const algorithm = 'old'; + + const parts = state.split('.'); + if (parts.length !== 2) return null; + const [body_b64, sig_b64] = parts; + + let body_bytes: Uint8Array; + let sig_bytes: Uint8Array; + try { + body_bytes = b64url_decode(body_b64); + sig_bytes = b64url_decode(sig_b64); + } catch { + return null; + } + + const expected = await hmac_sha256(require_state_secret(), body_bytes); + if (!timing_safe_equal(expected, sig_bytes)) return null; + + try { + `; + + const updated = dedent` + const algorithm = 'new'; + + const parts = state.split('.'); + + if (parts.length !== 2) return null; + + const [body_b64, sig_b64] = parts; + let body_bytes: Uint8Array; + let sig_bytes: Uint8Array; + + try { + body_bytes = b64url_decode(body_b64); + sig_bytes = b64url_decode(sig_b64); + } catch { + return null; + } + const expected = await hmac_sha256(require_state_secret(), body_bytes); + if (!timing_safe_equal(expected, sig_bytes)) return null; + try { + `; + + expect(minimizeDiff(old, updated)).toBe(old.replace("'old'", "'new'")); + }); + it('keeps an original blank line that is bundled into a real change', () => { // The diff groups the leading blank line together with the changed line. The blank line is // layout, not content, so it must survive even though the adjacent line really changed. @@ -214,6 +362,38 @@ describe('minimizeDiff', () => { expect(minimizeDiff(old, updated)).toBe(expected); }); + it('drops printer-inserted blank lines around a newly inserted line', () => { + const old = dedent` + function require_env(name: string): string { + const value = env[name]; + if (!value) throw new Error(name); + return value; + } + `; + + const updated = dedent` + function require_env(name: string): string { + // @migration-task Rewrite dynamic env lookup manually. + const value = env[name]; + + if (!value) throw new Error(name); + + return value; + } + `; + + const expected = dedent` + function require_env(name: string): string { + // @migration-task Rewrite dynamic env lookup manually. + const value = env[name]; + if (!value) throw new Error(name); + return value; + } + `; + + expect(minimizeDiff(old, updated)).toBe(expected); + }); + it('restores formatting-only hunks when the original uses CRLF line endings', () => { // On Windows the original file is often checked out with CRLF while the printer emits LF. // Without normalization the diff collapses into a single hunk and the formatting-only @@ -377,6 +557,60 @@ describe('minimizeDiff', () => { expect(minimizeDiff(old, updated)).toBe(old); }); + it('restores original formatting when a typed property is collapsed onto one line', () => { + const old = dedent` + type Version = 'old'; + + type Connection = { + pageInfo: { + hasPreviousPage: boolean; + startCursor: string | null; + }; + }; + `; + + const updated = dedent` + type Version = 'new'; + + type Connection = { + pageInfo: { hasPreviousPage: boolean; startCursor: string | null }; + }; + `; + + expect(minimizeDiff(old, updated)).toBe(old.replace("'old'", "'new'")); + }); + + it('restores original formatting when a call with trailing commas is collapsed onto one line', () => { + const old = dedent` + const version = 'old'; + + const sha = await canonicalize( + opts.owner, + opts.repo, + opts.commit, + ); + `; + + const updated = dedent` + const version = 'new'; + + const sha = await canonicalize(opts.owner, opts.repo, opts.commit); + `; + + expect(minimizeDiff(old, updated)).toBe(old.replace("'old'", "'new'")); + }); + + it('keeps oversized inputs verbatim instead of diffing them', () => { + // worst case for diffLines is O(N·D); past the size cap the updated content is returned as-is + const line = (i: number) => `const value_${i} = compute(${i});`; + const old = Array.from({ length: 20_000 }, (_, i) => line(i)).join('\n'); + const updated = Array.from({ length: 20_000 }, (_, i) => `\t${line(i)}\n`).join(''); + + const start = performance.now(); + expect(minimizeDiff(old, updated)).toBe(updated); + expect(performance.now() - start).toBeLessThan(1000); + }); + it('keeps updated hunks when non-trailing commas change the meaning', () => { const old = dedent` const values = ['a' + 'b']; diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/.env.example b/packages/sv/src/cli/tests/snapshots/create-experimental/.env.example index 6bb40ccbe..34a24e9fe 100644 --- a/packages/sv/src/cli/tests/snapshots/create-experimental/.env.example +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/.env.example @@ -1,6 +1,8 @@ # Drizzle DATABASE_URL=file:local.db + ORIGIN="" + # Better Auth # For production use 32 characters and generated with high entropy # https://www.better-auth.com/docs/installation diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/app.d.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/src/app.d.ts index 6ccfd4dd2..16910ac28 100644 --- a/packages/sv/src/cli/tests/snapshots/create-experimental/src/app.d.ts +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/app.d.ts @@ -1,4 +1,5 @@ import type { User, Session } from 'better-auth'; + // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { @@ -9,7 +10,9 @@ declare global { caches: CacheStorage; cf?: IncomingRequestCfProperties } + interface Locals { user?: User; session?: Session } + // interface Error {} // interface PageData {} // interface PageState {} diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/db/schema.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/db/schema.ts index 74eb9ddbf..35de29be6 100644 --- a/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/db/schema.ts +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/db/schema.ts @@ -5,4 +5,5 @@ export const task = sqliteTable('task', { title: text('title').notNull(), priority: integer('priority').notNull().default(1) }); + export * from './auth.schema'; diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/vite.config.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/vite.config.ts index 60c7bfb22..ed92d13b0 100644 --- a/packages/sv/src/cli/tests/snapshots/create-experimental/vite.config.ts +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/vite.config.ts @@ -10,7 +10,6 @@ export default defineConfig({ runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true }, - adapter: adapter(), experimental: { explicitEnvironmentVariables: true }, typescript: { diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.env.example b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.env.example index 6bb40ccbe..34a24e9fe 100644 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.env.example +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.env.example @@ -1,6 +1,8 @@ # Drizzle DATABASE_URL=file:local.db + ORIGIN="" + # Better Auth # For production use 32 characters and generated with high entropy # https://www.better-auth.com/docs/installation diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/app.d.ts b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/app.d.ts index f4734f616..7a2a5d2e4 100644 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/app.d.ts +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/app.d.ts @@ -1,9 +1,11 @@ import type { User, Session } from 'better-auth'; + // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { namespace App { interface Locals { user?: User; session?: Session } + // interface Error {} // interface PageData {} // interface PageState {} diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/hooks.server.ts b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/hooks.server.ts index 167ed2bdb..f0872bb70 100644 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/hooks.server.ts +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/hooks.server.ts @@ -16,10 +16,13 @@ const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(even const handleBetterAuth: Handle = async ({ event, resolve }) => { const session = await auth.api.getSession({ headers: event.request.headers }); + if (session) { event.locals.session = session.session; event.locals.user = session.user; } + return svelteKitHandler({ event, resolve, auth, building }); }; + export const handle: Handle = sequence(handleParaglide, handleBetterAuth); diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/lib/server/db/schema.ts b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/lib/server/db/schema.ts index 74eb9ddbf..35de29be6 100644 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/lib/server/db/schema.ts +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/lib/server/db/schema.ts @@ -5,4 +5,5 @@ export const task = sqliteTable('task', { title: text('title').notNull(), priority: integer('priority').notNull().default(1) }); + export * from './auth.schema'; diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/routes/+layout.svelte b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/routes/+layout.svelte index d3dabe29a..0f0a134b4 100644 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/routes/+layout.svelte +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/routes/+layout.svelte @@ -14,6 +14,7 @@ {@render children()} +
{#each locales as locale (locale)} filename.split(/[/\\]/).includes('node_modules') ? undefined : true }, - adapter: adapter(), preprocess: [mdsvex({ extensions: ['.svx', '.md'] })], extensions: ['.svelte', '.svx', '.md'], @@ -44,6 +43,7 @@ export default defineConfig({ exclude: ['src/lib/server/**'] } }, + { extends: './vite.config.ts', test: { diff --git a/packages/sv/src/migrate/migrations/app-state/tests/app-state/strings-comments.snapshot.svelte b/packages/sv/src/migrate/migrations/app-state/tests/app-state/strings-comments.snapshot.svelte index 54db38e97..80829ce6d 100644 --- a/packages/sv/src/migrate/migrations/app-state/tests/app-state/strings-comments.snapshot.svelte +++ b/packages/sv/src/migrate/migrations/app-state/tests/app-state/strings-comments.snapshot.svelte @@ -6,4 +6,5 @@ * This is a page */ + Nice page!
{page.url}
diff --git a/packages/sv/src/migrate/migrations/sveltekit-3/tests/environment/dynamic-import-namespace.snapshot.ts b/packages/sv/src/migrate/migrations/sveltekit-3/tests/environment/dynamic-import-namespace.snapshot.ts index 8278edc28..50546bc05 100644 --- a/packages/sv/src/migrate/migrations/sveltekit-3/tests/environment/dynamic-import-namespace.snapshot.ts +++ b/packages/sv/src/migrate/migrations/sveltekit-3/tests/environment/dynamic-import-namespace.snapshot.ts @@ -1,6 +1,7 @@ export async function requireDynamicImportEnv(name: string): Promise { // @migration-task Declare the imported env variables in src/env.ts manually. const privateEnv = await import('$app/env/private'); + const value = privateEnv[name]; if (!value) throw new Error(`${name} must be set`); return value; diff --git a/packages/sv/src/migrate/migrations/sveltekit-3/tests/svelte-config/eslint.config.snapshot.js b/packages/sv/src/migrate/migrations/sveltekit-3/tests/svelte-config/eslint.config.snapshot.js index 5bb4f4c31..1dc6c34a4 100644 --- a/packages/sv/src/migrate/migrations/sveltekit-3/tests/svelte-config/eslint.config.snapshot.js +++ b/packages/sv/src/migrate/migrations/sveltekit-3/tests/svelte-config/eslint.config.snapshot.js @@ -1,6 +1,7 @@ import js from '@eslint/js'; import svelte from 'eslint-plugin-svelte'; import { defineConfig } from 'eslint/config'; + // @migration-task svelteConfig should not be needed anymore, see https://github.com/sveltejs/eslint-plugin-svelte/issues/1550 import svelteConfig from './svelte.config.js'; diff --git a/packages/sv/src/migrate/migrations/sveltekit-3/tests/svelte-config/vite.config.snapshot.ts b/packages/sv/src/migrate/migrations/sveltekit-3/tests/svelte-config/vite.config.snapshot.ts index 757db8736..1cb998f7b 100644 --- a/packages/sv/src/migrate/migrations/sveltekit-3/tests/svelte-config/vite.config.snapshot.ts +++ b/packages/sv/src/migrate/migrations/sveltekit-3/tests/svelte-config/vite.config.snapshot.ts @@ -9,7 +9,9 @@ import devtoolsJson from 'vite-plugin-devtools-json'; // derive paths and adapter config from the environment const { paths, adapterConfig } = helper(process.env['SOME_ENV_VAR']); + const base = process.env['VITEST'] ? '' : '/some-base'; // empty base while testing + export default defineConfig({ plugins: [ tailwindcss(),