diff --git a/deno.json b/deno.json index 84bb1982abb..2163626ef75 100644 --- a/deno.json +++ b/deno.json @@ -86,7 +86,7 @@ "tailwindcss": "npm:tailwindcss@^4.1.10", "postcss": "npm:postcss@8.5.6", - "ts-morph": "npm:ts-morph@^26.0.0", + "ts-morph": "npm:ts-morph@^27.0.2", "@std/front-matter": "jsr:@std/front-matter@^1.0.5", "github-slugger": "npm:github-slugger@^2.0.0", diff --git a/deno.lock b/deno.lock index 5641159b478..7f58ac81c0e 100644 --- a/deno.lock +++ b/deno.lock @@ -116,7 +116,7 @@ "npm:stripe@^19.1.0": "19.1.0_@types+node@24.9.2", "npm:tailwindcss@^3.4.17": "3.4.18_postcss@8.5.6_jiti@1.21.7", "npm:tailwindcss@^4.1.10": "4.1.16", - "npm:ts-morph@26": "26.0.0", + "npm:ts-morph@^27.0.2": "27.0.2", "npm:vite-plugin-inspect@^11.3.2": "11.3.3_vite@7.1.12__@types+node@24.9.2__picomatch@4.0.3_@types+node@24.9.2", "npm:vite@^7.1.4": "7.1.12_@types+node@24.9.2_picomatch@4.0.3", "npm:vite@^7.1.5": "7.1.12_@types+node@24.9.2_picomatch@4.0.3" @@ -1791,12 +1791,12 @@ "@trysound/sax@0.2.0": { "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==" }, - "@ts-morph/common@0.27.0": { - "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", + "@ts-morph/common@0.28.1": { + "integrity": "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==", "dependencies": [ - "fast-glob", "minimatch@10.1.1", - "path-browserify" + "path-browserify", + "tinyglobby" ] }, "@types/babel__core@7.20.5": { @@ -3561,8 +3561,8 @@ "ts-interface-checker@0.1.13": { "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, - "ts-morph@26.0.0": { - "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", + "ts-morph@27.0.2": { + "integrity": "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==", "dependencies": [ "@ts-morph/common", "code-block-writer" @@ -3848,7 +3848,7 @@ "npm:redis@^5.8.2", "npm:rollup@^4.50.0", "npm:tailwindcss@^4.1.10", - "npm:ts-morph@26", + "npm:ts-morph@^27.0.2", "npm:vite@^7.1.5" ], "members": { diff --git a/packages/update/src/update.ts b/packages/update/src/update.ts index d4fac200b00..62bacd08422 100644 --- a/packages/update/src/update.ts +++ b/packages/update/src/update.ts @@ -3,6 +3,7 @@ import * as JSONC from "@std/jsonc"; import * as tsmorph from "ts-morph"; import * as colors from "@std/fmt/colors"; import { ProgressBar } from "@std/cli/unstable-progress-bar"; +import { walk } from "@std/fs/walk"; export const SyntaxKind = tsmorph.ts.SyntaxKind; @@ -10,8 +11,10 @@ export const FRESH_VERSION = "2.2.0"; export const PREACT_VERSION = "10.27.2"; export const PREACT_SIGNALS_VERSION = "2.5.0"; -// Function to filter out node_modules and vendor directories from logs -const HIDE_FILES = /[\\/]+(node_modules|vendor)[\\/]+/; +// Paths we never want to process or surface in logs. Used both for walking the +// tree (skip) and for hiding vendor-ish paths from user-facing summaries. +const SKIP_DIRS = + /(?:[\\/]\.[^\\/]+(?:[\\/]|$)|[\\/](node_modules|vendor|docs|\.git|\.next|\.turbo|_fresh|dist|build|target|\.cache)(?:[\\/]|$))/; export interface DenoJson { lock?: boolean; tasks?: Record; @@ -170,17 +173,28 @@ export async function updateProject(dir: string) { // Update routes folder const project = new tsmorph.Project(); - const sfs = project.addSourceFilesAtPaths( - path.join(dir, "**", "*.{js,jsx,ts,tsx}"), - ); - // Filter out node_modules and vendor files for user display - const userFiles = sfs.filter((sf) => !HIDE_FILES.test(sf.getFilePath())); + // First pass: collect file paths so we can size the progress bar and avoid + // walking the tree twice. + const filesToProcess: string[] = []; + let userFileCount = 0; + for await ( + const entry of walk(dir, { + includeDirs: false, + includeFiles: true, + exts: ["js", "jsx", "ts", "tsx"], + skip: [SKIP_DIRS], + }) + ) { + filesToProcess.push(entry.path); + if (!SKIP_DIRS.test(entry.path)) userFileCount++; + } + const totalFiles = filesToProcess.length; // deno-lint-ignore no-console - console.log(colors.cyan(`📁 Found ${userFiles.length} files to process`)); + console.log(colors.cyan(`📁 Found ${userFileCount} files to process`)); - if (sfs.length === 0) { + if (totalFiles === 0) { // deno-lint-ignore no-console console.log(colors.green("🎉 Migration completed successfully!")); return; @@ -195,21 +209,22 @@ export async function updateProject(dir: string) { // Create a progress bar const bar = new ProgressBar({ - max: sfs.length, + max: totalFiles, formatter(x) { return `[${x.styledTime}] [${x.progressBar}] [${x.value}/${x.max} files]`; }, }); - // process files sequentially to show proper progress - await Promise.all(sfs.map(async (sourceFile) => { + // Second pass: process each file one-by-one to keep memory flat. We add a + // SourceFile, transform it, then immediately forget it so ts-morph releases + // the AST from memory. + for (const filePath of filesToProcess) { + const sourceFile = project.addSourceFileAtPath(filePath); try { const wasModified = await updateFile(sourceFile); if (wasModified) { modifiedFilesList.push(sourceFile.getFilePath()); } - - return wasModified; } catch (err) { // deno-lint-ignore no-console console.error(`Could not process ${sourceFile.getFilePath()}`); @@ -217,29 +232,34 @@ export async function updateProject(dir: string) { } finally { completedFiles++; bar.value = completedFiles; + // Drop the AST to avoid unbounded memory growth + sourceFile.forget(); + project.removeSourceFile(sourceFile); } - })); + } // Clear the progress line and add a newline await bar.stop(); // Filter modified files to show only user files const modifiedFilesToShow = modifiedFilesList.filter((filePath) => - !HIDE_FILES.test(filePath) + !SKIP_DIRS.test(filePath) + ); + const unmodifiedCount = Math.max( + userFileCount - modifiedFilesToShow.length, + 0, ); // add migration summary // deno-lint-ignore no-console console.log("\n" + colors.bold("📊 Migration Summary:")); // deno-lint-ignore no-console - console.log(` Total files processed: ${userFiles.length}`); + console.log(` Total files processed: ${userFileCount}`); // deno-lint-ignore no-console console.log(` Successfully modified: ${modifiedFilesToShow.length}`); // deno-lint-ignore no-console console.log( - ` Unmodified (no changes needed): ${ - userFiles.length - modifiedFilesToShow.length - }`, + ` Unmodified (no changes needed): ${unmodifiedCount}`, ); // Display modified files list (filtered) diff --git a/packages/update/src/update_test.ts b/packages/update/src/update_test.ts index e2abdac761e..943c11cdf1f 100644 --- a/packages/update/src/update_test.ts +++ b/packages/update/src/update_test.ts @@ -714,11 +714,12 @@ export default function Foo(props: PageProps) { const files = await readFiles(dir); + // Should leave dependency trees untouched expect(files["/node_modules/foo/bar.ts"]).toEqual( - `import { IS_BROWSER } from "fresh/runtime";`, + `import { IS_BROWSER } from "$fresh/runtime.ts";`, ); expect(files["/vendor/foo/bar.ts"]).toEqual( - `import { IS_BROWSER } from "fresh/runtime";`, + `import { IS_BROWSER } from "$fresh/runtime.ts";`, ); expect(files["/routes/index.tsx"]).toEqual( `import { PageProps } from "fresh";