From e6b1264ea2d2df31d006bb7f425b1c69941de36e Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Thu, 19 Mar 2026 23:55:38 +0700 Subject: [PATCH 01/11] fix: scope getStaticProps revalidate parsing --- packages/vinext/src/build/report.ts | 94 +++++++++++++++++++++++++++-- tests/build-report.test.ts | 18 ++++++ 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index b4d2c6135..a0bf5004f 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -110,19 +110,101 @@ export function extractExportConstNumber(code: string, name: string): number | n * null — no `revalidate` key found (fully static) */ export function extractGetStaticPropsRevalidate(code: string): number | false | null { - // TODO: This regex matches `revalidate:` anywhere in the file, not scoped to - // the getStaticProps return object. A config object or comment elsewhere in - // the file (e.g. `const defaults = { revalidate: 30 }`) could produce a false - // positive. Rare in practice, but a proper AST-based approach would be more - // accurate. + const searchSpace = extractGetStaticPropsReturnObject(code) ?? code; const re = /\brevalidate\s*:\s*(-?\d+(?:\.\d+)?|Infinity|false)\b/; - const m = re.exec(code); + const m = re.exec(searchSpace); if (!m) return null; if (m[1] === "false") return false; if (m[1] === "Infinity") return Infinity; return parseFloat(m[1]); } +function extractGetStaticPropsReturnObject(code: string): string | null { + const declarationMatch = + /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+getStaticProps\b|(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+getStaticProps\b/.exec( + code, + ); + if (!declarationMatch) return null; + + const declaration = code.slice(declarationMatch.index); + const returnObjectStart = + declaration.search(/\breturn\s*\{/) !== -1 + ? declaration.search(/\breturn\s*\{/) + : declaration.search(/=>\s*\(\s*\{/); + if (returnObjectStart === -1) return declaration; + + const braceStart = declaration.indexOf("{", returnObjectStart); + if (braceStart === -1) return declaration; + + const braceEnd = findMatchingBrace(declaration, braceStart); + if (braceEnd === -1) return declaration; + + return declaration.slice(braceStart, braceEnd + 1); +} + +function findMatchingBrace(code: string, start: number): number { + let depth = 0; + let quote: '"' | "'" | "`" | null = null; + let inLineComment = false; + let inBlockComment = false; + + for (let i = start; i < code.length; i++) { + const char = code[i]; + const next = code[i + 1]; + + if (inLineComment) { + if (char === "\n") inLineComment = false; + continue; + } + + if (inBlockComment) { + if (char === "*" && next === "/") { + inBlockComment = false; + i++; + } + continue; + } + + if (quote) { + if (char === "\\") { + i++; + continue; + } + if (char === quote) quote = null; + continue; + } + + if (char === "/" && next === "/") { + inLineComment = true; + i++; + continue; + } + + if (char === "/" && next === "*") { + inBlockComment = true; + i++; + continue; + } + + if (char === '"' || char === "'" || char === "`") { + quote = char; + continue; + } + + if (char === "{") { + depth++; + continue; + } + + if (char === "}") { + depth--; + if (depth === 0) return i; + } + } + + return -1; +} + // ─── Route classification ───────────────────────────────────────────────────── /** diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 76f79b4b7..48e01ccb0 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -167,6 +167,24 @@ describe("extractGetStaticPropsRevalidate", () => { expect(extractGetStaticPropsRevalidate(code)).toBeNull(); }); + it("ignores unrelated revalidate values outside getStaticProps", () => { + const code = `const defaults = { revalidate: 30 }; + +export async function getStaticProps() { + return { props: { ok: true } }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBeNull(); + }); + + it("prefers revalidate inside getStaticProps over unrelated values elsewhere", () => { + const code = `const defaults = { revalidate: 30 }; + +export async function getStaticProps() { + return { props: {}, revalidate: 60 }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + it("handles inline comment after value (fixture file style)", () => { // From tests/fixtures/pages-basic/pages/isr-test.tsx: // revalidate: 1, // Revalidate every 1 second From 5a1f4c87f09c20869fef746c99dace6e3ca631ac Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Fri, 20 Mar 2026 00:53:03 +0700 Subject: [PATCH 02/11] fix: handle early returns in getStaticProps revalidate parsing --- packages/vinext/src/build/report.ts | 49 ++++++++++++++++++++++------- tests/build-report.test.ts | 10 ++++++ 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index a0bf5004f..c8c9ee192 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -110,16 +110,28 @@ export function extractExportConstNumber(code: string, name: string): number | n * null — no `revalidate` key found (fully static) */ export function extractGetStaticPropsRevalidate(code: string): number | false | null { - const searchSpace = extractGetStaticPropsReturnObject(code) ?? code; const re = /\brevalidate\s*:\s*(-?\d+(?:\.\d+)?|Infinity|false)\b/; - const m = re.exec(searchSpace); + const returnObjects = extractGetStaticPropsReturnObjects(code); + + if (returnObjects) { + for (const searchSpace of returnObjects) { + const m = re.exec(searchSpace); + if (!m) continue; + if (m[1] === "false") return false; + if (m[1] === "Infinity") return Infinity; + return parseFloat(m[1]); + } + return null; + } + + const m = re.exec(code); if (!m) return null; if (m[1] === "false") return false; if (m[1] === "Infinity") return Infinity; return parseFloat(m[1]); } -function extractGetStaticPropsReturnObject(code: string): string | null { +function extractGetStaticPropsReturnObjects(code: string): string[] | null { const declarationMatch = /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+getStaticProps\b|(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+getStaticProps\b/.exec( code, @@ -127,19 +139,32 @@ function extractGetStaticPropsReturnObject(code: string): string | null { if (!declarationMatch) return null; const declaration = code.slice(declarationMatch.index); - const returnObjectStart = - declaration.search(/\breturn\s*\{/) !== -1 - ? declaration.search(/\breturn\s*\{/) - : declaration.search(/=>\s*\(\s*\{/); - if (returnObjectStart === -1) return declaration; + const returnObjects: string[] = []; + const returnPattern = /\breturn\s*\{/g; + let returnMatch: RegExpExecArray | null; + + while ((returnMatch = returnPattern.exec(declaration)) !== null) { + const braceStart = declaration.indexOf("{", returnMatch.index); + if (braceStart === -1) continue; + + const braceEnd = findMatchingBrace(declaration, braceStart); + if (braceEnd === -1) continue; + + returnObjects.push(declaration.slice(braceStart, braceEnd + 1)); + } + + if (returnObjects.length > 0) return returnObjects; + + const arrowMatch = declaration.search(/=>\s*\(\s*\{/); + if (arrowMatch === -1) return []; - const braceStart = declaration.indexOf("{", returnObjectStart); - if (braceStart === -1) return declaration; + const braceStart = declaration.indexOf("{", arrowMatch); + if (braceStart === -1) return []; const braceEnd = findMatchingBrace(declaration, braceStart); - if (braceEnd === -1) return declaration; + if (braceEnd === -1) return []; - return declaration.slice(braceStart, braceEnd + 1); + return [declaration.slice(braceStart, braceEnd + 1)]; } function findMatchingBrace(code: string, start: number): number { diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 48e01ccb0..89f470885 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -185,6 +185,16 @@ export async function getStaticProps() { expect(extractGetStaticPropsRevalidate(code)).toBe(60); }); + it("finds revalidate in a later return when an earlier return redirects", () => { + const code = `export async function getStaticProps(ctx) { + if (!ctx.params?.slug) { + return { redirect: { destination: "/", permanent: false } }; + } + return { props: { data: 1 }, revalidate: 60 }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + it("handles inline comment after value (fixture file style)", () => { // From tests/fixtures/pages-basic/pages/isr-test.tsx: // revalidate: 1, // Revalidate every 1 second From 8e6136fde80b4906ed82cdde158b13fcda278eee Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Fri, 20 Mar 2026 01:08:00 +0700 Subject: [PATCH 03/11] fix: scope getStaticProps parsing to its declaration --- packages/vinext/src/build/report.ts | 47 ++++++++++++++++++++++++++++- tests/build-report.test.ts | 11 +++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index c8c9ee192..ccf5d850a 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -138,7 +138,9 @@ function extractGetStaticPropsReturnObjects(code: string): string[] | null { ); if (!declarationMatch) return null; - const declaration = code.slice(declarationMatch.index); + const declaration = extractGetStaticPropsDeclaration(code, declarationMatch); + if (declaration === null) return []; + const returnObjects: string[] = []; const returnPattern = /\breturn\s*\{/g; let returnMatch: RegExpExecArray | null; @@ -156,6 +158,8 @@ function extractGetStaticPropsReturnObjects(code: string): string[] | null { if (returnObjects.length > 0) return returnObjects; const arrowMatch = declaration.search(/=>\s*\(\s*\{/); + // getStaticProps was found but contains no return objects — return empty + // (non-null signals the caller to skip the whole-file fallback). if (arrowMatch === -1) return []; const braceStart = declaration.indexOf("{", arrowMatch); @@ -167,6 +171,47 @@ function extractGetStaticPropsReturnObjects(code: string): string[] | null { return [declaration.slice(braceStart, braceEnd + 1)]; } +function extractGetStaticPropsDeclaration( + code: string, + declarationMatch: RegExpExecArray, +): string | null { + const declarationStart = declarationMatch.index; + const declarationText = declarationMatch[0]; + const declarationTail = code.slice(declarationStart); + + if (declarationText.includes("function getStaticProps")) { + const bodyStart = code.indexOf("{", declarationStart + declarationText.length); + if (bodyStart === -1) return null; + + const bodyEnd = findMatchingBrace(code, bodyStart); + if (bodyEnd === -1) return null; + + return code.slice(bodyStart, bodyEnd + 1); + } + + const implicitArrowMatch = declarationTail.search(/=>\s*\(\s*\{/); + if (implicitArrowMatch !== -1) { + const braceStart = declarationTail.indexOf("{", implicitArrowMatch); + if (braceStart === -1) return null; + + const braceEnd = findMatchingBrace(declarationTail, braceStart); + if (braceEnd === -1) return null; + + return declarationTail.slice(0, braceEnd + 1); + } + + const blockBodyMatch = /=>\s*\{|(?:async\s+)?function\b[^{]*\{/.exec(declarationTail); + if (!blockBodyMatch) return null; + + const braceStart = declarationTail.indexOf("{", blockBodyMatch.index); + if (braceStart === -1) return null; + + const braceEnd = findMatchingBrace(declarationTail, braceStart); + if (braceEnd === -1) return null; + + return declarationTail.slice(braceStart, braceEnd + 1); +} + function findMatchingBrace(code: string, start: number): number { let depth = 0; let quote: '"' | "'" | "`" | null = null; diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 89f470885..62f024b06 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -195,6 +195,17 @@ export async function getStaticProps() { expect(extractGetStaticPropsRevalidate(code)).toBe(60); }); + it("ignores revalidate in a function defined after getStaticProps", () => { + const code = `export function getStaticProps() { + return { props: {} }; +} + +export function unrelated() { + return { revalidate: 999 }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBeNull(); + }); + it("handles inline comment after value (fixture file style)", () => { // From tests/fixtures/pages-basic/pages/isr-test.tsx: // revalidate: 1, // Revalidate every 1 second From 98598e2ab9f3039158a0d4d80f98e10a73dc275a Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Fri, 20 Mar 2026 01:10:41 +0700 Subject: [PATCH 04/11] fix: handle destructured getStaticProps params --- packages/vinext/src/build/report.ts | 48 +++++++++++++++++++++++------ tests/build-report.test.ts | 14 +++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index ccf5d850a..a2b884f5e 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -180,13 +180,7 @@ function extractGetStaticPropsDeclaration( const declarationTail = code.slice(declarationStart); if (declarationText.includes("function getStaticProps")) { - const bodyStart = code.indexOf("{", declarationStart + declarationText.length); - if (bodyStart === -1) return null; - - const bodyEnd = findMatchingBrace(code, bodyStart); - if (bodyEnd === -1) return null; - - return code.slice(bodyStart, bodyEnd + 1); + return extractFunctionBody(code, declarationStart + declarationText.length); } const implicitArrowMatch = declarationTail.search(/=>\s*\(\s*\{/); @@ -200,7 +194,12 @@ function extractGetStaticPropsDeclaration( return declarationTail.slice(0, braceEnd + 1); } - const blockBodyMatch = /=>\s*\{|(?:async\s+)?function\b[^{]*\{/.exec(declarationTail); + const functionExpressionMatch = /(?:async\s+)?function\b/.exec(declarationTail); + if (functionExpressionMatch) { + return extractFunctionBody(declarationTail, functionExpressionMatch.index); + } + + const blockBodyMatch = /=>\s*\{/.exec(declarationTail); if (!blockBodyMatch) return null; const braceStart = declarationTail.indexOf("{", blockBodyMatch.index); @@ -212,7 +211,36 @@ function extractGetStaticPropsDeclaration( return declarationTail.slice(braceStart, braceEnd + 1); } +function extractFunctionBody(code: string, functionStart: number): string | null { + const paramsStart = code.indexOf("(", functionStart); + if (paramsStart === -1) return null; + + const paramsEnd = findMatchingParen(code, paramsStart); + if (paramsEnd === -1) return null; + + const bodyStart = code.indexOf("{", paramsEnd + 1); + if (bodyStart === -1) return null; + + const bodyEnd = findMatchingBrace(code, bodyStart); + if (bodyEnd === -1) return null; + + return code.slice(bodyStart, bodyEnd + 1); +} + function findMatchingBrace(code: string, start: number): number { + return findMatchingToken(code, start, "{", "}"); +} + +function findMatchingParen(code: string, start: number): number { + return findMatchingToken(code, start, "(", ")"); +} + +function findMatchingToken( + code: string, + start: number, + openToken: string, + closeToken: string, +): number { let depth = 0; let quote: '"' | "'" | "`" | null = null; let inLineComment = false; @@ -261,12 +289,12 @@ function findMatchingBrace(code: string, start: number): number { continue; } - if (char === "{") { + if (char === openToken) { depth++; continue; } - if (char === "}") { + if (char === closeToken) { depth--; if (depth === 0) return i; } diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 62f024b06..87e0eacbf 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -206,6 +206,20 @@ export function unrelated() { expect(extractGetStaticPropsRevalidate(code)).toBeNull(); }); + it("extracts revalidate from a function declaration with destructured params", () => { + const code = `export async function getStaticProps({ params }) { + return { props: { slug: params?.slug ?? null }, revalidate: 60 }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + + it("extracts revalidate from a function expression with destructured params", () => { + const code = `export const getStaticProps = async function({ params }) { + return { props: { slug: params?.slug ?? null }, revalidate: 60 }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + it("handles inline comment after value (fixture file style)", () => { // From tests/fixtures/pages-basic/pages/isr-test.tsx: // revalidate: 1, // Revalidate every 1 second From 1a86e860d36e65e30594c02b482413a2bbc1b80a Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Fri, 20 Mar 2026 01:14:11 +0700 Subject: [PATCH 05/11] fix: ignore nested helper returns in getStaticProps --- packages/vinext/src/build/report.ts | 141 +++++++++++++++++++++++++--- tests/build-report.test.ts | 11 +++ 2 files changed, 139 insertions(+), 13 deletions(-) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index a2b884f5e..ff73659a0 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -141,19 +141,9 @@ function extractGetStaticPropsReturnObjects(code: string): string[] | null { const declaration = extractGetStaticPropsDeclaration(code, declarationMatch); if (declaration === null) return []; - const returnObjects: string[] = []; - const returnPattern = /\breturn\s*\{/g; - let returnMatch: RegExpExecArray | null; - - while ((returnMatch = returnPattern.exec(declaration)) !== null) { - const braceStart = declaration.indexOf("{", returnMatch.index); - if (braceStart === -1) continue; - - const braceEnd = findMatchingBrace(declaration, braceStart); - if (braceEnd === -1) continue; - - returnObjects.push(declaration.slice(braceStart, braceEnd + 1)); - } + const returnObjects = declaration.trimStart().startsWith("{") + ? collectReturnObjectsFromFunctionBody(declaration) + : []; if (returnObjects.length > 0) return returnObjects; @@ -227,6 +217,131 @@ function extractFunctionBody(code: string, functionStart: number): string | null return code.slice(bodyStart, bodyEnd + 1); } +function collectReturnObjectsFromFunctionBody(code: string): string[] { + const returnObjects: string[] = []; + let quote: '"' | "'" | "`" | null = null; + let inLineComment = false; + let inBlockComment = false; + + for (let i = 0; i < code.length; i++) { + const char = code[i]; + const next = code[i + 1]; + + if (inLineComment) { + if (char === "\n") inLineComment = false; + continue; + } + + if (inBlockComment) { + if (char === "*" && next === "/") { + inBlockComment = false; + i++; + } + continue; + } + + if (quote) { + if (char === "\\") { + i++; + continue; + } + if (char === quote) quote = null; + continue; + } + + if (char === "/" && next === "/") { + inLineComment = true; + i++; + continue; + } + + if (char === "/" && next === "*") { + inBlockComment = true; + i++; + continue; + } + + if (char === '"' || char === "'" || char === "`") { + quote = char; + continue; + } + + if (matchesKeywordAt(code, i, "function")) { + const nestedBody = extractFunctionBody(code, i); + if (nestedBody !== null) { + i += nestedBody.length - 1; + } + continue; + } + + if (matchesKeywordAt(code, i, "class")) { + const classBody = extractClassBody(code, i); + if (classBody !== null) { + i += classBody.length - 1; + } + continue; + } + + if (char === "=" && next === ">") { + const nestedBody = extractArrowFunctionBody(code, i); + if (nestedBody !== null) { + i += nestedBody.length - 1; + } + continue; + } + + if (matchesKeywordAt(code, i, "return")) { + const braceStart = findNextNonWhitespaceIndex(code, i + "return".length); + if (braceStart === -1 || code[braceStart] !== "{") continue; + + const braceEnd = findMatchingBrace(code, braceStart); + if (braceEnd === -1) continue; + + returnObjects.push(code.slice(braceStart, braceEnd + 1)); + i = braceEnd; + } + } + + return returnObjects; +} + +function extractArrowFunctionBody(code: string, arrowIndex: number): string | null { + const bodyStart = findNextNonWhitespaceIndex(code, arrowIndex + 2); + if (bodyStart === -1 || code[bodyStart] !== "{") return null; + + const bodyEnd = findMatchingBrace(code, bodyStart); + if (bodyEnd === -1) return null; + + return code.slice(bodyStart, bodyEnd + 1); +} + +function extractClassBody(code: string, classStart: number): string | null { + const bodyStart = code.indexOf("{", classStart + "class".length); + if (bodyStart === -1) return null; + + const bodyEnd = findMatchingBrace(code, bodyStart); + if (bodyEnd === -1) return null; + + return code.slice(bodyStart, bodyEnd + 1); +} + +function findNextNonWhitespaceIndex(code: string, start: number): number { + for (let i = start; i < code.length; i++) { + if (!/\s/.test(code[i])) return i; + } + return -1; +} + +function matchesKeywordAt(code: string, index: number, keyword: string): boolean { + const before = index === 0 ? "" : code[index - 1]; + const after = code[index + keyword.length] ?? ""; + return ( + code.startsWith(keyword, index) && + (before === "" || !/[A-Za-z0-9_$]/.test(before)) && + (after === "" || !/[A-Za-z0-9_$]/.test(after)) + ); +} + function findMatchingBrace(code: string, start: number): number { return findMatchingToken(code, start, "{", "}"); } diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 87e0eacbf..e17220e12 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -220,6 +220,17 @@ export function unrelated() { expect(extractGetStaticPropsRevalidate(code)).toBe(60); }); + it("ignores revalidate in a nested helper function inside getStaticProps", () => { + const code = `export function getStaticProps() { + const helper = () => { + return { revalidate: 999 }; + }; + + return { props: {} }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBeNull(); + }); + it("handles inline comment after value (fixture file style)", () => { // From tests/fixtures/pages-basic/pages/isr-test.tsx: // revalidate: 1, // Revalidate every 1 second From 8942db5cc745229d0fdb5e704db00135a2e1823f Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Fri, 20 Mar 2026 01:16:17 +0700 Subject: [PATCH 06/11] fix: avoid re-export false positives in getStaticProps parsing --- packages/vinext/src/build/report.ts | 10 +++++++++- tests/build-report.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index ff73659a0..a27341ba5 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -136,7 +136,15 @@ function extractGetStaticPropsReturnObjects(code: string): string[] | null { /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+getStaticProps\b|(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+getStaticProps\b/.exec( code, ); - if (!declarationMatch) return null; + if (!declarationMatch) { + // A file can re-export getStaticProps from another module without defining + // it locally. In that case we can't safely infer revalidate from this file, + // so skip the whole-file fallback to avoid unrelated false positives. + if (/(?:^|\n)\s*export\s*\{[^}]*\bgetStaticProps\b[^}]*\}\s*from\b/.test(code)) { + return []; + } + return null; + } const declaration = extractGetStaticPropsDeclaration(code, declarationMatch); if (declaration === null) return []; diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index e17220e12..d3505af1b 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -231,6 +231,14 @@ export function unrelated() { expect(extractGetStaticPropsRevalidate(code)).toBeNull(); }); + it("ignores unrelated revalidate when getStaticProps is re-exported from another file", () => { + const code = `const defaults = { revalidate: 30 }; + +export { getStaticProps } from "./shared"; +`; + expect(extractGetStaticPropsRevalidate(code)).toBeNull(); + }); + it("handles inline comment after value (fixture file style)", () => { // From tests/fixtures/pages-basic/pages/isr-test.tsx: // revalidate: 1, // Revalidate every 1 second From 4280a136fed97c49378a7c9d80ccdf19b90e8ff3 Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Fri, 20 Mar 2026 02:33:20 +0700 Subject: [PATCH 07/11] fix: harden getStaticProps revalidate parsing --- packages/vinext/src/build/report.ts | 262 +++++++++++++++++++++++----- tests/build-report.test.ts | 71 ++++++++ 2 files changed, 289 insertions(+), 44 deletions(-) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index a27341ba5..12d8da982 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -115,11 +115,8 @@ export function extractGetStaticPropsRevalidate(code: string): number | false | if (returnObjects) { for (const searchSpace of returnObjects) { - const m = re.exec(searchSpace); - if (!m) continue; - if (m[1] === "false") return false; - if (m[1] === "Infinity") return Infinity; - return parseFloat(m[1]); + const revalidate = extractTopLevelRevalidateValue(searchSpace); + if (revalidate !== null) return revalidate; } return null; } @@ -131,6 +128,110 @@ export function extractGetStaticPropsRevalidate(code: string): number | false | return parseFloat(m[1]); } +function extractTopLevelRevalidateValue(code: string): number | false | null { + let braceDepth = 0; + let parenDepth = 0; + let bracketDepth = 0; + let quote: '"' | "'" | "`" | null = null; + let inLineComment = false; + let inBlockComment = false; + + for (let i = 0; i < code.length; i++) { + const char = code[i]; + const next = code[i + 1]; + + if (inLineComment) { + if (char === "\n") inLineComment = false; + continue; + } + + if (inBlockComment) { + if (char === "*" && next === "/") { + inBlockComment = false; + i++; + } + continue; + } + + if (quote) { + if (char === "\\") { + i++; + continue; + } + if (char === quote) quote = null; + continue; + } + + if (char === "/" && next === "/") { + inLineComment = true; + i++; + continue; + } + + if (char === "/" && next === "*") { + inBlockComment = true; + i++; + continue; + } + + if (char === '"' || char === "'" || char === "`") { + quote = char; + continue; + } + + if (char === "{") { + braceDepth++; + continue; + } + + if (char === "}") { + braceDepth--; + continue; + } + + if (char === "(") { + parenDepth++; + continue; + } + + if (char === ")") { + parenDepth--; + continue; + } + + if (char === "[") { + bracketDepth++; + continue; + } + + if (char === "]") { + bracketDepth--; + continue; + } + + if ( + braceDepth === 1 && + parenDepth === 0 && + bracketDepth === 0 && + matchesKeywordAt(code, i, "revalidate") + ) { + const colonIndex = findNextNonWhitespaceIndex(code, i + "revalidate".length); + if (colonIndex === -1 || code[colonIndex] !== ":") continue; + + const valueStart = findNextNonWhitespaceIndex(code, colonIndex + 1); + if (valueStart === -1) return null; + + const valueMatch = /^(-?\d+(?:\.\d+)?|Infinity|false)\b/.exec(code.slice(valueStart)); + if (!valueMatch) return null; + if (valueMatch[1] === "false") return false; + if (valueMatch[1] === "Infinity") return Infinity; + return parseFloat(valueMatch[1]); + } + } + + return null; +} + function extractGetStaticPropsReturnObjects(code: string): string[] | null { const declarationMatch = /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+getStaticProps\b|(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+getStaticProps\b/.exec( @@ -181,35 +282,38 @@ function extractGetStaticPropsDeclaration( return extractFunctionBody(code, declarationStart + declarationText.length); } - const implicitArrowMatch = declarationTail.search(/=>\s*\(\s*\{/); - if (implicitArrowMatch !== -1) { - const braceStart = declarationTail.indexOf("{", implicitArrowMatch); + const functionExpressionMatch = /(?:async\s+)?function\b/.exec(declarationTail); + if (functionExpressionMatch) { + return extractFunctionBody(declarationTail, functionExpressionMatch.index); + } + + const blockBodyMatch = /=>\s*\{/.exec(declarationTail); + if (blockBodyMatch) { + const braceStart = declarationTail.indexOf("{", blockBodyMatch.index); if (braceStart === -1) return null; const braceEnd = findMatchingBrace(declarationTail, braceStart); if (braceEnd === -1) return null; - return declarationTail.slice(0, braceEnd + 1); - } - - const functionExpressionMatch = /(?:async\s+)?function\b/.exec(declarationTail); - if (functionExpressionMatch) { - return extractFunctionBody(declarationTail, functionExpressionMatch.index); + return declarationTail.slice(braceStart, braceEnd + 1); } - const blockBodyMatch = /=>\s*\{/.exec(declarationTail); - if (!blockBodyMatch) return null; + const implicitArrowMatch = declarationTail.search(/=>\s*\(\s*\{/); + if (implicitArrowMatch === -1) return null; - const braceStart = declarationTail.indexOf("{", blockBodyMatch.index); - if (braceStart === -1) return null; + const implicitBraceStart = declarationTail.indexOf("{", implicitArrowMatch); + if (implicitBraceStart === -1) return null; - const braceEnd = findMatchingBrace(declarationTail, braceStart); - if (braceEnd === -1) return null; + const implicitBraceEnd = findMatchingBrace(declarationTail, implicitBraceStart); + if (implicitBraceEnd === -1) return null; - return declarationTail.slice(braceStart, braceEnd + 1); + return declarationTail.slice(0, implicitBraceEnd + 1); } function extractFunctionBody(code: string, functionStart: number): string | null { + const bodyEnd = findFunctionBodyEnd(code, functionStart); + if (bodyEnd === -1) return null; + const paramsStart = code.indexOf("(", functionStart); if (paramsStart === -1) return null; @@ -219,9 +323,6 @@ function extractFunctionBody(code: string, functionStart: number): string | null const bodyStart = code.indexOf("{", paramsEnd + 1); if (bodyStart === -1) return null; - const bodyEnd = findMatchingBrace(code, bodyStart); - if (bodyEnd === -1) return null; - return code.slice(bodyStart, bodyEnd + 1); } @@ -275,29 +376,35 @@ function collectReturnObjectsFromFunctionBody(code: string): string[] { } if (matchesKeywordAt(code, i, "function")) { - const nestedBody = extractFunctionBody(code, i); - if (nestedBody !== null) { - i += nestedBody.length - 1; + const nestedBodyEnd = findFunctionBodyEnd(code, i); + if (nestedBodyEnd !== -1) { + i = nestedBodyEnd; } continue; } if (matchesKeywordAt(code, i, "class")) { - const classBody = extractClassBody(code, i); - if (classBody !== null) { - i += classBody.length - 1; + const classBodyEnd = findClassBodyEnd(code, i); + if (classBodyEnd !== -1) { + i = classBodyEnd; } continue; } if (char === "=" && next === ">") { - const nestedBody = extractArrowFunctionBody(code, i); - if (nestedBody !== null) { - i += nestedBody.length - 1; + const nestedBodyEnd = findArrowFunctionBodyEnd(code, i); + if (nestedBodyEnd !== -1) { + i = nestedBodyEnd; } continue; } + const methodBodyEnd = findObjectMethodBodyEnd(code, i); + if (methodBodyEnd !== -1) { + i = methodBodyEnd; + continue; + } + if (matchesKeywordAt(code, i, "return")) { const braceStart = findNextNonWhitespaceIndex(code, i + "return".length); if (braceStart === -1 || code[braceStart] !== "{") continue; @@ -313,24 +420,91 @@ function collectReturnObjectsFromFunctionBody(code: string): string[] { return returnObjects; } -function extractArrowFunctionBody(code: string, arrowIndex: number): string | null { - const bodyStart = findNextNonWhitespaceIndex(code, arrowIndex + 2); - if (bodyStart === -1 || code[bodyStart] !== "{") return null; +function findFunctionBodyEnd(code: string, functionStart: number): number { + const paramsStart = code.indexOf("(", functionStart); + if (paramsStart === -1) return -1; - const bodyEnd = findMatchingBrace(code, bodyStart); - if (bodyEnd === -1) return null; + const paramsEnd = findMatchingParen(code, paramsStart); + if (paramsEnd === -1) return -1; - return code.slice(bodyStart, bodyEnd + 1); + const bodyStart = code.indexOf("{", paramsEnd + 1); + if (bodyStart === -1) return -1; + + return findMatchingBrace(code, bodyStart); } -function extractClassBody(code: string, classStart: number): string | null { +function findClassBodyEnd(code: string, classStart: number): number { const bodyStart = code.indexOf("{", classStart + "class".length); - if (bodyStart === -1) return null; + if (bodyStart === -1) return -1; - const bodyEnd = findMatchingBrace(code, bodyStart); - if (bodyEnd === -1) return null; + return findMatchingBrace(code, bodyStart); +} - return code.slice(bodyStart, bodyEnd + 1); +function findArrowFunctionBodyEnd(code: string, arrowIndex: number): number { + const bodyStart = findNextNonWhitespaceIndex(code, arrowIndex + 2); + if (bodyStart === -1 || code[bodyStart] !== "{") return -1; + + return findMatchingBrace(code, bodyStart); +} + +function findObjectMethodBodyEnd(code: string, start: number): number { + let i = start; + + if (matchesKeywordAt(code, i, "async")) { + const afterAsync = findNextNonWhitespaceIndex(code, i + "async".length); + if (afterAsync === -1) return -1; + if (code[afterAsync] !== "(") { + i = afterAsync; + } + } + + if (code[i] === "*") { + i = findNextNonWhitespaceIndex(code, i + 1); + if (i === -1) return -1; + } + + if (!/[A-Za-z_$]/.test(code[i] ?? "")) return -1; + + const nameStart = i; + while (/[A-Za-z0-9_$]/.test(code[i] ?? "")) i++; + const name = code.slice(nameStart, i); + + if ( + name === "if" || + name === "for" || + name === "while" || + name === "switch" || + name === "catch" || + name === "function" || + name === "return" || + name === "const" || + name === "let" || + name === "var" || + name === "new" + ) { + return -1; + } + + if (name === "get" || name === "set") { + const afterAccessor = findNextNonWhitespaceIndex(code, i); + if (afterAccessor === -1) return -1; + if (code[afterAccessor] !== "(") { + i = afterAccessor; + if (!/[A-Za-z_$]/.test(code[i] ?? "")) return -1; + while (/[A-Za-z0-9_$]/.test(code[i] ?? "")) i++; + } + } + + const paramsStart = findNextNonWhitespaceIndex(code, i); + if (paramsStart === -1 || code[paramsStart] !== "(") return -1; + + const paramsEnd = findMatchingParen(code, paramsStart); + if (paramsEnd === -1) return -1; + + const bodyStart = findNextNonWhitespaceIndex(code, paramsEnd + 1); + if (bodyStart === -1 || code[bodyStart] !== "{") return -1; + + return findMatchingBrace(code, bodyStart); } function findNextNonWhitespaceIndex(code: string, start: number): number { diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index d3505af1b..82abce9c6 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -231,6 +231,77 @@ export function unrelated() { expect(extractGetStaticPropsRevalidate(code)).toBeNull(); }); + it("ignores revalidate in a nested named function inside getStaticProps", () => { + const code = `export function getStaticProps() { + function helper(paramOne, paramTwo, paramThree, paramFour, paramFive) { + return { revalidate: 999 }; + } + + return { props: {} }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBeNull(); + }); + + it("ignores revalidate in a nested implicit-arrow helper inside block-body getStaticProps", () => { + const code = `export const getStaticProps = async () => { + const helper = () => ({ revalidate: 999 }); + + return { props: {} }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBeNull(); + }); + + it("ignores revalidate in a nested implicit-arrow helper inside function-expression getStaticProps", () => { + const code = `export const getStaticProps = async function() { + const helper = () => ({ revalidate: 999 }); + + return { props: {} }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBeNull(); + }); + + it("ignores revalidate nested inside props data", () => { + const code = `export async function getStaticProps() { + return { + props: { + config: { + revalidate: 999, + }, + }, + }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBeNull(); + }); + + it("ignores revalidate in an object-method helper inside getStaticProps", () => { + const code = `export function getStaticProps() { + const helper = { + build() { + return { revalidate: 999 }; + }, + }; + + return { props: {} }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBeNull(); + }); + + it("ignores revalidate in object-method helpers named get and async", () => { + const code = `export function getStaticProps() { + const helper = { + get() { + return { revalidate: 999 }; + }, + async() { + return { revalidate: 998 }; + }, + }; + + return { props: {} }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBeNull(); + }); + it("ignores unrelated revalidate when getStaticProps is re-exported from another file", () => { const code = `const defaults = { revalidate: 30 }; From f3d2a6081882aa97afc711fdbe54ecc7bb718c7d Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Fri, 20 Mar 2026 03:22:15 +0700 Subject: [PATCH 08/11] chore: polish getStaticProps parser follow-up --- packages/vinext/src/build/report.ts | 19 +++++++++++++------ tests/build-report.test.ts | 2 ++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 12d8da982..b63deaf42 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -110,7 +110,6 @@ export function extractExportConstNumber(code: string, name: string): number | n * null — no `revalidate` key found (fully static) */ export function extractGetStaticPropsRevalidate(code: string): number | false | null { - const re = /\brevalidate\s*:\s*(-?\d+(?:\.\d+)?|Infinity|false)\b/; const returnObjects = extractGetStaticPropsReturnObjects(code); if (returnObjects) { @@ -121,7 +120,7 @@ export function extractGetStaticPropsRevalidate(code: string): number | false | return null; } - const m = re.exec(code); + const m = /\brevalidate\s*:\s*(-?\d+(?:\.\d+)?|Infinity|false)\b/.exec(code); if (!m) return null; if (m[1] === "false") return false; if (m[1] === "Infinity") return Infinity; @@ -399,10 +398,18 @@ function collectReturnObjectsFromFunctionBody(code: string): string[] { continue; } - const methodBodyEnd = findObjectMethodBodyEnd(code, i); - if (methodBodyEnd !== -1) { - i = methodBodyEnd; - continue; + if ( + (char >= "A" && char <= "Z") || + (char >= "a" && char <= "z") || + char === "_" || + char === "$" || + char === "*" + ) { + const methodBodyEnd = findObjectMethodBodyEnd(code, i); + if (methodBodyEnd !== -1) { + i = methodBodyEnd; + continue; + } } if (matchesKeywordAt(code, i, "return")) { diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 82abce9c6..ed2114d62 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -145,6 +145,8 @@ describe("extractGetStaticPropsRevalidate", () => { expect(extractGetStaticPropsRevalidate(code)).toBe(60); }); + // These bare return-object cases intentionally exercise the whole-file + // fallback path used when no local getStaticProps declaration is present. it("extracts revalidate: 0 (treat as SSR)", () => { const code = `return { props: {}, revalidate: 0 };`; expect(extractGetStaticPropsRevalidate(code)).toBe(0); From 78a438b13bc27da55d42ea0906efda684587b017 Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Fri, 20 Mar 2026 17:15:57 +0700 Subject: [PATCH 09/11] Improve build report route analysis with AST parsing --- packages/vinext/package.json | 1 + packages/vinext/src/build/report.ts | 870 +++++++++--------- tests/build-report.test.ts | 262 +++++- .../lib/build-report-shared-gsp.ts | 3 + .../pages-basic/lib/build-report-types.ts | 7 + .../pages/build-report-alias-export.tsx | 7 + .../pages/build-report-default-export-gsp.tsx | 3 + .../build-report-default-export-gssp.tsx | 3 + .../pages/build-report-factory-gsp.tsx | 11 + .../pages/build-report-generic-gsp.ts | 5 + .../build-report-import-reexport-gsp.tsx | 7 + .../build-report-local-identifier-gsp.tsx | 7 + .../pages/build-report-reexport-gsp.tsx | 5 + .../pages/build-report-type-only-gsp.tsx | 5 + .../pages/build-report-type-only-gssp.tsx | 5 + 15 files changed, 775 insertions(+), 426 deletions(-) create mode 100644 tests/fixtures/pages-basic/lib/build-report-shared-gsp.ts create mode 100644 tests/fixtures/pages-basic/lib/build-report-types.ts create mode 100644 tests/fixtures/pages-basic/pages/build-report-alias-export.tsx create mode 100644 tests/fixtures/pages-basic/pages/build-report-default-export-gsp.tsx create mode 100644 tests/fixtures/pages-basic/pages/build-report-default-export-gssp.tsx create mode 100644 tests/fixtures/pages-basic/pages/build-report-factory-gsp.tsx create mode 100644 tests/fixtures/pages-basic/pages/build-report-generic-gsp.ts create mode 100644 tests/fixtures/pages-basic/pages/build-report-import-reexport-gsp.tsx create mode 100644 tests/fixtures/pages-basic/pages/build-report-local-identifier-gsp.tsx create mode 100644 tests/fixtures/pages-basic/pages/build-report-reexport-gsp.tsx create mode 100644 tests/fixtures/pages-basic/pages/build-report-type-only-gsp.tsx create mode 100644 tests/fixtures/pages-basic/pages/build-report-type-only-gssp.tsx diff --git a/packages/vinext/package.json b/packages/vinext/package.json index 76191ee4f..7a3dff401 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -60,6 +60,7 @@ "@vercel/og": "catalog:", "magic-string": "catalog:", "rsc-html-stream": "catalog:", + "typescript": "catalog:", "vite-plugin-commonjs": "catalog:", "vite-tsconfig-paths": "catalog:" }, diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index b63deaf42..d618ab683 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -8,9 +8,9 @@ * ? Unknown — no explicit config; likely dynamic but not confirmed * λ API — API route handler * - * Classification uses regex-based static source analysis (no module - * execution). Vite's parseAst() is NOT used because it doesn't handle - * TypeScript syntax. + * Classification uses AST-based static source analysis (no module execution) + * via the TypeScript compiler API. Vite's parseAst() is not used because it + * doesn't handle TypeScript syntax. * * Limitation: without running the build, we cannot detect dynamic API usage * (headers(), cookies(), connection(), etc.) that implicitly forces a route @@ -21,6 +21,7 @@ import fs from "node:fs"; import path from "node:path"; +import ts from "typescript"; import type { Route } from "../routing/pages-router.js"; import type { AppRoute } from "../routing/app-router.js"; import type { PrerenderResult } from "./prerender.js"; @@ -42,7 +43,45 @@ export interface RouteRow { prerendered?: boolean; } -// ─── Regex-based export detection ──────────────────────────────────────────── +// ─── AST-based export detection ────────────────────────────────────────────── + +type ResolvedExport = + | { + kind: "local"; + localName: string; + } + | { + kind: "reexport"; + }; + +interface ModuleAnalysis { + sourceFile: ts.SourceFile; + localBindings: Map; + exports: Map; +} + +interface ParsedSourceCandidate { + sourceFile: ts.SourceFile; + diagnosticCount: number; + scriptKind: ts.ScriptKind; +} + +type ResolvedFunctionLike = ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction; + +type GetStaticPropsResolution = + | { + kind: "absent"; + } + | { + kind: "reexport"; + } + | { + kind: "function"; + fn: ResolvedFunctionLike; + } + | { + kind: "unresolved"; + }; /** * Returns true if the source code contains a named export with the given name. @@ -51,20 +90,8 @@ export interface RouteRow { * export const foo = ... * export { foo } */ -export function hasNamedExport(code: string, name: string): boolean { - // Function / generator / async function declaration - const fnRe = new RegExp(`(?:^|\\n)\\s*export\\s+(?:async\\s+)?function\\s+${name}\\b`); - if (fnRe.test(code)) return true; - - // Variable declaration (const / let / var) - const varRe = new RegExp(`(?:^|\\n)\\s*export\\s+(?:const|let|var)\\s+${name}\\s*[=:]`); - if (varRe.test(code)) return true; - - // Re-export specifier: export { foo } or export { foo as bar } - const reRe = new RegExp(`export\\s*\\{[^}]*\\b${name}\\b[^}]*\\}`); - if (reRe.test(code)) return true; - - return false; +export function hasNamedExport(code: string, name: string, filePath?: string): boolean { + return hasNamedExportFromAnalysis(getModuleAnalysis(code, filePath), name); } /** @@ -73,13 +100,26 @@ export function hasNamedExport(code: string, name: string): boolean { * export const dynamic: string = "force-dynamic" * Returns null if the export is absent or not a string literal. */ -export function extractExportConstString(code: string, name: string): string | null { - const re = new RegExp( - `^\\s*export\\s+const\\s+${name}\\s*(?::[^=]+)?\\s*=\\s*['"]([^'"]+)['"]`, - "m", - ); - const m = re.exec(code); - return m ? m[1] : null; +export function extractExportConstString( + code: string, + name: string, + filePath?: string, +): string | null { + return extractExportConstStringFromAnalysis(getModuleAnalysis(code, filePath), name); +} + +function extractExportConstStringFromAnalysis( + analysis: ModuleAnalysis, + name: string, +): string | null { + const initializer = resolveExportedConstExpression(analysis, name); + if (!initializer) return null; + + if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) { + return initializer.text; + } + + return null; } /** @@ -88,14 +128,19 @@ export function extractExportConstString(code: string, name: string): string | n * Handles optional TypeScript type annotations. * Returns null if the export is absent or not a number. */ -export function extractExportConstNumber(code: string, name: string): number | null { - const re = new RegExp( - `^\\s*export\\s+const\\s+${name}\\s*(?::[^=]+)?\\s*=\\s*(-?\\d+(?:\\.\\d+)?|Infinity)`, - "m", - ); - const m = re.exec(code); - if (!m) return null; - return m[1] === "Infinity" ? Infinity : parseFloat(m[1]); +export function extractExportConstNumber( + code: string, + name: string, + filePath?: string, +): number | null { + return extractExportConstNumberFromAnalysis(getModuleAnalysis(code, filePath), name); +} + +function extractExportConstNumberFromAnalysis( + analysis: ModuleAnalysis, + name: string, +): number | null { + return extractNumericLiteralValue(resolveExportedConstExpression(analysis, name) ?? undefined); } /** @@ -107,504 +152,467 @@ export function extractExportConstNumber(code: string, name: string): number | n * 0 — treat as SSR (revalidate every request) * false — fully static (no revalidation) * Infinity — fully static (treated same as false by Next.js) - * null — no `revalidate` key found (fully static) + * null — no local `revalidate` key found, or it could not be inferred */ -export function extractGetStaticPropsRevalidate(code: string): number | false | null { - const returnObjects = extractGetStaticPropsReturnObjects(code); +export function extractGetStaticPropsRevalidate( + code: string, + filePath?: string, +): number | false | null { + return extractGetStaticPropsRevalidateFromAnalysis(getModuleAnalysis(code, filePath)); +} - if (returnObjects) { - for (const searchSpace of returnObjects) { - const revalidate = extractTopLevelRevalidateValue(searchSpace); +function hasNamedExportFromAnalysis(analysis: ModuleAnalysis, name: string): boolean { + return analysis.exports.has(name); +} + +function extractGetStaticPropsRevalidateFromAnalysis( + analysis: ModuleAnalysis, +): number | false | null { + const getStaticProps = resolveGetStaticProps(analysis); + + if (getStaticProps.kind === "absent") { + for (const returnObject of collectTopLevelReturnObjectLiterals(analysis.sourceFile)) { + const revalidate = extractObjectLiteralRevalidate(returnObject); if (revalidate !== null) return revalidate; } return null; } - const m = /\brevalidate\s*:\s*(-?\d+(?:\.\d+)?|Infinity|false)\b/.exec(code); - if (!m) return null; - if (m[1] === "false") return false; - if (m[1] === "Infinity") return Infinity; - return parseFloat(m[1]); -} + if (getStaticProps.kind !== "function") { + return null; + } -function extractTopLevelRevalidateValue(code: string): number | false | null { - let braceDepth = 0; - let parenDepth = 0; - let bracketDepth = 0; - let quote: '"' | "'" | "`" | null = null; - let inLineComment = false; - let inBlockComment = false; + for (const returnObject of collectReturnObjectsFromFunctionLike(getStaticProps.fn)) { + const revalidate = extractObjectLiteralRevalidate(returnObject); + if (revalidate !== null) return revalidate; + } - for (let i = 0; i < code.length; i++) { - const char = code[i]; - const next = code[i + 1]; + return null; +} - if (inLineComment) { - if (char === "\n") inLineComment = false; - continue; - } +function getModuleAnalysis(code: string, filePath?: string): ModuleAnalysis { + const sourceFile = filePath + ? createSourceFileForPath(code, filePath) + : createBestSourceFile(code); - if (inBlockComment) { - if (char === "*" && next === "/") { - inBlockComment = false; - i++; - } - continue; - } + const localBindings = new Map(); + const exports = new Map(); - if (quote) { - if (char === "\\") { - i++; - continue; - } - if (char === quote) quote = null; - continue; - } + for (const statement of sourceFile.statements) { + collectLocalBindings(statement, localBindings); + } - if (char === "/" && next === "/") { - inLineComment = true; - i++; - continue; - } + for (const statement of sourceFile.statements) { + collectExports(statement, exports, localBindings); + } - if (char === "/" && next === "*") { - inBlockComment = true; - i++; - continue; - } + return { sourceFile, localBindings, exports }; +} - if (char === '"' || char === "'" || char === "`") { - quote = char; - continue; - } +function createBestSourceFile(code: string): ts.SourceFile { + const preferredKind = ts.ScriptKind.TSX; + const candidates = [ + createSourceFileForKind(code, ts.ScriptKind.TS), + createSourceFileForKind(code, ts.ScriptKind.TSX), + ]; - if (char === "{") { - braceDepth++; - continue; - } + let best = candidates[0]; - if (char === "}") { - braceDepth--; + for (const candidate of candidates.slice(1)) { + if (candidate.diagnosticCount < best.diagnosticCount) { + best = candidate; continue; } - if (char === "(") { - parenDepth++; - continue; + if ( + candidate.diagnosticCount === best.diagnosticCount && + candidate.scriptKind === preferredKind && + best.scriptKind !== preferredKind + ) { + best = candidate; } + } - if (char === ")") { - parenDepth--; - continue; - } + return best.sourceFile; +} - if (char === "[") { - bracketDepth++; - continue; - } +function createSourceFileForPath(code: string, filePath: string): ts.SourceFile { + const scriptKind = getScriptKindFromFilePath(filePath); + if (scriptKind === null) { + return createBestSourceFile(code); + } - if (char === "]") { - bracketDepth--; - continue; - } + return createSourceFileForKind(code, scriptKind, filePath).sourceFile; +} - if ( - braceDepth === 1 && - parenDepth === 0 && - bracketDepth === 0 && - matchesKeywordAt(code, i, "revalidate") - ) { - const colonIndex = findNextNonWhitespaceIndex(code, i + "revalidate".length); - if (colonIndex === -1 || code[colonIndex] !== ":") continue; +function createSourceFileForKind( + code: string, + scriptKind: ts.ScriptKind, + fileName = getSyntheticFileName(scriptKind), +): ParsedSourceCandidate { + const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, scriptKind); + return { + sourceFile, + diagnosticCount: getParseDiagnosticCount(sourceFile), + scriptKind, + }; +} - const valueStart = findNextNonWhitespaceIndex(code, colonIndex + 1); - if (valueStart === -1) return null; +function getSyntheticFileName(scriptKind: ts.ScriptKind): string { + switch (scriptKind) { + case ts.ScriptKind.TS: + return "report-analysis.ts"; + case ts.ScriptKind.TSX: + return "report-analysis.tsx"; + case ts.ScriptKind.JS: + return "report-analysis.js"; + case ts.ScriptKind.JSX: + return "report-analysis.jsx"; + default: + return "report-analysis.tsx"; + } +} - const valueMatch = /^(-?\d+(?:\.\d+)?|Infinity|false)\b/.exec(code.slice(valueStart)); - if (!valueMatch) return null; - if (valueMatch[1] === "false") return false; - if (valueMatch[1] === "Infinity") return Infinity; - return parseFloat(valueMatch[1]); - } +function getScriptKindFromFilePath(filePath: string): ts.ScriptKind | null { + switch (path.extname(filePath).toLowerCase()) { + case ".ts": + case ".mts": + case ".cts": + return ts.ScriptKind.TS; + case ".tsx": + return ts.ScriptKind.TSX; + case ".js": + case ".mjs": + case ".cjs": + return ts.ScriptKind.JS; + case ".jsx": + return ts.ScriptKind.JSX; + default: + return null; } +} - return null; +function getParseDiagnosticCount(sourceFile: ts.SourceFile): number { + return ( + (sourceFile as ts.SourceFile & { parseDiagnostics?: readonly ts.DiagnosticWithLocation[] }) + .parseDiagnostics?.length ?? 0 + ); } -function extractGetStaticPropsReturnObjects(code: string): string[] | null { - const declarationMatch = - /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+getStaticProps\b|(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+getStaticProps\b/.exec( - code, - ); - if (!declarationMatch) { - // A file can re-export getStaticProps from another module without defining - // it locally. In that case we can't safely infer revalidate from this file, - // so skip the whole-file fallback to avoid unrelated false positives. - if (/(?:^|\n)\s*export\s*\{[^}]*\bgetStaticProps\b[^}]*\}\s*from\b/.test(code)) { - return []; +function collectLocalBindings( + statement: ts.Statement, + localBindings: Map, +): void { + if (ts.isFunctionDeclaration(statement) && statement.name) { + localBindings.set(statement.name.text, statement); + return; + } + + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + localBindings.set(declaration.name.text, declaration); + } } - return null; + } +} + +function collectExports( + statement: ts.Statement, + exports: Map, + localBindings: Map, +): void { + if (ts.isFunctionDeclaration(statement) && statement.name && hasNamedExportModifier(statement)) { + exports.set(statement.name.text, { kind: "local", localName: statement.name.text }); + return; } - const declaration = extractGetStaticPropsDeclaration(code, declarationMatch); - if (declaration === null) return []; + if (ts.isVariableStatement(statement) && hasNamedExportModifier(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + exports.set(declaration.name.text, { kind: "local", localName: declaration.name.text }); + } + } + } - const returnObjects = declaration.trimStart().startsWith("{") - ? collectReturnObjectsFromFunctionBody(declaration) - : []; + if (!ts.isExportDeclaration(statement) || !statement.exportClause) { + return; + } - if (returnObjects.length > 0) return returnObjects; + if (statement.isTypeOnly) { + return; + } - const arrowMatch = declaration.search(/=>\s*\(\s*\{/); - // getStaticProps was found but contains no return objects — return empty - // (non-null signals the caller to skip the whole-file fallback). - if (arrowMatch === -1) return []; + if (!ts.isNamedExports(statement.exportClause)) { + return; + } - const braceStart = declaration.indexOf("{", arrowMatch); - if (braceStart === -1) return []; + for (const specifier of statement.exportClause.elements) { + if (specifier.isTypeOnly) { + continue; + } - const braceEnd = findMatchingBrace(declaration, braceStart); - if (braceEnd === -1) return []; + const exportName = specifier.name.text; + if (statement.moduleSpecifier) { + exports.set(exportName, { kind: "reexport" }); + continue; + } - return [declaration.slice(braceStart, braceEnd + 1)]; + const localName = specifier.propertyName?.text ?? exportName; + exports.set( + exportName, + localBindings.has(localName) ? { kind: "local", localName } : { kind: "reexport" }, + ); + } } -function extractGetStaticPropsDeclaration( - code: string, - declarationMatch: RegExpExecArray, -): string | null { - const declarationStart = declarationMatch.index; - const declarationText = declarationMatch[0]; - const declarationTail = code.slice(declarationStart); +function hasNamedExportModifier(node: ts.Node): boolean { + if (!ts.canHaveModifiers(node)) return false; - if (declarationText.includes("function getStaticProps")) { - return extractFunctionBody(code, declarationStart + declarationText.length); - } + const modifiers = ts.getModifiers(node) ?? []; + const hasExport = modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + const hasDefault = modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword); + const hasDeclare = modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword); - const functionExpressionMatch = /(?:async\s+)?function\b/.exec(declarationTail); - if (functionExpressionMatch) { - return extractFunctionBody(declarationTail, functionExpressionMatch.index); - } + return hasExport && !hasDefault && !hasDeclare; +} - const blockBodyMatch = /=>\s*\{/.exec(declarationTail); - if (blockBodyMatch) { - const braceStart = declarationTail.indexOf("{", blockBodyMatch.index); - if (braceStart === -1) return null; +function resolveExportedConstExpression( + analysis: ModuleAnalysis, + exportName: string, +): ts.Expression | null { + const resolved = analysis.exports.get(exportName); + if (!resolved || resolved.kind !== "local") return null; - const braceEnd = findMatchingBrace(declarationTail, braceStart); - if (braceEnd === -1) return null; + return resolveLocalConstExpression(analysis, resolved.localName); +} - return declarationTail.slice(braceStart, braceEnd + 1); +function resolveLocalConstExpression( + analysis: ModuleAnalysis, + localName: string, + seen = new Set(), +): ts.Expression | null { + if (seen.has(localName)) return null; + seen.add(localName); + + const declaration = analysis.localBindings.get(localName); + if (!declaration || !ts.isVariableDeclaration(declaration)) return null; + + const declarationList = declaration.parent; + if (!ts.isVariableDeclarationList(declarationList)) return null; + if ((declarationList.flags & ts.NodeFlags.Const) === 0) return null; + + const initializer = unwrapExpression(declaration.initializer); + if (!initializer) return null; + if (ts.isIdentifier(initializer)) { + const resolvedInitializer = resolveLocalConstExpression(analysis, initializer.text, seen); + return resolvedInitializer ?? initializer; } - const implicitArrowMatch = declarationTail.search(/=>\s*\(\s*\{/); - if (implicitArrowMatch === -1) return null; + return initializer; +} - const implicitBraceStart = declarationTail.indexOf("{", implicitArrowMatch); - if (implicitBraceStart === -1) return null; +function resolveGetStaticProps(analysis: ModuleAnalysis): GetStaticPropsResolution { + const exportedBinding = analysis.exports.get("getStaticProps"); + if (!exportedBinding) return { kind: "absent" }; + if (exportedBinding.kind === "reexport") return { kind: "reexport" }; - const implicitBraceEnd = findMatchingBrace(declarationTail, implicitBraceStart); - if (implicitBraceEnd === -1) return null; + const fn = resolveLocalFunctionLike(analysis, exportedBinding.localName); + if (!fn) return { kind: "unresolved" }; - return declarationTail.slice(0, implicitBraceEnd + 1); + return { kind: "function", fn }; } -function extractFunctionBody(code: string, functionStart: number): string | null { - const bodyEnd = findFunctionBodyEnd(code, functionStart); - if (bodyEnd === -1) return null; +function resolveLocalFunctionLike( + analysis: ModuleAnalysis, + localName: string, + seen = new Set(), +): ResolvedFunctionLike | null { + if (seen.has(localName)) return null; + seen.add(localName); - const paramsStart = code.indexOf("(", functionStart); - if (paramsStart === -1) return null; + const declaration = analysis.localBindings.get(localName); - const paramsEnd = findMatchingParen(code, paramsStart); - if (paramsEnd === -1) return null; + if (declaration && ts.isFunctionDeclaration(declaration)) { + return declaration; + } - const bodyStart = code.indexOf("{", paramsEnd + 1); - if (bodyStart === -1) return null; + if (!declaration || !ts.isVariableDeclaration(declaration)) { + return null; + } - return code.slice(bodyStart, bodyEnd + 1); -} + const initializer = unwrapExpression(declaration.initializer); + if (!initializer) return null; -function collectReturnObjectsFromFunctionBody(code: string): string[] { - const returnObjects: string[] = []; - let quote: '"' | "'" | "`" | null = null; - let inLineComment = false; - let inBlockComment = false; + if (ts.isFunctionExpression(initializer) || ts.isArrowFunction(initializer)) { + return initializer; + } - for (let i = 0; i < code.length; i++) { - const char = code[i]; - const next = code[i + 1]; + if (ts.isIdentifier(initializer)) { + return resolveLocalFunctionLike(analysis, initializer.text, seen); + } - if (inLineComment) { - if (char === "\n") inLineComment = false; - continue; - } + return null; +} - if (inBlockComment) { - if (char === "*" && next === "/") { - inBlockComment = false; - i++; - } +function unwrapExpression(expression: ts.Expression | undefined): ts.Expression | undefined { + let current = expression; + + while (current) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; continue; } - if (quote) { - if (char === "\\") { - i++; - continue; - } - if (char === quote) quote = null; + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; continue; } - if (char === "/" && next === "/") { - inLineComment = true; - i++; + if (ts.isSatisfiesExpression(current)) { + current = current.expression; continue; } - if (char === "/" && next === "*") { - inBlockComment = true; - i++; + if (ts.isNonNullExpression(current)) { + current = current.expression; continue; } - if (char === '"' || char === "'" || char === "`") { - quote = char; - continue; + break; + } + + return current; +} + +function collectReturnObjectsFromFunctionLike( + fn: ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction, +): ts.ObjectLiteralExpression[] { + if (!fn.body) return []; + + if (!ts.isBlock(fn.body)) { + const body = unwrapExpression(fn.body); + if (body && ts.isObjectLiteralExpression(body)) { + return [body]; } + return []; + } + + return collectTopLevelReturnObjectLiterals(fn.body); +} + +function collectTopLevelReturnObjectLiterals( + container: ts.Block | ts.SourceFile, +): ts.ObjectLiteralExpression[] { + const returnObjects: ts.ObjectLiteralExpression[] = []; - if (matchesKeywordAt(code, i, "function")) { - const nestedBodyEnd = findFunctionBodyEnd(code, i); - if (nestedBodyEnd !== -1) { - i = nestedBodyEnd; + const visitStatement = (statement: ts.Statement): void => { + if (ts.isReturnStatement(statement)) { + const expression = unwrapExpression(statement.expression); + if (expression && ts.isObjectLiteralExpression(expression)) { + returnObjects.push(expression); } - continue; + return; } - if (matchesKeywordAt(code, i, "class")) { - const classBodyEnd = findClassBodyEnd(code, i); - if (classBodyEnd !== -1) { - i = classBodyEnd; - } - continue; + if (ts.isBlock(statement)) { + for (const child of statement.statements) visitStatement(child); + return; } - if (char === "=" && next === ">") { - const nestedBodyEnd = findArrowFunctionBodyEnd(code, i); - if (nestedBodyEnd !== -1) { - i = nestedBodyEnd; - } - continue; + if (ts.isIfStatement(statement)) { + visitStatement(statement.thenStatement); + if (statement.elseStatement) visitStatement(statement.elseStatement); + return; } if ( - (char >= "A" && char <= "Z") || - (char >= "a" && char <= "z") || - char === "_" || - char === "$" || - char === "*" + ts.isForStatement(statement) || + ts.isForInStatement(statement) || + ts.isForOfStatement(statement) || + ts.isWhileStatement(statement) || + ts.isDoStatement(statement) || + ts.isLabeledStatement(statement) || + ts.isWithStatement(statement) ) { - const methodBodyEnd = findObjectMethodBodyEnd(code, i); - if (methodBodyEnd !== -1) { - i = methodBodyEnd; - continue; - } + visitStatement(statement.statement); + return; } - if (matchesKeywordAt(code, i, "return")) { - const braceStart = findNextNonWhitespaceIndex(code, i + "return".length); - if (braceStart === -1 || code[braceStart] !== "{") continue; - - const braceEnd = findMatchingBrace(code, braceStart); - if (braceEnd === -1) continue; + if (ts.isSwitchStatement(statement)) { + for (const clause of statement.caseBlock.clauses) { + for (const child of clause.statements) visitStatement(child); + } + return; + } - returnObjects.push(code.slice(braceStart, braceEnd + 1)); - i = braceEnd; + if (ts.isTryStatement(statement)) { + visitStatement(statement.tryBlock); + if (statement.catchClause) visitStatement(statement.catchClause.block); + if (statement.finallyBlock) visitStatement(statement.finallyBlock); } + }; + + for (const statement of container.statements) { + visitStatement(statement); } return returnObjects; } -function findFunctionBodyEnd(code: string, functionStart: number): number { - const paramsStart = code.indexOf("(", functionStart); - if (paramsStart === -1) return -1; - - const paramsEnd = findMatchingParen(code, paramsStart); - if (paramsEnd === -1) return -1; - - const bodyStart = code.indexOf("{", paramsEnd + 1); - if (bodyStart === -1) return -1; - - return findMatchingBrace(code, bodyStart); -} +function extractObjectLiteralRevalidate(node: ts.ObjectLiteralExpression): number | false | null { + for (const property of node.properties) { + if (!ts.isPropertyAssignment(property)) continue; + if (getPropertyNameText(property.name) !== "revalidate") continue; -function findClassBodyEnd(code: string, classStart: number): number { - const bodyStart = code.indexOf("{", classStart + "class".length); - if (bodyStart === -1) return -1; + const value = extractLiteralRevalidateValue(property.initializer); + if (value !== null) return value; + return null; + } - return findMatchingBrace(code, bodyStart); + return null; } -function findArrowFunctionBodyEnd(code: string, arrowIndex: number): number { - const bodyStart = findNextNonWhitespaceIndex(code, arrowIndex + 2); - if (bodyStart === -1 || code[bodyStart] !== "{") return -1; +function getPropertyNameText(name: ts.PropertyName): string | null { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } - return findMatchingBrace(code, bodyStart); + return null; } -function findObjectMethodBodyEnd(code: string, start: number): number { - let i = start; +function extractLiteralRevalidateValue(expression: ts.Expression): number | false | null { + const initializer = unwrapExpression(expression); + if (!initializer) return null; - if (matchesKeywordAt(code, i, "async")) { - const afterAsync = findNextNonWhitespaceIndex(code, i + "async".length); - if (afterAsync === -1) return -1; - if (code[afterAsync] !== "(") { - i = afterAsync; - } + if (initializer.kind === ts.SyntaxKind.FalseKeyword) { + return false; } - if (code[i] === "*") { - i = findNextNonWhitespaceIndex(code, i + 1); - if (i === -1) return -1; - } + return extractNumericLiteralValue(initializer); +} - if (!/[A-Za-z_$]/.test(code[i] ?? "")) return -1; +function extractNumericLiteralValue(expression: ts.Expression | undefined): number | null { + const initializer = unwrapExpression(expression); + if (!initializer) return null; - const nameStart = i; - while (/[A-Za-z0-9_$]/.test(code[i] ?? "")) i++; - const name = code.slice(nameStart, i); + if (ts.isNumericLiteral(initializer)) { + return Number(initializer.text); + } if ( - name === "if" || - name === "for" || - name === "while" || - name === "switch" || - name === "catch" || - name === "function" || - name === "return" || - name === "const" || - name === "let" || - name === "var" || - name === "new" + ts.isPrefixUnaryExpression(initializer) && + initializer.operator === ts.SyntaxKind.MinusToken && + ts.isNumericLiteral(initializer.operand) ) { - return -1; + return -Number(initializer.operand.text); } - if (name === "get" || name === "set") { - const afterAccessor = findNextNonWhitespaceIndex(code, i); - if (afterAccessor === -1) return -1; - if (code[afterAccessor] !== "(") { - i = afterAccessor; - if (!/[A-Za-z_$]/.test(code[i] ?? "")) return -1; - while (/[A-Za-z0-9_$]/.test(code[i] ?? "")) i++; - } + if (ts.isIdentifier(initializer) && initializer.text === "Infinity") { + return Infinity; } - const paramsStart = findNextNonWhitespaceIndex(code, i); - if (paramsStart === -1 || code[paramsStart] !== "(") return -1; - - const paramsEnd = findMatchingParen(code, paramsStart); - if (paramsEnd === -1) return -1; - - const bodyStart = findNextNonWhitespaceIndex(code, paramsEnd + 1); - if (bodyStart === -1 || code[bodyStart] !== "{") return -1; - - return findMatchingBrace(code, bodyStart); -} - -function findNextNonWhitespaceIndex(code: string, start: number): number { - for (let i = start; i < code.length; i++) { - if (!/\s/.test(code[i])) return i; - } - return -1; -} - -function matchesKeywordAt(code: string, index: number, keyword: string): boolean { - const before = index === 0 ? "" : code[index - 1]; - const after = code[index + keyword.length] ?? ""; - return ( - code.startsWith(keyword, index) && - (before === "" || !/[A-Za-z0-9_$]/.test(before)) && - (after === "" || !/[A-Za-z0-9_$]/.test(after)) - ); -} - -function findMatchingBrace(code: string, start: number): number { - return findMatchingToken(code, start, "{", "}"); -} - -function findMatchingParen(code: string, start: number): number { - return findMatchingToken(code, start, "(", ")"); -} - -function findMatchingToken( - code: string, - start: number, - openToken: string, - closeToken: string, -): number { - let depth = 0; - let quote: '"' | "'" | "`" | null = null; - let inLineComment = false; - let inBlockComment = false; - - for (let i = start; i < code.length; i++) { - const char = code[i]; - const next = code[i + 1]; - - if (inLineComment) { - if (char === "\n") inLineComment = false; - continue; - } - - if (inBlockComment) { - if (char === "*" && next === "/") { - inBlockComment = false; - i++; - } - continue; - } - - if (quote) { - if (char === "\\") { - i++; - continue; - } - if (char === quote) quote = null; - continue; - } - - if (char === "/" && next === "/") { - inLineComment = true; - i++; - continue; - } - - if (char === "/" && next === "*") { - inBlockComment = true; - i++; - continue; - } - - if (char === '"' || char === "'" || char === "`") { - quote = char; - continue; - } - - if (char === openToken) { - depth++; - continue; - } - - if (char === closeToken) { - depth--; - if (depth === 0) return i; - } - } - - return -1; + return null; } // ─── Route classification ───────────────────────────────────────────────────── @@ -632,12 +640,19 @@ export function classifyPagesRoute(filePath: string): { return { type: "unknown" }; } - if (hasNamedExport(code, "getServerSideProps")) { + const analysis = getModuleAnalysis(code, filePath); + + if (hasNamedExportFromAnalysis(analysis, "getServerSideProps")) { return { type: "ssr" }; } - if (hasNamedExport(code, "getStaticProps")) { - const revalidate = extractGetStaticPropsRevalidate(code); + if (hasNamedExportFromAnalysis(analysis, "getStaticProps")) { + const getStaticProps = resolveGetStaticProps(analysis); + if (getStaticProps.kind === "reexport" || getStaticProps.kind === "unresolved") { + return { type: "unknown" }; + } + + const revalidate = extractGetStaticPropsRevalidateFromAnalysis(analysis); if (revalidate === null || revalidate === false || revalidate === Infinity) { return { type: "static" }; @@ -679,8 +694,10 @@ export function classifyAppRoute( return { type: "unknown" }; } + const analysis = getModuleAnalysis(code, filePath); + // Check `export const dynamic` - const dynamicValue = extractExportConstString(code, "dynamic"); + const dynamicValue = extractExportConstStringFromAnalysis(analysis, "dynamic"); if (dynamicValue === "force-dynamic") { return { type: "ssr" }; } @@ -691,7 +708,7 @@ export function classifyAppRoute( } // Check `export const revalidate` - const revalidateValue = extractExportConstNumber(code, "revalidate"); + const revalidateValue = extractExportConstNumberFromAnalysis(analysis, "revalidate"); if (revalidateValue !== null) { if (revalidateValue === Infinity) return { type: "static" }; if (revalidateValue === 0) return { type: "ssr" }; @@ -736,7 +753,12 @@ export function buildReportRows(options: { for (const route of options.pageRoutes ?? []) { const { type, revalidate } = classifyPagesRoute(route.filePath); - rows.push({ pattern: route.pattern, type, revalidate }); + if (type === "unknown" && renderedRoutes.has(route.pattern)) { + // Speculative prerender confirmed this route is static. + rows.push({ pattern: route.pattern, type: "static", prerendered: true }); + } else { + rows.push({ pattern: route.pattern, type, revalidate }); + } } for (const route of options.apiRoutes ?? []) { diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index ed2114d62..94a602b9e 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -1,7 +1,7 @@ /** * Build report tests — verifies route classification, formatting, and sorting. * - * Tests the regex-based export detection helpers and the classification + * Tests the AST-based export detection helpers and the classification * logic for both Pages Router and App Router routes, using real fixture files * where integration testing is needed. */ @@ -49,13 +49,54 @@ describe("hasNamedExport", () => { }); it("detects re-export with alias", () => { - expect(hasNamedExport("export { getStaticProps as gsp };", "getStaticProps")).toBe(true); + expect(hasNamedExport("export { getStaticProps as gsp };", "gsp")).toBe(true); + }); + + it("detects aliased export name from a local binding", () => { + expect(hasNamedExport("const foo = 1; export { foo as revalidate };", "revalidate")).toBe(true); + }); + + it("does not treat the local side of an aliased export as the exported name", () => { + expect(hasNamedExport("export { getStaticProps as helper };", "getStaticProps")).toBe(false); }); it("returns false when export is absent", () => { expect(hasNamedExport("export default function Page() {}", "getStaticProps")).toBe(false); }); + it("does not treat default-exported getStaticProps as a named export", () => { + expect(hasNamedExport("export default function getStaticProps() {}", "getStaticProps")).toBe( + false, + ); + }); + + it("does not treat default-exported getServerSideProps as a named export", () => { + expect( + hasNamedExport("export default async function getServerSideProps() {}", "getServerSideProps"), + ).toBe(false); + }); + + it("does not treat `export type` re-exports as named runtime exports", () => { + expect( + hasNamedExport('export type { getStaticProps } from "./shared";', "getStaticProps"), + ).toBe(false); + }); + + it("does not treat `export { type ... }` specifiers as named runtime exports", () => { + expect( + hasNamedExport('export { type getServerSideProps } from "./shared";', "getServerSideProps"), + ).toBe(false); + }); + + it("does not treat declared exports as named runtime exports", () => { + expect( + hasNamedExport( + "export declare function getServerSideProps(): Promise;", + "getServerSideProps", + ), + ).toBe(false); + }); + it("does not match partial names (false positive guard)", () => { // 'getStaticPropsExtra' should not match 'getStaticProps' expect(hasNamedExport("export function getStaticPropsExtra() {}", "getStaticProps")).toBe( @@ -94,6 +135,21 @@ describe("extractExportConstString", () => { ); }); + it("extracts value from an `as const` string export", () => { + expect( + extractExportConstString("export const dynamic = 'force-static' as const;", "dynamic"), + ).toBe("force-static"); + }); + + it("extracts value from a `satisfies` string export", () => { + expect( + extractExportConstString( + "export const dynamic = 'force-static' satisfies string;", + "dynamic", + ), + ).toBe("force-static"); + }); + it("returns null when export is absent", () => { expect(extractExportConstString("export const revalidate = 60;", "dynamic")).toBeNull(); }); @@ -101,6 +157,18 @@ describe("extractExportConstString", () => { it("returns null for non-string value", () => { expect(extractExportConstString("export const revalidate = 60;", "revalidate")).toBeNull(); }); + + it("extracts string from a local const re-exported by specifier", () => { + expect( + extractExportConstString("const mode = 'error'; export { mode as dynamic };", "dynamic"), + ).toBe("error"); + }); + + it("extracts string from a local const identifier alias", () => { + expect( + extractExportConstString("const mode = 'error'; export const dynamic = mode;", "dynamic"), + ).toBe("error"); + }); }); // ─── extractExportConstNumber ───────────────────────────────────────────────── @@ -130,9 +198,39 @@ describe("extractExportConstNumber", () => { ); }); + it("extracts from an `as const` numeric export", () => { + expect(extractExportConstNumber("export const revalidate = 60 as const;", "revalidate")).toBe( + 60, + ); + }); + + it("extracts from a `satisfies` numeric export", () => { + expect( + extractExportConstNumber("export const revalidate = 60 satisfies number;", "revalidate"), + ).toBe(60); + }); + it("returns null when export is absent", () => { expect(extractExportConstNumber("export const dynamic = 'auto';", "revalidate")).toBeNull(); }); + + it("extracts number from a local const re-exported by specifier", () => { + expect( + extractExportConstNumber( + "const interval = 120; export { interval as revalidate };", + "revalidate", + ), + ).toBe(120); + }); + + it("extracts number from a local const identifier alias", () => { + expect( + extractExportConstNumber( + "const interval = 120; export const revalidate = interval;", + "revalidate", + ), + ).toBe(120); + }); }); // ─── extractGetStaticPropsRevalidate ────────────────────────────────────────── @@ -145,6 +243,20 @@ describe("extractGetStaticPropsRevalidate", () => { expect(extractGetStaticPropsRevalidate(code)).toBe(60); }); + it("extracts revalidate from an `as const` value inside getStaticProps", () => { + const code = `export async function getStaticProps() { + return { props: {}, revalidate: 60 as const }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + + it("extracts revalidate from a `satisfies` value inside getStaticProps", () => { + const code = `export async function getStaticProps() { + return { props: {}, revalidate: 60 satisfies number }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + // These bare return-object cases intentionally exercise the whole-file // fallback path used when no local getStaticProps declaration is present. it("extracts revalidate: 0 (treat as SSR)", () => { @@ -222,6 +334,17 @@ export function unrelated() { expect(extractGetStaticPropsRevalidate(code)).toBe(60); }); + it("extracts revalidate from a generic arrow-function getStaticProps", () => { + const code = `export const getStaticProps = () => ({ props: {}, revalidate: 60 });`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + + it("respects the provided file extension when parsing generic arrow getStaticProps", () => { + const code = `export const getStaticProps = () => ({ props: {}, revalidate: 60 });`; + expect(extractGetStaticPropsRevalidate(code, "page.ts")).toBe(60); + expect(extractGetStaticPropsRevalidate(code, "page.tsx")).toBeNull(); + }); + it("ignores revalidate in a nested helper function inside getStaticProps", () => { const code = `export function getStaticProps() { const helper = () => { @@ -312,12 +435,57 @@ export { getStaticProps } from "./shared"; expect(extractGetStaticPropsRevalidate(code)).toBeNull(); }); + it("ignores fallback-path returns when an imported getStaticProps is re-exported locally", () => { + const code = `import { getStaticProps } from "./shared"; +export { getStaticProps }; + +return { props: {}, revalidate: 1 };`; + expect(extractGetStaticPropsRevalidate(code)).toBeNull(); + }); + it("handles inline comment after value (fixture file style)", () => { // From tests/fixtures/pages-basic/pages/isr-test.tsx: // revalidate: 1, // Revalidate every 1 second const code = `return { props: {}, revalidate: 1, // comment\n};`; expect(extractGetStaticPropsRevalidate(code)).toBe(1); }); + + it("extracts revalidate when getStaticProps is exported under an alias", () => { + const code = `const loadStaticProps = async () => { + return { props: {}, revalidate: 60 }; +}; + +export { loadStaticProps as getStaticProps };`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + + it("extracts revalidate when getStaticProps is exported before its local declaration", () => { + const code = `export { getStaticProps }; + +const getStaticProps = async () => { + return { props: {}, revalidate: 60 }; +};`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + + it("extracts revalidate when getStaticProps is exported via a local identifier alias", () => { + const code = `const loadStaticProps = async () => ({ props: {}, revalidate: 60 }); +export const getStaticProps = loadStaticProps;`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + + it("does not fall back to unrelated top-level returns for non-analyzable local getStaticProps", () => { + const code = `function createGSP() { + return async function generatedGetStaticProps() { + return { props: {}, revalidate: 60 }; + }; +} + +export const getStaticProps = createGSP(); + +return { props: {}, revalidate: 1 };`; + expect(extractGetStaticPropsRevalidate(code)).toBeNull(); + }); }); // ─── classifyPagesRoute (integration — real fixture files) ──────────────────── @@ -347,6 +515,68 @@ describe("classifyPagesRoute", () => { it("returns unknown on file read failure (consistent with classifyAppRoute)", () => { expect(classifyPagesRoute("/nonexistent/pages/page.tsx")).toEqual({ type: "unknown" }); }); + + it("does not classify an aliased local export as getStaticProps", () => { + const filePath = path.resolve("tests/fixtures/pages-basic/pages/build-report-alias-export.tsx"); + expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); + }); + + it("classifies a generic-arrow getStaticProps in a .ts file as isr", () => { + const filePath = path.resolve("tests/fixtures/pages-basic/pages/build-report-generic-gsp.ts"); + expect(classifyPagesRoute(filePath)).toEqual({ type: "isr", revalidate: 60 }); + }); + + it("does not classify a default-exported getStaticProps as data fetching", () => { + const filePath = path.resolve( + "tests/fixtures/pages-basic/pages/build-report-default-export-gsp.tsx", + ); + expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); + }); + + it("does not classify a default-exported getServerSideProps as data fetching", () => { + const filePath = path.resolve( + "tests/fixtures/pages-basic/pages/build-report-default-export-gssp.tsx", + ); + expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); + }); + + it("does not classify type-only getStaticProps exports as data fetching", () => { + const filePath = path.resolve( + "tests/fixtures/pages-basic/pages/build-report-type-only-gsp.tsx", + ); + expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); + }); + + it("does not classify type-only getServerSideProps exports as data fetching", () => { + const filePath = path.resolve( + "tests/fixtures/pages-basic/pages/build-report-type-only-gssp.tsx", + ); + expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); + }); + + it("classifies direct getStaticProps re-exports as unknown", () => { + const filePath = path.resolve("tests/fixtures/pages-basic/pages/build-report-reexport-gsp.tsx"); + expect(classifyPagesRoute(filePath)).toEqual({ type: "unknown" }); + }); + + it("classifies imported getStaticProps re-exports as unknown", () => { + const filePath = path.resolve( + "tests/fixtures/pages-basic/pages/build-report-import-reexport-gsp.tsx", + ); + expect(classifyPagesRoute(filePath)).toEqual({ type: "unknown" }); + }); + + it("classifies local identifier-aliased getStaticProps as isr", () => { + const filePath = path.resolve( + "tests/fixtures/pages-basic/pages/build-report-local-identifier-gsp.tsx", + ); + expect(classifyPagesRoute(filePath)).toEqual({ type: "isr", revalidate: 60 }); + }); + + it("classifies non-analyzable local getStaticProps factories as unknown", () => { + const filePath = path.resolve("tests/fixtures/pages-basic/pages/build-report-factory-gsp.tsx"); + expect(classifyPagesRoute(filePath)).toEqual({ type: "unknown" }); + }); }); // ─── classifyAppRoute ───────────────────────────────────────────────────────── @@ -475,6 +705,34 @@ describe("buildReportRows", () => { expect(rows[0].pattern).toBe("/aaa"); expect(rows[1].pattern).toBe("/zzz"); }); + + it("upgrades unknown Pages routes to static when speculative prerender rendered them", () => { + const pageRoutes = [ + { + pattern: "/reexported-gsp", + patternParts: ["/reexported-gsp"], + filePath: path.resolve("tests/fixtures/pages-basic/pages/build-report-reexport-gsp.tsx"), + isDynamic: false, + params: [], + }, + ]; + + const rows = buildReportRows({ + pageRoutes, + prerenderResult: { + routes: [ + { + route: "/reexported-gsp", + status: "rendered", + outputFiles: ["index.html"], + revalidate: false, + }, + ], + }, + }); + + expect(rows).toEqual([{ pattern: "/reexported-gsp", type: "static", prerendered: true }]); + }); }); // ─── formatBuildReport ──────────────────────────────────────────────────────── diff --git a/tests/fixtures/pages-basic/lib/build-report-shared-gsp.ts b/tests/fixtures/pages-basic/lib/build-report-shared-gsp.ts new file mode 100644 index 000000000..667eea6c4 --- /dev/null +++ b/tests/fixtures/pages-basic/lib/build-report-shared-gsp.ts @@ -0,0 +1,3 @@ +export const getStaticProps = async () => { + return { props: {}, revalidate: 60 }; +}; diff --git a/tests/fixtures/pages-basic/lib/build-report-types.ts b/tests/fixtures/pages-basic/lib/build-report-types.ts new file mode 100644 index 000000000..6149106c5 --- /dev/null +++ b/tests/fixtures/pages-basic/lib/build-report-types.ts @@ -0,0 +1,7 @@ +export type getStaticProps = { + kind: "getStaticProps"; +}; + +export type getServerSideProps = { + kind: "getServerSideProps"; +}; diff --git a/tests/fixtures/pages-basic/pages/build-report-alias-export.tsx b/tests/fixtures/pages-basic/pages/build-report-alias-export.tsx new file mode 100644 index 000000000..89b472ee2 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/build-report-alias-export.tsx @@ -0,0 +1,7 @@ +const getStaticProps = async () => ({ props: {}, revalidate: 60 }); + +export { getStaticProps as helper }; + +export default function Page() { + return null; +} diff --git a/tests/fixtures/pages-basic/pages/build-report-default-export-gsp.tsx b/tests/fixtures/pages-basic/pages/build-report-default-export-gsp.tsx new file mode 100644 index 000000000..2e47d1ba0 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/build-report-default-export-gsp.tsx @@ -0,0 +1,3 @@ +export default function getStaticProps() { + return null; +} diff --git a/tests/fixtures/pages-basic/pages/build-report-default-export-gssp.tsx b/tests/fixtures/pages-basic/pages/build-report-default-export-gssp.tsx new file mode 100644 index 000000000..05bb0d383 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/build-report-default-export-gssp.tsx @@ -0,0 +1,3 @@ +export default async function getServerSideProps() { + return null; +} diff --git a/tests/fixtures/pages-basic/pages/build-report-factory-gsp.tsx b/tests/fixtures/pages-basic/pages/build-report-factory-gsp.tsx new file mode 100644 index 000000000..13b049d8a --- /dev/null +++ b/tests/fixtures/pages-basic/pages/build-report-factory-gsp.tsx @@ -0,0 +1,11 @@ +function createGSP() { + return async function generatedGetStaticProps() { + return { props: {}, revalidate: 60 }; + }; +} + +export const getStaticProps = createGSP(); + +export default function Page() { + return null; +} diff --git a/tests/fixtures/pages-basic/pages/build-report-generic-gsp.ts b/tests/fixtures/pages-basic/pages/build-report-generic-gsp.ts new file mode 100644 index 000000000..7bacbcb3d --- /dev/null +++ b/tests/fixtures/pages-basic/pages/build-report-generic-gsp.ts @@ -0,0 +1,5 @@ +export const getStaticProps = () => ({ props: {}, revalidate: 60 }); + +export default function Page() { + return null; +} diff --git a/tests/fixtures/pages-basic/pages/build-report-import-reexport-gsp.tsx b/tests/fixtures/pages-basic/pages/build-report-import-reexport-gsp.tsx new file mode 100644 index 000000000..ae4e422e4 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/build-report-import-reexport-gsp.tsx @@ -0,0 +1,7 @@ +import { getStaticProps } from "../lib/build-report-shared-gsp"; + +export { getStaticProps }; + +export default function Page() { + return null; +} diff --git a/tests/fixtures/pages-basic/pages/build-report-local-identifier-gsp.tsx b/tests/fixtures/pages-basic/pages/build-report-local-identifier-gsp.tsx new file mode 100644 index 000000000..90e161644 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/build-report-local-identifier-gsp.tsx @@ -0,0 +1,7 @@ +const loadStaticProps = async () => ({ props: {}, revalidate: 60 }); + +export const getStaticProps = loadStaticProps; + +export default function Page() { + return null; +} diff --git a/tests/fixtures/pages-basic/pages/build-report-reexport-gsp.tsx b/tests/fixtures/pages-basic/pages/build-report-reexport-gsp.tsx new file mode 100644 index 000000000..ed29cbef3 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/build-report-reexport-gsp.tsx @@ -0,0 +1,5 @@ +export { getStaticProps } from "../lib/build-report-shared-gsp"; + +export default function Page() { + return null; +} diff --git a/tests/fixtures/pages-basic/pages/build-report-type-only-gsp.tsx b/tests/fixtures/pages-basic/pages/build-report-type-only-gsp.tsx new file mode 100644 index 000000000..3327f07f5 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/build-report-type-only-gsp.tsx @@ -0,0 +1,5 @@ +export type { getStaticProps } from "../lib/build-report-types"; + +export default function Page() { + return null; +} diff --git a/tests/fixtures/pages-basic/pages/build-report-type-only-gssp.tsx b/tests/fixtures/pages-basic/pages/build-report-type-only-gssp.tsx new file mode 100644 index 000000000..8c0cede6f --- /dev/null +++ b/tests/fixtures/pages-basic/pages/build-report-type-only-gssp.tsx @@ -0,0 +1,5 @@ +export { type getServerSideProps } from "../lib/build-report-types"; + +export default function Page() { + return null; +} From ff4b46336a84efd1103ab49b021b80d9de3de5a6 Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Fri, 20 Mar 2026 17:32:05 +0700 Subject: [PATCH 10/11] Sync lockfile for build report AST dependency --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15a69e1d1..91e345ce8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -810,6 +810,9 @@ importers: rsc-html-stream: specifier: 'catalog:' version: 0.0.7 + typescript: + specifier: 'catalog:' + version: 5.9.3 vite-plugin-commonjs: specifier: 'catalog:' version: 0.10.4 From 536d7e2a52c6b027c32bf54b58d1cd1830cc746c Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Fri, 20 Mar 2026 17:57:20 +0700 Subject: [PATCH 11/11] test: isolate build report fixtures from pages-router app --- tests/build-report.test.ts | 23 ++++++++++--------- .../lib/build-report-shared-gsp.ts | 0 .../lib/build-report-types.ts | 0 .../pages/build-report-alias-export.tsx | 0 .../pages/build-report-default-export-gsp.tsx | 0 .../build-report-default-export-gssp.tsx | 0 .../pages/build-report-factory-gsp.tsx | 0 .../pages/build-report-generic-gsp.ts | 0 .../build-report-import-reexport-gsp.tsx | 7 ++++++ .../build-report-local-identifier-gsp.tsx | 0 .../pages/build-report-reexport-gsp.tsx | 0 .../pages/build-report-type-only-gsp.tsx | 0 .../pages/build-report-type-only-gssp.tsx | 0 .../build-report-import-reexport-gsp.tsx | 7 ------ 14 files changed, 19 insertions(+), 18 deletions(-) rename tests/fixtures/{pages-basic => build-report}/lib/build-report-shared-gsp.ts (100%) rename tests/fixtures/{pages-basic => build-report}/lib/build-report-types.ts (100%) rename tests/fixtures/{pages-basic => build-report}/pages/build-report-alias-export.tsx (100%) rename tests/fixtures/{pages-basic => build-report}/pages/build-report-default-export-gsp.tsx (100%) rename tests/fixtures/{pages-basic => build-report}/pages/build-report-default-export-gssp.tsx (100%) rename tests/fixtures/{pages-basic => build-report}/pages/build-report-factory-gsp.tsx (100%) rename tests/fixtures/{pages-basic => build-report}/pages/build-report-generic-gsp.ts (100%) create mode 100644 tests/fixtures/build-report/pages/build-report-import-reexport-gsp.tsx rename tests/fixtures/{pages-basic => build-report}/pages/build-report-local-identifier-gsp.tsx (100%) rename tests/fixtures/{pages-basic => build-report}/pages/build-report-reexport-gsp.tsx (100%) rename tests/fixtures/{pages-basic => build-report}/pages/build-report-type-only-gsp.tsx (100%) rename tests/fixtures/{pages-basic => build-report}/pages/build-report-type-only-gssp.tsx (100%) delete mode 100644 tests/fixtures/pages-basic/pages/build-report-import-reexport-gsp.tsx diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 94a602b9e..ab8e9ddfb 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -19,6 +19,7 @@ import { } from "../packages/vinext/src/build/report.js"; const FIXTURES_PAGES = path.resolve("tests/fixtures/pages-basic/pages"); +const FIXTURES_BUILD_REPORT = path.resolve("tests/fixtures/build-report/pages"); const FIXTURES_APP = path.resolve("tests/fixtures/app-basic/app"); // ─── hasNamedExport ─────────────────────────────────────────────────────────── @@ -517,64 +518,64 @@ describe("classifyPagesRoute", () => { }); it("does not classify an aliased local export as getStaticProps", () => { - const filePath = path.resolve("tests/fixtures/pages-basic/pages/build-report-alias-export.tsx"); + const filePath = path.join(FIXTURES_BUILD_REPORT, "build-report-alias-export.tsx"); expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); }); it("classifies a generic-arrow getStaticProps in a .ts file as isr", () => { - const filePath = path.resolve("tests/fixtures/pages-basic/pages/build-report-generic-gsp.ts"); + const filePath = path.join(FIXTURES_BUILD_REPORT, "build-report-generic-gsp.ts"); expect(classifyPagesRoute(filePath)).toEqual({ type: "isr", revalidate: 60 }); }); it("does not classify a default-exported getStaticProps as data fetching", () => { const filePath = path.resolve( - "tests/fixtures/pages-basic/pages/build-report-default-export-gsp.tsx", + path.join(FIXTURES_BUILD_REPORT, "build-report-default-export-gsp.tsx"), ); expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); }); it("does not classify a default-exported getServerSideProps as data fetching", () => { const filePath = path.resolve( - "tests/fixtures/pages-basic/pages/build-report-default-export-gssp.tsx", + path.join(FIXTURES_BUILD_REPORT, "build-report-default-export-gssp.tsx"), ); expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); }); it("does not classify type-only getStaticProps exports as data fetching", () => { const filePath = path.resolve( - "tests/fixtures/pages-basic/pages/build-report-type-only-gsp.tsx", + path.join(FIXTURES_BUILD_REPORT, "build-report-type-only-gsp.tsx"), ); expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); }); it("does not classify type-only getServerSideProps exports as data fetching", () => { const filePath = path.resolve( - "tests/fixtures/pages-basic/pages/build-report-type-only-gssp.tsx", + path.join(FIXTURES_BUILD_REPORT, "build-report-type-only-gssp.tsx"), ); expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); }); it("classifies direct getStaticProps re-exports as unknown", () => { - const filePath = path.resolve("tests/fixtures/pages-basic/pages/build-report-reexport-gsp.tsx"); + const filePath = path.join(FIXTURES_BUILD_REPORT, "build-report-reexport-gsp.tsx"); expect(classifyPagesRoute(filePath)).toEqual({ type: "unknown" }); }); it("classifies imported getStaticProps re-exports as unknown", () => { const filePath = path.resolve( - "tests/fixtures/pages-basic/pages/build-report-import-reexport-gsp.tsx", + path.join(FIXTURES_BUILD_REPORT, "build-report-import-reexport-gsp.tsx"), ); expect(classifyPagesRoute(filePath)).toEqual({ type: "unknown" }); }); it("classifies local identifier-aliased getStaticProps as isr", () => { const filePath = path.resolve( - "tests/fixtures/pages-basic/pages/build-report-local-identifier-gsp.tsx", + path.join(FIXTURES_BUILD_REPORT, "build-report-local-identifier-gsp.tsx"), ); expect(classifyPagesRoute(filePath)).toEqual({ type: "isr", revalidate: 60 }); }); it("classifies non-analyzable local getStaticProps factories as unknown", () => { - const filePath = path.resolve("tests/fixtures/pages-basic/pages/build-report-factory-gsp.tsx"); + const filePath = path.join(FIXTURES_BUILD_REPORT, "build-report-factory-gsp.tsx"); expect(classifyPagesRoute(filePath)).toEqual({ type: "unknown" }); }); }); @@ -711,7 +712,7 @@ describe("buildReportRows", () => { { pattern: "/reexported-gsp", patternParts: ["/reexported-gsp"], - filePath: path.resolve("tests/fixtures/pages-basic/pages/build-report-reexport-gsp.tsx"), + filePath: path.join(FIXTURES_BUILD_REPORT, "build-report-reexport-gsp.tsx"), isDynamic: false, params: [], }, diff --git a/tests/fixtures/pages-basic/lib/build-report-shared-gsp.ts b/tests/fixtures/build-report/lib/build-report-shared-gsp.ts similarity index 100% rename from tests/fixtures/pages-basic/lib/build-report-shared-gsp.ts rename to tests/fixtures/build-report/lib/build-report-shared-gsp.ts diff --git a/tests/fixtures/pages-basic/lib/build-report-types.ts b/tests/fixtures/build-report/lib/build-report-types.ts similarity index 100% rename from tests/fixtures/pages-basic/lib/build-report-types.ts rename to tests/fixtures/build-report/lib/build-report-types.ts diff --git a/tests/fixtures/pages-basic/pages/build-report-alias-export.tsx b/tests/fixtures/build-report/pages/build-report-alias-export.tsx similarity index 100% rename from tests/fixtures/pages-basic/pages/build-report-alias-export.tsx rename to tests/fixtures/build-report/pages/build-report-alias-export.tsx diff --git a/tests/fixtures/pages-basic/pages/build-report-default-export-gsp.tsx b/tests/fixtures/build-report/pages/build-report-default-export-gsp.tsx similarity index 100% rename from tests/fixtures/pages-basic/pages/build-report-default-export-gsp.tsx rename to tests/fixtures/build-report/pages/build-report-default-export-gsp.tsx diff --git a/tests/fixtures/pages-basic/pages/build-report-default-export-gssp.tsx b/tests/fixtures/build-report/pages/build-report-default-export-gssp.tsx similarity index 100% rename from tests/fixtures/pages-basic/pages/build-report-default-export-gssp.tsx rename to tests/fixtures/build-report/pages/build-report-default-export-gssp.tsx diff --git a/tests/fixtures/pages-basic/pages/build-report-factory-gsp.tsx b/tests/fixtures/build-report/pages/build-report-factory-gsp.tsx similarity index 100% rename from tests/fixtures/pages-basic/pages/build-report-factory-gsp.tsx rename to tests/fixtures/build-report/pages/build-report-factory-gsp.tsx diff --git a/tests/fixtures/pages-basic/pages/build-report-generic-gsp.ts b/tests/fixtures/build-report/pages/build-report-generic-gsp.ts similarity index 100% rename from tests/fixtures/pages-basic/pages/build-report-generic-gsp.ts rename to tests/fixtures/build-report/pages/build-report-generic-gsp.ts diff --git a/tests/fixtures/build-report/pages/build-report-import-reexport-gsp.tsx b/tests/fixtures/build-report/pages/build-report-import-reexport-gsp.tsx new file mode 100644 index 000000000..f44e40cc9 --- /dev/null +++ b/tests/fixtures/build-report/pages/build-report-import-reexport-gsp.tsx @@ -0,0 +1,7 @@ +import { getStaticProps as sharedGetStaticProps } from "../lib/build-report-shared-gsp"; + +export { sharedGetStaticProps as getStaticProps }; + +export default function Page() { + return null; +} diff --git a/tests/fixtures/pages-basic/pages/build-report-local-identifier-gsp.tsx b/tests/fixtures/build-report/pages/build-report-local-identifier-gsp.tsx similarity index 100% rename from tests/fixtures/pages-basic/pages/build-report-local-identifier-gsp.tsx rename to tests/fixtures/build-report/pages/build-report-local-identifier-gsp.tsx diff --git a/tests/fixtures/pages-basic/pages/build-report-reexport-gsp.tsx b/tests/fixtures/build-report/pages/build-report-reexport-gsp.tsx similarity index 100% rename from tests/fixtures/pages-basic/pages/build-report-reexport-gsp.tsx rename to tests/fixtures/build-report/pages/build-report-reexport-gsp.tsx diff --git a/tests/fixtures/pages-basic/pages/build-report-type-only-gsp.tsx b/tests/fixtures/build-report/pages/build-report-type-only-gsp.tsx similarity index 100% rename from tests/fixtures/pages-basic/pages/build-report-type-only-gsp.tsx rename to tests/fixtures/build-report/pages/build-report-type-only-gsp.tsx diff --git a/tests/fixtures/pages-basic/pages/build-report-type-only-gssp.tsx b/tests/fixtures/build-report/pages/build-report-type-only-gssp.tsx similarity index 100% rename from tests/fixtures/pages-basic/pages/build-report-type-only-gssp.tsx rename to tests/fixtures/build-report/pages/build-report-type-only-gssp.tsx diff --git a/tests/fixtures/pages-basic/pages/build-report-import-reexport-gsp.tsx b/tests/fixtures/pages-basic/pages/build-report-import-reexport-gsp.tsx deleted file mode 100644 index ae4e422e4..000000000 --- a/tests/fixtures/pages-basic/pages/build-report-import-reexport-gsp.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { getStaticProps } from "../lib/build-report-shared-gsp"; - -export { getStaticProps }; - -export default function Page() { - return null; -}