Skip to content

Commit ae51ac5

Browse files
kazuponfengmk2
andauthored
fix: resolve ~137 DTS warnings in vp pack when importing UserConfig (#993)
Fixes #991 ## Changes ### `packages/cli/src/pack-bin.ts` - Add rolldown transform plugin (`vite-plus:external-dts-type-only`) that converts `import`/`export` to `import type`/`export type` in postcss and lightningcss `.d.ts` files at bundle time - Also transforms `import * as PostCSS from "postcss"` to `import type` in `@voidzero-dev/vite-plus-core` `.d.ts` files to prevent `IMPORT_IS_UNDEFINED` warnings ### `packages/test/build.ts` - Fix `vitest/browser` path mapping from `index.js` to `context.js` to match vitest's own `package.json` exports, resolving `CDPSession` missing export - Extend `patchModuleAugmentations()` to merge augmented type definitions (e.g. `ExpectPollOptions`) into target `.d.ts` files, since rolldown cannot resolve cross-file `declare module` augmentations - Add `BrowserCommands` re-export to `context.d.ts` ## Testing - Run `vp pack` in [kazupon/vp-config](https://github.com/kazupon/vp-config) and verify zero `MISSING_EXPORT` / `IMPORT_IS_UNDEFINED` warnings --------- Co-authored-by: MK (fengmk2) <fengmk2@gmail.com>
1 parent 3252ee5 commit ae51ac5

4 files changed

Lines changed: 229 additions & 20 deletions

File tree

.github/workflows/e2e-test.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,12 @@ jobs:
251251
node-version: 24
252252
command: |
253253
vp test run
254+
- name: vp-config
255+
node-version: 22
256+
command: |
257+
vp check
258+
vp pack
259+
vp test
254260
exclude:
255261
# frm-stack uses Docker (testcontainers) which doesn't work the same way on Windows
256262
- os: windows-latest

ecosystem-ci/repo.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,10 @@
6060
"branch": "main",
6161
"hash": "451925ad7c07750a23de1d6ed454825d0eb14092",
6262
"forceFreshMigration": true
63+
},
64+
"vp-config": {
65+
"repository": "https://github.com/kazupon/vp-config.git",
66+
"branch": "main",
67+
"hash": "b58c48d71a17c25dec71a003535e6312791ce2aa"
6368
}
6469
}

packages/cli/src/pack-bin.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,48 @@ import { cac } from 'cac';
1313

1414
import { resolveViteConfig } from './resolve-vite-config.js';
1515

16+
/**
17+
* Rolldown plugin that transforms value imports/exports to type-only in external
18+
* packages' .d.ts files. Some packages (e.g. postcss, lightningcss) use
19+
* `import { X }` and `export { X } from` instead of their type-only equivalents,
20+
* which causes MISSING_EXPORT warnings from the DTS bundler.
21+
*
22+
* Since .d.ts files contain only type information, all imports/exports are
23+
* inherently type-only, so this transformation is always safe.
24+
*/
25+
const EXTERNAL_DTS_INTERNAL_RE = /node_modules\/(postcss|lightningcss)\/.*\.d\.(ts|mts|cts)$/;
26+
// Match consumer .d.ts files that import from postcss/lightningcss.
27+
// In CI (installed from tgz): node_modules/vite-plus-core/dist/...
28+
// In local development (symlinked workspace): packages/core/dist/...
29+
const EXTERNAL_DTS_CONSUMER_RE =
30+
/(?:vite-plus-core|packages\/core)\/.*lightningcssOptions\.d\.ts$|(?:vite-plus-core|packages\/core)\/dist\/.*\.d\.ts$/;
31+
const EXTERNAL_DTS_FIX_RE = new RegExp(
32+
`${EXTERNAL_DTS_INTERNAL_RE.source}|${EXTERNAL_DTS_CONSUMER_RE.source}`,
33+
);
34+
35+
function externalDtsTypeOnlyPlugin() {
36+
return {
37+
name: 'vite-plus:external-dts-type-only',
38+
transform: {
39+
filter: { id: { include: [EXTERNAL_DTS_FIX_RE] } },
40+
handler(code: string, rawId: string) {
41+
// Normalize Windows backslash paths to forward slashes for regex matching
42+
const id = rawId.replaceAll('\\', '/');
43+
if (EXTERNAL_DTS_INTERNAL_RE.test(id)) {
44+
// postcss/lightningcss internal files: transform imports only
45+
// (exports may include value re-exports like `export const Features`)
46+
return code.replace(/^(import\s+)(?!type\s)/gm, 'import type ');
47+
}
48+
// Consumer files: only transform imports from postcss/lightningcss
49+
return code.replace(
50+
/^(import\s+)(?!type\s)(.+from\s+['"](?:postcss|lightningcss)['"])/gm,
51+
'import type $2',
52+
);
53+
},
54+
},
55+
};
56+
}
57+
1658
const cli = cac('vp pack');
1759
cli.help();
1860

@@ -98,7 +140,14 @@ cli
98140
? viteConfig.pack
99141
: [viteConfig.pack ?? {}];
100142
for (const packConfig of packConfigs) {
101-
const resolvedConfig = await resolveUserConfig({ ...packConfig, ...flags }, flags);
143+
const merged = { ...packConfig, ...flags };
144+
// Inject plugin to fix MISSING_EXPORT warnings from external .d.ts files
145+
// (postcss, lightningcss use `import`/`export` instead of `import type`/`export type`)
146+
if (merged.dts) {
147+
const existingPlugins = Array.isArray(merged.plugins) ? merged.plugins : [];
148+
merged.plugins = [...existingPlugins, externalDtsTypeOnlyPlugin()];
149+
}
150+
const resolvedConfig = await resolveUserConfig(merged, flags);
102151
configs.push(...resolvedConfig);
103152
}
104153

packages/test/build.ts

Lines changed: 168 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -931,8 +931,9 @@ async function rewriteVitestImports(leafDepToVendorPath: Map<string, string>) {
931931
vitest: resolve(distDir, 'index.js'),
932932
'vitest/node': resolve(distDir, 'node.js'),
933933
'vitest/config': resolve(distDir, 'config.js'),
934-
// vitest/browser exports page, server, etc from @vitest/browser
935-
'vitest/browser': resolve(distDir, '@vitest/browser/index.js'),
934+
// vitest/browser exports page, server, CDPSession, BrowserCommands, etc from @vitest/browser/context
935+
// This matches vitest's own package.json exports: "./browser" -> "./browser/context.d.ts"
936+
'vitest/browser': resolve(distDir, '@vitest/browser/context.js'),
936937
// vitest/internal/browser exports browser-safe __INTERNAL and stringify (NOT @vitest/browser/index.js which has Node.js code)
937938
'vitest/internal/browser': resolve(distDir, 'browser.js'),
938939
'vitest/runners': resolve(distDir, 'runners.js'),
@@ -2064,7 +2065,7 @@ export * from '../dist/@vitest/browser/context.d.ts'
20642065
}
20652066

20662067
/**
2067-
* Patch module augmentations in global.d.*.d.ts files to use relative paths.
2068+
* Patch module augmentations in global.d.*.d.ts files.
20682069
*
20692070
* The original vitest types use module augmentation like:
20702071
* declare module "@vitest/expect" { interface Assertion<T> { toMatchSnapshot: ... } }
@@ -2073,12 +2074,11 @@ export * from '../dist/@vitest/browser/context.d.ts'
20732074
* "@vitest/expect" doesn't exist as a package for consumers. This breaks the
20742075
* module augmentation - TypeScript can't find @vitest/expect to augment.
20752076
*
2076-
* The fix: Change module augmentation to use relative paths that TypeScript CAN resolve:
2077-
* declare module "../@vitest/expect/index.js" { ... }
2078-
*
2079-
* This makes TypeScript augment the same module that our index.d.ts imports from,
2080-
* so the augmented properties (toMatchSnapshot, toMatchInlineSnapshot, etc.)
2081-
* appear on the Assertion type that consumers import.
2077+
* The fix has two parts:
2078+
* 1. Change module augmentation to use relative paths that TypeScript CAN resolve:
2079+
* declare module "../@vitest/expect/index.js" { ... }
2080+
* 2. Merge augmented interface/type definitions into the target .d.ts files so that
2081+
* downstream DTS bundlers (rolldown) can resolve them without cross-file augmentation.
20822082
*/
20832083
async function patchModuleAugmentations() {
20842084
console.log('\nPatching module augmentations in global.d.*.d.ts files...');
@@ -2096,31 +2096,180 @@ async function patchModuleAugmentations() {
20962096
return;
20972097
}
20982098

2099-
// Module augmentation mappings: bare specifier -> relative path from chunks/
2100-
const augmentationMappings: Record<string, string> = {
2101-
'@vitest/expect': '../@vitest/expect/index.js',
2102-
'@vitest/runner': '../@vitest/runner/index.js',
2099+
// Module augmentation mappings: bare specifier -> [relative path, target .d.ts file]
2100+
const augmentationMappings: Record<string, { relativePath: string; targetFile: string }> = {
2101+
'@vitest/expect': {
2102+
relativePath: '../@vitest/expect/index.js',
2103+
targetFile: join(distDir, '@vitest/expect/index.d.ts'),
2104+
},
2105+
'@vitest/runner': {
2106+
relativePath: '../@vitest/runner/index.js',
2107+
targetFile: join(distDir, '@vitest/runner/utils.d.ts'),
2108+
},
21032109
};
21042110

21052111
for (const file of globalDtsFiles) {
21062112
let content = await readFile(file, 'utf-8');
21072113
let modified = false;
21082114

2109-
for (const [bareSpecifier, relativePath] of Object.entries(augmentationMappings)) {
2115+
for (const [bareSpecifier, { relativePath, targetFile }] of Object.entries(
2116+
augmentationMappings,
2117+
)) {
21102118
const oldPattern = `declare module "${bareSpecifier}"`;
2111-
const newPattern = `declare module "${relativePath}"`;
21122119

2113-
if (content.includes(oldPattern)) {
2114-
content = content.replaceAll(oldPattern, newPattern);
2115-
modified = true;
2116-
console.log(` Patched: ${bareSpecifier} -> ${relativePath} in ${basename(file)}`);
2120+
// Extract the augmentation block content using brace matching
2121+
const startIdx = content.indexOf(oldPattern);
2122+
const braceStart = startIdx !== -1 ? content.indexOf('{', startIdx) : -1;
2123+
if (braceStart === -1) {
2124+
continue;
2125+
}
2126+
2127+
let depth = 0;
2128+
let braceEnd = -1;
2129+
for (let i = braceStart; i < content.length; i++) {
2130+
if (content[i] === '{') {
2131+
depth++;
2132+
} else if (content[i] === '}') {
2133+
depth--;
2134+
if (depth === 0) {
2135+
braceEnd = i;
2136+
break;
2137+
}
2138+
}
2139+
}
2140+
if (braceEnd === -1) {
2141+
continue;
2142+
}
2143+
2144+
const innerContent = content.slice(braceStart + 1, braceEnd).trim();
2145+
2146+
// Merge only NEW type declarations into the target .d.ts file.
2147+
// Interfaces that already exist (e.g., ExpectStatic, Assertion, MatcherState) must NOT
2148+
// be re-declared, as that would shadow extends clauses and break call signatures.
2149+
if (innerContent && existsSync(targetFile)) {
2150+
let targetContent = await readFile(targetFile, 'utf-8');
2151+
2152+
// Extract individual interface blocks from the augmentation content
2153+
const interfaceRegex = /(?:export\s+)?interface\s+(\w+)(?:<[^>]*>)?\s*\{/g;
2154+
let match;
2155+
const newDeclarations: string[] = [];
2156+
2157+
while ((match = interfaceRegex.exec(innerContent)) !== null) {
2158+
const name = match[1];
2159+
// Only merge if this interface does NOT already exist in the target file.
2160+
// Check both direct declarations (interface Name) and re-exports (export type { Name }).
2161+
const hasDirectDecl = new RegExp(`\\binterface\\s+${name}\\b`).test(targetContent);
2162+
const exportTypeMatch = targetContent.match(/export\s+type\s*\{([^}]*)\}/);
2163+
const isReExported =
2164+
exportTypeMatch != null && new RegExp(`\\b${name}\\b`).test(exportTypeMatch[1]);
2165+
if (hasDirectDecl || isReExported) {
2166+
console.log(
2167+
` Skipped existing interface "${name}" (already in ${basename(targetFile)})`,
2168+
);
2169+
continue;
2170+
}
2171+
2172+
// Extract this interface block using brace matching
2173+
const ifaceStart = match.index;
2174+
const ifaceBraceStart = innerContent.indexOf('{', ifaceStart);
2175+
let ifaceDepth = 0;
2176+
let ifaceBraceEnd = -1;
2177+
for (let i = ifaceBraceStart; i < innerContent.length; i++) {
2178+
if (innerContent[i] === '{') {
2179+
ifaceDepth++;
2180+
} else if (innerContent[i] === '}') {
2181+
ifaceDepth--;
2182+
if (ifaceDepth === 0) {
2183+
ifaceBraceEnd = i;
2184+
break;
2185+
}
2186+
}
2187+
}
2188+
if (ifaceBraceEnd === -1) {
2189+
continue;
2190+
}
2191+
2192+
let block = innerContent.slice(ifaceStart, ifaceBraceEnd + 1).trim();
2193+
if (!block.startsWith('export')) {
2194+
block = `export ${block}`;
2195+
}
2196+
newDeclarations.push(block);
2197+
console.log(` Merged new interface "${name}" into ${basename(targetFile)}`);
2198+
}
2199+
2200+
if (newDeclarations.length > 0) {
2201+
targetContent += `\n// Merged from module augmentation: declare module "${bareSpecifier}"\n${newDeclarations.join('\n')}\n`;
2202+
await writeFile(targetFile, targetContent, 'utf-8');
2203+
}
21172204
}
2205+
2206+
// Rewrite declare module path to relative
2207+
const newPattern = `declare module "${relativePath}"`;
2208+
content = content.replaceAll(oldPattern, newPattern);
2209+
modified = true;
2210+
console.log(` Patched: ${bareSpecifier} -> ${relativePath} in ${basename(file)}`);
21182211
}
21192212

21202213
if (modified) {
21212214
await writeFile(file, content, 'utf-8');
21222215
}
21232216
}
2217+
2218+
// Re-export BrowserCommands from context.d.ts (imported but not exported)
2219+
const contextDtsPath = join(distDir, '@vitest/browser/context.d.ts');
2220+
if (existsSync(contextDtsPath)) {
2221+
let content = await readFile(contextDtsPath, 'utf-8');
2222+
if (
2223+
content.includes('BrowserCommands') &&
2224+
!content.match(/export\s+(type\s+)?\{[^}]*BrowserCommands/)
2225+
) {
2226+
content += '\nexport type { BrowserCommands };\n';
2227+
await writeFile(contextDtsPath, content, 'utf-8');
2228+
console.log(' Added BrowserCommands re-export to context.d.ts');
2229+
}
2230+
}
2231+
2232+
// Validate: ensure no duplicate top-level interface declarations were introduced by merging.
2233+
// Only count interfaces at the module scope (not nested inside declare global, namespace, etc.)
2234+
for (const [bareSpecifier, { targetFile }] of Object.entries(augmentationMappings)) {
2235+
if (!existsSync(targetFile)) {
2236+
continue;
2237+
}
2238+
const finalContent = await readFile(targetFile, 'utf-8');
2239+
2240+
// Extract top-level interface names by tracking brace depth
2241+
const topLevelInterfaces: string[] = [];
2242+
let depth = 0;
2243+
for (let i = 0; i < finalContent.length; i++) {
2244+
if (finalContent[i] === '{') {
2245+
depth++;
2246+
} else if (finalContent[i] === '}') {
2247+
depth--;
2248+
} else if (depth === 0) {
2249+
const remaining = finalContent.slice(i);
2250+
const m = remaining.match(/^interface\s+(\w+)/);
2251+
if (m) {
2252+
topLevelInterfaces.push(m[1]);
2253+
i += m[0].length - 1;
2254+
}
2255+
}
2256+
}
2257+
2258+
const counts = new Map<string, number>();
2259+
for (const name of topLevelInterfaces) {
2260+
counts.set(name, (counts.get(name) || 0) + 1);
2261+
}
2262+
2263+
for (const [name, count] of counts) {
2264+
if (count > 1) {
2265+
throw new Error(
2266+
`Interface "${name}" is declared ${count} times at top level in ${basename(targetFile)}. ` +
2267+
`Module augmentation merge for "${bareSpecifier}" likely created a duplicate ` +
2268+
`declaration that will shadow extends clauses and break type signatures.`,
2269+
);
2270+
}
2271+
}
2272+
}
21242273
}
21252274

21262275
/**

0 commit comments

Comments
 (0)