diff --git a/.chronus/changes/copilot-add-error-check-function-implementation-2026-5-21.md b/.chronus/changes/copilot-add-error-check-function-implementation-2026-5-21.md new file mode 100644 index 00000000000..99edd5f2765 --- /dev/null +++ b/.chronus/changes/copilot-add-error-check-function-implementation-2026-5-21.md @@ -0,0 +1,7 @@ +--- +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/checker.ts b/packages/compiler/src/core/checker.ts index cd23221098d..e44e3079879 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -4848,6 +4848,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkSourceFile(file); } + checkJsImplementations(); internalDecoratorValidation(); assertNoPendingResolutions(); runPostValidators(postCheckValidators); @@ -4924,6 +4925,41 @@ export function createChecker(program: Program, resolver: NameResolver): Checker validateInheritanceDiscriminatedUnions(program); } + /** + * Validate that every function implementation in a JS `$functions` map has a corresponding + * `extern fn` declaration in TypeSpec. Without a declaration the function implementation is + * silently ignored. Report an error to help the user diagnose the problem. + */ + function checkJsImplementations() { + for (const jsFile of program.jsSourceFiles.values()) { + checkJsSymbolTableForUnboundFunctions(jsFile.symbol.exports!); + } + } + + function checkJsSymbolTableForUnboundFunctions(table: SymbolTable) { + for (const sym of table.values()) { + if (sym.flags & SymbolFlags.Namespace) { + if (sym.exports) { + checkJsSymbolTableForUnboundFunctions(sym.exports); + } + } else if (sym.flags & SymbolFlags.Function && sym.flags & SymbolFlags.Implementation) { + const mergedSym = getMergedSymbol(sym); + const hasTypeSpecDeclaration = mergedSym.declarations.some( + (decl) => decl.kind === SyntaxKind.FunctionDeclarationStatement, + ); + if (!hasTypeSpecDeclaration) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "implementation-without-extern", + format: { name: sym.name }, + target: sym.declarations[0], + }), + ); + } + } + } + } + function checkSourceFile(file: TypeSpecScriptNode) { for (const statement of file.statements) { checkNode(CheckContext.DEFAULT, statement, undefined); diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 457ae774915..a55f1180668 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -588,6 +588,15 @@ const diagnostics = { default: "Extern declaration must have an implementation in JS file.", }, }, + "implementation-without-extern": { + 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/implementation-without-extern", + messages: { + default: paramMessage`Function "${"name"}" is declared in \`$functions\` but does not have a corresponding \`extern fn\` declaration in TypeSpec. Add an \`extern fn\` declaration.`, + }, + }, "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..1cd87c399dc 100644 --- a/packages/compiler/test/checker/functions.test.ts +++ b/packages/compiler/test/checker/functions.test.ts @@ -46,16 +46,27 @@ let tester: Tester = BaseTester; describe("declaration", () => { let testImpl: any; let nsFnImpl: any; + let testFnTester: Tester; + let nsFnTester: Tester; beforeEach(() => { testImpl = (_ctx: FunctionContext) => undefined; nsFnImpl = (_ctx: FunctionContext) => undefined; - tester = BaseTester.files({ + testFnTester = BaseTester.files({ "test.js": mockFile.js({ $functions: { "": { testFn: testImpl, }, + }, + }), + }) + .import("./test.js") + .using("TypeSpec.Reflection"); + + nsFnTester = BaseTester.files({ + "test.js": mockFile.js({ + $functions: { "Foo.Bar": { nsFn: nsFnImpl, }, @@ -68,7 +79,7 @@ describe("declaration", () => { describe("bind implementation to declaration", () => { it("defined at root via direct export", async () => { - const [{ program }, diagnostics] = await tester.compileAndDiagnose(` + const [{ program }, diagnostics] = await testFnTester.compileAndDiagnose(` extern fn testFn(); `); @@ -78,7 +89,7 @@ describe("declaration", () => { }); it("in namespace via $functions map", async () => { - const [{ program }, diagnostics] = await tester.compileAndDiagnose(` + const [{ program }, diagnostics] = await nsFnTester.compileAndDiagnose(` namespace Foo.Bar { extern fn nsFn(); } `); expectFunctionDiagnosticsEmpty(diagnostics); @@ -89,7 +100,8 @@ describe("declaration", () => { }); it("errors if function is missing extern modifier", async () => { - const diagnostics = await tester.diagnose(`fn testFn();`); + // fn testFn() (no extern) creates a declaration node that binds the impl, so testFn is not orphaned + const diagnostics = await testFnTester.diagnose(`fn testFn();`); expectFunctionDiagnostics(diagnostics, { code: "invalid-modifier", message: "Declaration of type 'function' is missing required modifier 'extern'.", @@ -97,7 +109,9 @@ describe("declaration", () => { }); it("errors if extern function is missing implementation", async () => { - const diagnostics = await tester.diagnose(`extern fn missing();`); + // Use a clean tester with no $functions — we just need a JS import context + const cleanTester = BaseTester.using("TypeSpec.Reflection"); + const diagnostics = await cleanTester.diagnose(`extern fn missing();`); expectFunctionDiagnostics(diagnostics, { code: "missing-implementation", message: "Extern declaration must have an implementation in JS file.", @@ -105,7 +119,8 @@ describe("declaration", () => { }); it("errors if rest parameter type is not array", async () => { - const diagnostics = await tester.diagnose(`extern fn f(...rest: string);`); + const cleanTester = BaseTester.using("TypeSpec.Reflection"); + const diagnostics = await cleanTester.diagnose(`extern fn f(...rest: string);`); expectFunctionDiagnostics(diagnostics, [ { code: "missing-implementation", @@ -117,38 +132,136 @@ describe("declaration", () => { }, ]); }); + + describe("implementation without extern declaration", () => { + it("errors if function in $functions has no corresponding extern fn declaration", async () => { + const localTester = BaseTester.files({ + "test.js": mockFile.js({ + $functions: { + "": { + orphanedFn: (_ctx: FunctionContext) => undefined, + }, + }, + }), + }) + .import("./test.js") + .using("TypeSpec.Reflection"); + + const diagnostics = await localTester.diagnose(`alias X = string;`); + + expectDiagnostics( + diagnostics.filter((d) => d.code !== "experimental-feature"), + { + code: "implementation-without-extern", + message: `Function "orphanedFn" is declared in \`$functions\` but does not have a corresponding \`extern fn\` declaration in TypeSpec. Add an \`extern fn\` declaration.`, + }, + ); + }); + + it("errors if function in namespaced $functions has no corresponding extern fn declaration", async () => { + const localTester = BaseTester.files({ + "test.js": mockFile.js({ + $functions: { + MyLib: { + orphanedFn: (_ctx: FunctionContext) => undefined, + }, + }, + }), + }) + .import("./test.js") + .using("TypeSpec.Reflection"); + + const diagnostics = await localTester.diagnose(`alias X = string;`); + + expectDiagnostics( + diagnostics.filter((d) => d.code !== "experimental-feature"), + { + code: "implementation-without-extern", + message: `Function "orphanedFn" is declared in \`$functions\` but does not have a corresponding \`extern fn\` declaration in TypeSpec. Add an \`extern fn\` declaration.`, + }, + ); + }); + + it("no error when $functions implementation has a corresponding extern fn declaration", async () => { + const localTester = BaseTester.files({ + "test.js": mockFile.js({ + $functions: { + "": { + boundFn: (_ctx: FunctionContext) => undefined, + }, + }, + }), + }) + .import("./test.js") + .using("TypeSpec.Reflection"); + + const diagnostics = await localTester.diagnose(`extern fn boundFn(): unknown;`); + + expectFunctionDiagnosticsEmpty(diagnostics); + }); + + it("no error when namespaced $functions implementation has a corresponding extern fn declaration", async () => { + const localTester = BaseTester.files({ + "test.js": mockFile.js({ + $functions: { + MyLib: { + boundFn: (_ctx: FunctionContext) => undefined, + }, + }, + }), + }) + .import("./test.js") + .using("TypeSpec.Reflection"); + + const diagnostics = await localTester.diagnose( + `namespace MyLib { extern fn boundFn(): unknown; }`, + ); + + expectFunctionDiagnosticsEmpty(diagnostics); + }); + }); }); describe("usage", () => { let calledArgs: any[] | undefined; beforeEach(() => { calledArgs = undefined; + }); - tester = BaseTester.files({ + // Individual function implementations — each closes over `calledArgs` + function testFnImpl(ctx: FunctionContext, a: any, b: any, ...rest: any[]) { + calledArgs = [ctx, a, b, ...rest]; + return a; + } + 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]; + } + + const usageImpls: Record any> = { + testFn: testFnImpl, + sum: sumImpl, + valFirst: valFirstImpl, + voidFn: voidFnImpl, + }; + + /** Create a tester with only the single named function registered in $functions. */ + function makeUsageTester(fnName: string) { + return 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 - }, - }, + "": { [fnName]: usageImpls[fnName] }, }, }), }) .import("./test.js") .using("TypeSpec.Reflection"); - }); + } function expectNotCalled() { ok(calledArgs === undefined, "Expected function not to be called."); @@ -167,7 +280,9 @@ describe("usage", () => { call: string, match: DiagnosticMatch[] = [], ): Promise { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const fnName = signature.match(/extern fn (\w+)/)![1]; + const localTester = makeUsageTester(fnName); + const [{ p }, diagnostics] = await localTester.compileAndDiagnose(t.code` ${signature}; model Observer { @@ -184,7 +299,9 @@ describe("usage", () => { call: string, match: DiagnosticMatch[] = [], ): Promise { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const fnName = signature.match(/extern fn (\w+)/)![1]; + const localTester = makeUsageTester(fnName); + const [{ p }, diagnostics] = await localTester.compileAndDiagnose(t.code` ${signature}; model Observer { @@ -198,7 +315,10 @@ describe("usage", () => { } it("errors if function not declared", async () => { - const diagnostics = await tester.diagnose(`const X = missing();`); + // No $functions needed — testing that calling an undeclared identifier fails + const diagnostics = await BaseTester.using("TypeSpec.Reflection").diagnose( + `const X = missing();`, + ); expectDiagnostics(diagnostics, { code: "invalid-ref", @@ -389,7 +509,8 @@ describe("usage", () => { }); it("accepts valueof model argument", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const localTester = makeUsageTester("testFn"); + const [{ p }, diagnostics] = await localTester.compileAndDiagnose(t.code` model M { x: string } extern fn testFn(m: valueof M): valueof M; @@ -413,7 +534,8 @@ describe("usage", () => { }); it("does not accept invalid valueof model argument", async () => { - const diagnostics = await tester.diagnose(` + const localTester = makeUsageTester("testFn"); + const diagnostics = await localTester.diagnose(` model M { x: string } extern fn testFn(m: valueof M): valueof M; @@ -484,7 +606,8 @@ describe("usage", () => { }); it("accepts literal types where parameter is a rest array of a literal union", async () => { - const diagnostics = await tester.diagnose(` + const localTester = makeUsageTester("testFn"); + const diagnostics = await localTester.diagnose(` alias U = "a" | 10 | true; extern fn testFn(...args: U[]): "a" | 10 | true; @@ -513,7 +636,8 @@ describe("usage", () => { }); it("accepts enum member where parameter is enum", async () => { - const diagnostics = await tester.diagnose(` + const localTester = makeUsageTester("testFn"); + const diagnostics = await localTester.diagnose(` enum E { A, B } extern fn testFn(e: E): E; @@ -529,7 +653,8 @@ describe("usage", () => { }); it("accepts enum value where parameter is valueof enum", async () => { - const [{ E, p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const localTester = makeUsageTester("testFn"); + const [{ E, p }, diagnostics] = await localTester.compileAndDiagnose(t.code` enum ${t.enum("E")} { A, B } extern fn testFn(e: valueof E): valueof E; @@ -556,7 +681,8 @@ describe("usage", () => { }); it("calls function bound to const", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const localTester = makeUsageTester("sum"); + const [{ p }, diagnostics] = await localTester.compileAndDiagnose(t.code` extern fn sum(...addends: valueof int32[]): valueof int32; const f = sum; @@ -728,41 +854,55 @@ describe("value marshalling", () => { beforeEach(() => { receivedValue = undefined; - tester = BaseTester.files({ + }); + + // Individual implementations — all close over `receivedValue` + function marshalExpectImpl(_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) }; + } + + const marshalImpls: Record any> = { + expect: marshalExpectImpl, + returnInvalidJsValue: returnInvalidJsValueImpl, + returnComplexObject: returnComplexObjectImpl, + returnIndeterminate: returnIndeterminateImpl, + }; + + /** Create a tester with only the single named function registered in $functions. */ + function makeMarshalTester(fnName: string) { + return 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) }; - }, - }, + "": { [fnName]: marshalImpls[fnName] }, }, }), }) .import("./test.js") .using("TypeSpec.Reflection"); - }); + } 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 localTester = makeMarshalTester(fnName); + const [{ p }, diagnostics] = await localTester.compileAndDiagnose(t.code` ${signature}; model Observer { @@ -905,7 +1045,21 @@ describe("value marshalling", () => { }); it("handles indeterminate entities coerced to values", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + // This test needs two functions: returnIndeterminate + expect. Create a two-function tester. + const localTester = BaseTester.files({ + "test.js": mockFile.js({ + $functions: { + "": { + returnIndeterminate: returnIndeterminateImpl, + expect: marshalExpectImpl, + }, + }, + }), + }) + .import("./test.js") + .using("TypeSpec.Reflection"); + + const [{ p }, diagnostics] = await localTester.compileAndDiagnose(t.code` extern fn returnIndeterminate(): valueof int32; extern fn expect(n: valueof int32): valueof int32; const X = expect(returnIndeterminate()); @@ -923,7 +1077,8 @@ describe("value marshalling", () => { }); it("handles indeterminate entities coerced to types", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const localTester = makeMarshalTester("returnIndeterminate"); + const [{ p }, diagnostics] = await localTester.compileAndDiagnose(t.code` extern fn returnIndeterminate(): int32; alias X = returnIndeterminate(); @@ -945,33 +1100,39 @@ describe("union type constraints", () => { 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"; - } - }, - }, - }, - }), + 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"; + } + } + + function makeAcceptTester() { + return BaseTester.files({ + "test.js": mockFile.js({ $functions: { "": { accept: acceptImpl } } }), }) .import("./test.js") .using("TypeSpec.Reflection"); - }); + } + + function makeReturnTypeOrValueTester() { + return BaseTester.files({ + "test.js": mockFile.js({ $functions: { "": { returnTypeOrValue: returnTypeOrValueImpl } } }), + }) + .import("./test.js") + .using("TypeSpec.Reflection"); + } it("accepts type parameter", async () => { - const diagnostics = await tester.diagnose(` + const diagnostics = await makeAcceptTester().diagnose(` extern fn accept(arg: unknown | valueof unknown): unknown; alias TypeResult = accept(string); @@ -985,7 +1146,7 @@ describe("union type constraints", () => { }); it("prefers value when applicable", async () => { - const diagnostics = await tester.diagnose(` + const diagnostics = await makeAcceptTester().diagnose(` extern fn accept(arg: string | valueof string): valueof string; const ValueResult = accept("hello"); @@ -997,7 +1158,7 @@ describe("union type constraints", () => { }); it("accepts multiple specific types", async () => { - const diagnostics = await tester.diagnose(` + const diagnostics = await makeAcceptTester().diagnose(` extern fn accept(arg: Reflection.Model | Reflection.Enum): Reflection.Model | Reflection.Enum; model TestModel {} @@ -1011,7 +1172,7 @@ describe("union type constraints", () => { }); it("accepts multiple value types", async () => { - const diagnostics = await tester.diagnose(` + const diagnostics = await makeAcceptTester().diagnose(` extern fn accept(arg: valueof (string | int32)): valueof (string | int32); const StringResult = accept("test"); @@ -1022,7 +1183,7 @@ describe("union type constraints", () => { }); it("errors when argument doesn't match union constraint", async () => { - const diagnostics = await tester.diagnose(` + const diagnostics = await makeAcceptTester().diagnose(` extern fn accept(arg: Reflection.Model | Reflection.Enum): Reflection.Model | Reflection.Enum; scalar TestScalar extends string; @@ -1038,7 +1199,7 @@ describe("union type constraints", () => { }); it("can return type from function", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const [{ p }, diagnostics] = await makeReturnTypeOrValueTester().compileAndDiagnose(t.code` extern fn returnTypeOrValue(returnType: valueof boolean): unknown; model Observer { @@ -1055,7 +1216,7 @@ describe("union type constraints", () => { }); it("can return value from function", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const [{ p }, diagnostics] = await makeReturnTypeOrValueTester().compileAndDiagnose(t.code` extern fn returnTypeOrValue(returnType: valueof boolean): valueof string; const ValueResult = returnTypeOrValue(false); @@ -1076,40 +1237,19 @@ 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; - }, - }, - }, - }), + function makeErrorCaseTester(name: string, impl: (...args: any[]) => any) { + return BaseTester.files({ + "test.js": mockFile.js({ $functions: { "": { [name]: impl } } }), }) .import("./test.js") .using("TypeSpec.Reflection"); - }); + } it("errors when function returns wrong entity kind", async () => { - const diagnostics = await tester.diagnose(` + const localTester = makeErrorCaseTester("returnWrongEntityKind", (_ctx: FunctionContext) => { + return "string value"; // Returns value when type expected + }); + const diagnostics = await localTester.diagnose(` extern fn returnWrongEntityKind(): unknown; alias X = returnWrongEntityKind(); `); @@ -1122,7 +1262,10 @@ describe("error cases and edge cases", () => { }); it("errors when function returns wrong value type", async () => { - const diagnostics = await tester.diagnose(` + const localTester = makeErrorCaseTester("returnWrongValueType", (_ctx: FunctionContext) => { + return 42; // Returns number when string expected + }); + const diagnostics = await localTester.diagnose(` extern fn returnWrongValueType(): valueof string; const X = returnWrongValueType(); `); @@ -1135,8 +1278,11 @@ describe("error cases and edge cases", () => { }); it("thrown JS error bubbles up as ICE", async () => { + const localTester = makeErrorCaseTester("throwError", (_ctx: FunctionContext) => { + throw new Error("JS error"); + }); try { - await tester.diagnose(` + await localTester.diagnose(` extern fn throwError(): unknown; alias X = throwError(); `); @@ -1149,7 +1295,10 @@ 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 localTester = makeErrorCaseTester("returnUndefined", (_ctx: FunctionContext) => { + return undefined; + }); + const [{ p }, diagnostics] = await localTester.compileAndDiagnose(t.code` extern fn returnUndefined(): valueof unknown; model Observer { @@ -1164,7 +1313,10 @@ describe("error cases and edge cases", () => { }); it("handles null return value", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const localTester = makeErrorCaseTester("returnNull", (_ctx: FunctionContext) => { + return null; + }); + const [{ p }, diagnostics] = await localTester.compileAndDiagnose(t.code` extern fn returnNull(): valueof unknown; const X = returnNull(); @@ -1180,7 +1332,13 @@ 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 localTester = makeErrorCaseTester( + "expectNonOptionalAfterOptional", + (_ctx: FunctionContext, _opt: any, req: any) => { + return req; + }, + ); + const diagnostics = await localTester.diagnose(` extern fn expectNonOptionalAfterOptional(opt?: valueof string, req: valueof string): valueof string; const X = expectNonOptionalAfterOptional("test"); `); @@ -1192,7 +1350,8 @@ describe("error cases and edge cases", () => { }); it("cannot be used as a type", async () => { - const diagnostics = await tester.diagnose(` + const localTester = makeErrorCaseTester("testFn", () => {}); + const diagnostics = await localTester.diagnose(` extern fn testFn(): unknown; model M { @@ -1209,14 +1368,16 @@ describe("error cases and edge cases", () => { describe("default function results", () => { it("collapses to undefined for missing value-returning function", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const [{ p }, diagnostics] = await BaseTester.using("TypeSpec.Reflection").compileAndDiagnose( + t.code` extern fn missingValueFn(): valueof string; const X = missingValueFn(); model Observer { ${t.modelProperty("p")}: string = X; } - `); + `, + ); expectFunctionDiagnostics(diagnostics, { code: "missing-implementation", @@ -1226,7 +1387,8 @@ describe("default function results", () => { }); it("returns default type for missing type-returning function", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const [{ p }, diagnostics] = await BaseTester.using("TypeSpec.Reflection").compileAndDiagnose( + t.code` extern fn missingTypeFn(): unknown; alias X = missingTypeFn(); @@ -1244,7 +1406,8 @@ describe("default function results", () => { }); it("returns appropriate default for union return type", async () => { - const [{ p }, diagnostics] = await tester.compileAndDiagnose(t.code` + const [{ p }, diagnostics] = await BaseTester.using("TypeSpec.Reflection").compileAndDiagnose( + t.code` extern fn missingUnionFn(): unknown | valueof string; const X = missingUnionFn(); @@ -1253,7 +1416,8 @@ describe("default function results", () => { model Observer { ${t.modelProperty("p")}: T = X; } - `); + `, + ); expectFunctionDiagnostics(diagnostics, { code: "missing-implementation", @@ -1267,14 +1431,27 @@ describe("default function results", () => { }); describe("template and generic scenarios", () => { - beforeEach(() => { - tester = BaseTester.files({ + function makeProcessGenericTester() { + return BaseTester.files({ "templates.js": mockFile.js({ $functions: { "": { processGeneric(ctx: FunctionContext, type: Type) { return $(ctx.program).array.create(type); }, + }, + }, + }), + }) + .import("./templates.js") + .using("TypeSpec.Reflection"); + } + + function makeProcessConstrainedGenericTester() { + return BaseTester.files({ + "templates.js": mockFile.js({ + $functions: { + "": { processConstrainedGeneric(_ctx: FunctionContext, type: Type) { return type; }, @@ -1284,10 +1461,11 @@ describe("template and generic scenarios", () => { }) .import("./templates.js") .using("TypeSpec.Reflection"); - }); + } it("works with template aliases", async () => { - const [{ program, prop }, diagnostics] = await tester.compileAndDiagnose(t.code` + const [{ program, prop }, diagnostics] = await makeProcessGenericTester().compileAndDiagnose( + t.code` extern fn processGeneric(T: unknown): unknown; alias ArrayOf = processGeneric(T); @@ -1295,7 +1473,8 @@ describe("template and generic scenarios", () => { model TestModel { ${t.modelProperty("prop")}: ArrayOf; } - `); + `, + ); expectFunctionDiagnosticsEmpty(diagnostics); @@ -1304,7 +1483,7 @@ describe("template and generic scenarios", () => { }); it("works with constrained templates", async () => { - const diagnostics = await tester.diagnose(` + const diagnostics = await makeProcessConstrainedGenericTester().diagnose(` extern fn processConstrainedGeneric(T: Reflection.Model): Reflection.Model; alias ProcessModel = processConstrainedGeneric(T); @@ -1317,7 +1496,7 @@ describe("template and generic scenarios", () => { }); it("errors when template constraint not satisfied", async () => { - const diagnostics = await tester.diagnose(` + const diagnostics = await makeProcessConstrainedGenericTester().diagnose(` extern fn processConstrainedGeneric(T: Reflection.Model): Reflection.Model; alias ProcessModel = processConstrainedGeneric(T); @@ -1332,7 +1511,8 @@ 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` + const [{ program, A, B }, diagnostics] = + await makeProcessGenericTester().compileAndDiagnose(t.code` extern fn processGeneric(T: unknown): unknown; alias ArrayOf = processGeneric(T); @@ -1460,8 +1640,10 @@ describe("assignability of functions to fn types", () => { }); describe("function type assignability", () => { + const assignabilityTester = BaseTester.using("TypeSpec.Reflection"); + async function diagnoseFunctionAssignment(source: string, target: string) { - const diagnostics = await tester.diagnose(` + const diagnostics = await assignabilityTester.diagnose(` alias Source = ${source}; alias Target = ${target}; @@ -1565,24 +1747,24 @@ describe("function type assignability", () => { }); describe("calling template arguments", () => { - beforeEach(() => { - tester = BaseTester.files({ - "templates.js": mockFile.js({ - $functions: { - "": { - f(_ctx: FunctionContext, T: Model) { - return T.name; - }, + // Only the third test actually uses `f`; the first two test template-parameter-call failures + // and don't need any $functions registered. + const fTester = BaseTester.files({ + "templates.js": mockFile.js({ + $functions: { + "": { + f(_ctx: FunctionContext, T: Model) { + return T.name; }, }, - }), - }) - .import("./templates.js") - .using("TypeSpec.Reflection"); - }); + }, + }), + }) + .import("./templates.js") + .using("TypeSpec.Reflection"); it("does not allow calling an unconstrained template parameter", async () => { - const diagnostics = await tester.diagnose(` + const diagnostics = await BaseTester.using("TypeSpec.Reflection").diagnose(` model Test { p: string = F(); } @@ -1596,7 +1778,7 @@ 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(` + const diagnostics = await BaseTester.using("TypeSpec.Reflection").diagnose(` model Test valueof string> { p: string = F(); } @@ -1610,7 +1792,7 @@ describe("calling template arguments", () => { }); it("allows calling a template parameter constrained to a function value", async () => { - const [{ Instance }, diagnostics] = await tester.compileAndDiagnose(t.code` + const [{ Instance }, diagnostics] = await fTester.compileAndDiagnose(t.code` extern fn f(T: Model): valueof string; model Foo {} diff --git a/packages/compiler/test/semantic-walker.test.ts b/packages/compiler/test/semantic-walker.test.ts index 50176edf7fe..76f1db9ad0f 100644 --- a/packages/compiler/test/semantic-walker.test.ts +++ b/packages/compiler/test/semantic-walker.test.ts @@ -26,16 +26,6 @@ import { mockFile, t } from "../src/testing/index.js"; import { Tester } from "./tester.js"; describe("compiler: semantic walker", () => { - const NavigatorTester = Tester.files({ - "main.js": mockFile.js({ - $functions: { - Extern: { - foo() {}, - }, - }, - }), - }).import("./main.js"); - function createCollector(customListener?: SemanticNodeListener) { const result = { enums: [] as Enum[], @@ -145,7 +135,7 @@ describe("compiler: semantic walker", () => { customListener?: SemanticNodeListener, options?: NavigationOptions, ) { - const { program } = await NavigatorTester.compile(typespec, { + const { program } = await Tester.compile(typespec, { compilerOptions: { nostdlib: true }, }); @@ -697,15 +687,33 @@ describe("compiler: semantic walker", () => { }); it("include functions", async () => { - const results = await runNavigator(` + const FunctionTester = Tester.files({ + "fn.js": mockFile.js({ + $functions: { + Extern: { + foo() {}, + }, + }, + }), + }).import("./fn.js"); + + const { program } = await FunctionTester.compile( + ` namespace Extern; #suppress "experimental-feature" extern fn foo(): string; - `); + `, + { + compilerOptions: { nostdlib: true }, + }, + ); + + const [result, listener] = createCollector(); + navigateProgram(program, listener); - expect(results.functions).toHaveLength(1); - expect(results.functions[0].name).toBe("foo"); + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("foo"); }); it("emits exit events for template parameter access types", () => { diff --git a/website/src/content/docs/docs/standard-library/diags/implementation-without-extern.md b/website/src/content/docs/docs/standard-library/diags/implementation-without-extern.md new file mode 100644 index 00000000000..32f0a2fb1ba --- /dev/null +++ b/website/src/content/docs/docs/standard-library/diags/implementation-without-extern.md @@ -0,0 +1,59 @@ +--- +title: implementation-without-extern +--- + +A function is registered in a JS file's `$functions` map but there is no matching `extern fn` declaration in TypeSpec. Without the declaration the implementation is silently ignored. + +#### ❌ Incorrect + +```js +// lib.js — "testFn" is registered but never declared in TypeSpec +export const $functions = { + "": { + testFn: (context, target) => target, + }, +}; +``` + +```tsp +// lib.tsp — missing extern fn declaration; testFn is silently ignored +using TypeSpec.Reflection; +``` + +#### ✅ Correct + +```js +// lib.js +export const $functions = { + "": { + testFn: (context, target) => target, + }, +}; +``` + +```tsp +// lib.tsp +using TypeSpec.Reflection; + +#suppress "experimental-feature" "Functions are experimental" +extern fn testFn(target: unknown): unknown; +``` + +For namespaced functions, make sure the namespace in `$functions` matches the TypeSpec namespace: + +```js +// lib.js +export const $functions = { + "MyLib": { + myFn: (context, value) => value, + }, +}; +``` + +```tsp +// lib.tsp +#suppress "experimental-feature" "Functions are experimental" +namespace MyLib { + extern fn myFn(value: unknown): unknown; +} +```