microsoft / microsoft/typespec

Versioning misses a reference to a type removed before the referrer, emitting a dangling ref with no diagnostic

Open
#11,807 0 comments 1 reaction 0 assignees View on GitHub
bug lib:versioning triaged:core
Dominant language
Java
Stars
5.9k
Forks
394
Avg merge
1d 23h
Merged PRs (30d)
104

Description

`@typespec/versioning` does not report a reference to a type that was **removed before** the referrer exists, so a versioned spec can emit a dangling `$ref` with no diagnostic.

The symmetric case — target *added after* the referrer — is correctly caught, which is what makes this easy to miss.

## Repro

```tsp
import "@typespec/versioning";
using TypeSpec.Versioning;

@service
@versioned(Versions)
namespace Svc;

enum Versions { v1, v2, v3 }

@removed(Versions.v2)
model Target { x: string; }

@added(Versions.v1)
model Source { t: Target; } // dangling at v2 and v3

op get(): Source;
```

```
$ tsp compile . --no-emit
Compilation completed successfully.
```

`Source` is available at v1, v2 and v3. `Target` is gone from v2 onward. At v2 and v3, `Source.t` references a type that does not exist in that version.

## Control — the mirror case *is* caught

Changing only the `Target` decorator to `@added(Versions.v3)`:

```
main.tsp:14:16 - error @typespec/versioning/incompatible-versioned-reference:
'Svc.Source.t' was added in version 'v1' but referencing type 'Svc.Target' added in version 'v3'.
```

## Cause

`validateAvailabilityForRef` ([`packages/versioning/src/validate.ts#L770-L873`](https://github.com/microsoft/typespec/blob/main/packages/versioning/src/validate.ts#L770-L873)) is a **boundary-event** check, not a pointwise availability check. It walks the shared version key set and only acts when the *source's* availability at that point is `Added` or `Removed`:

```ts
switch (sourceAvail) {
case Availability.Added: /* compare against target */ break;
case Availability.Removed: /* compare against target */ break;
// Availability.Available -> nothing
}
```

Once `Source` is merely `Available` (v2, v3) rather than transitioning, the target's availability is never consulted. In the control case the source's `Added` event at v1 lands on a key where the target is `Unavailable`, so it fires.

The check appears to assume the source's own boundary events are the only places a mismatch can be introduced, but a target can disappear at any later point while the source keeps existing.

## Suggested fix

Make it pointwise: for every key in the shared key set where the source is present (`Added` **or** `Available`), require the target to be present too, rather than only inspecting source transition points.

I have a standalone prototype of the pointwise/containment formulation that flags exactly this case while still producing the existing diagnostics, in https://github.com/timotheeguerin/typespec/pull/32 (`spike/scope-algebra.ts`) — happy to turn it into a real fix if the approach looks right.

## Environment

`@typespec/compiler@1.15.0`, `@typespec/versioning@0.85.0`

Found while spiking https://github.com/microsoft/typespec/issues/10551, where versioning's availability reasoning was the model for cross-scope reference validation.

Contributor guide

Open the contributing guide

Research direction

Start in packages/versioning/src/validate.ts, especially validateAvailabilityForRef around lines 770–873, and run the TypeSpec reproduction from the issue with tsp compile . --no-emit. Verify that references from a still-present source to a removed target produce diagnostics at v2 and v3, while the existing added-after-referrer diagnostic remains unchanged.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, backend-api-design
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
62/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.