diff --git a/.chronus/changes/fix-removed-reference-validation-2026-09-10.md b/.chronus/changes/fix-removed-reference-validation-2026-09-10.md new file mode 100644 index 00000000000..09873dd7f17 --- /dev/null +++ b/.chronus/changes/fix-removed-reference-validation-2026-09-10.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/versioning" +--- + +Report references that remain available after their target type is removed. diff --git a/packages/versioning/src/validate.ts b/packages/versioning/src/validate.ts index ae2bab1d07a..f55a29aa115 100644 --- a/packages/versioning/src/validate.ts +++ b/packages/versioning/src/validate.ts @@ -773,6 +773,24 @@ function findAvailabilityOnOrBeforeVersion( return undefined; } +function isFirstUnavailableVersion( + version: string, + avail: Map, +): boolean { + let previous: Availability | undefined; + for (const [key, current] of avail) { + if (key === version) { + return ( + [Availability.Removed, Availability.Unavailable].includes(current) && + previous !== undefined && + [Availability.Added, Availability.Available].includes(previous) + ); + } + previous = current; + } + return false; +} + function validateAvailabilityForRef( program: Program, sourceAvail: Map | undefined, @@ -847,6 +865,23 @@ function validateAvailabilityForRef( codefixes: getVersionAdditionCodefixes(targetVersion, target, program), }); } + if ( + sourceVal === Availability.Available && + isFirstUnavailableVersion(key, targetAvail) && + findAvailabilityAfterVersion(key, Availability.Removed, sourceAvail) === undefined + ) { + reportDiagnostic(program, { + code: "incompatible-versioned-reference", + messageId: "doesNotExist", + format: { + sourceName: getTypeName(source), + targetName: getTypeName(target), + version: key, + }, + target: source, + codefixes: getVersionRemovalCodeFixes(key, source, program), + }); + } if ( [Availability.Removed].includes(sourceVal) && [Availability.Unavailable].includes(targetVal) diff --git a/packages/versioning/test/incompatible-versioning.test.ts b/packages/versioning/test/incompatible-versioning.test.ts index 51413b7ba7f..87504161903 100644 --- a/packages/versioning/test/incompatible-versioning.test.ts +++ b/packages/versioning/test/incompatible-versioning.test.ts @@ -314,6 +314,23 @@ describe("versioning: validate incompatible references", () => { }); }); + it("emit diagnostic when referenced type is removed while property remains available", async () => { + const diagnostics = await runner.diagnose(` + @removed(Versions.v2) + model Target {} + + @added(Versions.v1) + model Source { + target: Target; + } + `); + expectDiagnostics(diagnostics, { + code: "@typespec/versioning/incompatible-versioned-reference", + message: + "'TestService.Source.target' is referencing type 'TestService.Target' which does not exist in version 'v2'.", + }); + }); + it("emit diagnostic when using @typeChangedFrom with a type parameter that does not yet exist", async () => { const diagnostics = await runner.diagnose(` @test @@ -1002,6 +1019,37 @@ describe("versioning: validate incompatible references", () => { }); }); + it("emit diagnostic when dependency mapping skips the target removal version", async () => { + const diagnostics = await Tester.diagnose(` + @versioned(Versions) + namespace VersionedLib { + enum Versions {l1, l2, l3} + @removed(Versions.l2) + model Foo {} + } + + @versioned(Versions) + namespace TestService { + enum Versions { + @useDependency(VersionedLib.Versions.l1) + v1, + @useDependency(VersionedLib.Versions.l3) + v2, + @useDependency(VersionedLib.Versions.l3) + v3 + } + + @added(Versions.v1) + op test(): VersionedLib.Foo; + } + `); + expectDiagnostics(diagnostics, { + code: "@typespec/versioning/incompatible-versioned-reference", + message: + "'TestService.test' is referencing type 'VersionedLib.Foo' which does not exist in version 'v2'.", + }); + }); + it("doesn't emit diagnostic if all version use the same one", async () => { // Here Foo was added in v2 which makes it only available in 1 & 2. const diagnostics = await Tester.diagnose(`