From 8b6b70837f78c4b1430ad86fbe82ec19f2966b5e Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 15 Sep 2026 10:47:41 -0400 Subject: [PATCH] feat(compiler): support explicit property optionality overrides --- ...ptionality-overrides-2026-8-15-10-42-27.md | 14 ++ ...ptionality-overrides-2026-8-15-10-42-30.md | 7 + packages/compiler/README.md | 48 ++++ packages/compiler/src/core/checker.ts | 20 ++ .../compiler/src/core/property-optionality.ts | 144 +++++++++++ packages/compiler/src/experimental/index.ts | 5 + .../compiler/src/experimental/mutators.ts | 5 +- packages/compiler/src/lib/decorators.ts | 3 +- packages/compiler/src/typekit/kits/type.ts | 4 + .../experimental/property-optionality.test.ts | 180 ++++++++++++++ packages/versioning/src/decorators.ts | 32 ++- .../test/optionality-overrides.test.ts | 223 ++++++++++++++++++ 12 files changed, 677 insertions(+), 8 deletions(-) create mode 100644 .chronus/changes/feat-versioning-optionality-overrides-2026-8-15-10-42-27.md create mode 100644 .chronus/changes/feat-versioning-optionality-overrides-2026-8-15-10-42-30.md create mode 100644 packages/compiler/src/core/property-optionality.ts create mode 100644 packages/compiler/test/experimental/property-optionality.test.ts create mode 100644 packages/versioning/test/optionality-overrides.test.ts diff --git a/.chronus/changes/feat-versioning-optionality-overrides-2026-8-15-10-42-27.md b/.chronus/changes/feat-versioning-optionality-overrides-2026-8-15-10-42-27.md new file mode 100644 index 00000000000..1935e50632c --- /dev/null +++ b/.chronus/changes/feat-versioning-optionality-overrides-2026-8-15-10-42-27.md @@ -0,0 +1,14 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add experimental explicit optionality overrides for derived properties. Overrides retain same-value transform intent and inherited annotation provenance through compiler and typekit cloning. + +```ts +import { unsafe_overridePropertyOptionality } from "@typespec/compiler/experimental"; + +// In a decorator, pass its context so cloning does not repeat the transform. +unsafe_overridePropertyOptionality(derivedProperty, true, context); +``` \ No newline at end of file diff --git a/.chronus/changes/feat-versioning-optionality-overrides-2026-8-15-10-42-30.md b/.chronus/changes/feat-versioning-optionality-overrides-2026-8-15-10-42-30.md new file mode 100644 index 00000000000..81492d988b9 --- /dev/null +++ b/.chronus/changes/feat-versioning-optionality-overrides-2026-8-15-10-42-30.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/versioning" +--- + +Keep OptionalProperties properties optional in every version, including spreads and properties already made optional. Honor explicit structural overrides instead of inherited @madeRequired and @madeOptional history, while preserving presence, rename, and type history and validating newly authored annotations. \ No newline at end of file diff --git a/packages/compiler/README.md b/packages/compiler/README.md index ad1601b2f50..abe15b15c71 100644 --- a/packages/compiler/README.md +++ b/packages/compiler/README.md @@ -3,6 +3,54 @@ This package implements the core of the [TypeSpec](https://github.com/microsoft/typespec) compiler and its command-line interface. +## Experimental optionality overrides (prototype) + +Transforms can explicitly replace a derived property's inherited optionality: + +```ts +import type { DecoratorContext, Model } from "@typespec/compiler"; +import { unsafe_overridePropertyOptionality } from "@typespec/compiler/experimental"; + +export function $relaxed(context: DecoratorContext, target: Model) { + for (const property of target.properties.values()) { + unsafe_overridePropertyOptionality(property, true, context); + } +} +``` + +`true` makes the property optional; `false` makes it required. Only mutate +properties owned by the transform, typically copied using a spread, `model is`, +the checker, or typekit. The source and sibling copies are not changed. + +- Calling the API records intent even if `property.optional` already has that + value. `@withOptionalProperties` (and therefore `OptionalProperties`) uses + this contract. +- Decorators must pass their context. Compiler/typekit clones preserve the + override and its decorator provenance. Replaying an already-applied transform + does not overwrite a later explicit transform or a version snapshot. + Non-decorator transforms omit the context; the latest explicit override wins. +- Only optionality is replaced. Property presence, names, types, documentation, + and unrelated metadata are unchanged. +- Libraries owning optionality metadata can store its `DecoratorContext` and + consult `unsafe_getPropertyOptionalityOverride(property)?.supersedes(context)`. + This distinguishes inherited annotations from newly authored annotations and + augments on the transformed copy. It also works when state was recorded before + the transform, without deleting decorators or unrelated library state. +- Versioning uses this contract for both inherited `@madeRequired` and + `@madeOptional`. Newly authored history still applies and is still validated; + invalid annotations on the original property still diagnose. + +Custom optionality transforms must adopt this API to receive these semantics. +Arbitrary `property.optional = value` assignments cannot express same-value +intent. Direct assignments remain appropriate when **realizing** a version +snapshot: they do not establish a new semantic override. + +This is an experimental, optionality-only prototype, not a general transform or +decorator replay framework. It does not add support for augment targets that +the name resolver cannot statically bind (for example, properties introduced +through template-parameter spreads). Use statically resolvable copies when +adding new augment metadata. + ## See also - [TypeSpec Getting Started](https://github.com/microsoft/typespec#getting-started) diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 76fd381003b..06beec99b94 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -41,6 +41,11 @@ import { visitChildren, } from "./parser.js"; import type { Program } from "./program.js"; +import { + copyPropertyOptionality, + getPropertyOptionalityOverride, + registerOptionalityDecoratorContext, +} from "./property-optionality.js"; import { createTypeRelationChecker } from "./type-relation-checker.js"; import { getFullyQualifiedSymbolName, @@ -8358,10 +8363,21 @@ export function createChecker(program: Program, resolver: NameResolver): Checker stats.finishedTypes++; if (!options.skipDecorators) { + const optionality = + typeDef.kind === "ModelProperty" && getPropertyOptionalityOverride(typeDef); + const optional = typeDef.kind === "ModelProperty" && typeDef.optional; let postSelfValidators: ValidatorFn[] = []; if ("decorators" in typeDef) { postSelfValidators = applyDecoratorsToType(typeDef); } + // Replay must not undo a transform or a snapshot realization. A new + // explicit override made during replay still takes precedence. + if (typeDef.kind === "ModelProperty") { + const current = getPropertyOptionalityOverride(typeDef); + if (current) { + typeDef.optional = current === optionality ? optional : current.optional; + } + } typeDef.isFinished = true; Object.setPrototypeOf(typeDef, typePrototype); runPostValidators(postSelfValidators); @@ -8533,6 +8549,9 @@ export function createChecker(program: Program, resolver: NameResolver): Checker break; } + if (type.kind === "ModelProperty" && clone.kind === "ModelProperty") { + copyPropertyOptionality(type, clone); + } return clone as T; } @@ -9181,6 +9200,7 @@ function createDecoratorContext(program: Program, decApp: DecoratorApplication): }, }; + registerOptionalityDecoratorContext(decApp, decCtx, passthrough.decorator); return decCtx; } diff --git a/packages/compiler/src/core/property-optionality.ts b/packages/compiler/src/core/property-optionality.ts new file mode 100644 index 00000000000..b352f0a2c72 --- /dev/null +++ b/packages/compiler/src/core/property-optionality.ts @@ -0,0 +1,144 @@ +import type { DecoratorApplication, DecoratorContext, ModelProperty } from "./types.js"; + +/** + * An explicit replacement of a property's inherited optionality. + * + * This experimental contract covers optionality only, not presence, name, type, + * or other metadata. Libraries owning optionality metadata can retain its + * decorator context and use `supersedes` when reading that metadata. + * + * @experimental + */ +export interface PropertyOptionalityOverride { + /** The semantic optionality chosen by the transform, not a version snapshot. */ + readonly optional: boolean; + + /** + * Whether this override supersedes metadata from this decorator application. + * Only applications inherited before the transform are superseded. Annotations + * authored on the transformed copy remain applicable, including new augments. + */ + supersedes(context: DecoratorContext): boolean; +} + +interface OverrideState { + readonly value: PropertyOptionalityOverride; + readonly superseded: ReadonlySet; + readonly transforms: ReadonlyMap; +} + +interface ApplicationContext { + readonly origin: object; + readonly execution: object; +} + +const stateKey = Symbol.for("TypeSpec.PropertyOptionality"); +interface OptionalityState { + overrides: WeakMap; + inheritedApplications: WeakMap>; + applicationOrigins: WeakMap; + contextOrigins: WeakMap; +} +// Like Realm, compiler and typekit instances can cross module boundaries. +const { overrides, inheritedApplications, applicationOrigins, contextOrigins } = (( + globalThis as typeof globalThis & { [stateKey]?: OptionalityState } +)[stateKey] ??= { + overrides: new WeakMap(), + inheritedApplications: new WeakMap(), + applicationOrigins: new WeakMap(), + contextOrigins: new WeakMap(), +}); + +function applicationOrigin(application: DecoratorApplication): object { + let origin = applicationOrigins.get(application); + if (!origin) { + origin = {}; + applicationOrigins.set(application, origin); + } + return origin; +} + +/** @internal */ +export function copyOptionalityDecoratorOrigin( + source: DecoratorApplication, + clone: DecoratorApplication, +): void { + applicationOrigins.set(clone, applicationOrigin(source)); +} + +/** @internal */ +export function registerOptionalityDecoratorContext( + application: DecoratorApplication, + ...contexts: DecoratorContext[] +): void { + const value = { origin: applicationOrigin(application), execution: {} }; + for (const context of contexts) contextOrigins.set(context, value); +} + +/** @internal */ +export function copyPropertyOptionality(source: ModelProperty, clone: ModelProperty): void { + if (source.decorators.length > 0) { + inheritedApplications.set(clone, new Set(source.decorators.map(applicationOrigin))); + } + const override = overrides.get(source); + if (override) overrides.set(clone, override); +} + +/** + * Replace a property's inherited optionality, even when its boolean value does + * not change. Call on an owned derived property, never a shared source. + * + * Decorators must pass their context so replay does not repeat a semantic + * transform or overwrite a later transform/version realization. Non-decorator + * transforms omit it. A subsequent explicit override wins. + * + * Ordinary `property.optional = value` assignments do not record intent and + * remain appropriate for realizing version snapshots. + * + * @experimental + */ +export function overridePropertyOptionality( + property: ModelProperty, + optional: boolean, + context?: DecoratorContext, +): void { + const previous = overrides.get(property); + const application = context && contextOrigins.get(context); + const origin = application?.origin ?? context; + const execution = application?.execution ?? context; + if (origin && previous?.transforms.has(origin) && previous.transforms.get(origin) !== execution) { + return; + } + + const superseded = new Set([ + ...(previous?.superseded ?? []), + ...(inheritedApplications.get(property) ?? []), + ]); + const transforms = new Map(previous?.transforms); + if (origin && execution) transforms.set(origin, execution); + + overrides.set(property, { + value: Object.freeze({ + optional, + supersedes: (context: DecoratorContext) => { + const origin = contextOrigins.get(context)?.origin; + return origin !== undefined && superseded.has(origin); + }, + }), + superseded, + transforms, + }); + property.optional = optional; +} + +/** + * Get the explicit semantic override, if any. Compiler and typekit cloning + * preserve it and its annotation provenance without modifying the source. + * + * @experimental + */ +export function getPropertyOptionalityOverride( + property: ModelProperty, +): PropertyOptionalityOverride | undefined { + return overrides.get(property)?.value; +} diff --git a/packages/compiler/src/experimental/index.ts b/packages/compiler/src/experimental/index.ts index 150d010af85..af236d44597 100644 --- a/packages/compiler/src/experimental/index.ts +++ b/packages/compiler/src/experimental/index.ts @@ -1,3 +1,8 @@ +export { + getPropertyOptionalityOverride as unsafe_getPropertyOptionalityOverride, + overridePropertyOptionality as unsafe_overridePropertyOptionality, + type PropertyOptionalityOverride as unsafe_PropertyOptionalityOverride, +} from "../core/property-optionality.js"; export { createSourceLoader as unsafe_createSourceLoader } from "../core/source-loader.js"; export { useCache as unsafe_useCache } from "./cache.js"; export { diff --git a/packages/compiler/src/experimental/mutators.ts b/packages/compiler/src/experimental/mutators.ts index 783381b6645..c96468ad6c2 100644 --- a/packages/compiler/src/experimental/mutators.ts +++ b/packages/compiler/src/experimental/mutators.ts @@ -2,6 +2,7 @@ import { compilerAssert } from "../core/diagnostics.js"; import { getLocationContext } from "../core/helpers/location-context.js"; import { isNumeric } from "../core/numeric.js"; import type { Program } from "../core/program.js"; +import { copyOptionalityDecoratorOrigin } from "../core/property-optionality.js"; import { isTemplateInstance, isType, isValue } from "../core/type-utils.js"; import type { DecoratedType, @@ -764,7 +765,9 @@ function createMutatorEngine( } if (mutating) { - type.decorators[index] = { ...dec, args }; + const clone = { ...dec, args }; + copyOptionalityDecoratorOrigin(dec, clone); + type.decorators[index] = clone; } } } diff --git a/packages/compiler/src/lib/decorators.ts b/packages/compiler/src/lib/decorators.ts index 3d7f4ba788a..16e11aba388 100644 --- a/packages/compiler/src/lib/decorators.ts +++ b/packages/compiler/src/lib/decorators.ts @@ -71,6 +71,7 @@ import { parseMimeType } from "../core/mime-type.js"; import type { Numeric } from "../core/numeric.js"; import { isNumeric } from "../core/numeric.js"; import type { Program } from "../core/program.js"; +import { overridePropertyOptionality } from "../core/property-optionality.js"; import { isArrayModelType, isValue } from "../core/type-utils.js"; import type { AugmentDecoratorStatementNode, @@ -1011,7 +1012,7 @@ export const $withOptionalProperties: WithOptionalPropertiesDecorator = ( target: Model, ) => { // Make all properties of the target type optional - target.properties.forEach((p) => (p.optional = true)); + target.properties.forEach((p) => overridePropertyOptionality(p, true, context)); }; // -- @withoutOmittedProperties decorator ---------------------- diff --git a/packages/compiler/src/typekit/kits/type.ts b/packages/compiler/src/typekit/kits/type.ts index 37b0d65c2c7..6d3142d9e5e 100644 --- a/packages/compiler/src/typekit/kits/type.ts +++ b/packages/compiler/src/typekit/kits/type.ts @@ -9,6 +9,7 @@ import { getMinValue, getMinValueExclusive, } from "../../core/intrinsic-type-state.js"; +import { copyPropertyOptionality } from "../../core/property-optionality.js"; import { isNeverType } from "../../core/type-utils.js"; import type { Entity, @@ -262,6 +263,9 @@ defineKit({ }); break; } + if (type.kind === "ModelProperty" && clone.kind === "ModelProperty") { + copyPropertyOptionality(type, clone); + } this.realm.addType(clone); return clone; }, diff --git a/packages/compiler/test/experimental/property-optionality.test.ts b/packages/compiler/test/experimental/property-optionality.test.ts new file mode 100644 index 00000000000..bb48f37373c --- /dev/null +++ b/packages/compiler/test/experimental/property-optionality.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import { + unsafe_getPropertyOptionalityOverride as getPropertyOptionalityOverride, + unsafe_overridePropertyOptionality as overridePropertyOptionality, +} from "../../src/experimental/index.js"; +import { mutateSubgraph } from "../../src/experimental/mutators.js"; +import type { DecoratorContext, Model, ModelProperty } from "../../src/index.js"; +import { mockFile, t } from "../../src/testing/index.js"; +import { $ } from "../../src/typekit/index.js"; +import { Tester } from "../tester.js"; + +const history = new WeakMap(); +const OverrideTester = Tester.files({ + "overrides.js": mockFile.js({ + $history(context: DecoratorContext, property: ModelProperty) { + history.set(property, context); + property.optional = false; + }, + $optional(context: DecoratorContext, model: Model) { + for (const property of model.properties.values()) { + overridePropertyOptionality(property, true, context); + } + }, + }), +}).import("./overrides.js"); + +describe("explicit property optionality", () => { + it("records same-value intent and leaves ordinary assignments unmarked", async () => { + const { p } = await Tester.compile(t.code`model M { ${t.modelProperty("p")}?: string; }`); + p.optional = true; + expect(getPropertyOptionalityOverride(p)).toBeUndefined(); + overridePropertyOptionality(p, true); + expect(getPropertyOptionalityOverride(p)?.optional).toBe(true); + }); + + it("preserves overrides through is, spreads, and decorator replay", async () => { + const { Derived, Source } = await OverrideTester.compile(t.code` + model ${t.model("Source")} { @history p: string; } + @optional model Transform { ...T; } + model First is Transform; + model Second { ...First; } + model ${t.model("Derived")} is Second; + `); + const source = Source.properties.get("p")!; + const derived = Derived.properties.get("p")!; + expect(derived.optional).toBe(true); + expect(getPropertyOptionalityOverride(derived)?.supersedes(history.get(derived)!)).toBe(true); + expect(source.optional).toBe(false); + expect(getPropertyOptionalityOverride(source)).toBeUndefined(); + }); + + it("does not supersede directly authored annotations", async () => { + const { p } = await OverrideTester.compile(t.code` + @optional model M { @history ${t.modelProperty("p")}: string; } + `); + expect(getPropertyOptionalityOverride(p)?.supersedes(history.get(p)!)).toBe(false); + }); + + it("does not absorb new augments when a model decorator is replayed", async () => { + const { Derived } = await OverrideTester.compile(t.code` + model Source { p: string; } + @optional model Transform { ...Source; } + model ${t.model("Derived")} is Transform; + @@history(Derived.p); + `); + const p = Derived.properties.get("p")!; + expect(history.get(p)).toBeDefined(); + expect(getPropertyOptionalityOverride(p)?.supersedes(history.get(p)!)).toBe(false); + }); + + for (const cloneKind of ["checker", "typekit"] as const) { + it(`copies provenance using ${cloneKind} without changing siblings`, async () => { + const { p, program } = await OverrideTester.compile(t.code` + model M { @history ${t.modelProperty("p")}: string; } + `); + const clone = + cloneKind === "checker" ? program.checker.cloneType(p) : $(program).type.clone(p); + overridePropertyOptionality(clone, true); + $(program).type.finishType(clone); + const sibling = program.checker.cloneType(p); + const descendant = program.checker.cloneType(clone); + expect(clone.optional).toBe(true); + expect(descendant.optional).toBe(true); + expect(getPropertyOptionalityOverride(descendant)?.supersedes(history.get(descendant)!)).toBe( + true, + ); + expect(getPropertyOptionalityOverride(p)).toBeUndefined(); + expect(getPropertyOptionalityOverride(sibling)).toBeUndefined(); + expect(p.optional).toBe(false); + expect(sibling.optional).toBe(false); + }); + } + + it("keeps the latest explicit override through multiple mutations and model replay", async () => { + const { M, program } = await OverrideTester.compile(t.code` + model Source { p: string; } + @optional model ${t.model("M")} { ...Source; } + `); + const required = mutateSubgraph( + program, + [ + { + name: "required", + Model() {}, + ModelProperty(_source, clone) { + overridePropertyOptionality(clone, false); + }, + }, + ], + M, + ).type; + if (required.kind !== "Model") throw new Error("Expected model"); + expect(required.properties.get("p")!.optional).toBe(false); + const second = mutateSubgraph( + program, + [ + { + name: "unrelated", + Model() {}, + ModelProperty(_source, clone) { + clone.name = "renamed"; + }, + }, + ], + required, + ).type; + expect(second.kind).toBe("Model"); + if (second.kind !== "Model") throw new Error("Expected model"); + expect([...second.properties.values()][0].optional).toBe(false); + expect(M.properties.get("p")!.optional).toBe(true); + }); + + it("preserves provenance for decorator applications without syntax nodes", async () => { + const { M, program } = await Tester.compile(t.code` + model ${t.model("M")} { p: string; } + `); + const p = M.properties.get("p")!; + p.decorators.push({ + decorator(context: DecoratorContext, target: ModelProperty) { + history.set(target, context); + }, + args: [], + }); + program.checker.finishType(p); + const clone = program.checker.cloneType(M); + overridePropertyOptionality(clone.properties.get("p")!, true); + const result = mutateSubgraph( + program, + [ + { + name: "replay", + Model() {}, + ModelProperty() {}, + }, + ], + clone, + ).type; + if (result.kind !== "Model") throw new Error("Expected model"); + const mutated = result.properties.get("p")!; + expect(history.get(mutated)).toBeDefined(); + expect(getPropertyOptionalityOverride(mutated)?.supersedes(history.get(mutated)!)).toBe(true); + }); + + it("allows multiple explicit replacements within one decorator execution", async () => { + const { p, program } = await Tester.files({ + "multiple.js": mockFile.js({ + $multiple(context: DecoratorContext, property: ModelProperty) { + overridePropertyOptionality(property, true, context); + overridePropertyOptionality(property, false, context); + }, + }), + }).import("./multiple.js").compile(t.code` + model M { @multiple ${t.modelProperty("p")}: string; } + `); + expect(p.optional).toBe(false); + expect(getPropertyOptionalityOverride(p)?.optional).toBe(false); + const clone = program.checker.cloneType(p); + expect(clone.optional).toBe(false); + }); +}); diff --git a/packages/versioning/src/decorators.ts b/packages/versioning/src/decorators.ts index e3c655f2c88..cf74bedc61e 100644 --- a/packages/versioning/src/decorators.ts +++ b/packages/versioning/src/decorators.ts @@ -14,6 +14,7 @@ import type { Union, UnionVariant, } from "@typespec/compiler"; +import { unsafe_getPropertyOptionalityOverride as getPropertyOptionalityOverride } from "@typespec/compiler/experimental"; import type { AddedDecorator, MadeOptionalDecorator, @@ -222,7 +223,7 @@ export const $madeOptional: MadeOptionalDecorator = ( if (!version) { return; } - program.stateMap(VersioningStateKeys.madeOptional).set(t, version); + program.stateMap(VersioningStateKeys.madeOptional).set(t, { version, context }); }; export const $madeRequired: MadeRequiredDecorator = ( @@ -235,14 +236,32 @@ export const $madeRequired: MadeRequiredDecorator = ( if (!version) { return; } - program.stateMap(VersioningStateKeys.madeRequired).set(t, version); + program.stateMap(VersioningStateKeys.madeRequired).set(t, { version, context }); }; +interface OptionalityHistory { + readonly version: Version; + readonly context: DecoratorContext; +} + +function getOptionalityHistory(p: Program, t: Type, key: symbol): Version | undefined { + const history: OptionalityHistory | undefined = p.stateMap(key).get(t); + if ( + t.kind === "ModelProperty" && + history && + getPropertyOptionalityOverride(t)?.supersedes(history.context) + ) { + return undefined; + } + return history?.version; +} + /** - * @returns version when the given type was made required if applicable. + * @returns version when the given type was made required, unless an explicit + * structural optionality override supersedes that inherited history. */ export function getMadeRequiredOn(p: Program, t: Type): Version | undefined { - return p.stateMap(VersioningStateKeys.madeRequired).get(t); + return getOptionalityHistory(p, t, VersioningStateKeys.madeRequired); } /** @@ -268,10 +287,11 @@ export function getRemovedOnVersions(p: Program, t: Type): Version[] | undefined } /** - * @returns version when the given type was made optional if applicable. + * @returns version when the given type was made optional, unless an explicit + * structural optionality override supersedes that inherited history. */ export function getMadeOptionalOn(p: Program, t: Type): Version | undefined { - return p.stateMap(VersioningStateKeys.madeOptional).get(t); + return getOptionalityHistory(p, t, VersioningStateKeys.madeOptional); } export class VersionMap { diff --git a/packages/versioning/test/optionality-overrides.test.ts b/packages/versioning/test/optionality-overrides.test.ts new file mode 100644 index 00000000000..fb34b88b3c9 --- /dev/null +++ b/packages/versioning/test/optionality-overrides.test.ts @@ -0,0 +1,223 @@ +import type { DecoratorContext, Model, Namespace } from "@typespec/compiler"; +import { + unsafe_getPropertyOptionalityOverride as getPropertyOptionalityOverride, + unsafe_overridePropertyOptionality as overridePropertyOptionality, + unsafe_mutateSubgraphWithNamespace, +} from "@typespec/compiler/experimental"; +import { expectDiagnostics, mockFile } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { getMadeOptionalOn, getMadeRequiredOn } from "../src/decorators.js"; +import { getVersioningMutators } from "../src/mutator.js"; +import { Tester } from "./test-host.js"; + +const VersionedTester = Tester.wrap( + (code) => ` + @versioned(Versions) + namespace Service { + enum Versions { v1, v2, v3 } + ${code} + } + `, +); + +const CustomTester = VersionedTester.files({ + "custom.js": mockFile.js({ + $relaxed(context: DecoratorContext, model: Model) { + for (const property of model.properties.values()) { + overridePropertyOptionality(property, true, context); + } + }, + $strict(context: DecoratorContext, model: Model) { + for (const property of model.properties.values()) { + overridePropertyOptionality(property, false, context); + } + }, + }), +}).import("./custom.js"); + +async function snapshots(code: string, tester = VersionedTester) { + const { program } = await tester.compile(code); + const service = program.getGlobalNamespaceType().namespaces.get("Service")!; + const versioning = getVersioningMutators(program, service); + if (versioning?.kind !== "versioned") throw new Error("Expected versioned service"); + return { + program, + service, + versions: versioning.snapshots.map(({ mutator }) => { + const { type } = unsafe_mutateSubgraphWithNamespace(program, [mutator], service); + if (type.kind !== "Namespace") throw new Error("Expected namespace"); + return type; + }), + }; +} + +function property(namespace: Namespace, model: string, name = "foo") { + return namespace.models.get(model)!.properties.get(name)!; +} + +describe("structural optionality overrides", () => { + for (const source of [ + "@madeRequired(Versions.v2) foo: string;", + "@madeOptional(Versions.v2) foo?: string;", + ]) { + for (const declaration of [ + "model Derived is OptionalProperties;", + "model Derived { ...OptionalProperties; }", + `model First is OptionalProperties; + model Second { ...First; } + model Third is Second; + model Derived { ...Third; }`, + ]) { + it(`${source} through ${declaration}`, async () => { + const { versions, service, program } = await snapshots(` + model Source { ${source} } + model Transparent { ...Source; } + ${declaration} + `); + for (const version of versions) { + expect(property(version, "Derived").optional).toBe(true); + } + const expected = source.includes("madeRequired") + ? [true, false, false] + : [false, true, true]; + expect(versions.map((v) => property(v, "Source").optional)).toEqual(expected); + expect(versions.map((v) => property(v, "Transparent").optional)).toEqual(expected); + expect(property(service, "Source").optional).toBe(expected[2]); + expect(getPropertyOptionalityOverride(property(service, "Source"))).toBeUndefined(); + for (const version of versions) { + expect(getPropertyOptionalityOverride(property(version, "Source"))).toBeUndefined(); + } + expect(getMadeRequiredOn(program, property(service, "Derived"))).toBeUndefined(); + expect(getMadeOptionalOn(program, property(service, "Derived"))).toBeUndefined(); + }); + } + } + + it("preserves presence, rename, and type history", async () => { + const { versions } = await snapshots(` + model Source { + @added(Versions.v2) @madeRequired(Versions.v3) later: string; + @renamedFrom(Versions.v2, "oldFoo") + @typeChangedFrom(Versions.v2, int32) + @madeOptional(Versions.v2) foo?: string; + } + model Derived { ...OptionalProperties; } + `); + expect(versions[0].models.get("Derived")!.properties.has("later")).toBe(false); + for (const version of versions.slice(1)) { + expect(property(version, "Derived", "later").optional).toBe(true); + } + const oldFoo = property(versions[0], "Derived", "oldFoo"); + expect(oldFoo.optional).toBe(true); + expect(oldFoo.type).toMatchObject({ kind: "Scalar", name: "int32" }); + expect(property(versions[1], "Derived").type).toMatchObject({ kind: "Scalar", name: "string" }); + }); + + it("still diagnoses invalid directly authored history", async () => { + const diagnostics = await VersionedTester.diagnose(` + @withOptionalProperties + model Invalid { @madeRequired(Versions.v2) foo: string; } + `); + expectDiagnostics(diagnostics, { code: "@typespec/versioning/made-required-optional" }); + }); + + it.each([ + "@withOptionalProperties model Partial { ...Source; } model Derived { ...Partial; }", + "@withOptionalProperties model Derived { ...Source; }", + ])("still diagnoses a new augment on a transformed copy: %s", async (declaration) => { + const diagnostics = await VersionedTester.diagnose(` + model Source { @madeRequired(Versions.v2) foo: string; } + ${declaration} + @@madeRequired(Derived.foo, Versions.v3); + `); + expectDiagnostics(diagnostics, { code: "@typespec/versioning/made-required-optional" }); + }); + + for (const helper of ["relaxed", "strict"]) { + for (const source of [ + "@madeRequired(Versions.v2) foo: string;", + "@madeOptional(Versions.v2) foo?: string;", + ]) { + for (const declaration of [ + "model Derived is Reshape;", + "model Derived { ...Reshape; }", + ]) { + it(`custom ${helper} overrides ${source} via ${declaration}`, async () => { + const { versions } = await snapshots( + ` + model Source { ${source} } + @${helper} model Reshape { ...T; } + ${declaration} + `, + CustomTester, + ); + expect(versions.map((v) => property(v, "Derived").optional)).toEqual([ + helper === "relaxed", + helper === "relaxed", + helper === "relaxed", + ]); + }); + } + } + } + + it("keeps newly authored optionality history on the derived copy", async () => { + const { versions, service, program } = await snapshots( + ` + model Source { @madeRequired(Versions.v2) foo: string; } + @relaxed model Derived { ...Source; } + @@madeOptional(Derived.foo, Versions.v3); + model Sibling { ...Source; } + `, + CustomTester, + ); + expect(versions.map((v) => property(v, "Derived").optional)).toEqual([false, false, true]); + expect(versions.map((v) => property(v, "Sibling").optional)).toEqual([true, false, false]); + expect(getMadeOptionalOn(program, property(service, "Derived"))?.name).toBe("v3"); + expect(getMadeRequiredOn(program, property(service, "Derived"))).toBeUndefined(); + }); + + it("still diagnoses newly authored madeOptional on a required transformed copy", async () => { + const diagnostics = await CustomTester.diagnose(` + model Source { @madeOptional(Versions.v2) foo?: string; } + @strict model Derived { ...Source; } + @@madeOptional(Derived.foo, Versions.v3); + `); + expectDiagnostics(diagnostics, { code: "@typespec/versioning/made-optional-not-optional" }); + }); + + it("keeps newly authored required history on a required transformed copy", async () => { + const { versions } = await snapshots( + ` + model Source { @madeOptional(Versions.v2) foo?: string; } + @strict model Derived { ...Source; } + @@madeRequired(Derived.foo, Versions.v3); + `, + CustomTester, + ); + expect(versions.map((v) => property(v, "Derived").optional)).toEqual([true, true, false]); + }); + + it("lets the last explicit transform replace an earlier override", async () => { + const { versions } = await snapshots( + ` + model Source { @madeOptional(Versions.v2) foo?: string; } + @strict model Strict { ...T; } + @relaxed model Relaxed { ...T; } + model Derived { ...Strict>; } + model OptionalAgain is Relaxed>; + `, + CustomTester, + ); + expect(versions.map((v) => property(v, "Derived").optional)).toEqual([false, false, false]); + expect(versions.map((v) => property(v, "OptionalAgain").optional)).toEqual([true, true, true]); + }); + + it("does not hide an invalid annotation on the source", async () => { + const diagnostics = await VersionedTester.diagnose(` + model Source { @madeRequired(Versions.v2) foo?: string; } + model Derived { ...OptionalProperties; } + `); + expectDiagnostics(diagnostics, { code: "@typespec/versioning/made-required-optional" }); + }); +});