Skip to content

fix(check): only flag config options used as property keys - #1778

Merged
james-elicx merged 9 commits into
mainfrom
fix/check-webpack-false-positive
Jun 6, 2026
Merged

james-elicx merged 9 commits into
mainfrom
fix/check-webpack-false-positive

Conversation

@james-elicx

@james-elicx james-elicx commented Jun 5, 2026 •

Copy link
Copy Markdown
Member

Problem

vinext check reported webpack as an unsupported config option even when the project had no custom webpack config.

analyzeConfig used the heuristic new RegExp(\\b${opt}\b`), which matches the option name *anywhere* in next.config. So a project was flagged for webpack if the file merely contained a comment (// removed our custom webpack config) or a value with the substring ("react-server-dom-webpack"). webpackis the one users notice because it's the onlyunsupported` entry in the scanned list, but the loose matching affected every option.

Fix

Two layers:

  1. stripCommentsAndStrings pre-pass — a single-pass, lexer-grade scanner (modeled on the existing hasFreeCjsGlobal) that runs before detection:

    • Removes line + block comments entirely.
    • Empties string/template-literal values ("react-server-dom-webpack" → ""), preserving quoted property keys ("webpack":) by peeking for a trailing :.
    • Regex-literal aware, so a quote inside /['"]/ can't hijack string state and swallow following code.
    • O(n) time / O(template-nesting) stack — safe on large files, consistent with fix(check): prevent regex stack overflow / hang on very large files #1776.
  2. Property-key regex — match the option only as an actual key (opt:, method shorthand opt(, assignment opt =, quoted "opt":), guarding against identifier substrings.

Both the per-option and nested dot-notation checks run against the stripped content.

Tests

tests/check.test.ts:

  • stripCommentsAndStrings unit tests: comments removed, string values emptied, quoted keys preserved, ////* inside strings not treated as comments, escaped quotes, regex literal not hijacking, template interpolation scanned as code, division not misread as regex.
  • analyzeConfig: webpack in a comment (with and without trailing punctuation) → not flagged; webpack/headers embedded in string values → not flagged (real keys still detected); webpack as method shorthand / quoted key → flagged.

All check tests pass; vp check is clean.

Notes

Accepted limitations (documented in the helper JSDoc, mirroring hasFreeCjsGlobal): a value-position regex after a block } is read as division, and a string in ternary key position (cond ? "x" : y) is preserved. Both are rare in next.config and the check is advisory.

vinext check reported `webpack` as an unsupported config option even
when the project had no custom webpack config. The analyzeConfig
heuristic matched the bare word `webpack` anywhere in next.config (a
comment, or a value substring like `react-server-dom-webpack`),
producing false positives.

Match the option name only when it appears as an actual object property
key (`opt:`, method shorthand `opt(`, assignment `opt =`, or quoted
`"opt":`).
@pkg-pr-new

pkg-pr-new Bot commented Jun 5, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1778
npm i https://pkg.pr.new/vinext@1778

commit: b7d171a

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Review: fix(check): only flag config options used as property keys

The core change is a clear improvement. Switching from \b${opt}\b (matches anywhere) to requiring a key-like boundary plus a :, (, or = follower eliminates the most common false positives, and the new tests cover the headline cases. Tests pass locally (vp test run tests/check.test.ts, 127 passed).

That said, the new heuristic does not fully deliver on the PR's stated goal of not flagging options that appear in comments — it only fixes comments that happen to lack a :/(/= after the option name. A couple of realistic inputs still produce false positives:

1. Comments that mention the option followed by : / ( / = are still flagged

The PR body says "a comment, e.g. // removed our custom webpack config" is the false positive being fixed. But these still match:

// TODO: webpack: removed, migrate to vite          -> webpack flagged (unsupported)
/* old webpack(config) hook lived here */            -> webpack flagged (unsupported)
// headers: we no longer set custom headers          -> headers flagged

The leading [\s,{("'] boundary is satisfied by the space before webpack, and webpack: / webpack( then matches. So the comment case is only partially solved. The test added (does not flag webpack when it only appears in a comment) passes only because that specific comment string has no :/(/= adjacent to the word.

2. The option appearing inside a string value is still flagged

The ["']? optional-quote branch (meant for quoted keys like "webpack":) also matches a quote that is actually the start of a string value:

module.exports = { images: { domains: ["env:1234"] } }      -> env flagged
module.exports = { env: { X: "(headers:foo)" } }            -> headers flagged

Here the " before env is a string delimiter, not a key quote, but the regex treats "env: the same as a quoted key. The PR's own "substring of a value" test (react-server-dom-webpack) only passes because - is not in the boundary set — a value containing "<opt>:, (<opt>:, or <opt>= slips through.

Why this matters

These aren't contrived: // webpack: ... style migration comments and option names embedded in URL/string values are exactly the kind of thing this PR is meant to stop flagging. The fix narrows the false-positive surface substantially but the PR description overstates it ("a comment mentioning webpack ... would be reported" is presented as fixed, but only the no-punctuation variant is).

Suggestions

A regex over raw file text will always be approximate, so this is a judgement call on how far to go. Two pragmatic options:

  • Strip comments before scanning. A light pre-pass removing //... and /* ... */ (and ideally string literals) before running the per-option regexes would kill both classes of false positive far more reliably than tightening the regex. This matches the spirit of the PR better than the current boundary tweak.
  • At minimum, document the residual limitation in the code comment (currently it claims the comment case is handled) and consider adding the // webpack: ... comment variant and the "env:..." string-value variant as tests so the gap is explicit rather than silently passing.

Minor

  • The nested dot-notation block just below (lines ~705-713) still uses the old loose \b...\b matching for child refs. Not in scope here, but worth a follow-up for consistency since it has the same false-positive class.
  • config.output = ... (dotted assignment) no longer matches because . isn't in the boundary set. Likely fine for next.config style, just noting the behavior change vs. the old \b regex.

Overall: net improvement and safe to ship, but the comment/string-value false positives that the PR claims to fix are only partially addressed. Consider either the comment-stripping approach or tightening the description + tests to reflect the actual coverage.

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

To https://github.com/cloudflare/vinext.git
! [rejected] fix/check-webpack-false-positive -> fix/check-webpack-false-positive (non-fast-forward)
error: failed to push some refs to 'https://github.com/cloudflare/vinext.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. If you want to integrate the remote changes,
hint: use 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

github run

@ask-bonk

ask-bonk Bot commented Jun 5, 2026 •

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

Addresses review: the boundary+follower regex alone only fixed comments
without a trailing :/(/= and still flagged options mentioned in comments
(// webpack: removed) or embedded in string values ("webpack:1234").

Add a single-pass, lexer-grade pre-pass (modeled on hasFreeCjsGlobal)
that removes comments and empties string/template-literal values before
running the per-option regexes. It is regex-literal aware so a quote
inside /['"]/ cannot hijack string state, and quoted property keys
("webpack":) are preserved. Runs in O(n) time so it is safe on large
files, consistent with the #1776 large-file fix.

Adds tests for the reviewer's comment and string-value cases plus
scanner edge cases (regex literals, template interpolation, division).
@james-elicx

Copy link
Copy Markdown
Member Author

Thanks for the thorough review — both false-positive classes are now fixed properly rather than partially.

Instead of relying solely on the boundary+follower regex (which, as you noted, still matched // webpack: ... comments and "webpack:1234" string values), I added a single-pass pre-pass stripCommentsAndStrings that runs before the per-option regexes:

  • Comments (line + block) are removed entirely → // TODO: webpack: removed and /* old webpack(config) */ no longer match.
  • String/template-literal values are emptied ("react-server-dom-webpack" → "") → option names embedded in values no longer match. Quoted property keys ("webpack":) are preserved by peeking for a trailing :.
  • It's modeled on the existing hasFreeCjsGlobal scanner (same frame-based, regex-literal-aware approach), so a quote inside a regex literal like /['"]/ can't hijack string state, and it's O(n) — safe on large files, consistent with fix(check): prevent regex stack overflow / hang on very large files #1776.

On your minor points:

  • The nested dot-notation child-ref check now runs against the stripped content too, so it inherits the same protection.
  • I kept the accepted-limitation note (ternary key-position strings, value-position regex after a block }) documented in the helper's JSDoc, mirroring hasFreeCjsGlobal.

Added tests for both reviewer cases (comment-with-punctuation, string-value) plus scanner edge cases (regex literal not hijacking, template interpolation scanned as code, division not misread as regex).

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk re-review for issues

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Re-review: fix(check): only flag config options used as property keys

The stripCommentsAndStrings pre-pass is the right call and resolves both false-positive classes I raised in the first review. I verified the previously-failing inputs are now handled correctly:

  • // TODO: webpack: removed and /* old webpack(config) */ comments — no longer flagged (comment stripping).
  • "webpack:1234" / "(headers:foo)" string values — no longer flagged (value emptying), while real images/env keys are still detected.
  • Template-literal values (`react-server-dom-webpack`) emptied; interpolations still scanned as code.
  • Regex-literal quote-hijack (/['"]/), escaped quotes, division-vs-regex, and CRLF comments all verified working.
  • Identifier-substring guard holds: myWebpackConfig, headersList, environment are not flagged.

Tests pass (vp test run tests/check.test.ts, 137 passed) and vp check is clean. Net improvement, safe to ship.

Findings

1. Dot-notation child check still decouples parent and child (pre-existing, not a regression). The nested-option block (check.ts:898-906) tests parentBlock and childRef as two independent regexes anywhere in the file, so the child does not need to live inside the parent block. This produces false positives:

module.exports = { experimental: { inlineCss: true }, foo: ppr }     // -> flags experimental.ppr
module.exports = { i18n: { locales: ["en"] }, images: { domains: ["x.com"] } }  // -> flags i18n.domains

The second case is realistic: images.domains is a common key, and any config with both i18n and images.domains will wrongly report i18n.domains. This logic is identical on main — the PR slightly improves it by running against stripped content (so the child ref can't come from a comment/string), but the structural decoupling remains. Reasonable to leave as the documented follow-up you mentioned, just flagging that the realistic images.domains collision exists today.

2. Documented limitation is real but acceptable. The ternary key-position case ({ [cond ? "webpack" : "x"]: 1 }) preserves "webpack" (followed by :), and since the per-option regex treats a leading " as a boundary, this would be a false positive. It's correctly documented in the JSDoc as an accepted limitation and is genuinely rare in next.config. No action needed.

3. Behavior change vs old \b regex (worth being aware of). Dotted-assignment forms like config.output = ... no longer match (. isn't in the boundary set), and const webpack = require("webpack") still matches via the opt = branch. Both are fine for typical next.config shapes — noting for the record.

Minor / nits

  • parentBlock/childRef interpolate key slices into RegExp without escaping. Currently safe (all dot-keys are experimental.* / i18n.domains, no regex metacharacters), but if a future CONFIG_SUPPORT key contained a metachar it would silently misbehave. A \\-escape helper (or a comment noting the invariant) would future-proof it.

Overall the core fix is solid and the new tests cover the important cases. The remaining false-positive surface is confined to the pre-existing dot-notation check, which is appropriate to handle as a separate follow-up.

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Re-review posted to PR #1778.

Summary

The stripCommentsAndStrings pre-pass correctly addresses both false-positive classes I raised in the first review. I verified via direct probing:

Confirmed fixed:

  • Comments with punctuation (// TODO: webpack:, /* webpack(config) */) — no longer flagged
  • String values ("webpack:1234", "(headers:foo)") — no longer flagged; real keys still detected
  • Template-literal values emptied, interpolations scanned as code
  • Regex-quote hijack, escaped quotes, division-vs-regex, CRLF comments — all handled
  • Identifier-substring guard holds (myWebpackConfig, environment not flagged)

Findings (none blocking):

  1. Pre-existing dot-notation decoupling — parentBlock and childRef are tested independently, so i18n: {...} + images.domains wrongly flags i18n.domains. Identical on main, slightly improved by this PR (runs on stripped content). Appropriate as the documented follow-up.
  2. Documented ternary key-position limitation is real but rare and correctly noted in JSDoc.
  3. Minor: unescaped key interpolation into RegExp in the dot-notation block — safe today, worth a future-proofing note.

Tests pass (137) and vp check is clean. Net improvement, safe to ship.

github run

…keys

Addresses Bonk re-review findings:

- Dot-notation options (e.g. i18n.domains) were detected by testing the
  parent block and child name as two independent regexes anywhere in the
  file, so a config with both `i18n: {…}` and `images: { domains: [...] }`
  wrongly reported `i18n.domains`. Extract the parent block body (naive
  brace balancing is safe on already-stripped content) and require the
  child to appear inside it.
- Escape interpolated option/key names before building RegExps via a new
  escapeRegExp helper, future-proofing against keys with metacharacters.

Adds tests for both scoping cases.
@james-elicx

Copy link
Copy Markdown
Member Author

Addressed the re-review findings:

  • Dot-notation decoupling (finding 1): analyzeConfig now extracts the parent block body (via extractBlockBody, naive brace balancing which is safe on already-stripped content) and requires the child to appear inside it. So i18n: {…} + images: { domains: [...] } no longer flags i18n.domains. Added tests for the images.domains collision and a child-outside-parent case.
  • Unescaped RegExp interpolation (minor nit): added an escapeRegExp helper used for both the per-option regex and the dot-notation parent/child regexes.
  • Ternary key-position limitation (finding 2): left as the documented accepted limitation, as you suggested.

139 check tests pass, vp check clean.

/bigbonk re-review for issues

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Re-review: dot-notation decoupling + escape helper

The extractBlockBody approach resolves the headline finding from the last round. I verified directly:

  • images.domains collision fixed. { i18n: { locales:["en"] }, images: { domains:["x.com"] } } no longer flags i18n.domains — the child lookup is now scoped to the i18n block body.
  • Genuine i18n.domains still detected. { i18n: { domains: [...] } } still reports i18n.domains:partial.
  • Child-in-string guard holds. { i18n: { defaultLocale: "domains" } } is not flagged (string stripping + block scoping).
  • escapeRegExp is correctly applied to the per-option regex, the extractBlockBody opener, and the childRef regex. 139 check tests pass, vp check clean.

The brace-balancing in extractBlockBody is safe because it runs on already-stripped content, so braces inside comments/strings can't unbalance it. Good call documenting that invariant in the JSDoc.

Findings (none blocking)

1. extractBlockBody only inspects the first matching parent block — new false-negative vs main (rare). The loop in analyzeConfig (check.ts:931-940) calls extractBlockBody once and takes the first name: {…} block. If a config has two blocks with the same parent name and the child only appears in the second, the child is missed:

const a = { experimental: { inlineCss: true } };
module.exports = { experimental: { ppr: true } };   // experimental.ppr NOT flagged

On main, childRef.test(content) scanned the whole file, so ppr anywhere + any experimental: { block flagged it. The new scoping trades that for the (more valuable) precision against cross-block collisions. Two experimental blocks in one next.config is contrived, so I think this is an acceptable trade — just flagging it as a real behavior delta. If you want to close it, iterate over all opener matches rather than the first.

2. Quoted parent keys are still not matched (pre-existing, not introduced here). { "experimental": { ppr: true } } flags nothing — the \b${name}\s*[:=]\s*\{ opener doesn't allow the closing " between the key and the :. This is identical to main's parentBlock regex, so not a regression, but worth noting it survives this PR. The non-dotted options handle quoted keys (via the ["']? branch in the per-option regex); the dot-notation parent does not. Reasonable as a follow-up since quoted keys in next.config are uncommon.

3. Sub-object over-match (intentional, minor). { experimental: { foo: { ppr: 1 } } } flags experimental.ppr even though ppr is nested one level deeper. This is the documented "child anywhere inside the parent block" semantics and is the right default — flagging here is conservative-correct.

Verdict

The core fix is solid and the new tests (images.domains collision, child-outside-parent) cover the important cases. The remaining gaps (first-block-only, quoted parent) are narrow and appropriate to leave as documented follow-ups. Net improvement, safe to ship.

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Re-review posted to PR #1778.

Summary

The extractBlockBody change resolves the headline finding from the last round. I verified directly (probes against the PR branch + vp check):

Confirmed fixed:

  • images.domains collision: { i18n: {…}, images: { domains:[…] } } no longer flags i18n.domains
  • Genuine i18n.domains still detected
  • Child-in-string (defaultLocale: "domains") not flagged
  • escapeRegExp correctly applied to all three interpolation sites (per-option regex, extractBlockBody opener, childRef)
  • 139 check tests pass, vp check clean

Findings (none blocking):

  1. First-block-only — extractBlockBody inspects only the first matching parent block, so two same-named blocks where the child lives in the second produce a false-negative (experimental.ppr missed). Behavior delta vs main, but contrived in real configs. Closable by iterating all opener matches.
  2. Quoted parent keys ("experimental": {) still not matched by the dot-notation opener — pre-existing, identical to main, not a regression.
  3. Sub-object over-match is intentional and conservative-correct.

The brace-balancing is safe since it runs on stripped content. The trade (cross-block precision over whole-file child matching) is the right call. Net improvement, safe to ship.

github run

Closes the two non-blocking findings from Bonk re-review:

- extractBlockBody only inspected the first matching parent block, so a
  child key in a later same-named block (two `experimental: {}` objects)
  was missed. Renamed to extractBlockBodies, iterating every match.
- The dot-notation opener now allows an optional closing quote, so quoted
  parent keys (`"experimental": {}`) are matched, consistent with how the
  per-option regex handles quoted keys.

Adds tests for both cases.
@james-elicx

Copy link
Copy Markdown
Member Author

Closed both non-blocking findings from the re-review:

  • Finding 1 (first-block-only): extractBlockBody → extractBlockBodies, which iterates every matching parent block. So a child in a later same-named block (e.g. two experimental: {} objects) is now found. Added a test.
  • Finding 2 (quoted parent keys): the dot-notation opener now allows an optional closing quote (\b${name}["']?\s*[:=]\s*\{), so "experimental": {} is matched, consistent with the per-option regex's quoted-key handling. Added a test.
  • Finding 3 (sub-object over-match): left as-is — intentional conservative-correct behavior.

141 check tests pass, vp check clean.

/bigbonk re-review for issues

@ask-bonk

ask-bonk Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Re-review: scan all parent blocks + match quoted parent keys

Both non-blocking findings from the last round are now properly closed, and finding 3 is correctly left as documented intentional behavior. I verified each change by direct probing against the stripped-content pipeline plus the full test file.

Finding 1 (first-block-only) — fixed ✅

extractBlockBody → extractBlockBodies now iterates every matching parent block via a global-flag opener and resumes lastIndex past each block's closing brace. Verified:

  • const a = { experimental: { inlineCss: true } }; module.exports = { experimental: { ppr: true } }; — experimental.ppr is now flagged (the child in the second block is found). This was the false-negative vs main.
  • Two sibling blocks both contribute bodies; the child is matched if it appears in any of them.
  • Nested same-named blocks (experimental: { experimental: { ppr } }) don't double-count or skip — the outer body subsumes the inner and lastIndex advances past the outer close. ppr still found.
  • Unterminated block (experimental: { ppr: true) returns the remainder, identical to the old content.slice(start) fallback — no behavior change there.

Finding 2 (quoted parent keys) — fixed ✅

The opener gained an optional closing quote (\b${name}["']?\s*[:=]\s*\{), so "experimental": { ppr } and 'experimental': { ppr } are now matched, consistent with the per-option regex's ["']? quoted-key handling. Verified both quote styles flag experimental.ppr, and confirmed the per-option and parent-opener regexes treat bare vs quoted keys identically.

No over-match regression from the optional quote: a string value "experimental" (not a key) is emptied by stripCommentsAndStrings before the opener runs, so it can't be mistaken for a quoted key. Confirmed someVar === "experimental" ? { ppr } : null produces no experimental body. The \b boundary still guards identifier substrings (myExperimental: {} not matched).

Finding 3 (sub-object over-match) — left as-is ✅

experimental: { foo: { ppr: 1 } } still flags experimental.ppr even though ppr is one level deeper. This is the documented "child anywhere inside the parent block" semantics and is conservative-correct — agreed it's the right default.

Other notes

  • escapeRegExp correctly applied to the opener; the brace-balancing remains safe because it runs on already-stripped content (braces in comments/strings can't unbalance it).
  • JSDoc on extractBlockBodies accurately documents the all-blocks + quoted-key behavior.

141 check tests pass and vp check is clean on the changed files. No remaining findings — net improvement, safe to ship.

@ask-bonk

ask-bonk Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Re-review posted to PR #1778.

Summary

Both non-blocking findings from the last round are properly closed, and finding 3 is correctly left as documented intentional behavior. Verified each change by direct probing plus the full test file.

Finding 1 (first-block-only) — fixed. extractBlockBody → extractBlockBodies iterates every matching parent block (global-flag opener, lastIndex resumes past each block). Confirmed experimental.ppr in a second experimental: {} block is now flagged. Checked siblings, nested same-named blocks (no double-count/skip), and the unterminated-block fallback (identical to old behavior).

Finding 2 (quoted parent keys) — fixed. Opener now allows an optional closing quote (\b${name}["']?\s*[:=]\s*\{), matching "experimental": {} consistently with the per-option regex. No over-match regression: string values are emptied by stripCommentsAndStrings before the opener runs, and \b still guards identifier substrings.

Finding 3 (sub-object over-match) — left as-is. Conservative-correct, documented, right default.

141 check tests pass, vp check clean. No remaining findings — net improvement, safe to ship.

github run

@james-elicx
james-elicx marked this pull request as draft June 6, 2026 00:05
Replace the hand-rolled comment/string scanner + regex heuristics with a
proper AST parse via oxc-parser (the project's own toolchain). analyzeConfig
now reads option keys directly off the exported config object, which is
exact: comments, string values, regex literals, template literals, and
identifier substrings can never be mistaken for a config key.

This deletes stripCommentsAndStrings, extractBlockBodies, and escapeRegExp
(~115 net lines) and also handles cases the text scan couldn't: configs
wrapped in plugin calls (withMDX(config)), variable-referenced configs, and
TS as/satisfies annotations. The dot-notation child lookup is now a true
parent/child relationship from the AST rather than scoped text matching.

Adds oxc-parser as a dependency (already in the install tree via vite-plus).
@james-elicx

Copy link
Copy Markdown
Member Author

Reworked the approach entirely per feedback that a hand-rolled scanner for a config file is overkill.

analyzeConfig now parses next.config to an AST with oxc-parser (the project's own toolchain) and reads the option keys directly off the exported config object. This is exact by construction — comments, string values, regex literals, template literals, and identifier substrings are simply not property keys, so none of the prior false-positive classes can occur.

Deleted stripCommentsAndStrings, extractBlockBodies, and escapeRegExp (~115 net lines removed from check.ts). The AST approach also handles cases the text scan never could:

  • config wrapped in plugin calls — withMDX(config), defineConfig({…})
  • config assigned to a variable then exported
  • as / satisfies annotations
  • module.exports = {…} and export default

Dot-notation options (experimental.ppr) are now a real parent→child key relationship from the AST, not scoped text matching, so all three prior findings (decoupling, first-block-only, quoted parent keys) are moot.

oxc-parser added as a dependency (already present in the install tree via vite-plus). 135 check tests + 61 init tests pass, build is clean, oxc-parser is correctly externalized in dist.

/bigbonk re-review for issues

Vite already re-exports parseAst (its bundled oxc/rolldown parser), so
there's no need to add oxc-parser as a separate dependency. Parse with
{ lang: "ts" } so .ts configs with type annotations / as / satisfies parse
the same as .js. Types come from Vite's exported ESTree namespace.

Drops the oxc-parser dependency and catalog entry added in the previous
commit; vite is already a required peer dependency of vinext.
@james-elicx

Copy link
Copy Markdown
Member Author

Good catch — dropped the oxc-parser dependency. Vite already re-exports parseAst (its bundled oxc/rolldown parser), and vite is already a required peer dep of vinext, so there's nothing new to install.

collectConfigKeys now calls parseAst(source, { lang: "ts" }) and types come from Vite's exported ESTree namespace. { lang: "ts" } makes .ts configs with type annotations / as / satisfies parse identically to .js/.mjs. 135 check + 61 init tests pass, build clean, dist imports parseAst from vite.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk re-review for issues

@ask-bonk

ask-bonk Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Re-review: AST-based collectConfigKeys

The switch to parsing the config with Vite's parseAst is the right call — it makes detection exact by construction and the prior text-scanning false-positive classes (comments, string values, identifier substrings) are genuinely gone. Verified the headline cases and that parseAst/ESTree are real exports of the resolved vite, tests pass (135 check + 61 init), vp check clean.

However, the rewrite introduces a new regression vs main: function-form next.config is no longer detected at all.

Function-form config reports nothing (regression)

resolveObject unwraps variable refs, wrapper calls, as/satisfies, and parens — but not a function that returns the config object. Next.js explicitly supports the function form:

// next.config.js — documented Next.js pattern (phase, { defaultConfig }) => config
module.exports = (phase) => ({
  webpack: (config) => config,
  basePath: "/app",
});

I ran analyzeConfig against this on the PR branch:

PR branch (AST): []        // webpack + basePath silently missed

On main, the \bwebpack\b / \bbasePath\b text regex matches and both are reported. So this is a real behavior loss, not a parity-neutral refactor.

This isn't a contrived shape:

  • It's the canonical way to use next/constants PHASE_* (which vinext lists as supported in IMPORT_SUPPORT).
  • vinext's own config loader already handles it — config/next-config.ts:532 does if (typeof config === "function") { const result = await config(phase, …) }, and there's an existing fixture using exactly module.exports = function(phase, { defaultConfig }) in tests/shims.test.ts:11141.

So a user with a function-form config gets a working runtime but a vinext check that reports zero config options — the one shape where the report goes silent is one vinext fully supports at runtime.

Fix: teach resolveObject to look through function bodies. Concise arrow bodies are already ObjectExpression (() => ({...})), so ArrowFunctionExpression with a non-block body just needs resolveObject(node.body). For block bodies (FunctionExpression / ArrowFunctionExpression with BlockStatement), resolve the first top-level ReturnStatement's argument. I confirmed the node shapes:

  • () => ({...}) → ArrowFunctionExpression, body.type === "ObjectExpression"
  • function(phase){ return {...} } → FunctionExpression, body.type === "BlockStatement", return arg ObjectExpression

A next-config-runtime-shapes-style fixture (or just adding a function-form case to analyzeConfig tests) would lock this in.

Other notes (non-blocking)

  • Parse-failure silent fallback. A config that fails to parse returns empty sets → the report silently shows no config options rather than surfacing "couldn't analyze". Acceptable for an advisory check, but worth a brief code comment that this is intentional (it is documented in the JSDoc already — good).
  • module.exports only at statement top level. const c = {...}; if (x) module.exports = c; won't resolve, but that's vanishingly rare and fine to ignore.

Verdict

The AST approach is a clear improvement and the right direction. The function-form regression is the one blocking item — it's a documented Next.js config shape that vinext supports at runtime, so check going silent on it is a real parity loss vs main. Worth fixing in this PR before merge; everything else is solid.

@ask-bonk

ask-bonk Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Re-review posted to PR #1778.

Summary

The AST-based rewrite (collectConfigKeys via Vite's parseAst) is a solid improvement — it eliminates the text-scanning false-positive classes by construction, and I verified parseAst/ESTree are real vite exports, 135 check + 61 init tests pass, and vp check is clean.

One blocking finding — function-form config regression:

resolveObject unwraps variable refs, wrapper calls, as/satisfies, and parens, but not a function that returns the config object. The documented Next.js function form:

module.exports = (phase) => ({ webpack: (c) => c, basePath: "/app" });

now reports nothing (analyzeConfig → []), whereas main's text regex detects webpack + basePath. This is a real parity loss because:

  • It's the canonical use of next/constants PHASE_* (which vinext lists as supported).
  • vinext's own loader already handles it (config/next-config.ts:532), with an existing fixture at tests/shims.test.ts:11141.

So the one config shape where check goes silent is one vinext fully supports at runtime.

Suggested fix: extend resolveObject to handle ArrowFunctionExpression/FunctionExpression — concise arrow body is already ObjectExpression; block bodies need resolving the first ReturnStatement argument. I confirmed the node shapes.

Non-blocking: parse-failure silent fallback (documented, acceptable) and module.exports only resolved at statement top level (rare). Everything else is correct and well-tested.

github run

Bonk re-review caught a regression vs main: function-form configs — a
documented Next.js pattern that vinext supports at runtime
(config/next-config.ts) — reported no options under the AST rewrite
because resolveObject did not look through functions.

Extend resolveObject to unwrap arrow/function configs: a concise arrow
body is the config object directly; a block body resolves the first
top-level return statement's argument. Covers both
`(phase) => ({…})` and `function(phase, { defaultConfig }){ return {…} }`.

Adds tests for both forms.
@james-elicx

Copy link
Copy Markdown
Member Author

Fixed the blocking function-form regression. resolveObject now unwraps arrow/function configs:

  • concise arrow ((phase) => ({…})) → the config object is the body directly
  • block body (function(phase, { defaultConfig }) { … return {…} }) → resolves the first top-level return argument

So module.exports = (phase) => ({ basePath, webpack }) and the next/constants PHASE_* block form both report correctly again, matching main. Added tests for both forms (137 check tests pass, vp check clean).

On the non-blocking notes: the parse-failure silent fallback is intentional (documented in the JSDoc), and module.exports only resolved at statement top level is fine to leave.

/bigbonk re-review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk re-review for issues

4 similar comments
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk re-review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk re-review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk re-review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk re-review for issues

resolveObject handled FunctionExpression (module.exports = function(){…})
and arrow forms but not FunctionDeclaration, which is what
`export default function (phase) { return {…} }` parses to. That form
silently reported no options.

Add FunctionDeclaration to the function-form branch and widen the param
type to ESTree.Function. Adds a regression test.
@james-elicx

Copy link
Copy Markdown
Member Author

Fixed one more function-form gap found in review: export default function (phase) { return {…} } parses as a FunctionDeclaration (not FunctionExpression like module.exports = function(){…}), so it was silently reporting no options. Added FunctionDeclaration to the function-form branch in resolveObject and a regression test. 138 check tests pass, vp check clean.

/bigbonk re-review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk re-review for issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk re-review for issues

@ask-bonk

ask-bonk Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Re-review: AST-based collectConfigKeys + function-form handling

The AST approach is solid and the export default function (FunctionDeclaration) fix from the last round is correct. I re-verified the full matrix against the PR branch (probes through analyzeConfig + vp check clean + 138 check tests pass):

Confirmed working:

  • Concise arrow ((phase) => ({…})), block-body function(phase){ return {…} }, and export default function (phase) { return {…} } (FunctionDeclaration) — all detected.
  • Named export default function makeConfig(phase) { … return {…} } — detected (the id doesn't interfere).
  • Function-form assigned to a variable then exported (const config = (phase) => ({…}); module.exports = config) — detected.
  • async (phase) => ({…}) — detected.
  • Wrapper calls (withMDX, defineConfig), as/satisfies, parens, quoted nested keys — all detected.
  • No throws on curried arrows ((phase) => () => ({…})) — returns the inner object, fine.

One finding (non-blocking, narrow false-negative)

Multi-phase early-return pattern misses the early branch. resolveObject for block bodies returns the first top-level ReturnStatement argument. In the canonical next/constants multi-phase shape — which this PR's own commit message and JSDoc cite as the supported case — the first top-level return is the default config (after the if), so options used only in the phase-specific early return (nested inside the if) are silently dropped:

const { PHASE_DEVELOPMENT_SERVER } = require("next/constants");
module.exports = (phase, { defaultConfig }) => {
  if (phase === PHASE_DEVELOPMENT_SERVER) {
    return { trailingSlash: true };   // <- NOT reported
  }
  return { webpack: (c) => c };       // <- reported
};

Probed on the branch: only webpack is reported; trailingSlash is missed. This is the documented Next.js multi-phase config form, and it's the exact reason a function takes phase at all — single-object function configs don't need it.

This is a false-negative (conservative — check is advisory), and the previous text-scan on main would have matched both via \btrailingSlash\b. It's narrow, so it's reasonable to ship and track as a follow-up, but worth being explicit since the PR specifically advertises PHASE_* support and the current behavior only covers the single-return phase function, not the branching one. If you want to close it: collect all ReturnStatement arguments in the block (a shallow walk that doesn't descend into nested functions) and merge their keys, rather than taking the first.

Other notes (no action)

  • Spread at top level ({ ...base, webpack }) only reports webpack; keys from the spread source object aren't followed. Behavior delta vs main, but spreads are genuinely hard to resolve statically — conservative-correct.
  • Parse-failure → empty report is intentional and documented in the JSDoc. Fine for an advisory check.
  • module.exports is only resolved as a top-level statement (not inside an if), which is vanishingly rare.

Verdict

Net improvement, exact-by-construction for the common cases, and the prior false-positive classes are genuinely gone. The multi-phase early-return miss is the only behavior gap I'd flag, and it's narrow enough to ship + follow-up. Safe to merge.

@ask-bonk

ask-bonk Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Re-review posted to PR #1778.

Summary

The AST-based collectConfigKeys (via Vite's parseAst) is a solid, exact-by-construction approach and the prior text-scanning false-positive classes are genuinely gone. The export default function (FunctionDeclaration) fix from the last round is correct. Verified via direct probing + 138 check tests pass + vp check clean.

Confirmed working: concise arrow, block-body function, export default function (named and anonymous), function-form-via-variable, async arrow, wrapper calls, as/satisfies, quoted nested keys, curried arrows (no throw).

One finding (non-blocking, narrow false-negative):

  • Multi-phase early-return pattern — resolveObject takes the first top-level return in a block body. In the canonical next/constants multi-phase shape (if (phase === PHASE_DEVELOPMENT_SERVER) return {…}; return {…}), the early return is nested in the if, so options used only there are silently dropped. Probed: only the default branch's webpack is reported, trailingSlash (dev branch) is missed. It's conservative (false-negative on an advisory check) and narrow, but it's the documented Next.js form the PR advertises PHASE_* support for. Closable by collecting all top-level ReturnStatement args and merging keys.

Other notes (no action): top-level spread keys aren't followed (delta vs main, but statically hard); parse-failure → empty report is intentional/documented.

Verdict: Net improvement, safe to merge; the multi-phase miss is reasonable to track as a follow-up.

github run

resolveObject took only the first top-level return of a function body, so
the canonical next/constants multi-phase form — where the phase-specific
config is an early return nested in an if and the default is the trailing
return — only reported the default branch's keys.

Resolve all object literals a config expression can denote and merge their
keys: collect every reachable return arg (descending through if/switch/try
but not into nested functions) and handle ternary branches. Covers the
PHASE_* if/return form and the ternary function form.

Adds tests for both.
@james-elicx

Copy link
Copy Markdown
Member Author

Closed the multi-phase finding. resolveObject (now resolveObjects) returns all object literals a config expression can denote, and collectConfigKeys merges keys across them.

For function block bodies it collects every reachable return argument, descending through if/switch/try but not into nested functions, so the canonical next/constants form reports both branches:

module.exports = (phase, { defaultConfig }) => {
  if (phase === PHASE_DEVELOPMENT_SERVER) return { trailingSlash: true }; // now reported
  return { webpack: (c) => c };
};

Also handles the ternary function form ((phase) => phase === X ? {…} : {…}). Nested keys merge across branches too (e.g. experimental from multiple returns). Added tests for both. 140 check tests pass, vp check clean.

Remaining notes left as documented/accepted: top-level spread sources aren't followed (statically hard), and parse-failure → empty report is intentional.

/bigbonk re-review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk re-review for issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk re-review for issues

@ask-bonk

ask-bonk Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Re-review: multi-phase branch merging + AST option detection

The resolveObjects/collectReturnArgs change closes the last open finding (multi-phase early-return). I verified the full matrix by probing analyzeConfig directly against the branch (140 check tests pass, vp check clean on the changed file).

Confirmed working

  • Multi-phase early return — if (phase === PHASE_DEVELOPMENT_SERVER) return { trailingSlash } ; return { webpack } now reports both trailingSlash and webpack. This was the false-negative from last round.
  • Ternary function ((phase) => phase === X ? {…} : {…}) — both branches merged.
  • Function-via-variable (const config = (phase) => ({…}); module.exports = config) — detected (var resolves to the arrow, then through it).
  • export default function (FunctionDeclaration, named + anonymous), block-body function(phase){ return {…} }, async arrow — all detected.
  • Curried plugin call composePlugins(p1,p2)(nextConfig) and nested wrappers withA(withB(c)) — detected (the call's first object-resolving argument wins).
  • Comment / string-value false positives genuinely gone (AST keys only): // TODO: webpack: removed, "react-server-dom-webpack", "webpack:1234" — none flagged, real images/env keys still detected.
  • Dot-notation precision: { i18n: {…}, images: { domains } } no longer flags i18n.domains; genuine i18n.domains still flagged; quoted parent "experimental": {…} works.
  • Termination safety: cyclic vars (const a=b; const b=a) and the depth > 10 bound — no hang, returns (none).
  • No throws on non-config defaults (export default class Foo {} → (none)), thanks to the resolveObjects fall-through.

Behavior deltas vs main (all acceptable, noting for the record)

  1. Deeply-nested child no longer over-matches. { experimental: { foo: { ppr: 1 } } } now reports (none) for experimental.ppr (AST only inspects direct children), whereas the old text-scan flagged it. This is more correct — ppr there isn't experimental.ppr — so it's a strict improvement, just a delta from the previously-documented "conservative over-match."

  2. Spread sources not followed ({ ...base, webpack } misses base's keys). Already documented as statically-hard; conservative false-negative on an advisory check. Fine.

  3. Computed keys ({ [k]: (c)=>c }) and exports.webpack = … (bare exports, not module.exports) are not detected. Both rare in next.config; reasonable to leave.

Verdict

No blocking findings. The AST rewrite is exact-by-construction for the common shapes, eliminates every prior false-positive class, and now covers the multi-phase form end-to-end. The remaining gaps are narrow, conservative (false-negatives, not false-positives), and appropriate to leave as-is on an advisory check. Net improvement — safe to merge.

@ask-bonk

ask-bonk Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Re-review posted to PR #1778.

Summary

The final round (resolveObjects + collectReturnArgs merging keys across all reachable return branches) closes the last open finding from the previous review. I verified the full matrix by probing analyzeConfig directly against the branch.

Confirmed fixed (the headline finding): the canonical next/constants multi-phase form with an early return inside an if now reports options from both branches (trailingSlash + webpack), not just the default branch. Ternary function form merges both arms too.

Everything else verified working: function-via-variable, export default function (named/anon), async arrow, curried/nested plugin wrappers, comment/string-value false positives gone, dot-notation precision (i18n/images collision, quoted parents), cyclic-var/depth termination, and no throws on non-config defaults.

Behavior deltas (all acceptable):

  1. Deeply-nested child (experimental.foo.ppr) no longer over-matches experimental.ppr — actually more correct than the old text-scan.
  2. Spread sources not followed — documented, statically hard, conservative.
  3. Computed keys and bare exports.x = not detected — rare, fine.

No blocking findings. The AST approach is exact-by-construction, kills every prior false-positive class, and the remaining gaps are narrow conservative false-negatives on an advisory check. 140 tests pass, vp check clean. Safe to merge.

github run

@james-elicx
james-elicx marked this pull request as ready for review June 6, 2026 23:43
@james-elicx
james-elicx merged commit 2181c90 into main Jun 6, 2026
50 checks passed
@james-elicx
james-elicx deleted the fix/check-webpack-false-positive branch June 6, 2026 23:43
@github-actions github-actions Bot mentioned this pull request Jun 6, 2026
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.

1 participant