Skip to content

fix(build-report): parse route exports with OXC AST#1260

Draft
NathanDrake2406 wants to merge 3 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/issue-605-route-report-static-info
Draft

fix(build-report): parse route exports with OXC AST#1260
NathanDrake2406 wants to merge 3 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/issue-605-route-report-static-info

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented May 16, 2026

Overview

Field Detail
Goal Make route report classification use parser-backed static facts instead of regex and hand-written token scanning.
Core change Parse route source with oxc-parser, then inspect top-level exports and getStaticProps return objects from the AST.
Main boundary This remains static source analysis only. Runtime/prerender results still take precedence when available.
Primary files packages/vinext/src/build/report.ts, tests/build-report.test.ts
Expected impact Fewer false positives and better support for valid TS/JS syntax in CLI route reports.

Fixes #605.

Why

Route report generation needs syntactic facts about a module: exported names, literal segment config values, and top-level getStaticProps return objects. Regexes cannot reliably distinguish code from comments or nested helper functions, and they miss valid syntax once TypeScript and newer JavaScript literals enter the source. Next.js handles this class of work through a static analysis pass over a parsed module, so vinext should do the same at the route-report boundary.

Area Principle / invariant What this PR changes
Export detection Comments and strings are not declarations. hasNamedExport now reads ExportNamedDeclaration nodes from OXC's AST.
Segment config Literal config should be extracted from top-level static export const declarations only. dynamic and revalidate extraction now unwrap TS assertions and supports string template literals plus numeric separators.
Pages getStaticProps revalidate belongs to the exported function body, not arbitrary source text. The scanner now finds the exported/local function and walks relevant statements, skipping nested helper bodies.
Next.js compatibility Static export specifier recognition should match Next's analyzer. Re-export specifiers are matched by local/original binding name, consistent with Next's checkExports.

What changed

Scenario Before After
Block comment contains export const dynamic = "force-dynamic" Could classify from commented text. Ignored because comments are not AST declarations.
No-substitution template literal dynamic export Missed. Extracted as a static string literal.
export const revalidate = 60_000 Missed by numeric regex. Extracted as 60000.
TypeScript file has generic arrow syntax before config TSX parse ambiguity could hide valid config. Parser tries TSX first, then TS for valid .ts syntax.
getStaticProps contains nested helper returns Hand scanning was fragile. Revalidate extraction is scoped to the exported function body and skips nested function bodies.
Validation
  • pnpm exec vp test run tests/build-report.test.ts tests/layout-classification.test.ts tests/route-classification-manifest.test.ts tests/nitro-route-rules.test.ts - 4 files, 140 tests passed
  • pnpm exec vp check packages/vinext/src/build/report.ts tests/build-report.test.ts packages/vinext/package.json pnpm-workspace.yaml - formatting, lint, and type checks passed for touched source/test files
  • pnpm exec vp fmt --check packages/vinext/src/build/report.ts tests/build-report.test.ts packages/vinext/package.json pnpm-workspace.yaml - formatting passed
  • pnpm exec vp run vinext#build - package build completed
  • pnpm install --frozen-lockfile --lockfile-only - lockfile consistency passed
  • git diff --check - no whitespace errors
Risk / compatibility
  • Public API: no public API shape changes.
  • Runtime: no route runtime behavior changes. This affects build report classification only.
  • Dependency: adds direct oxc-parser dependency for packages/vinext.
  • Version note: npm currently reports oxc-parser@0.131.0 as latest, but it was published on 2026-05-15 at 16:03 UTC. The repo has minimumReleaseAge: 1440, and verification was at 2026-05-16 07:53 UTC, so this PR uses 0.130.0, the newest version that clears the repo's 24-hour package-age policy.
  • Static-analysis limit: dynamic expressions, imported config constants, and externally re-exported getStaticProps remain intentionally unresolved, matching the previous report boundary and Next's static-analysis style.
Maintainer review path
  1. packages/vinext/src/build/report.ts - review the OXC parser boundary, static export helpers, and getStaticProps AST walking.
  2. tests/build-report.test.ts - review the regression cases for comment false positives, literal syntax support, TS parser fallback, and Next-style export specifier handling.
  3. packages/vinext/package.json, pnpm-workspace.yaml, pnpm-lock.yaml - review the direct parser dependency and locked native bindings.

References

Reference Why it matters
Issue #605 Requested parser-backed route report analysis.
Review on PR #592 Original maintainer feedback that regex scanning was not the right long-term approach.
Next.js get-page-static-info.ts Next's AST-backed route/static-info analysis and export checks.
Next.js parse-module.ts Next's parsed-module boundary for static analysis.
Next.js extract-const-value.ts Next's AST literal extraction approach for exported constants.

Route report classification relied on regexes and a hand-written return-object scanner. That made comments look like exports and missed valid static syntax such as no-substitution template literals and numeric separators.

The report now parses route modules with oxc-parser, reads top-level static exports from the AST, and keeps getStaticProps revalidate extraction scoped to the exported function body. Tests cover the prior false positives, supported literal forms, TypeScript generic syntax fallback, and Next.js-style export specifier handling.
@NathanDrake2406 NathanDrake2406 marked this pull request as draft May 16, 2026 07:56
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 16, 2026 07:57
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 16, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1260

commit: 088fef5

@NathanDrake2406 NathanDrake2406 marked this pull request as draft May 16, 2026 08:00
The CLI imported the route report module at startup, which also loaded the native parser dependency added for build report classification. That widened the parser and native binding path to non-build commands.

Load the build report module inside buildApp() immediately before printing the report so dev, start, check, lint, deploy, and help paths do not touch the parser-backed report code.
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 16, 2026 11:53
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: AST-based route report classification

Strong improvement. Replacing hand-rolled token scanning with OXC parser output is the right direction — it eliminates the comment/string false-positive class of bugs entirely and handles TS syntax naturally. The code is well-structured, the test additions are targeted, and the fallback path for bare return-object snippets is a reasonable pragmatic choice.

A few items below, roughly ordered by importance.

Repeated parsing of the same source

This is the main structural concern. For a single Pages Router route, classifyPagesRoute can call parseRouteModule(code) 3–4 times on the same source string (once per hasNamedExport call, once in extractGetStaticPropsRevalidate, plus potentially the wrapped fallback). For App Router routes, classifyAppRoute parses twice (extractExportConstString + extractExportConstNumber). OXC is fast, but this is still O(routes × parses_per_route) with a constant of 2–4 where 1 would suffice.

This doesn't need to block the PR, but consider a follow-up that parses once at the classifier level and threads the Program through the helpers. The exported helpers can keep their code: string signature for external callers, with internal overloads that accept a pre-parsed AST.

Missing explicit return in extractStaticNumberValue

The UnaryExpression branch (line 256–261) falls through to the final return null when the operator isn't - or +, but it also falls through when the recursive call succeeds with - or +. The current code happens to work because falling through to return null at line 263 is harmless for unhandled operators, but the successful - / + cases are missing an explicit return. See inline comment.

hasNamedExport semantic change is intentional and correct

Verified that checking specifier.local (the original binding name) matches Next.js's checkExports implementation, which uses specifier.orig.value. The new test at line 62 correctly encodes this. Good documentation in the JSDoc.

cli.ts lazy import

Moving printBuildReport from a static import to a dynamic await import() is a nice touch — avoids pulling in oxc-parser at CLI startup for commands that don't need it.

Tests

Good regression coverage for the new capabilities (block comments, template literals, numeric separators, TS generic arrow fallback, alias export semantics). The wrapped-code fallback path (extractWrappedGetStaticPropsRevalidate) could use a dedicated test that exercises it specifically — right now it's only tested indirectly through the bare return { ... } cases.

Comment on lines +256 to 261
if (unwrapped.type === "UnaryExpression") {
const argument = extractStaticNumberValue(unwrapped.argument);
if (typeof argument !== "number") return null;
if (unwrapped.operator === "-") return -argument;
if (unwrapped.operator === "+") return argument;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UnaryExpression branch is missing an explicit return for the - and + cases. The code works by accident because the fall-through hits return null at line 263, but only the unhandled-operator case should reach that. The - and + branches need to return their computed value:

Suggested change
if (unwrapped.type === "UnaryExpression") {
const argument = extractStaticNumberValue(unwrapped.argument);
if (typeof argument !== "number") return null;
if (unwrapped.operator === "-") return -argument;
if (unwrapped.operator === "+") return argument;
}
if (unwrapped.operator === "-") return -argument;
if (unwrapped.operator === "+") return argument;
return null;
}

Without this, extractStaticNumberValue for -42 returns null instead of -42 — which would break export const revalidate = -1 detection.

Actually wait — I re-read this and the if block at line 256 is not early-returning, it's just an if with no braces-in-the-if-sense. Let me re-check.

OK, I see it now — the if at line 256 enters the block (lines 257–261), and if operator is -, it returns -argument. If operator is +, it returns argument. If neither, it falls through to line 263's return null. So the behavior is actually correct for all operators.

But: if the operator is something other than - or + (e.g., ~, !, void, typeof, delete), the code falls through the if body without returning, then hits the final return null. This is correct behavior but would be clearer with an explicit return null at the end of the if block to show intent. Minor style nit.

}

function parseRouteModule(code: string): Program | null {
return parseRouteModuleWithLang(code, "tsx") ?? parseRouteModuleWithLang(code, "ts");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every call to parseRouteModule tries TSX first, then falls back to TS. For .ts files with generic arrow syntax (like the test at build-report.test.ts:173), this means two parseSync calls per parseRouteModule invocation — and since each classifier calls parseRouteModule 2–4 times on the same code, you can end up with 4–8 parseSync calls for a single route file.

Not blocking, but worth noting for the follow-up: if you parse once at the classifier level, the TSX→TS fallback cost is also paid only once.

Comment thread packages/vinext/src/build/report.ts Outdated
const localName = moduleExportNameValue(specifier.local);
if (localName !== "getStaticProps") continue;
if (node.source !== null) return "external";
localExportNames.add(localName);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The set localExportNames will only ever contain the string "getStaticProps" because of the guard at line 284. So localExportNames.size is at most 1, and iterating over it later is always a single-element loop. This is fine but the Set<string> is slightly misleading — a simple let localBindingName: string | null = null would be clearer and avoid the allocation.

Minor nit, not blocking.

const paramsStart = code.indexOf("(", functionStart);
if (paramsStart === -1) return -1;
function extractWrappedGetStaticPropsRevalidate(code: string): number | false | null {
const program = parseRouteModule(`function __vinextGetStaticProps() {\n${code}\n}`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clever fallback: wrapping the code in a synthetic function to make bare return { ... } statements parseable as module source. This handles the existing test cases where extractGetStaticPropsRevalidate is called on a code fragment rather than a full module.

Worth a brief comment explaining why this exists — the tests call extractGetStaticPropsRevalidate with bare return statements that aren't valid module source, so this wrapper makes them parseable. Without the comment, a future reader might wonder when parseRouteModule would succeed but findExportedGetStaticProps would return null (answer: it can't happen for well-formed modules, this path is for code fragments in tests and edge cases).

if (declarationHasBindingName(node.declaration, name)) return true;

for (const specifier of node.specifiers) {
if (moduleExportNameValue(specifier.local) === name) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — using specifier.local here correctly matches Next.js's checkExports which uses specifier.orig.value. The JSDoc at line 125–126 clearly documents this.

One subtlety worth noting: this means hasNamedExport answers "does this module have a local binding with this name that's exported?" rather than "does this module export this name?". If hasNamedExport is ever used for middleware or proxy export detection, it would need the opposite check (specifier.exported), per Next.js's validateMiddlewareProxyExports. The current usage for getStaticProps/getServerSideProps is correct.

}

return findMatchingBrace(code, bodyStart);
if (!fn.body || fn.body.type !== "BlockStatement") return null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition !fn.body can't actually be true here — FunctionDeclaration, FunctionExpression, and ArrowFunctionExpression all have a body property in OXC's AST. The arrow-with-expression-body case is already handled at line 340. This guard is defensive and harmless, but strictly unreachable for the types in FunctionLike.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted. Here's a summary of the feedback:

Main structural concern: Each route classification parses the same source code 2–4 times via independent parseRouteModule(code) calls. OXC is fast, but a follow-up to parse once at the classifier level and thread the Program down would eliminate redundant work.

Inline comments posted (6):

  1. extractStaticNumberValue (lines 256–261) — The UnaryExpression branch style: the -/+ cases return correctly, but adding an explicit return null at the end of the if block would clarify intent for unhandled operators.

  2. parseRouteModule TSX→TS fallback (line 98) — Each parseRouteModule call can trigger 2 parseSync calls. Combined with the repeated parsing per route, this compounds.

  3. localExportNames Set (line 286) — This set can only ever contain "getStaticProps" due to the guard. A simple nullable string would be clearer and avoid the allocation.

  4. extractWrappedGetStaticPropsRevalidate (line 327) — Clever fallback for bare return-statement fragments. Deserves a comment explaining why it exists.

  5. specifier.local in hasNamedExport (line 142) — Confirmed correct per Next.js's checkExports. Noted that middleware/proxy detection would need specifier.exported if this function is ever reused for that purpose.

  6. Defensive !fn.body guard (line 345) — Unreachable for the types in FunctionLike, but harmless.

github run

Route classification reparsed the same source for each static export query. With the TSX-to-TS fallback, a single route could invoke the parser several times before producing one report row.

Thread the parsed OXC Program through internal route-report helpers so classifier paths parse each source once while preserving the public string-based helper signatures. Also clarifies the synthetic getStaticProps wrapper path and the unsupported unary-operator branch.
Copy link
Copy Markdown
Contributor Author

Addressed the valid review items in 088fef5c:

  • classifier paths now parse each route/layout source once and thread the parsed Program through internal helpers, while keeping the exported string-based helper signatures unchanged
  • the TSX-to-TS fallback is now paid once per classifier path instead of once per export query
  • replaced the single-value localExportNames set with a boolean for the local getStaticProps export case
  • added an explicit return null for unsupported unary operators and documented the synthetic wrapper fallback

The unary branch was not a functional bug after re-checking, but the explicit return is clearer.

Validation after the patch:

  • pnpm exec vp test run tests/build-report.test.ts tests/layout-classification.test.ts tests/route-classification-manifest.test.ts tests/nitro-route-rules.test.ts - 140 tests passed
  • pnpm exec vp check packages/vinext/src/build/report.ts tests/build-report.test.ts packages/vinext/src/cli.ts packages/vinext/package.json pnpm-workspace.yaml - passed
  • pnpm exec vp run vinext#build - completed
  • git diff --check - clean

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after author's response

The author addressed the substantive feedback from the first review well:

  1. Parse-once threading — Classifiers (classifyPagesRoute, classifyAppRoute, classifyLayoutSegmentConfig) now parse once and thread the Program through internal *InProgram / *FromProgram helpers. The exported string-signature helpers remain for external callers. This eliminates the repeated parsing.

  2. localExportNames set replaced — Now uses let hasLocalGetStaticPropsExport = false, which is clearer for the single-value case.

  3. Explicit return null in unary branch — Added at line 289 for non--/+ operators.

  4. Synthetic wrapper commentextractWrappedGetStaticPropsRevalidate now documents why it exists (lines 354-355).

The code is well-structured, the test additions are targeted, and the overall approach (OXC parser replacing hand-rolled token scanning) is the right direction. The extractStatementRevalidate walker correctly avoids descending into nested function declarations/expressions by not handling VariableDeclaration initializers or FunctionDeclaration bodies — the AST naturally scopes the revalidate search to the exported function's own statements.

Pre-existing observation (not blocking)

When parseRouteModule(code) returns null in classifyPagesRoute (report.ts:534), neither the getServerSideProps nor getStaticProps checks run, and the function falls through to { type: "static" } at line 553. This silently treats unparseable files as static. The old regex code had the same behavior (no match → static), so this is parity-preserving. But "unknown" would be a safer default for genuinely unparseable source. Worth a follow-up if you want to tighten this.

LGTM — good to merge.

}

return findMatchingBrace(code, bodyStart);
if (!fn.body || fn.body.type !== "BlockStatement") return null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The !fn.body guard is still here — the previous review noted it's unreachable for the types in FunctionLike. Harmless, and arguably defensive-in-depth if the OXC AST types ever broaden. Fine to keep.

depth--;
if (depth === 0) return i;
}
return extractStaticNumberValue(property.value);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: extractStaticNumberValue can return false (for revalidate: false in the object literal), and that propagates correctly through extractObjectRevalidateextractFunctionRevalidateextractGetStaticPropsRevalidateFromProgram. The typing is sound.

const paramsEnd = findMatchingParen(code, paramsStart);
if (paramsEnd === -1) return -1;
function extractWrappedGetStaticPropsRevalidate(code: string): number | false | null {
// Exported helpers are also used by tests with bare `return { ... }` fragments,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good comment. This clearly explains the synthetic wrapper purpose.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted successfully. Here's a summary of what I posted:

Approved the PR with the following feedback:

  • All 4 items from the first review were addressed: parse-once threading, localExportNames simplification, explicit return null in unary branch, and the synthetic wrapper comment.
  • Confirmed the extractStatementRevalidate walker correctly avoids descending into nested functions via the AST's natural scoping.
  • Confirmed type soundness for the false propagation through extractStaticNumberValueextractObjectRevalidate chain.
  • Noted one pre-existing (non-blocking) observation: classifyPagesRoute treats unparseable files as "static" rather than "unknown" — parity-preserving with the old regex behavior, but worth tightening in a follow-up.

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

Can we use the parse function Vite exports instead of adding the separate dep? Should still be oxc.

@james-elicx james-elicx marked this pull request as draft May 16, 2026 19:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explore using ASTs to improve route report generation

2 participants