[BUG] design-sync: two bugs in tsconfigPathsPlugin — the "@/*" path alias breaks the tsconfig parse, and the resolver returns directories
Two independent defects. They must be fixed together: fixing #1 alone converts a
silently-inert plugin into an actively-wrong one and breaks builds that currently work.
Environment: design-sync as vendored into .ds-sync/, Windows, Node 24, esbuild 0.28.
Reproduced against a Next.js App Router project using the stock create-next-app path alias.
---
Bug 1 — comment stripping matches inside string literals, so "@/*" breaks the parse
lib/bundle.mjs, tsconfigPathsPlugin():
const raw = readFileSync(tsconfigPath, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/(^|[^:])\/\/.*$/gm, '$1'); // line comments
({ paths, baseUrl = '.' } = JSON.parse(raw).compilerOptions ?? {});
Neither regex tracks string state, so both match inside string literals.
The block-comment regex begins matching at the /* inside the path-alias key "@/*" and
runs to the first */ — which is inside "**/*.ts" in include. A minimal reproducer:
{
"compilerOptions": { "paths": { "@/*": ["./*"] } },
"include": ["**/*.ts"]
}
becomes
{
"compilerOptions": { "paths": { "@*.ts"] }
and JSON.parse throws Expected ':' after property name in JSON at position N.
"@/*": ["./*"] is the alias create-next-app generates by default, so this fires on a large
share of Next.js projects.
The line-comment pass has the same defect
The [^:] guard only protects a // immediately preceded by : (i.e. https://). Verified:
| input | result |
|---|---|
| {"x":"https://example.com/a"} | unchanged — parses |
| {"x":"//cdn.example.com/a"} | mangled → Unterminated string in JSON |
| {"paths":{"a":["./x//y"]}} | mangled → Unterminated string in JSON |
Why nobody noticed
The catch { return null; } swallows the parse error with no output, and null is
indistinguishable from "this tsconfig declares no paths". esbuild then resolves @/ aliases via
its own upward tsconfig discovery, so any project whose entry lives inside the tsconfig'sinclude builds correctly with the plugin dead.
It only surfaces on the case the function's own docstring says it exists for — the synth entry
written into the OUT dir, which native discovery does not cover:
| entry location | plugin returns null (today) | plugin working |
|---|---|---|
| inside repo, covered by include | resolves | resolves |
| synth entry in OUT dir | Could not resolve "@/…" | resolves |
Fix
Replace both regexes with a string-aware scanner (or a real JSONC parser):
function stripJsonComments(src) {
if (src.charCodeAt(0) === 0xfeff) src = src.slice(1); // BOM — see Bug 3
let out = '';
let inStr = false, esc = false, inLine = false, inBlock = false;
for (let i = 0; i < src.length; i++) {
const c = src[i], n = src[i + 1];
if (inLine) { if (c === '\n') { inLine = false; out += c; } continue; }
if (inBlock) { if (c === '*' && n === '/') { inBlock = false; i++; out += ' '; } continue; }
if (inStr) {
out += c;
if (esc) esc = false;
else if (c === '\\') esc = true;
else if (c === '"') inStr = false;
continue;
}
if (c === '"') { inStr = true; out += c; continue; }
if (c === '/' && n === '/') { inLine = true; i++; continue; }
if (c === '/' && n === '*') { inBlock = true; i++; continue; }
out += c;
}
return out;
}
Validated differentially against TypeScript's own ts.parseConfigFileTextToJson over a 33-case
battery — escaped quotes, backslash runs at string end, \uXXXX, /* and // and */ inside
strings, "//" as a key, /* /* */, /**/, /*/, CRLF, comment-strip token joining
({"a"/*x*/:1}), unterminated strings. All string-state cases match.
Please also make the failure loud. The silent catch is what hid this:
} catch (e) {
console.error(`[TSCONFIG_PARSE] ${tsconfigPath}: ${e.message} — path aliases will NOT be resolved`);
return null;
}
(Note any at position N in the message is an offset into the stripped text, not the file.)
---
Bug 2 — existsSync matches directories, so the resolver returns a directory path
Same function, the resolve loop:
const exts = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '/index.ts', ...];
for (const ext of exts) {
if (existsSync(stem + ext)) return { path: stem + ext }; // ← true for directories
}
exts starts with '' and existsSync is true for directories. Any specifier that has both
a module and a same-named sibling directory resolves to the directory:
project/
lib/
foo.ts ← correct target
foo/ ← what the plugin returns
helper.ts
import … from '@/lib/foo' → the plugin hands esbuild <project>/lib/foo, and the build dies:
Cannot read file "lib/foo": Incorrect function.
This shape (a barrel module beside a directory of its parts) is common. In the project where this
was found it occurred on a module reached directly from the configured bundle entry, so the
failure was total, not partial.
This is why the two fixes must ship together. Today Bug 1 makes the plugin return null, so
Bug 2 is unreachable and builds succeed via esbuild's native resolution. Fix Bug 1 alone and the
plugin activates — and immediately breaks those same builds.
Fix
Require a file, not mere existence (statSync is already imported at the top of the module):
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
...
if (isFile(stem + ext)) return { path: stem + ext };
The /index.* entries still cover genuine directory imports. After this fix the configured entry
built byte-identically with and without the plugin — neutral where esbuild already resolved
correctly, load-bearing only where it didn't.
Related
bundleExportEvidence has its own catch { return null; }, so a resolution failure there
silently downgrades the provider gate to scan evidence rather than surfacing — a second instance
of the swallow-the-error pattern behind Bug 1.
---
Bug 3 (minor) — leading UTF-8 BOM is not stripped
Neither the current regexes nor a naive scanner replacement handle a BOM, so a tsconfig.json
saved as "UTF-8 with BOM" (routine on Windows) throws Unexpected token and silently disables
path aliases. One line, included in the Bug 1 fix above:
if (src.charCodeAt(0) === 0xfeff) src = src.slice(1);
Still unhandled after that, and worth a decision: trailing commas are legal in tsconfig.json
and remain a JSON.parse failure. A real JSONC parser would cover comments, BOM, and trailing
commas in one move.
---
Two notes on the fork-override mechanism
Encountered while working around the above.
1. lib/source-kit.mjs bypasses loadLib. It does a static import { resolveDistEntry } from, so a repo that forks
'./bundle.mjs'bundle.mjs via .design-sync/overrides/ gets two copies
of the module in one process: package-build.mjs and preview-rebuild.mjs see the fork,source-kit.mjs keeps the bundled original. Harmless while those functions agree, but it
silently breaks the override contract for any fork touching resolveDistEntry. The same applies
to the dts.mjs fork path.
2. Forking bundle.mjs is impossible to do cleanly because it doesimport { IIFE_IMPORT_META_DEFINE } from './common.mjs', which cannot resolve from.design-sync/overrides/. (dts.mjs is forkable only because it happens to import nothing
relative.) Every workaround is bad: a ../../.ds-sync/lib/common.mjs path hard-crashes when the
tool runs with cwd set to a git worktree — loadLib resolves the fork cwd-relative, and.ds-sync/ is gitignored so it isn't beside that checkout — and inlining the constant invites
silent drift.
This matters because package-build.mjs and preview-rebuild.mjs both describe forkingbundle.mjs/emit.mjs as unsupported (app-contract surface) while still routing them throughloadLib. If forking them is genuinely unsupported, [OVERRIDE_FORBIDDEN] should reject them the
way it rejects sync-hashes.mjs, rather than accepting the fork and printing an affirmative[OVERRIDE] using … line. As it stands a repo can adopt a fork that appears blessed, and the
relative-import trap is only discovered afterwards.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗