From c4a23daee90ebdafb1970f37b21b8988934d4335 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 22 May 2026 12:56:40 -0400 Subject: [PATCH 1/3] add error when fn implemention with no extern --- packages/compiler/src/core/checker.ts | 40 +++++++++++++++++++ packages/compiler/src/core/messages.ts | 6 +++ .../compiler/test/checker/functions.test.ts | 38 +++++++++++++----- .../compiler/test/semantic-walker.test.ts | 2 +- 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index cd23221098d..fe792a7499c 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -95,6 +95,7 @@ import { IntersectionExpressionNode, IntrinsicScalarName, JsNamespaceDeclarationNode, + JsSourceFileNode, LiteralNode, LiteralType, LocationContext, @@ -4848,6 +4849,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkSourceFile(file); } + checkOrphanedFunctionImplementations(); internalDecoratorValidation(); assertNoPendingResolutions(); runPostValidators(postCheckValidators); @@ -4880,6 +4882,44 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } + /** + * Check that all function implementations exported via $functions have a corresponding + * `extern fn` declaration in TypeSpec. Reports an error for each orphaned implementation. + */ + function checkOrphanedFunctionImplementations() { + for (const file of program.jsSourceFiles.values()) { + checkSymbolTableForOrphanedFunctions(file.symbol.exports, file); + } + } + + function checkSymbolTableForOrphanedFunctions( + exports: SymbolTable | undefined, + sourceFile: JsSourceFileNode, + ) { + if (!exports) return; + for (const sym of exports.values()) { + if (sym.flags & SymbolFlags.Function && sym.flags & SymbolFlags.Implementation) { + const merged = getMergedSymbol(sym); + const hasFunctionDeclaration = merged.declarations.some( + (d) => d.kind === SyntaxKind.FunctionDeclarationStatement, + ); + if (!hasFunctionDeclaration) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "missing-extern-declaration", + format: { name: sym.name }, + target: sourceFile, + }), + ); + } + } + // Recurse into namespace symbols + if (sym.flags & SymbolFlags.Namespace && sym.exports) { + checkSymbolTableForOrphanedFunctions(sym.exports, sourceFile); + } + } + } + /** Report error with duplicate using in the same scope. */ function checkDuplicateUsings(file: TypeSpecScriptNode) { const duplicateTrackers = new Map>(); diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 457ae774915..e3d9fe449bf 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -588,6 +588,12 @@ const diagnostics = { default: "Extern declaration must have an implementation in JS file.", }, }, + "missing-extern-declaration": { + severity: "error", + messages: { + default: paramMessage`Function implementation "${"name"}" is exported in JS via $functions but has no corresponding 'extern fn' declaration in TypeSpec.`, + }, + }, "overload-same-parent": { severity: "error", messages: { diff --git a/packages/compiler/test/checker/functions.test.ts b/packages/compiler/test/checker/functions.test.ts index 7693cd49746..c20f88c6d51 100644 --- a/packages/compiler/test/checker/functions.test.ts +++ b/packages/compiler/test/checker/functions.test.ts @@ -117,6 +117,20 @@ describe("declaration", () => { }, ]); }); + + it("errors if $functions export has no matching extern fn declaration", async () => { + const diagnostics = await tester.diagnose(``); + expectDiagnostics(diagnostics, [ + { + code: "missing-extern-declaration", + message: `Function implementation "testFn" is exported in JS via $functions but has no corresponding 'extern fn' declaration in TypeSpec.`, + }, + { + code: "missing-extern-declaration", + message: `Function implementation "nsFn" is exported in JS via $functions but has no corresponding 'extern fn' declaration in TypeSpec.`, + }, + ]); + }); }); describe("usage", () => { @@ -199,8 +213,8 @@ describe("usage", () => { it("errors if function not declared", async () => { const diagnostics = await tester.diagnose(`const X = missing();`); - - expectDiagnostics(diagnostics, { + const filtered = diagnostics.filter((d) => d.code !== "missing-extern-declaration"); + expectDiagnostics(filtered, { code: "invalid-ref", message: "Unknown identifier missing", }); @@ -1269,6 +1283,10 @@ describe("default function results", () => { describe("template and generic scenarios", () => { beforeEach(() => { tester = BaseTester.files({ + "templates.tsp": ` + extern fn processGeneric(T: unknown): unknown; + extern fn processConstrainedGeneric(T: Reflection.Model): Reflection.Model; + `, "templates.js": mockFile.js({ $functions: { "": { @@ -1282,13 +1300,13 @@ describe("template and generic scenarios", () => { }, }), }) + .import("./templates.tsp") .import("./templates.js") .using("TypeSpec.Reflection"); }); it("works with template aliases", async () => { const [{ program, prop }, diagnostics] = await tester.compileAndDiagnose(t.code` - extern fn processGeneric(T: unknown): unknown; alias ArrayOf = processGeneric(T); @@ -1305,8 +1323,6 @@ describe("template and generic scenarios", () => { it("works with constrained templates", async () => { const diagnostics = await tester.diagnose(` - extern fn processConstrainedGeneric(T: Reflection.Model): Reflection.Model; - alias ProcessModel = processConstrainedGeneric(T); model TestModel {} @@ -1318,7 +1334,6 @@ describe("template and generic scenarios", () => { it("errors when template constraint not satisfied", async () => { const diagnostics = await tester.diagnose(` - extern fn processConstrainedGeneric(T: Reflection.Model): Reflection.Model; alias ProcessModel = processConstrainedGeneric(T); @@ -1333,7 +1348,6 @@ describe("template and generic scenarios", () => { it("template instantiations of function calls yield identical instances", async () => { const [{ program, A, B }, diagnostics] = await tester.compileAndDiagnose(t.code` - extern fn processGeneric(T: unknown): unknown; alias ArrayOf = processGeneric(T); @@ -1460,6 +1474,10 @@ describe("assignability of functions to fn types", () => { }); describe("function type assignability", () => { + beforeEach(() => { + tester = BaseTester; + }); + async function diagnoseFunctionAssignment(source: string, target: string) { const diagnostics = await tester.diagnose(` alias Source = ${source}; @@ -1583,12 +1601,13 @@ describe("calling template arguments", () => { it("does not allow calling an unconstrained template parameter", async () => { const diagnostics = await tester.diagnose(` + extern fn f(T: Model): string; model Test { p: string = F(); } `); - expectDiagnostics(diagnostics, { + expectFunctionDiagnostics(diagnostics, { code: "non-callable", message: "Template parameter 'F extends unknown' is not callable. Ensure it is constrained to a function value or callable type (scalar or scalar constructor).", @@ -1597,12 +1616,13 @@ describe("calling template arguments", () => { it("does not allow calling a template paremeter constrained to a type that is possibly not a function", async () => { const diagnostics = await tester.diagnose(` + extern fn f(T: Model): string; model Test valueof string> { p: string = F(); } `); - expectDiagnostics(diagnostics, { + expectFunctionDiagnostics(diagnostics, { code: "non-callable", message: "Template parameter 'F extends Model | valueof fn () => valueof string' is not callable. Ensure it is constrained to a function value or callable type (scalar or scalar constructor).", diff --git a/packages/compiler/test/semantic-walker.test.ts b/packages/compiler/test/semantic-walker.test.ts index 50176edf7fe..6b866db16e9 100644 --- a/packages/compiler/test/semantic-walker.test.ts +++ b/packages/compiler/test/semantic-walker.test.ts @@ -145,7 +145,7 @@ describe("compiler: semantic walker", () => { customListener?: SemanticNodeListener, options?: NavigationOptions, ) { - const { program } = await NavigatorTester.compile(typespec, { + const [{ program }] = await NavigatorTester.compileAndDiagnose(typespec, { compilerOptions: { nostdlib: true }, }); From 8673afdb6827b344b1f85d5c9cc5006f8fe052af Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 22 May 2026 13:12:02 -0400 Subject: [PATCH 2/3] fix tests --- .../compiler/test/checker/functions.test.ts | 340 +++++++++++------- 1 file changed, 203 insertions(+), 137 deletions(-) diff --git a/packages/compiler/test/checker/functions.test.ts b/packages/compiler/test/checker/functions.test.ts index c20f88c6d51..fee33f9343b 100644 --- a/packages/compiler/test/checker/functions.test.ts +++ b/packages/compiler/test/checker/functions.test.ts @@ -43,6 +43,20 @@ function expectFunctionDiagnosticsEmpty(diagnostics: readonly Diagnostic[]) { let tester: Tester = BaseTester; +function createTesterForFn(fnName: string, impl: (...args: any[]) => any) { + return BaseTester.files({ + [`${fnName}.js`]: mockFile.js({ + $functions: { + "": { + [fnName]: impl, + }, + }, + }), + }) + .import(`./${fnName}.js`) + .using("TypeSpec.Reflection"); +} + describe("declaration", () => { let testImpl: any; let nsFnImpl: any; @@ -70,6 +84,7 @@ describe("declaration", () => { it("defined at root via direct export", async () => { const [{ program }, diagnostics] = await tester.compileAndDiagnose(` extern fn testFn(); + namespace Foo.Bar { extern fn nsFn(); } `); expectFunctionDiagnosticsEmpty(diagnostics); @@ -79,6 +94,7 @@ describe("declaration", () => { it("in namespace via $functions map", async () => { const [{ program }, diagnostics] = await tester.compileAndDiagnose(` + extern fn testFn(); namespace Foo.Bar { extern fn nsFn(); } `); expectFunctionDiagnosticsEmpty(diagnostics); @@ -89,7 +105,10 @@ describe("declaration", () => { }); it("errors if function is missing extern modifier", async () => { - const diagnostics = await tester.diagnose(`fn testFn();`); + const diagnostics = await tester.diagnose(` + namespace Foo.Bar { extern fn nsFn(); } + fn testFn(); + `); expectFunctionDiagnostics(diagnostics, { code: "invalid-modifier", message: "Declaration of type 'function' is missing required modifier 'extern'.", @@ -97,7 +116,11 @@ describe("declaration", () => { }); it("errors if extern function is missing implementation", async () => { - const diagnostics = await tester.diagnose(`extern fn missing();`); + const diagnostics = await tester.diagnose(` + extern fn testFn(); + namespace Foo.Bar { extern fn nsFn(); } + extern fn missing(); + `); expectFunctionDiagnostics(diagnostics, { code: "missing-implementation", message: "Extern declaration must have an implementation in JS file.", @@ -105,7 +128,11 @@ describe("declaration", () => { }); it("errors if rest parameter type is not array", async () => { - const diagnostics = await tester.diagnose(`extern fn f(...rest: string);`); + const diagnostics = await tester.diagnose(` + extern fn testFn(); + namespace Foo.Bar { extern fn nsFn(); } + extern fn f(...rest: string); + `); expectFunctionDiagnostics(diagnostics, [ { code: "missing-implementation", @@ -135,35 +162,29 @@ describe("declaration", () => { describe("usage", () => { let calledArgs: any[] | undefined; + beforeEach(() => { calledArgs = undefined; - - tester = BaseTester.files({ - "test.js": mockFile.js({ - $functions: { - "": { - testFn(ctx: FunctionContext, a: any, b: any, ...rest: any[]) { - calledArgs = [ctx, a, b, ...rest]; - return a; // Return first arg - }, - sum(_ctx: FunctionContext, ...addends: number[]) { - return addends.reduce((a, b) => a + b, 0); - }, - valFirst(_ctx: FunctionContext, v: any) { - return v; - }, - voidFn(ctx: FunctionContext, arg: any) { - calledArgs = [ctx, arg]; - // No return value - }, - }, - }, - }), - }) - .import("./test.js") - .using("TypeSpec.Reflection"); }); + function testFnImpl(ctx: FunctionContext, a: any, b: any, ...rest: any[]) { + calledArgs = [ctx, a, b, ...rest]; + return a; // Return first arg + } + + function sumImpl(_ctx: FunctionContext, ...addends: number[]) { + return addends.reduce((a, b) => a + b, 0); + } + + function valFirstImpl(_ctx: FunctionContext, v: any) { + return v; + } + + function voidFnImpl(ctx: FunctionContext, arg: any) { + calledArgs = [ctx, arg]; + // No return value + } + function expectNotCalled() { ok(calledArgs === undefined, "Expected function not to be called."); } @@ -176,12 +197,30 @@ describe("usage", () => { } } + function getTesterForSignature(signature: string): Tester { + const match = signature.match(/extern fn (\w+)/); + const fnName = match?.[1]; + switch (fnName) { + case "testFn": + return createTesterForFn("testFn", testFnImpl); + case "sum": + return createTesterForFn("sum", sumImpl); + case "valFirst": + return createTesterForFn("valFirst", valFirstImpl); + case "voidFn": + return createTesterForFn("voidFn", voidFnImpl); + default: + throw new Error(`Unknown function in signature: ${signature}`); + } + } + async function expectFunctionTypeUsage( signature: string, call: string, match: DiagnosticMatch[] = [], ): Promise { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const fnTester = getTesterForSignature(signature); + const [{ p }, diagnostics] = await fnTester.compileAndDiagnose(t.code` ${signature}; model Observer { @@ -198,7 +237,8 @@ describe("usage", () => { call: string, match: DiagnosticMatch[] = [], ): Promise { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const fnTester = getTesterForSignature(signature); + const [{ p }, diagnostics] = await fnTester.compileAndDiagnose(t.code` ${signature}; model Observer { @@ -212,9 +252,12 @@ describe("usage", () => { } it("errors if function not declared", async () => { - const diagnostics = await tester.diagnose(`const X = missing();`); - const filtered = diagnostics.filter((d) => d.code !== "missing-extern-declaration"); - expectDiagnostics(filtered, { + const fnTester = createTesterForFn("testFn", testFnImpl); + const diagnostics = await fnTester.diagnose(` + extern fn testFn(...args: unknown[]): unknown; + const X = missing(); + `); + expectFunctionDiagnostics(diagnostics, { code: "invalid-ref", message: "Unknown identifier missing", }); @@ -403,7 +446,8 @@ describe("usage", () => { }); it("accepts valueof model argument", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const fnTester = createTesterForFn("testFn", testFnImpl); + const [{ p }, diagnostics] = await fnTester.compileAndDiagnose(t.code` model M { x: string } extern fn testFn(m: valueof M): valueof M; @@ -427,7 +471,8 @@ describe("usage", () => { }); it("does not accept invalid valueof model argument", async () => { - const diagnostics = await tester.diagnose(` + const fnTester = createTesterForFn("testFn", testFnImpl); + const diagnostics = await fnTester.diagnose(` model M { x: string } extern fn testFn(m: valueof M): valueof M; @@ -498,7 +543,8 @@ describe("usage", () => { }); it("accepts literal types where parameter is a rest array of a literal union", async () => { - const diagnostics = await tester.diagnose(` + const fnTester = createTesterForFn("testFn", testFnImpl); + const diagnostics = await fnTester.diagnose(` alias U = "a" | 10 | true; extern fn testFn(...args: U[]): "a" | 10 | true; @@ -527,7 +573,8 @@ describe("usage", () => { }); it("accepts enum member where parameter is enum", async () => { - const diagnostics = await tester.diagnose(` + const fnTester = createTesterForFn("testFn", testFnImpl); + const diagnostics = await fnTester.diagnose(` enum E { A, B } extern fn testFn(e: E): E; @@ -543,7 +590,8 @@ describe("usage", () => { }); it("accepts enum value where parameter is valueof enum", async () => { - const [{ E, p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const fnTester = createTesterForFn("testFn", testFnImpl); + const [{ E, p }, diagnostics] = await fnTester.compileAndDiagnose(t.code` enum ${t.enum("E")} { A, B } extern fn testFn(e: valueof E): valueof E; @@ -570,7 +618,8 @@ describe("usage", () => { }); it("calls function bound to const", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const fnTester = createTesterForFn("sum", sumImpl); + const [{ p }, diagnostics] = await fnTester.compileAndDiagnose(t.code` extern fn sum(...addends: valueof int32[]): valueof int32; const f = sum; @@ -742,41 +791,45 @@ describe("value marshalling", () => { beforeEach(() => { receivedValue = undefined; - tester = BaseTester.files({ - "test.js": mockFile.js({ - $functions: { - "": { - expect(_ctx: FunctionContext, arg: any) { - receivedValue = arg; - return arg; - }, - returnInvalidJsValue(_ctx: FunctionContext) { - return Symbol("invalid"); - }, - returnComplexObject(_ctx: FunctionContext) { - return { - nested: { value: 42 }, - array: [1, "test", true], - null: null, - }; - }, - returnIndeterminate(ctx: FunctionContext): IndeterminateEntity { - return { entityKind: "Indeterminate", type: $(ctx.program).literal.create(42) }; - }, - }, - }, - }), - }) - .import("./test.js") - .using("TypeSpec.Reflection"); }); + function expectImpl(_ctx: FunctionContext, arg: any) { + receivedValue = arg; + return arg; + } + + function returnInvalidJsValueImpl(_ctx: FunctionContext) { + return Symbol("invalid"); + } + + function returnComplexObjectImpl(_ctx: FunctionContext) { + return { + nested: { value: 42 }, + array: [1, "test", true], + null: null, + }; + } + + function returnIndeterminateImpl(ctx: FunctionContext): IndeterminateEntity { + return { entityKind: "Indeterminate", type: $(ctx.program).literal.create(42) }; + } + async function expectValueUsage( signature: string, argument: string, match: DiagnosticMatch[] = [], ): Promise { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const fnName = signature.match(/extern fn (\w+)/)![1]; + const impl = + fnName === "expect" + ? expectImpl + : fnName === "returnInvalidJsValue" + ? returnInvalidJsValueImpl + : fnName === "returnComplexObject" + ? returnComplexObjectImpl + : returnIndeterminateImpl; + const fnTester = createTesterForFn(fnName, impl); + const [{ p }, diagnostics] = await fnTester.compileAndDiagnose(t.code` ${signature}; model Observer { @@ -919,7 +972,19 @@ describe("value marshalling", () => { }); it("handles indeterminate entities coerced to values", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const indeterminateTester = BaseTester.files({ + "indeterminate.js": mockFile.js({ + $functions: { + "": { + returnIndeterminate: returnIndeterminateImpl, + expect: expectImpl, + }, + }, + }), + }) + .import("./indeterminate.js") + .using("TypeSpec.Reflection"); + const [{ p }, diagnostics] = await indeterminateTester.compileAndDiagnose(t.code` extern fn returnIndeterminate(): valueof int32; extern fn expect(n: valueof int32): valueof int32; const X = expect(returnIndeterminate()); @@ -937,7 +1002,8 @@ describe("value marshalling", () => { }); it("handles indeterminate entities coerced to types", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const fnTester = createTesterForFn("returnIndeterminate", returnIndeterminateImpl); + const [{ p }, diagnostics] = await fnTester.compileAndDiagnose(t.code` extern fn returnIndeterminate(): int32; alias X = returnIndeterminate(); @@ -957,35 +1023,27 @@ describe("value marshalling", () => { describe("union type constraints", () => { let receivedArg: any; + function acceptImpl(_ctx: FunctionContext, arg: any) { + receivedArg = arg; + return arg; + } + + function returnTypeOrValueImpl(ctx: FunctionContext, returnType: boolean) { + receivedArg = returnType; + if (returnType) { + return ctx.program.checker.getStdType("string"); + } else { + return "hello"; + } + } + beforeEach(() => { receivedArg = undefined; - - tester = BaseTester.files({ - "test.js": mockFile.js({ - $functions: { - "": { - accept(_ctx: FunctionContext, arg: any) { - receivedArg = arg; - return arg; - }, - returnTypeOrValue(ctx: FunctionContext, returnType: boolean) { - receivedArg = returnType; - if (returnType) { - return ctx.program.checker.getStdType("string"); - } else { - return "hello"; - } - }, - }, - }, - }), - }) - .import("./test.js") - .using("TypeSpec.Reflection"); }); it("accepts type parameter", async () => { - const diagnostics = await tester.diagnose(` + const fnTester = createTesterForFn("accept", acceptImpl); + const diagnostics = await fnTester.diagnose(` extern fn accept(arg: unknown | valueof unknown): unknown; alias TypeResult = accept(string); @@ -999,7 +1057,8 @@ describe("union type constraints", () => { }); it("prefers value when applicable", async () => { - const diagnostics = await tester.diagnose(` + const fnTester = createTesterForFn("accept", acceptImpl); + const diagnostics = await fnTester.diagnose(` extern fn accept(arg: string | valueof string): valueof string; const ValueResult = accept("hello"); @@ -1011,7 +1070,8 @@ describe("union type constraints", () => { }); it("accepts multiple specific types", async () => { - const diagnostics = await tester.diagnose(` + const fnTester = createTesterForFn("accept", acceptImpl); + const diagnostics = await fnTester.diagnose(` extern fn accept(arg: Reflection.Model | Reflection.Enum): Reflection.Model | Reflection.Enum; model TestModel {} @@ -1025,7 +1085,8 @@ describe("union type constraints", () => { }); it("accepts multiple value types", async () => { - const diagnostics = await tester.diagnose(` + const fnTester = createTesterForFn("accept", acceptImpl); + const diagnostics = await fnTester.diagnose(` extern fn accept(arg: valueof (string | int32)): valueof (string | int32); const StringResult = accept("test"); @@ -1036,7 +1097,8 @@ describe("union type constraints", () => { }); it("errors when argument doesn't match union constraint", async () => { - const diagnostics = await tester.diagnose(` + const fnTester = createTesterForFn("accept", acceptImpl); + const diagnostics = await fnTester.diagnose(` extern fn accept(arg: Reflection.Model | Reflection.Enum): Reflection.Model | Reflection.Enum; scalar TestScalar extends string; @@ -1052,7 +1114,8 @@ describe("union type constraints", () => { }); it("can return type from function", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const fnTester = createTesterForFn("returnTypeOrValue", returnTypeOrValueImpl); + const [{ p }, diagnostics] = await fnTester.compileAndDiagnose(t.code` extern fn returnTypeOrValue(returnType: valueof boolean): unknown; model Observer { @@ -1069,7 +1132,8 @@ describe("union type constraints", () => { }); it("can return value from function", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const fnTester = createTesterForFn("returnTypeOrValue", returnTypeOrValueImpl); + const [{ p }, diagnostics] = await fnTester.compileAndDiagnose(t.code` extern fn returnTypeOrValue(returnType: valueof boolean): valueof string; const ValueResult = returnTypeOrValue(false); @@ -1090,40 +1154,29 @@ describe("union type constraints", () => { }); describe("error cases and edge cases", () => { - beforeEach(() => { - tester = BaseTester.files({ - "test.js": mockFile.js({ - $functions: { - "": { - testFn() {}, - returnWrongEntityKind(_ctx: FunctionContext) { - return "string value"; // Returns value when type expected - }, - returnWrongValueType(_ctx: FunctionContext) { - return 42; // Returns number when string expected - }, - throwError(_ctx: FunctionContext) { - throw new Error("JS error"); - }, - returnUndefined(_ctx: FunctionContext) { - return undefined; - }, - returnNull(_ctx: FunctionContext) { - return null; - }, - expectNonOptionalAfterOptional(_ctx: FunctionContext, _opt: any, req: any) { - return req; - }, - }, - }, - }), - }) - .import("./test.js") - .using("TypeSpec.Reflection"); - }); + const testFnImpl = () => {}; + const returnWrongEntityKindImpl = (_ctx: FunctionContext) => { + return "string value"; // Returns value when type expected + }; + const returnWrongValueTypeImpl = (_ctx: FunctionContext) => { + return 42; // Returns number when string expected + }; + const throwErrorImpl = (_ctx: FunctionContext) => { + throw new Error("JS error"); + }; + const returnUndefinedImpl = (_ctx: FunctionContext) => { + return undefined; + }; + const returnNullImpl = (_ctx: FunctionContext) => { + return null; + }; + const expectNonOptionalAfterOptionalImpl = (_ctx: FunctionContext, _opt: any, req: any) => { + return req; + }; it("errors when function returns wrong entity kind", async () => { - const diagnostics = await tester.diagnose(` + const fnTester = createTesterForFn("returnWrongEntityKind", returnWrongEntityKindImpl); + const diagnostics = await fnTester.diagnose(` extern fn returnWrongEntityKind(): unknown; alias X = returnWrongEntityKind(); `); @@ -1136,7 +1189,8 @@ describe("error cases and edge cases", () => { }); it("errors when function returns wrong value type", async () => { - const diagnostics = await tester.diagnose(` + const fnTester = createTesterForFn("returnWrongValueType", returnWrongValueTypeImpl); + const diagnostics = await fnTester.diagnose(` extern fn returnWrongValueType(): valueof string; const X = returnWrongValueType(); `); @@ -1149,8 +1203,9 @@ describe("error cases and edge cases", () => { }); it("thrown JS error bubbles up as ICE", async () => { + const fnTester = createTesterForFn("throwError", throwErrorImpl); try { - await tester.diagnose(` + await fnTester.diagnose(` extern fn throwError(): unknown; alias X = throwError(); `); @@ -1163,7 +1218,8 @@ describe("error cases and edge cases", () => { }); it("returns null for undefined return in value position", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const fnTester = createTesterForFn("returnUndefined", returnUndefinedImpl); + const [{ p }, diagnostics] = await fnTester.compileAndDiagnose(t.code` extern fn returnUndefined(): valueof unknown; model Observer { @@ -1178,7 +1234,8 @@ describe("error cases and edge cases", () => { }); it("handles null return value", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const fnTester = createTesterForFn("returnNull", returnNullImpl); + const [{ p }, diagnostics] = await fnTester.compileAndDiagnose(t.code` extern fn returnNull(): valueof unknown; const X = returnNull(); @@ -1194,7 +1251,11 @@ describe("error cases and edge cases", () => { }); it("validates required parameter after optional not allowed in regular param position", async () => { - const diagnostics = await tester.diagnose(` + const fnTester = createTesterForFn( + "expectNonOptionalAfterOptional", + expectNonOptionalAfterOptionalImpl, + ); + const diagnostics = await fnTester.diagnose(` extern fn expectNonOptionalAfterOptional(opt?: valueof string, req: valueof string): valueof string; const X = expectNonOptionalAfterOptional("test"); `); @@ -1206,7 +1267,8 @@ describe("error cases and edge cases", () => { }); it("cannot be used as a type", async () => { - const diagnostics = await tester.diagnose(` + const fnTester = createTesterForFn("testFn", testFnImpl); + const diagnostics = await fnTester.diagnose(` extern fn testFn(): unknown; model M { @@ -1222,6 +1284,10 @@ describe("error cases and edge cases", () => { }); describe("default function results", () => { + beforeEach(() => { + tester = BaseTester; + }); + it("collapses to undefined for missing value-returning function", async () => { const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` extern fn missingValueFn(): valueof string; From 87bee3c4f113aa5cf0f8358deaa3adb57d0d40c8 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 22 May 2026 13:43:27 -0400 Subject: [PATCH 3/3] doc --- ...ix-missing-extern-declaration-2026-5-22.md | 8 +++++ packages/compiler/src/core/messages.ts | 3 ++ .../diags/missing-extern-declaration.md | 29 +++++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 .chronus/changes/fix-missing-extern-declaration-2026-5-22.md create mode 100644 website/src/content/docs/docs/standard-library/diags/missing-extern-declaration.md diff --git a/.chronus/changes/fix-missing-extern-declaration-2026-5-22.md b/.chronus/changes/fix-missing-extern-declaration-2026-5-22.md new file mode 100644 index 00000000000..4fa86993382 --- /dev/null +++ b/.chronus/changes/fix-missing-extern-declaration-2026-5-22.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Report an error when a function is declared in the `$functions` map in a JS file but has no corresponding `extern fn` declaration in TypeSpec. Previously this would silently have no effect. diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index e3d9fe449bf..bffd745e44c 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -590,6 +590,9 @@ const diagnostics = { }, "missing-extern-declaration": { severity: "error", + description: + "Report when a function is registered in $functions in a JS file but has no corresponding `extern fn` declaration in TypeSpec.", + url: "https://typespec.io/docs/standard-library/diags/missing-extern-declaration", messages: { default: paramMessage`Function implementation "${"name"}" is exported in JS via $functions but has no corresponding 'extern fn' declaration in TypeSpec.`, }, diff --git a/website/src/content/docs/docs/standard-library/diags/missing-extern-declaration.md b/website/src/content/docs/docs/standard-library/diags/missing-extern-declaration.md new file mode 100644 index 00000000000..43c30af3fdb --- /dev/null +++ b/website/src/content/docs/docs/standard-library/diags/missing-extern-declaration.md @@ -0,0 +1,29 @@ +--- +title: missing-extern-declaration +--- + +A function implementation is exported via `$functions` in a JS file but has no corresponding `extern fn` declaration in TypeSpec. Without the declaration, the function is silently ignored at runtime. + +#### ❌ Incorrect + +```js +// my-lib.js +export const $functions = { myFunction: (...args) => {} }; +``` + +```tsp +// my-lib.tsp +// Missing: extern fn myFunction(): void; +``` + +#### ✅ Correct + +```js +// my-lib.js +export const $functions = { myFunction: (...args) => {} }; +``` + +```tsp +// my-lib.tsp +extern fn myFunction(): void; +```