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:
parent
9153506fba
commit
ab0bb9dece
2 changed files with 162 additions and 0 deletions
|
|
@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}` };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue