fix(extensions): detect TypeScript syntax in .js extension files and suggest renaming to .ts (#2386)

When a user creates a .js extension file but writes TypeScript syntax in it,
the loader now detects common TS patterns (type annotations, interfaces, enums,
generics) and provides a clear error message suggesting to rename the file to
.ts, instead of the previous cryptic "Extension does not export a valid factory
function" or opaque jiti parse errors.

Fixes #2381

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-24 15:12:36 -04:00 committed by GitHub
parent 9153506fba
commit ab0bb9dece
2 changed files with 162 additions and 0 deletions

View file

@ -4,6 +4,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { isProjectTrusted, trustProject, getUntrustedExtensionPaths } from "./project-trust.js";
import { containsTypeScriptSyntax, loadExtensions } from "./loader.js";
// ─── helpers ──────────────────────────────────────────────────────────────────
@ -139,3 +140,98 @@ describe("getUntrustedExtensionPaths", () => {
assert.deepEqual(result, paths);
});
});
// ─── containsTypeScriptSyntax ─────────────────────────────────────────────────
describe("containsTypeScriptSyntax", () => {
it("detects parameter type annotations", () => {
assert.ok(containsTypeScriptSyntax(`export default function activate(api: ExtensionAPI) {}`));
});
it("detects interface declarations", () => {
assert.ok(containsTypeScriptSyntax(`interface Config { name: string; }`));
});
it("detects type alias declarations", () => {
assert.ok(containsTypeScriptSyntax(`type Handler = (event: string) => void;`));
});
it("detects enum declarations", () => {
assert.ok(containsTypeScriptSyntax(`enum Direction { Up, Down, Left, Right }`));
});
it("detects return type annotations", () => {
assert.ok(containsTypeScriptSyntax(`function foo(): Promise<void> {}`));
});
it("detects generic type parameters on functions", () => {
assert.ok(containsTypeScriptSyntax(`function identity<T>(arg) { return arg; }`));
});
it("detects variable type annotations", () => {
assert.ok(containsTypeScriptSyntax(`const name: string = "hello";`));
});
it("returns false for plain JavaScript", () => {
assert.equal(containsTypeScriptSyntax(`export default function activate(api) { api.on("init", () => {}); }`), false);
});
it("returns false for empty string", () => {
assert.equal(containsTypeScriptSyntax(""), false);
});
it("returns false for JSDoc comments with type-like syntax", () => {
// JSDoc uses different syntax: @param {string} name
assert.equal(containsTypeScriptSyntax(`/** @param {string} name */\nexport default function activate(api) {}`), false);
});
});
// ─── loadExtensions: TypeScript syntax in .js files ───────────────────────────
describe("loadExtensions", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = makeTempDir();
});
afterEach(() => {
cleanDir(tmpDir);
});
it("reports helpful error when .js file contains TypeScript syntax", async () => {
// Create a .js file that uses TypeScript type annotations
const extPath = path.join(tmpDir, "my-extension.js");
fs.writeFileSync(
extPath,
`export default function activate(api: ExtensionAPI) {\n api.on("init", async () => {});\n}\n`,
);
const result = await loadExtensions([extPath], tmpDir);
assert.equal(result.errors.length, 1);
const errorMsg = result.errors[0].error;
// The error should mention TypeScript syntax and suggest .ts extension
assert.ok(
/TypeScript/.test(errorMsg) && /\.ts\b/.test(errorMsg),
`Expected error to mention TypeScript syntax and .ts extension, got: ${errorMsg}`,
);
});
it("reports helpful error when .js file contains TS interface declaration", async () => {
const extPath = path.join(tmpDir, "typed-ext.js");
fs.writeFileSync(
extPath,
`interface Config { name: string; }\nexport default function activate(api) { return; }\n`,
);
const result = await loadExtensions([extPath], tmpDir);
assert.equal(result.errors.length, 1);
const errorMsg = result.errors[0].error;
assert.ok(
/TypeScript/.test(errorMsg) && /\.ts\b/.test(errorMsg),
`Expected error to mention TypeScript syntax and .ts extension, got: ${errorMsg}`,
);
});
});

View file

@ -568,6 +568,39 @@ function createExtensionAPI(
return api;
}
/**
* Heuristic patterns that indicate TypeScript syntax in a source file.
* Used to detect when a .js file accidentally contains TypeScript code
* and provide a helpful error message instead of a cryptic parse failure.
*/
const TS_SYNTAX_PATTERNS: RegExp[] = [
// Variable type annotations: const name: string, let count: number
/\b(?:const|let|var)\s+\w+\s*:\s*(?:string|number|boolean|any|void|never|unknown|object|bigint|symbol|undefined|null)\b/,
// Parameter type annotations: (api: ExtensionAPI)
/\(\s*\w+\s*:\s*[A-Z]\w*/,
// Return type annotations: ): Promise<void> { or ): string =>
/\)\s*:\s*(?:Promise|string|number|boolean|void|any|never|unknown)\b/,
// Interface declarations
/\binterface\s+[A-Z]\w*\s*(?:<[^>]*>)?\s*\{/,
// Type alias declarations
/\btype\s+[A-Z]\w*\s*(?:<[^>]*>)?\s*=/,
// Angle-bracket type assertions: <Type>value
/(?:as\s+\w+(?:<[^>]*>)?)\s*[;,)\]}]/,
// Generic type parameters on functions: function foo<T>
/\bfunction\s+\w+\s*<[^>]+>/,
// Enum declarations
/\benum\s+[A-Z]\w*\s*\{/,
];
/**
* Check whether a source string likely contains TypeScript syntax.
* This is a heuristic it may produce false positives for unusual JS,
* but is tuned to catch the most common TS-in-JS mistakes.
*/
export function containsTypeScriptSyntax(source: string): boolean {
return TS_SYNTAX_PATTERNS.some((pattern) => pattern.test(source));
}
async function loadExtensionModule(extensionPath: string) {
// Pre-compiled extension loading: if the source is .ts and a sibling .js
// file exists with matching or newer mtime, use native import() to skip
@ -672,6 +705,22 @@ async function loadExtension(
return { extension: null, error: null };
}
logExtensionTiming(extensionPath, Date.now() - start, "failed");
// Check if a .js file contains TypeScript syntax
if (resolvedPath.endsWith(".js")) {
try {
const source = fs.readFileSync(resolvedPath, "utf-8");
if (containsTypeScriptSyntax(source)) {
return {
extension: null,
error: `Extension file "${extensionPath}" appears to contain TypeScript syntax but has a .js extension. Rename it to .ts so the loader can compile it.`,
};
}
} catch {
// Could not read file — fall through to generic error
}
}
return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` };
}
@ -684,6 +733,23 @@ async function loadExtension(
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logExtensionTiming(extensionPath, Date.now() - start, "failed");
// Check if a .js file contains TypeScript syntax — the parse error from
// jiti/Node is often cryptic, so surface a clearer diagnostic.
if (resolvedPath.endsWith(".js")) {
try {
const source = fs.readFileSync(resolvedPath, "utf-8");
if (containsTypeScriptSyntax(source)) {
return {
extension: null,
error: `Extension file "${extensionPath}" appears to contain TypeScript syntax but has a .js extension. Rename it to .ts so the loader can compile it.`,
};
}
} catch {
// Could not read file — fall through to generic error
}
}
return { extension: null, error: `Failed to load extension: ${message}` };
}
}