diff --git a/packages/pi-coding-agent/src/core/extensions/loader.test.ts b/packages/pi-coding-agent/src/core/extensions/loader.test.ts index ef98c1189..65691e949 100644 --- a/packages/pi-coding-agent/src/core/extensions/loader.test.ts +++ b/packages/pi-coding-agent/src/core/extensions/loader.test.ts @@ -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 {}`)); + }); + + it("detects generic type parameters on functions", () => { + assert.ok(containsTypeScriptSyntax(`function identity(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}`, + ); + }); +}); diff --git a/packages/pi-coding-agent/src/core/extensions/loader.ts b/packages/pi-coding-agent/src/core/extensions/loader.ts index 396ba9e9a..b87497138 100644 --- a/packages/pi-coding-agent/src/core/extensions/loader.ts +++ b/packages/pi-coding-agent/src/core/extensions/loader.ts @@ -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 { 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: value + /(?:as\s+\w+(?:<[^>]*>)?)\s*[;,)\]}]/, + // Generic type parameters on functions: function foo + /\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}` }; } }