diff --git a/docs/schema-codegen.md b/docs/schema-codegen.md index baa36b8c..fe0f4b35 100644 --- a/docs/schema-codegen.md +++ b/docs/schema-codegen.md @@ -118,6 +118,16 @@ schema uses (enums, closed/open objects, `$ref`, `anyOf [T, null]`, arrays, scalars); extending the wire model with a new construct may require teaching the emitter about it. +The conformance check covers both SDK surfaces: `wire-conformance.test.ts` pins +the one-shot public types in `sdk/src/types.ts`, and +`wire-conformance-state-aware.test.ts` pins the state-aware lifecycle types in +`sdk/src/state-aware-types.ts` (the `Phase` and sizing-profile enums, the Entra +user bundle, and the per-phase `IsolationSessionPhase` field set) against the +same generated wire defs. Both share the assertion helpers in +`sdk/tests/unit/conformance-helpers.ts` and check drift in both directions +(public→wire and wire→public) so a new wire field the SDK forgets to expose also +fails the build. + ### Why a hand-written emitter (alternatives considered) The generated `wire.ts` is a **drift oracle, not the public API**. The public diff --git a/sdk/package.json b/sdk/package.json index bb3bd9e0..84066615 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -23,7 +23,7 @@ "watch": "tsc --watch", "clean": "rimraf dist", "test": "npm run test:unit", - "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/wire-conformance.test.js", + "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/wire-conformance.test.js dist-tests/tests/unit/wire-conformance-state-aware.test.js", "test:integration": "cd tests/integration && npm install && npm run build && npm test", "prepublishOnly": "npm run build" }, diff --git a/sdk/tests/unit/conformance-helpers.ts b/sdk/tests/unit/conformance-helpers.ts new file mode 100644 index 00000000..720bdf2b --- /dev/null +++ b/sdk/tests/unit/conformance-helpers.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Shared compile-time type-assertion helpers for the wire-conformance oracles +// (`wire-conformance.test.ts` for the one-shot surface and +// `wire-conformance-state-aware.test.ts` for the state-aware surface). These are +// type-only; the emitted `.js` is empty, so this module is never run by the test +// runner — it exists to keep the two oracles' helpers identical. + +/** Compiles only when `T` is exactly `true`. */ +export type AssertTrue = T; + +/** Drop the `[k: string]: unknown` index signature the emitter writes on open objects. */ +export type StripIndex = { [K in keyof T as string extends K ? never : K]: T[K] }; + +/** + * Recursively drop index signatures (open objects nest: e.g. `experimental.wslc` + * and the `IsolationSession*` objects), so structural assignment is not tripped + * by an emitted `[k: string]: unknown` at any depth. Modifiers (`?`) are + * preserved because the mapped type is homomorphic over `keyof T`. + */ +export type DeepStripIndex = T extends (infer E)[] + ? DeepStripIndex[] + : T extends object + ? { [K in keyof T as string extends K ? never : K]: DeepStripIndex } + : T; + +/** `true` iff every value of `A` is assignable to `B` (index signatures stripped, recursively). */ +export type Assignable = [A] extends [DeepStripIndex] ? true : false; + +/** + * Keys present on the public type but absent from the wire type. `StripIndex` + * drops the `[k: string]: unknown` on open generated objects; without it + * `keyof Wire` would include `string` and `Exclude` would collapse to `never`, + * making public-only key checks vacuous for open objects. + */ +export type OnlyInPublic = Exclude>; + +/** + * Keys present on the wire type but absent from the public type. Because every + * generated wire field is optional, `Public extends Wire` stays true when the + * SDK simply forgets a new wire field, so the value/`OnlyInPublic` checks alone + * do NOT catch a wire-only ADDITION. This closes that direction. `StripIndex` + * drops the `[k: string]: unknown` so the open objects don't make `keyof` + * collapse to `string`. + */ +export type OnlyInWire = Exclude, keyof Pub>; + +/** `true` iff `A` and `B` are mutually assignable (same value set). */ +export type Equivalent = [A] extends [B] + ? [B] extends [A] + ? true + : false + : false; diff --git a/sdk/tests/unit/wire-conformance-state-aware.test.ts b/sdk/tests/unit/wire-conformance-state-aware.test.ts new file mode 100644 index 00000000..115a835c --- /dev/null +++ b/sdk/tests/unit/wire-conformance-state-aware.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// State-aware wire-type conformance oracle (Phase 2.5). +// +// The one-shot oracle (`wire-conformance.test.ts`) asserts that +// `sdk/src/types.ts` conforms to the generated wire types. This companion does +// the same for the STATE-AWARE lifecycle public types in +// `sdk/src/state-aware-types.ts`, against the generated wire state-aware defs +// (`Phase`, `IsolationConfigurationId`, `IsolationUser`, `IsolationSessionPhase`). +// Without it, a wire-model change to the state-aware surface — a new sizing +// profile, a field added to the Entra user bundle, a `Phase` change — would +// regenerate `wire.ts`, pass the codegen gate, and still leave the SDK silently +// lagging with no CI signal. +// +// Mapping note (why this is a separate file, not part of the one-shot oracle): +// the public per-phase call configs do NOT map 1:1 to a single wire type. Each +// mixes SDK-level / top-level wire fields with `IsolationSessionPhase` fields: +// +// public field wire location +// ------------------------------------ -------------------------------------- +// *Config.version top-level `version` (SDK fills default) +// ProvisionConfig.filesystem top-level `Filesystem` +// ExecConfig.process top-level `Process` +// StartConfig.configurationId IsolationSessionPhase.configurationId +// {Provision,Start}Config.user IsolationSessionPhase.user / IsolationUser +// +// The top-level fields are already covered by the one-shot oracle; here we (a) +// assert the per-phase configs REUSE those same public leaf types (so the +// delegation is real, not a re-derived shape that could escape the one-shot +// oracle), and (b) directly check the genuinely state-aware shapes (the phase +// enum, the sizing-profile enum, the user bundle, and the `IsolationSessionPhase` +// field set). The runtime body is a no-op; the guarantee is enforced at `tsc` +// time. + +import { test } from 'node:test'; + +import type { ProcessConfig, FilesystemConfig } from '../../src/types.js'; + +import type { + Phase, + IsolationSessionUserConfig, + IsolationSessionProvisionConfig, + IsolationSessionStartConfig, + IsolationSessionExecConfig, + IsolationSessionStopConfig, + IsolationSessionDeprovisionConfig, +} from '../../src/state-aware-types.js'; + +import type { + Phase as WirePhase, + IsolationUser as WireIsolationUser, + IsolationConfigurationId as WireIsolationConfigurationId, + IsolationSessionPhase as WireIsolationSessionPhase, +} from '../../src/generated/wire.js'; + +import type { + AssertTrue, + StripIndex, + OnlyInPublic, + OnlyInWire, + Equivalent, +} from './conformance-helpers.js'; + +// --- enum conformance ------------------------------------------------------ + +// The lifecycle phase enum must be value-for-value identical to the wire `Phase`. +type _Phase = AssertTrue>; + +// Sizing profile: the SDK exposes it inline on `startSandbox`. This is the exact +// drift case — a new wire `IsolationConfigurationId` value would otherwise be +// unrequestable through the SDK with no CI signal. +type _ConfigurationId = AssertTrue< + Equivalent, WireIsolationConfigurationId> +>; + +// --- user bundle conformance ---------------------------------------------- + +// `IsolationSessionUserConfig` is a class; compare its DATA shape (the symbol +// inspect method is not part of the wire contract) to wire `IsolationUser`. +// Value equivalence alone misses a NEW OPTIONAL wire field (an optional addition +// does not break mutual assignability), so the key sets are also pinned in both +// directions: a new wire credential field (optional or required) fails +// `_UserBundleWireKeys`, and a public-only field fails `_UserBundlePublicKeys`. +type PublicUserData = Pick; +type _UserBundleVals = AssertTrue>; +type _UserBundleWireKeys = AssertTrue, never>>; +type _UserBundlePublicKeys = AssertTrue, never>>; + +// Both phases that accept a user bundle must reuse the SAME public type, so the +// bundle check above covers them transitively. +type _ProvisionUserReuse = AssertTrue< + Equivalent, IsolationSessionUserConfig> +>; +type _StartUserReuse = AssertTrue< + Equivalent, IsolationSessionUserConfig> +>; + +// --- IsolationSessionPhase field-set conformance --------------------------- + +// The per-phase wire surface is DERIVED from the real public phase configs, not +// hand-restated, so a newly exposed public phase field cannot bypass the oracle +// (review finding F2). Each phase config splits into "lifted" fields that map to +// top-level wire locations (`version` is SDK metadata; `filesystem` → top-level +// `Filesystem`; `process` → top-level `Process`, all covered elsewhere) and +// backend-specific fields that map onto the wire `IsolationSessionPhase` object. +// `PublicPhaseKeys` is the union of those backend-specific keys across all five +// phase configs. +type LiftedPhaseKey = 'version' | 'filesystem' | 'process'; +type PublicPhaseKeys = Exclude< + | keyof IsolationSessionProvisionConfig + | keyof IsolationSessionStartConfig + | keyof IsolationSessionExecConfig + | keyof IsolationSessionStopConfig + | keyof IsolationSessionDeprovisionConfig, + LiftedPhaseKey +>; +type WirePhaseKeys = keyof StripIndex; + +// A public phase field with no wire `IsolationSessionPhase` counterpart fails +// (the SDK exposes a field the wire model does not define). +type _PhasePublicKeys = AssertTrue, never>>; +// A wire `IsolationSessionPhase` field no phase config exposes fails (the wire +// model gained a per-phase field the SDK forgot to surface). +type _PhaseWireKeys = AssertTrue, never>>; +// The per-field VALUES of the two backend-specific phase fields are pinned +// individually above: `configurationId` by `_ConfigurationId` and `user` by the +// `_UserBundle*` checks. + +// --- delegation to the one-shot oracle (documented, asserted) -------------- + +// The per-phase configs must REUSE the public one-shot leaf types for their +// top-level fields, so the one-shot oracle already pins those shapes. If a config +// re-declared an inline shape instead, it would escape that coverage — these +// assertions fail if that ever happens. +type _ExecProcessReuse = AssertTrue>; +type _ProvisionFilesystemReuse = AssertTrue< + Equivalent, FilesystemConfig> +>; + +// Reference the assertion aliases so they read as intentionally load-bearing. +export type StateAwareWireConformanceAssertions = [ + _Phase, + _ConfigurationId, + _UserBundleVals, + _UserBundleWireKeys, + _UserBundlePublicKeys, + _ProvisionUserReuse, + _StartUserReuse, + _PhaseWireKeys, + _PhasePublicKeys, + _ExecProcessReuse, + _ProvisionFilesystemReuse, +]; + +test('public state-aware SDK types conform to the generated wire schema (compile-time)', () => { + // Intentionally empty: the guarantee is enforced by the type aliases above at + // `tsc` time. If they fail to compile, `npm run build:test-unit` fails before + // this test ever runs. +}); diff --git a/sdk/tests/unit/wire-conformance.test.ts b/sdk/tests/unit/wire-conformance.test.ts index 18605a52..6043e8b0 100644 --- a/sdk/tests/unit/wire-conformance.test.ts +++ b/sdk/tests/unit/wire-conformance.test.ts @@ -83,48 +83,14 @@ import type { TransportProtocol as WireTransportProtocol, } from '../../src/generated/wire.js'; -// --- type-level assertion helpers ----------------------------------------- - -/** Compiles only when `T` is exactly `true`. */ -type AssertTrue = T; - -/** Drop the `[k: string]: unknown` index signature the emitter writes on open objects. */ -type StripIndex = { [K in keyof T as string extends K ? never : K]: T[K] }; - -/** - * Recursively drop index signatures (open objects nest: e.g. - * `experimental.wslc`), so structural assignment is not tripped by an emitted - * `[k: string]: unknown` at any depth. Modifiers (`?`) are preserved because the - * mapped type is homomorphic over `keyof T`. - */ -type DeepStripIndex = T extends (infer E)[] - ? DeepStripIndex[] - : T extends object - ? { [K in keyof T as string extends K ? never : K]: DeepStripIndex } - : T; - -/** `true` iff every value of `A` is assignable to `B` (index signatures stripped, recursively). */ -type Assignable = [A] extends [DeepStripIndex] ? true : false; - -/** Keys present on the public type but absent from the wire type. */ -type OnlyInPublic = Exclude; - -/** - * Keys present on the wire type but absent from the public type (review finding - * F1, gpt-5.5 pass). Because every generated wire field is optional, - * `Public extends Wire` stays true when the SDK simply forgets a new wire field, - * so the value/`OnlyInPublic` checks alone do NOT catch a wire-only ADDITION. - * This closes that direction. `StripIndex` drops the `[k: string]: unknown` so - * the open experimental objects don't make `keyof` collapse to `string`. - */ -type OnlyInWire = Exclude, keyof Pub>; - -/** `true` iff `A` and `B` are mutually assignable (same value set). */ -type Equivalent = [A] extends [B] - ? [B] extends [A] - ? true - : false - : false; +import type { + AssertTrue, + StripIndex, + Assignable, + OnlyInPublic, + OnlyInWire, + Equivalent, +} from './conformance-helpers.js'; // --- enum / union conformance ---------------------------------------------