Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions packages/vinext/src/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,16 +505,41 @@ export function checkConventions(root: string): CheckItem[] {
}
}

// Scan for ViewTransition import from react
// Scan all source files once for per-file checks:
// - ViewTransition import from react
// - free uses of __dirname / __filename (CJS globals, not available in ESM)
//
// For __dirname/__filename we use a single-pass alternation regex that skips over
// string literals, template literals, and comments before testing for the identifier,
// so tokens inside those contexts are never matched.
const allSourceFiles = findSourceFiles(root);
const viewTransitionRegex = /import\s+\{[^}]*\bViewTransition\b[^}]*\}\s+from\s+['"]react['"]/;
// Single-pass regex: skip tokens that can contain identifier-like text, expose everything else
// to the identifier capture branch. Template literals are skipped segment-by-segment between
// `${` boundaries — the `${...}` body itself is NOT consumed, so `__dirname` inside template
// expressions (e.g. `${__dirname}/views`) is correctly exposed to the identifier branch.
const cjsGlobalScanRegex =
/\/\/[^\n]*|\/\*[\s\S]*?\*\/|`(?:[^`\\$]|\\.|\$(?!\{))*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\b(__dirname|__filename)\b/g;
const viewTransitionFiles: string[] = [];
const cjsGlobalFiles: string[] = [];
for (const file of allSourceFiles) {
const content = fs.readFileSync(file, "utf-8");
const rel = path.relative(root, file);

if (viewTransitionRegex.test(content)) {
viewTransitionFiles.push(path.relative(root, file));
viewTransitionFiles.push(rel);
}

cjsGlobalScanRegex.lastIndex = 0;
let m;
while ((m = cjsGlobalScanRegex.exec(content)) !== null) {
if (m[1]) {
cjsGlobalFiles.push(rel);
break;
}
}
}
// Emit items for the combined scan results
if (viewTransitionFiles.length > 0) {
items.push({
name: "ViewTransition (React canary API)",
Expand Down Expand Up @@ -550,6 +575,16 @@ export function checkConventions(root: string): CheckItem[] {
}
}

if (cjsGlobalFiles.length > 0) {
items.push({
name: "__dirname / __filename (CommonJS globals)",
status: "unsupported",
detail:
"CJS globals unavailable in ESM — use fileURLToPath(import.meta.url) / dirname(...), or import.meta.dirname / import.meta.filename (Node 22+)",
files: cjsGlobalFiles,
});
}

return items;
}

Expand Down Expand Up @@ -669,6 +704,11 @@ export function formatReport(result: CheckResult, opts?: { calledFromInit?: bool
for (const item of allItems) {
if (item.status === "unsupported") {
lines.push(` \x1b[31m✗\x1b[0m ${item.name}${item.detail ? ` — ${item.detail}` : ""}`);
if (item.files && item.files.length > 0) {
for (const f of item.files) {
lines.push(` \x1b[90m${f}\x1b[0m`);
}
}
}
}
}
Expand Down
119 changes: 119 additions & 0 deletions tests/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,112 @@ describe("checkConventions", () => {
const postcss = items.find((i) => i.name.includes("PostCSS"));
expect(postcss).toBeUndefined();
});

it("detects __dirname usage in server files", () => {
writeFile("lib/db.ts", `import path from "path";\nconst dir = path.join(__dirname, "data");`);
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);

const items = checkConventions(tmpDir);
const cjs = items.find((i) => i.name.includes("__dirname"));
expect(cjs).toBeDefined();
expect(cjs?.status).toBe("unsupported");
expect(cjs?.detail).toContain("fileURLToPath");
expect(cjs?.detail).toContain("import.meta.dirname");
expect(cjs?.files).toContain("lib/db.ts");
});

it("detects __filename usage", () => {
writeFile("lib/logger.ts", `const file = __filename;`);
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);

const items = checkConventions(tmpDir);
const cjs = items.find((i) => i.name.includes("__dirname"));
expect(cjs).toBeDefined();
expect(cjs?.files).toContain("lib/logger.ts");
});

it("detects both __dirname and __filename in same file", () => {
writeFile("lib/util.ts", `const dir = __dirname;\nconst file = __filename;`);
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);

const items = checkConventions(tmpDir);
const cjs = items.find((i) => i.name.includes("__dirname"));
expect(cjs).toBeDefined();
expect(cjs?.files).toContain("lib/util.ts");
// Only one item for both globals
expect(
items.filter((i) => i.name.includes("__dirname") || i.name.includes("__filename")),
).toHaveLength(1);
});

it("does not flag __dirname inside string literals", () => {
writeFile(
"lib/comment.ts",
`const msg = "use __dirname instead";\nexport default function Home() { return null; }`,
);
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);

const items = checkConventions(tmpDir);
const cjs = items.find((i) => i.name.includes("__dirname"));
expect(cjs).toBeUndefined();
});

it("does not flag __dirname inside comments", () => {
writeFile("lib/note.ts", `// Previously used __dirname here\nexport const x = 1;`);
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);

const items = checkConventions(tmpDir);
const cjs = items.find((i) => i.name.includes("__dirname"));
expect(cjs).toBeUndefined();
});

it("does not flag __dirname inside a plain template literal (no interpolation)", () => {
writeFile("lib/msg.ts", "const msg = `use __dirname instead`;");
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);

const items = checkConventions(tmpDir);
const cjs = items.find((i) => i.name.includes("__dirname"));
expect(cjs).toBeUndefined();
});

it("detects __dirname inside a template expression ${...}", () => {
writeFile("lib/db.ts", "const dir = `${__dirname}/views`;");
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);

const items = checkConventions(tmpDir);
const cjs = items.find((i) => i.name.includes("__dirname"));
expect(cjs).toBeDefined();
expect(cjs?.files).toContain("lib/db.ts");
});

it("does not flag __dirname when not used at all", () => {
writeFile(
"lib/esm.ts",
`import { fileURLToPath } from "url";\nimport { dirname } from "path";\nconst __dirname = dirname(fileURLToPath(import.meta.url));`,
);
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);

// The ESM pattern itself reassigns __dirname — this is fine and should not be flagged
// because users are already using the correct ESM idiom.
// Our scanner will see `__dirname` in the assignment target — that's an edge case we accept.
// This test just ensures we don't crash.
const items = checkConventions(tmpDir);
// No assertion on presence/absence — just verify it doesn't throw
expect(Array.isArray(items)).toBe(true);
});

it("tracks multiple files that use __dirname", () => {
writeFile("lib/a.ts", `const d = __dirname;`);
writeFile("lib/b.ts", `const f = __filename;`);
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);

const items = checkConventions(tmpDir);
const cjs = items.find((i) => i.name.includes("__dirname"));
expect(cjs).toBeDefined();
expect(cjs?.files).toHaveLength(2);
expect(cjs?.files).toContain("lib/a.ts");
expect(cjs?.files).toContain("lib/b.ts");
});
});

// ── runCheck ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -800,6 +906,19 @@ describe("formatReport", () => {
expect(report).toContain("next/amp");
});

it("lists affected files under unsupported items in issues section", () => {
writeFile("lib/db.ts", `const dir = path.join(__dirname, "data");`);
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
writeFile("package.json", JSON.stringify({ type: "module", dependencies: {} }));

const result = runCheck(tmpDir);
const report = formatReport(result);

expect(report).toContain("Issues to address");
expect(report).toContain("__dirname");
expect(report).toContain("lib/db.ts");
});

it("shows partial support section when there are partial items", () => {
writeFile("app/page.tsx", `import { GoogleFont } from "next/font/google";`);
writeFile("package.json", JSON.stringify({ type: "module", dependencies: {} }));
Expand Down
Loading