From 2af507e9710076eed439affee1183997ed12db25 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 2 Nov 2026 15:32:06 -0500 Subject: [PATCH 1/7] feat: expose method audiences (FORGE-1) --- packages/forge/README.md | 5 ++++- packages/forge/plugin/forge.wit | 4 +++- packages/forge/src/index.ts | 12 ++++++++++-- packages/forge/test/plugin.test.js | 2 +- src/lib.rs | 16 +++++++++++++++- src/link.rs | 6 ++++++ src/link/openapi.rs | 6 ++++++ src/normalize/vendor/fern.rs | 4 +++- src/normalize/vendor/tests.rs | 1 - src/plugin/wasm.rs | 5 ++++- tests/normalize/normalize_cli.rs | 18 ++++++++++++++++++ tests/parse/aliases.rs | 3 ++- tests/parse/extensions.rs | 5 ++++- tests/parse/methods.rs | 16 ++++++++++++++++ tests/wasm.rs | 4 ++-- tests/wasm/dump.rs | 3 ++- tests/wasm/ts/src/lib.rs | 6 ++++++ tests/wasm/ts/wit/forge.wit | 4 +++- 18 files changed, 105 insertions(+), 15 deletions(-) diff --git a/packages/forge/README.md b/packages/forge/README.md index 75d6b62a9..6bb44922e 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -55,6 +55,9 @@ to select more than one. For example, What is rewritten: +- Fern method availability and audience tags become `x-forge-availability` + and `x-forge-audiences`. Forge exposes both to plugins but does not filter + methods by audience itself. - A group path written as an array becomes the names joined by dots (`["a", "b"]` becomes `"a.b"`), unless a name has a dot in it. - A skip for only some targets, written as a list, has no native form, since @@ -153,7 +156,7 @@ Array types expose `minItems` and `maxItems`; an absent bound is can handle it explicitly. Other unrecognized string formats remain `scalar: 'string'`. Forge does not validate request values. -The plugin interface is `forge:plugin@0.3.0`. Rebuild plugins compiled +The plugin interface is `forge:plugin@0.4.0`. Rebuild plugins compiled against `0.2.0` with the updated package before running them with this version of Forge. diff --git a/packages/forge/plugin/forge.wit b/packages/forge/plugin/forge.wit index 7a489f74e..a0a26611d 100644 --- a/packages/forge/plugin/forge.wit +++ b/packages/forge/plugin/forge.wit @@ -1,5 +1,5 @@ -package forge:plugin@0.3.0; +package forge:plugin@0.4.0; /// Read-only access to the forge a plugin runs on, which the host keeps. /// Each id is an index the host gave out. An id the host never gave out @@ -96,6 +96,8 @@ interface forge { description: string, deprecated: bool, availability: option, + /// Audience identifiers from `x-forge-audiences`, in written order. + audiences: list, /// Whether to leave the method out of listings, from `x-forge-hidden`. hidden: bool, /// The sentence to show before calling the method, from diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts index d40d60c63..eae131c01 100644 --- a/packages/forge/src/index.ts +++ b/packages/forge/src/index.ts @@ -32,8 +32,8 @@ // classes behind them stay private, so the bindings never show in // index.d.ts. -import * as host from 'forge:plugin/forge@0.3.0'; -import type * as hostPlugin from 'forge:plugin/plugin@0.3.0'; +import * as host from 'forge:plugin/forge@0.4.0'; +import type * as hostPlugin from 'forge:plugin/plugin@0.4.0'; // --------------------------------------------------------------------------- // The API. @@ -146,6 +146,8 @@ export interface Method { readonly deprecated: boolean; /** The stage of the API the method is in, from `x-forge-availability`, or undefined if it doesn't say. */ readonly availability: Availability | undefined; + /** Audience identifiers from `x-forge-audiences`, in written order. */ + readonly audiences: readonly string[]; /** * Whether to leave the method out of listings of methods, such as a CLI's * help, although it can still be called: `x-forge-hidden`. @@ -688,6 +690,7 @@ class MethodImpl implements Method { description: string; deprecated: boolean; availability: Availability | undefined; + audiences: readonly string[]; hidden: boolean; confirmation: string | undefined; request: host.TypeId | undefined; @@ -714,6 +717,7 @@ class MethodImpl implements Method { description: m.description, deprecated: m.deprecated, availability: opt(m.availability), + audiences: m.audiences, hidden: m.hidden, confirmation: opt(m.confirmation), request: opt(m.request), @@ -762,6 +766,10 @@ class MethodImpl implements Method { return this.info().availability; } + get audiences(): readonly string[] { + return this.info().audiences; + } + get hidden(): boolean { return this.info().hidden; } diff --git a/packages/forge/test/plugin.test.js b/packages/forge/test/plugin.test.js index be76a75f2..7227a948b 100644 --- a/packages/forge/test/plugin.test.js +++ b/packages/forge/test/plugin.test.js @@ -7,7 +7,7 @@ import { test } from 'node:test'; const hooks = registerHooks({ resolve(specifier, context, nextResolve) { return nextResolve( - specifier === 'forge:plugin/forge@0.3.0' + specifier === 'forge:plugin/forge@0.4.0' ? new URL('./fixtures/metadata-host.js', import.meta.url).href : specifier, context, diff --git a/src/lib.rs b/src/lib.rs index f9ba83e9f..4d46dff04 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -232,7 +232,7 @@ impl std::fmt::Debug for Group<'_> { /// /// An alias says, as an operation would, the method's /// [`group`](Method::group), [`name`](Method::name), -/// [`availability`](Method::availability), whether it is +/// [`availability`](Method::availability), [`audiences`](Method::audiences), whether it is /// [hidden](Method::is_hidden), its [`confirmation`](Method::confirmation) /// and its [`extensions`](Method::extensions), and whether it is ignored /// with `x-forge-skip`. None of these come from the operation: an alias @@ -333,6 +333,18 @@ impl<'f> Method<'f> { self.data().availability } + /// Returns the audiences the method belongs to, in the order written. + /// + /// Corresponds to `x-forge-audiences`, an array of audience identifiers. + /// An operation or alias without it belongs to no named audience. Forge + /// records the identifiers for plugins; it does not filter methods itself. + pub fn audiences(self) -> impl Iterator + 'f { + self.data() + .audiences + .iter() + .map(|&audience| self.forge.strings.get(audience)) + } + /// Reports whether the method should be left out of listings of /// methods, such as a CLI's help, although it can still be called. /// @@ -499,6 +511,7 @@ impl<'f> Method<'f> { /// [Operation Object](https://spec.openapis.org/oas/latest.html#operation-object) /// that starts with `x-`. Those forge reads, `x-forge-method-name`, /// `x-forge-group-name`, `x-forge-skip`, `x-forge-availability`, + /// `x-forge-audiences`, /// `x-forge-hidden`, `x-forge-require-confirmation` and /// `x-forge-aliases`, are left out. Other keys forge does not read, such as /// the extensions other tools write, are kept, and `forge normalize` is @@ -776,6 +789,7 @@ struct MethodData { description: Str, deprecated: bool, availability: Option, + audiences: Vec, hidden: bool, /// The sentence to confirm with, or `None` for none. confirmation: Option, diff --git a/src/link.rs b/src/link.rs index 9760ff814..ac736032a 100644 --- a/src/link.rs +++ b/src/link.rs @@ -595,6 +595,11 @@ impl<'d> Builder<'d> { )); } } + let audiences = doc + .list(definition.audiences) + .iter() + .map(|&audience| self.strings.push(doc.str(audience))) + .collect(); let mut group = None; for group_name in group_names { group = Some(self.child(group, group_name)); @@ -683,6 +688,7 @@ impl<'d> Builder<'d> { description: self.strings.push(doc.str(operation.description)), deprecated: operation.deprecated, availability, + audiences, hidden: definition.hidden, confirmation: definition.confirmation.map(|s| self.strings.push(doc.str(s))), request, diff --git a/src/link/openapi.rs b/src/link/openapi.rs index 7b8af55de..72b200388 100644 --- a/src/link/openapi.rs +++ b/src/link/openapi.rs @@ -256,6 +256,8 @@ pub(super) struct Definition { /// checked as the method is built, so that an error can name the /// operation. pub(super) availability: Option, + /// The audience identifiers in `x-forge-audiences`, as written. + pub(super) audiences: Span, /// Each other extension, which is not read, in `Doc::extensions`, in /// the order they are written. One written more than once has the last /// value written, in the place of the first. @@ -1191,6 +1193,7 @@ keys! { Hidden = "x-forge-hidden", Confirmation = "x-forge-require-confirmation", Availability = "x-forge-availability", + Audiences = "x-forge-audiences", } } @@ -1213,6 +1216,9 @@ impl Definition { DefinitionKey::Availability => { self.availability = Some(map.next_value_seed(seed::(doc))?); } + DefinitionKey::Audiences => { + self.audiences = map.next_value_seed(seed::>(doc))?; + } DefinitionKey::Other => skip(map)?, } Ok(()) diff --git a/src/normalize/vendor/fern.rs b/src/normalize/vendor/fern.rs index 0176b6d18..3496aff2b 100644 --- a/src/normalize/vendor/fern.rs +++ b/src/normalize/vendor/fern.rs @@ -8,15 +8,17 @@ static METHOD_NAME: Rename = Rename::same("x-forge-method-name"); static GROUP_NAME: Rename = Rename::group("x-forge-group-name"); static SKIP: Rename = Rename::same("x-forge-skip"); static AVAILABILITY: Rename = Rename::same("x-forge-availability"); +static AUDIENCES: Rename = Rename::same("x-forge-audiences"); static PARAMETER_NAME: Rename = Rename::same("x-forge-parameter-name"); static MODEL_NAME: Rename = Rename::same("x-forge-model-name"); static PROPERTY_NAME: Rename = Rename::same("x-forge-property-name"); -static DEFINITION: [Rule; 4] = [ +static DEFINITION: [Rule; 5] = [ ("x-fern-sdk-method-name", &METHOD_NAME), ("x-fern-sdk-group-name", &GROUP_NAME), ("x-fern-ignore", &SKIP), ("x-fern-availability", &AVAILABILITY), + ("x-fern-audiences", &AUDIENCES), ]; static PARAMETER: [Rule; 1] = [("x-fern-parameter-name", &PARAMETER_NAME)]; static SCHEMA: [Rule; 2] = [ diff --git a/src/normalize/vendor/tests.rs b/src/normalize/vendor/tests.rs index 0d10b60bc..0b5cc8a45 100644 --- a/src/normalize/vendor/tests.rs +++ b/src/normalize/vendor/tests.rs @@ -113,7 +113,6 @@ fn the_other_extensions_of_these_tools_are_not_read() { let sources = Sources::all(); for key in [ "x-fern-enum", - "x-fern-audiences", "x-fern-pagination", "x-fern-sdk-variables", "x-stainless-terraform-configurability", diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index 6d5729cd7..33211ea9b 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -60,7 +60,7 @@ macro_rules! wit { wit!( r#" -package forge:plugin@0.3.0; +package forge:plugin@0.4.0; /// Read-only access to the forge a plugin runs on, which the host keeps. /// Each id is an index the host gave out. An id the host never gave out @@ -157,6 +157,8 @@ interface forge { description: string, deprecated: bool, availability: option, + /// Audience identifiers from `x-forge-audiences`, in written order. + audiences: list, /// Whether to leave the method out of listings, from `x-forge-hidden`. hidden: bool, /// The sentence to show before calling the method, from @@ -508,6 +510,7 @@ impl wit::Host for State { Availability::GenerallyAvailable => wit::Availability::GenerallyAvailable, Availability::Deprecated => wit::Availability::Deprecated, }), + audiences: data.audiences.iter().map(|&audience| s(audience)).collect(), hidden: data.hidden, confirmation: data.confirmation.map(s), request: data.request.map(|id| id.0), diff --git a/tests/normalize/normalize_cli.rs b/tests/normalize/normalize_cli.rs index edc6de409..353f64a1d 100644 --- a/tests/normalize/normalize_cli.rs +++ b/tests/normalize/normalize_cli.rs @@ -134,6 +134,24 @@ fn standard_input_is_written_to_standard_output() { assert_eq!(text(&out.stdout), NATIVE); } +#[test] +fn fern_audiences_and_availability_become_native_method_metadata() { + let document = FERN.replace( + r#""x-fern-sdk-group-name":["a"]"#, + r#""x-fern-sdk-group-name":["a"],"x-fern-audiences":["sdk"],"x-fern-availability":"beta""#, + ); + + let out = normalize(&["--from", "fern", "-"], &document); + + succeeded(&out); + let value: Value = serde_json::from_slice(&out.stdout).unwrap(); + let operation = &value["paths"]["/a"]["get"]; + assert_eq!(operation["x-forge-audiences"], serde_json::json!(["sdk"])); + assert_eq!(operation["x-forge-availability"], "beta"); + assert!(operation.get("x-fern-audiences").is_none()); + assert!(operation.get("x-fern-availability").is_none()); +} + #[test] fn from_rewrites_only_the_selected_compatibility_sources_and_may_be_repeated() { let out = normalize(&["--from", "fern", "--from", "cloudflare", "-"], SOURCES); diff --git a/tests/parse/aliases.rs b/tests/parse/aliases.rs index f0d7db39d..ccb70571e 100644 --- a/tests/parse/aliases.rs +++ b/tests/parse/aliases.rs @@ -98,7 +98,7 @@ fn each_alias_has_its_own_availability_hidden_confirmation_and_extensions() { let forge = parse(&aliased( r#"[ {"x-forge-group-name": "a", "x-forge-method-name": "pause", - "x-forge-availability": "generally-available", "x-forge-hidden": true, + "x-forge-availability": "generally-available", "x-forge-audiences": ["sdk", "docs"], "x-forge-hidden": true, "x-forge-params": {"status": {"default": "pause", "hidden": true}}}, {"x-forge-group-name": "a", "x-forge-method-name": "terminate", "x-forge-availability": "beta", "x-forge-require-confirmation": "Sure?", "x-a": 1, "x-b": 2} @@ -108,6 +108,7 @@ fn each_alias_has_its_own_availability_hidden_confirmation_and_extensions() { let pause = method(&forge, "a.pause"); assert_eq!(pause.availability(), Some(Availability::GenerallyAvailable)); + assert_eq!(pause.audiences().collect::>(), ["sdk", "docs"]); assert!(pause.is_hidden()); assert_eq!(pause.confirmation(), None); let names: Vec<&str> = pause.extensions().map(|(name, _)| name).collect(); diff --git a/tests/parse/extensions.rs b/tests/parse/extensions.rs index 846791a71..da6d1236d 100644 --- a/tests/parse/extensions.rs +++ b/tests/parse/extensions.rs @@ -85,12 +85,15 @@ fn keys_that_are_not_extensions_are_not_kept() { #[test] fn extensions_forge_reads_are_not_kept() { - let forge = parse(&operation_with(r#""x-forge-skip": false, "x-a": 1,"#)); + let forge = parse(&operation_with( + r#""x-forge-skip": false, "x-forge-audiences": ["sdk"], "x-a": 1,"#, + )); let get = method(&forge, "a.get"); assert_eq!(names(&forge, "a.get"), ["x-a"]); assert_eq!(get.extension("x-forge-group-name"), None); assert_eq!(get.extension("x-forge-skip"), None); + assert_eq!(get.extension("x-forge-audiences"), None); } #[test] diff --git a/tests/parse/methods.rs b/tests/parse/methods.rs index ad67eb6f3..52746e470 100644 --- a/tests/parse/methods.rs +++ b/tests/parse/methods.rs @@ -239,6 +239,22 @@ fn method_without_the_extensions_is_listed_unconfirmed_and_of_no_availability() assert!(!get.is_hidden()); assert_eq!(get.confirmation(), None); assert_eq!(get.availability(), None); + assert_eq!(get.audiences().count(), 0); +} + +#[test] +fn method_audiences_are_kept_in_written_order() { + let forge = parse(&operation_with(r#""x-forge-audiences": ["sdk", "docs"],"#)); + + assert_eq!(method(&forge, "a.get").audiences().collect::>(), ["sdk", "docs"]); +} + +#[test] +fn audiences_must_be_an_array_of_strings() { + for (value, expected) in [(r#""sdk""#, "expected a sequence"), ("[1]", "expected a string")] { + let err = parse_err(&operation_with(&format!(r#""x-forge-audiences": {value},"#))); + assert!(err.contains(expected), "{err}"); + } } #[test] diff --git a/tests/wasm.rs b/tests/wasm.rs index 18bedd8b9..5464e08b7 100644 --- a/tests/wasm.rs +++ b/tests/wasm.rs @@ -139,7 +139,7 @@ const FEATURES: &str = r##"{ "parameters": [{"name": "X-Trace", "in": "header", "schema": {"type": "string"}}], "get": { "x-forge-group-name": ["pets"], "x-forge-method-name": "get", - "x-forge-availability": "beta", "x-forge-hidden": true, + "x-forge-availability": "beta", "x-forge-audiences": ["sdk", "docs"], "x-forge-hidden": true, "x-custom": {"b": 1, "a": [1.5, "two \"quoted\"\n", null, true, {}], "b": 2}, "x-flag": false, "x-count": 9007199254740993, "x-text": "é 😀", "parameters": [ @@ -215,7 +215,7 @@ fn reads_all_of_the_forge() { // Every feature is in the dump, so the comparison covers it. if spec == FEATURES { for seen in [ - "availability Some(\"beta\") hidden true", + "availability Some(\"beta\") audiences [\"sdk\", \"docs\"] hidden true", "confirmation Some(\"This deletes the pet.\")", "confirmation Some(\"\")", "extension \"x-custom\"", diff --git a/tests/wasm/dump.rs b/tests/wasm/dump.rs index 86c788b1d..c07b27a57 100644 --- a/tests/wasm/dump.rs +++ b/tests/wasm/dump.rs @@ -87,9 +87,10 @@ fn method(o: &mut String, method: Method<'_>) { o, 2, format_args!( - "deprecated {} availability {:?} hidden {} confirmation {:?}", + "deprecated {} availability {:?} audiences {:?} hidden {} confirmation {:?}", method.is_deprecated(), method.availability().map(|a| a.as_str()), + method.audiences().collect::>(), method.is_hidden(), method.confirmation(), ), diff --git a/tests/wasm/ts/src/lib.rs b/tests/wasm/ts/src/lib.rs index b289aa574..ee728a6ab 100644 --- a/tests/wasm/ts/src/lib.rs +++ b/tests/wasm/ts/src/lib.rs @@ -234,6 +234,7 @@ impl Forge { host::Availability::GenerallyAvailable => Availability::GenerallyAvailable, host::Availability::Deprecated => Availability::Deprecated, }), + audiences: m.audiences, hidden: m.hidden, confirmation: m.confirmation, request: m.request, @@ -390,6 +391,7 @@ struct MethodData { description: String, deprecated: bool, availability: Option, + audiences: Vec, hidden: bool, confirmation: Option, request: Option, @@ -597,6 +599,10 @@ impl<'f> Method<'f> { self.data().availability } + pub fn audiences(self) -> impl Iterator + 'f { + self.data().audiences.iter().map(String::as_str) + } + pub fn is_hidden(self) -> bool { self.data().hidden } diff --git a/tests/wasm/ts/wit/forge.wit b/tests/wasm/ts/wit/forge.wit index 7a489f74e..a0a26611d 100644 --- a/tests/wasm/ts/wit/forge.wit +++ b/tests/wasm/ts/wit/forge.wit @@ -1,5 +1,5 @@ -package forge:plugin@0.3.0; +package forge:plugin@0.4.0; /// Read-only access to the forge a plugin runs on, which the host keeps. /// Each id is an index the host gave out. An id the host never gave out @@ -96,6 +96,8 @@ interface forge { description: string, deprecated: bool, availability: option, + /// Audience identifiers from `x-forge-audiences`, in written order. + audiences: list, /// Whether to leave the method out of listings, from `x-forge-hidden`. hidden: bool, /// The sentence to show before calling the method, from From 1da1623d1f20f20db0c5ff5482d26ca02a273a55 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 2 Nov 2026 16:03:37 -0500 Subject: [PATCH 2/7] docs: propose conditional skips (FORGE-1) --- packages/forge/README.md | 5 +-- packages/forge/plugin/forge.wit | 4 +- packages/forge/src/index.ts | 12 +----- packages/forge/test/plugin.test.js | 2 +- rfcs/0003-conditional-skips.md | 62 ++++++++++++++++++++++++++++++ src/lib.rs | 16 +------- src/link.rs | 6 --- src/link/openapi.rs | 6 --- src/normalize/vendor/fern.rs | 4 +- src/normalize/vendor/tests.rs | 1 + src/plugin/wasm.rs | 5 +-- tests/normalize/normalize_cli.rs | 18 --------- tests/parse/aliases.rs | 3 +- tests/parse/extensions.rs | 5 +-- tests/parse/methods.rs | 16 -------- tests/wasm.rs | 4 +- tests/wasm/dump.rs | 3 +- tests/wasm/ts/src/lib.rs | 6 --- tests/wasm/ts/wit/forge.wit | 4 +- 19 files changed, 77 insertions(+), 105 deletions(-) create mode 100644 rfcs/0003-conditional-skips.md diff --git a/packages/forge/README.md b/packages/forge/README.md index 6bb44922e..75d6b62a9 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -55,9 +55,6 @@ to select more than one. For example, What is rewritten: -- Fern method availability and audience tags become `x-forge-availability` - and `x-forge-audiences`. Forge exposes both to plugins but does not filter - methods by audience itself. - A group path written as an array becomes the names joined by dots (`["a", "b"]` becomes `"a.b"`), unless a name has a dot in it. - A skip for only some targets, written as a list, has no native form, since @@ -156,7 +153,7 @@ Array types expose `minItems` and `maxItems`; an absent bound is can handle it explicitly. Other unrecognized string formats remain `scalar: 'string'`. Forge does not validate request values. -The plugin interface is `forge:plugin@0.4.0`. Rebuild plugins compiled +The plugin interface is `forge:plugin@0.3.0`. Rebuild plugins compiled against `0.2.0` with the updated package before running them with this version of Forge. diff --git a/packages/forge/plugin/forge.wit b/packages/forge/plugin/forge.wit index a0a26611d..7a489f74e 100644 --- a/packages/forge/plugin/forge.wit +++ b/packages/forge/plugin/forge.wit @@ -1,5 +1,5 @@ -package forge:plugin@0.4.0; +package forge:plugin@0.3.0; /// Read-only access to the forge a plugin runs on, which the host keeps. /// Each id is an index the host gave out. An id the host never gave out @@ -96,8 +96,6 @@ interface forge { description: string, deprecated: bool, availability: option, - /// Audience identifiers from `x-forge-audiences`, in written order. - audiences: list, /// Whether to leave the method out of listings, from `x-forge-hidden`. hidden: bool, /// The sentence to show before calling the method, from diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts index eae131c01..d40d60c63 100644 --- a/packages/forge/src/index.ts +++ b/packages/forge/src/index.ts @@ -32,8 +32,8 @@ // classes behind them stay private, so the bindings never show in // index.d.ts. -import * as host from 'forge:plugin/forge@0.4.0'; -import type * as hostPlugin from 'forge:plugin/plugin@0.4.0'; +import * as host from 'forge:plugin/forge@0.3.0'; +import type * as hostPlugin from 'forge:plugin/plugin@0.3.0'; // --------------------------------------------------------------------------- // The API. @@ -146,8 +146,6 @@ export interface Method { readonly deprecated: boolean; /** The stage of the API the method is in, from `x-forge-availability`, or undefined if it doesn't say. */ readonly availability: Availability | undefined; - /** Audience identifiers from `x-forge-audiences`, in written order. */ - readonly audiences: readonly string[]; /** * Whether to leave the method out of listings of methods, such as a CLI's * help, although it can still be called: `x-forge-hidden`. @@ -690,7 +688,6 @@ class MethodImpl implements Method { description: string; deprecated: boolean; availability: Availability | undefined; - audiences: readonly string[]; hidden: boolean; confirmation: string | undefined; request: host.TypeId | undefined; @@ -717,7 +714,6 @@ class MethodImpl implements Method { description: m.description, deprecated: m.deprecated, availability: opt(m.availability), - audiences: m.audiences, hidden: m.hidden, confirmation: opt(m.confirmation), request: opt(m.request), @@ -766,10 +762,6 @@ class MethodImpl implements Method { return this.info().availability; } - get audiences(): readonly string[] { - return this.info().audiences; - } - get hidden(): boolean { return this.info().hidden; } diff --git a/packages/forge/test/plugin.test.js b/packages/forge/test/plugin.test.js index 7227a948b..be76a75f2 100644 --- a/packages/forge/test/plugin.test.js +++ b/packages/forge/test/plugin.test.js @@ -7,7 +7,7 @@ import { test } from 'node:test'; const hooks = registerHooks({ resolve(specifier, context, nextResolve) { return nextResolve( - specifier === 'forge:plugin/forge@0.4.0' + specifier === 'forge:plugin/forge@0.3.0' ? new URL('./fixtures/metadata-host.js', import.meta.url).href : specifier, context, diff --git a/rfcs/0003-conditional-skips.md b/rfcs/0003-conditional-skips.md new file mode 100644 index 000000000..a8fee261c --- /dev/null +++ b/rfcs/0003-conditional-skips.md @@ -0,0 +1,62 @@ +# RFC 0003: Conditional skips + +## Summary + +Forge filters skipped operations and fields before a plugin receives its model. +`x-forge-skip` accepts a boolean or one condition rule: + +```yaml +x-forge-skip: true + +x-forge-skip: + when: [terraform] + +x-forge-skip: + unless: [sdk] +``` + +A generation has an unordered set of active condition names, such as `sdk` and +`typescript`. `when` skips when any named condition is active. `unless` skips +when none is active. The two keys are mutually exclusive. + +| Value | Result | +| ------------------- | -------------------------------------------- | +| missing or `false` | keep | +| `true` | skip | +| `{ when: names }` | skip when the active set intersects `names` | +| `{ unless: names }` | skip when the active set misses every `name` | + +Names are exact, case-sensitive strings. Their order and duplicates have no +effect. `when: []` is equivalent to `false`; `unless: []` is equivalent to +`true`. + +## Conditions + +The name follows Node's conditional exports: several independent conditions can +describe one output. For example, a TypeScript SDK can activate both `sdk` and +`typescript`. + +The CLI accepts additional conditions as a comma-separated set: + +```console +forge openapi.json --target wasm://plugin.wasm --conditions=sdk,typescript --out sdk +``` + +Built-in targets provide their own conditions. The host resolves skips before +building the Forge model, so plugins cannot disagree about filtering and never +receive skipped definitions or skip metadata. A future `--include-skipped` +option may bypass filtering for inspection; it is not part of this RFC. + +## Compatibility adapters + +The compatibility layer maps vendor intent instead of copying vendor shapes: + +- `x-stainless-skip: [terraform]` becomes + `x-forge-skip: { when: [terraform] }`. +- `x-fern-audiences: [sdk]` becomes + `x-forge-skip: { unless: [sdk] }`. +- Boolean vendor skips remain boolean. + +`x-forge-availability` remains independent lifecycle metadata and is not a +filter. This design adds no audience field to the plugin API and requires no WIT +change. diff --git a/src/lib.rs b/src/lib.rs index 4d46dff04..f9ba83e9f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -232,7 +232,7 @@ impl std::fmt::Debug for Group<'_> { /// /// An alias says, as an operation would, the method's /// [`group`](Method::group), [`name`](Method::name), -/// [`availability`](Method::availability), [`audiences`](Method::audiences), whether it is +/// [`availability`](Method::availability), whether it is /// [hidden](Method::is_hidden), its [`confirmation`](Method::confirmation) /// and its [`extensions`](Method::extensions), and whether it is ignored /// with `x-forge-skip`. None of these come from the operation: an alias @@ -333,18 +333,6 @@ impl<'f> Method<'f> { self.data().availability } - /// Returns the audiences the method belongs to, in the order written. - /// - /// Corresponds to `x-forge-audiences`, an array of audience identifiers. - /// An operation or alias without it belongs to no named audience. Forge - /// records the identifiers for plugins; it does not filter methods itself. - pub fn audiences(self) -> impl Iterator + 'f { - self.data() - .audiences - .iter() - .map(|&audience| self.forge.strings.get(audience)) - } - /// Reports whether the method should be left out of listings of /// methods, such as a CLI's help, although it can still be called. /// @@ -511,7 +499,6 @@ impl<'f> Method<'f> { /// [Operation Object](https://spec.openapis.org/oas/latest.html#operation-object) /// that starts with `x-`. Those forge reads, `x-forge-method-name`, /// `x-forge-group-name`, `x-forge-skip`, `x-forge-availability`, - /// `x-forge-audiences`, /// `x-forge-hidden`, `x-forge-require-confirmation` and /// `x-forge-aliases`, are left out. Other keys forge does not read, such as /// the extensions other tools write, are kept, and `forge normalize` is @@ -789,7 +776,6 @@ struct MethodData { description: Str, deprecated: bool, availability: Option, - audiences: Vec, hidden: bool, /// The sentence to confirm with, or `None` for none. confirmation: Option, diff --git a/src/link.rs b/src/link.rs index ac736032a..9760ff814 100644 --- a/src/link.rs +++ b/src/link.rs @@ -595,11 +595,6 @@ impl<'d> Builder<'d> { )); } } - let audiences = doc - .list(definition.audiences) - .iter() - .map(|&audience| self.strings.push(doc.str(audience))) - .collect(); let mut group = None; for group_name in group_names { group = Some(self.child(group, group_name)); @@ -688,7 +683,6 @@ impl<'d> Builder<'d> { description: self.strings.push(doc.str(operation.description)), deprecated: operation.deprecated, availability, - audiences, hidden: definition.hidden, confirmation: definition.confirmation.map(|s| self.strings.push(doc.str(s))), request, diff --git a/src/link/openapi.rs b/src/link/openapi.rs index 72b200388..7b8af55de 100644 --- a/src/link/openapi.rs +++ b/src/link/openapi.rs @@ -256,8 +256,6 @@ pub(super) struct Definition { /// checked as the method is built, so that an error can name the /// operation. pub(super) availability: Option, - /// The audience identifiers in `x-forge-audiences`, as written. - pub(super) audiences: Span, /// Each other extension, which is not read, in `Doc::extensions`, in /// the order they are written. One written more than once has the last /// value written, in the place of the first. @@ -1193,7 +1191,6 @@ keys! { Hidden = "x-forge-hidden", Confirmation = "x-forge-require-confirmation", Availability = "x-forge-availability", - Audiences = "x-forge-audiences", } } @@ -1216,9 +1213,6 @@ impl Definition { DefinitionKey::Availability => { self.availability = Some(map.next_value_seed(seed::(doc))?); } - DefinitionKey::Audiences => { - self.audiences = map.next_value_seed(seed::>(doc))?; - } DefinitionKey::Other => skip(map)?, } Ok(()) diff --git a/src/normalize/vendor/fern.rs b/src/normalize/vendor/fern.rs index 3496aff2b..0176b6d18 100644 --- a/src/normalize/vendor/fern.rs +++ b/src/normalize/vendor/fern.rs @@ -8,17 +8,15 @@ static METHOD_NAME: Rename = Rename::same("x-forge-method-name"); static GROUP_NAME: Rename = Rename::group("x-forge-group-name"); static SKIP: Rename = Rename::same("x-forge-skip"); static AVAILABILITY: Rename = Rename::same("x-forge-availability"); -static AUDIENCES: Rename = Rename::same("x-forge-audiences"); static PARAMETER_NAME: Rename = Rename::same("x-forge-parameter-name"); static MODEL_NAME: Rename = Rename::same("x-forge-model-name"); static PROPERTY_NAME: Rename = Rename::same("x-forge-property-name"); -static DEFINITION: [Rule; 5] = [ +static DEFINITION: [Rule; 4] = [ ("x-fern-sdk-method-name", &METHOD_NAME), ("x-fern-sdk-group-name", &GROUP_NAME), ("x-fern-ignore", &SKIP), ("x-fern-availability", &AVAILABILITY), - ("x-fern-audiences", &AUDIENCES), ]; static PARAMETER: [Rule; 1] = [("x-fern-parameter-name", &PARAMETER_NAME)]; static SCHEMA: [Rule; 2] = [ diff --git a/src/normalize/vendor/tests.rs b/src/normalize/vendor/tests.rs index 0b5cc8a45..0d10b60bc 100644 --- a/src/normalize/vendor/tests.rs +++ b/src/normalize/vendor/tests.rs @@ -113,6 +113,7 @@ fn the_other_extensions_of_these_tools_are_not_read() { let sources = Sources::all(); for key in [ "x-fern-enum", + "x-fern-audiences", "x-fern-pagination", "x-fern-sdk-variables", "x-stainless-terraform-configurability", diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index 33211ea9b..6d5729cd7 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -60,7 +60,7 @@ macro_rules! wit { wit!( r#" -package forge:plugin@0.4.0; +package forge:plugin@0.3.0; /// Read-only access to the forge a plugin runs on, which the host keeps. /// Each id is an index the host gave out. An id the host never gave out @@ -157,8 +157,6 @@ interface forge { description: string, deprecated: bool, availability: option, - /// Audience identifiers from `x-forge-audiences`, in written order. - audiences: list, /// Whether to leave the method out of listings, from `x-forge-hidden`. hidden: bool, /// The sentence to show before calling the method, from @@ -510,7 +508,6 @@ impl wit::Host for State { Availability::GenerallyAvailable => wit::Availability::GenerallyAvailable, Availability::Deprecated => wit::Availability::Deprecated, }), - audiences: data.audiences.iter().map(|&audience| s(audience)).collect(), hidden: data.hidden, confirmation: data.confirmation.map(s), request: data.request.map(|id| id.0), diff --git a/tests/normalize/normalize_cli.rs b/tests/normalize/normalize_cli.rs index 353f64a1d..edc6de409 100644 --- a/tests/normalize/normalize_cli.rs +++ b/tests/normalize/normalize_cli.rs @@ -134,24 +134,6 @@ fn standard_input_is_written_to_standard_output() { assert_eq!(text(&out.stdout), NATIVE); } -#[test] -fn fern_audiences_and_availability_become_native_method_metadata() { - let document = FERN.replace( - r#""x-fern-sdk-group-name":["a"]"#, - r#""x-fern-sdk-group-name":["a"],"x-fern-audiences":["sdk"],"x-fern-availability":"beta""#, - ); - - let out = normalize(&["--from", "fern", "-"], &document); - - succeeded(&out); - let value: Value = serde_json::from_slice(&out.stdout).unwrap(); - let operation = &value["paths"]["/a"]["get"]; - assert_eq!(operation["x-forge-audiences"], serde_json::json!(["sdk"])); - assert_eq!(operation["x-forge-availability"], "beta"); - assert!(operation.get("x-fern-audiences").is_none()); - assert!(operation.get("x-fern-availability").is_none()); -} - #[test] fn from_rewrites_only_the_selected_compatibility_sources_and_may_be_repeated() { let out = normalize(&["--from", "fern", "--from", "cloudflare", "-"], SOURCES); diff --git a/tests/parse/aliases.rs b/tests/parse/aliases.rs index ccb70571e..f0d7db39d 100644 --- a/tests/parse/aliases.rs +++ b/tests/parse/aliases.rs @@ -98,7 +98,7 @@ fn each_alias_has_its_own_availability_hidden_confirmation_and_extensions() { let forge = parse(&aliased( r#"[ {"x-forge-group-name": "a", "x-forge-method-name": "pause", - "x-forge-availability": "generally-available", "x-forge-audiences": ["sdk", "docs"], "x-forge-hidden": true, + "x-forge-availability": "generally-available", "x-forge-hidden": true, "x-forge-params": {"status": {"default": "pause", "hidden": true}}}, {"x-forge-group-name": "a", "x-forge-method-name": "terminate", "x-forge-availability": "beta", "x-forge-require-confirmation": "Sure?", "x-a": 1, "x-b": 2} @@ -108,7 +108,6 @@ fn each_alias_has_its_own_availability_hidden_confirmation_and_extensions() { let pause = method(&forge, "a.pause"); assert_eq!(pause.availability(), Some(Availability::GenerallyAvailable)); - assert_eq!(pause.audiences().collect::>(), ["sdk", "docs"]); assert!(pause.is_hidden()); assert_eq!(pause.confirmation(), None); let names: Vec<&str> = pause.extensions().map(|(name, _)| name).collect(); diff --git a/tests/parse/extensions.rs b/tests/parse/extensions.rs index da6d1236d..846791a71 100644 --- a/tests/parse/extensions.rs +++ b/tests/parse/extensions.rs @@ -85,15 +85,12 @@ fn keys_that_are_not_extensions_are_not_kept() { #[test] fn extensions_forge_reads_are_not_kept() { - let forge = parse(&operation_with( - r#""x-forge-skip": false, "x-forge-audiences": ["sdk"], "x-a": 1,"#, - )); + let forge = parse(&operation_with(r#""x-forge-skip": false, "x-a": 1,"#)); let get = method(&forge, "a.get"); assert_eq!(names(&forge, "a.get"), ["x-a"]); assert_eq!(get.extension("x-forge-group-name"), None); assert_eq!(get.extension("x-forge-skip"), None); - assert_eq!(get.extension("x-forge-audiences"), None); } #[test] diff --git a/tests/parse/methods.rs b/tests/parse/methods.rs index 52746e470..ad67eb6f3 100644 --- a/tests/parse/methods.rs +++ b/tests/parse/methods.rs @@ -239,22 +239,6 @@ fn method_without_the_extensions_is_listed_unconfirmed_and_of_no_availability() assert!(!get.is_hidden()); assert_eq!(get.confirmation(), None); assert_eq!(get.availability(), None); - assert_eq!(get.audiences().count(), 0); -} - -#[test] -fn method_audiences_are_kept_in_written_order() { - let forge = parse(&operation_with(r#""x-forge-audiences": ["sdk", "docs"],"#)); - - assert_eq!(method(&forge, "a.get").audiences().collect::>(), ["sdk", "docs"]); -} - -#[test] -fn audiences_must_be_an_array_of_strings() { - for (value, expected) in [(r#""sdk""#, "expected a sequence"), ("[1]", "expected a string")] { - let err = parse_err(&operation_with(&format!(r#""x-forge-audiences": {value},"#))); - assert!(err.contains(expected), "{err}"); - } } #[test] diff --git a/tests/wasm.rs b/tests/wasm.rs index 5464e08b7..18bedd8b9 100644 --- a/tests/wasm.rs +++ b/tests/wasm.rs @@ -139,7 +139,7 @@ const FEATURES: &str = r##"{ "parameters": [{"name": "X-Trace", "in": "header", "schema": {"type": "string"}}], "get": { "x-forge-group-name": ["pets"], "x-forge-method-name": "get", - "x-forge-availability": "beta", "x-forge-audiences": ["sdk", "docs"], "x-forge-hidden": true, + "x-forge-availability": "beta", "x-forge-hidden": true, "x-custom": {"b": 1, "a": [1.5, "two \"quoted\"\n", null, true, {}], "b": 2}, "x-flag": false, "x-count": 9007199254740993, "x-text": "é 😀", "parameters": [ @@ -215,7 +215,7 @@ fn reads_all_of_the_forge() { // Every feature is in the dump, so the comparison covers it. if spec == FEATURES { for seen in [ - "availability Some(\"beta\") audiences [\"sdk\", \"docs\"] hidden true", + "availability Some(\"beta\") hidden true", "confirmation Some(\"This deletes the pet.\")", "confirmation Some(\"\")", "extension \"x-custom\"", diff --git a/tests/wasm/dump.rs b/tests/wasm/dump.rs index c07b27a57..86c788b1d 100644 --- a/tests/wasm/dump.rs +++ b/tests/wasm/dump.rs @@ -87,10 +87,9 @@ fn method(o: &mut String, method: Method<'_>) { o, 2, format_args!( - "deprecated {} availability {:?} audiences {:?} hidden {} confirmation {:?}", + "deprecated {} availability {:?} hidden {} confirmation {:?}", method.is_deprecated(), method.availability().map(|a| a.as_str()), - method.audiences().collect::>(), method.is_hidden(), method.confirmation(), ), diff --git a/tests/wasm/ts/src/lib.rs b/tests/wasm/ts/src/lib.rs index ee728a6ab..b289aa574 100644 --- a/tests/wasm/ts/src/lib.rs +++ b/tests/wasm/ts/src/lib.rs @@ -234,7 +234,6 @@ impl Forge { host::Availability::GenerallyAvailable => Availability::GenerallyAvailable, host::Availability::Deprecated => Availability::Deprecated, }), - audiences: m.audiences, hidden: m.hidden, confirmation: m.confirmation, request: m.request, @@ -391,7 +390,6 @@ struct MethodData { description: String, deprecated: bool, availability: Option, - audiences: Vec, hidden: bool, confirmation: Option, request: Option, @@ -599,10 +597,6 @@ impl<'f> Method<'f> { self.data().availability } - pub fn audiences(self) -> impl Iterator + 'f { - self.data().audiences.iter().map(String::as_str) - } - pub fn is_hidden(self) -> bool { self.data().hidden } diff --git a/tests/wasm/ts/wit/forge.wit b/tests/wasm/ts/wit/forge.wit index a0a26611d..7a489f74e 100644 --- a/tests/wasm/ts/wit/forge.wit +++ b/tests/wasm/ts/wit/forge.wit @@ -1,5 +1,5 @@ -package forge:plugin@0.4.0; +package forge:plugin@0.3.0; /// Read-only access to the forge a plugin runs on, which the host keeps. /// Each id is an index the host gave out. An id the host never gave out @@ -96,8 +96,6 @@ interface forge { description: string, deprecated: bool, availability: option, - /// Audience identifiers from `x-forge-audiences`, in written order. - audiences: list, /// Whether to leave the method out of listings, from `x-forge-hidden`. hidden: bool, /// The sentence to show before calling the method, from From 9fc3ff12058d5f8c55c939655693df53bac27fd5 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 2 Nov 2026 16:10:14 -0500 Subject: [PATCH 3/7] docs: lead RFC with vendor migration --- rfcs/0003-conditional-skips.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rfcs/0003-conditional-skips.md b/rfcs/0003-conditional-skips.md index a8fee261c..3a45cd009 100644 --- a/rfcs/0003-conditional-skips.md +++ b/rfcs/0003-conditional-skips.md @@ -2,6 +2,12 @@ ## Summary +Forge receives the same generation decision in two opposite vendor forms: +Stainless lists targets to skip, while Fern lists audiences to keep. Copying +either shape would make Forge's model vendor-specific and leave plugins to +interpret it. Both should normalize into one Forge-native policy so specs can +migrate away from the vendor extensions. + Forge filters skipped operations and fields before a plugin receives its model. `x-forge-skip` accepts a boolean or one condition rule: From 5ddd01e3c09add69b9ec0dde3517de3d73686268 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 2 Nov 2026 16:51:56 -0500 Subject: [PATCH 4/7] docs: propose open stability stages (FORGE-1) --- rfcs/0004-open-stability.md | 64 +++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 rfcs/0004-open-stability.md diff --git a/rfcs/0004-open-stability.md b/rfcs/0004-open-stability.md new file mode 100644 index 000000000..8a08fce4f --- /dev/null +++ b/rfcs/0004-open-stability.md @@ -0,0 +1,64 @@ +# RFC 0004: Open stability stages + +## Summary + +Fern describes API maturity with `x-fern-availability`; existing specs also use +`x-stability`. Their vocabularies overlap but are not complete: stages such as a +release candidate do not fit Forge's current closed availability enum. + +Forge replaces that enum with the native extension `x-forge-stability`. Its +value resolves to an open enum: + +```yaml +x-forge-stability: release-candidate +``` + +```text +alpha +beta +preview +release-candidate +stable +deprecated +other(string) +``` + +Known values let plugins handle common stages exhaustively. Any other nonempty +string becomes `other` with its original value, so new vocabulary does not +require a Forge or plugin-interface change. Stages have no ordering and do not +filter the model. + +## Resolution + +Forge recognizes these exact, case-sensitive aliases: + +| Written value | Resolved stage | +| ---------------------- | ------------------- | +| `rc` | `release-candidate` | +| `release-candidate` | `release-candidate` | +| `ga` | `stable` | +| `generally-available` | `stable` | +| `stable` | `stable` | +| another nonempty value | `other(value)` | + +`deprecated` is a stability stage, not a second source of truth. OpenAPI +`deprecated: true` resolves to that stage where the field exists; +`x-forge-stability: deprecated` works on every Forge entity that supports +stability metadata. Writing `deprecated: true` with a different stability is an +error. APIs may keep an `is_deprecated` convenience derived from the resolved +stage. + +## Compatibility + +- `x-forge-stability` wins over compatibility sources. +- `x-forge-availability` remains a deprecated native alias. +- `x-fern-availability` maps through the aliases above. +- `x-stability` maps through the same resolver. + +The Rust, TypeScript and WIT plugin APIs replace `availability` with +`stability`. WIT represents the open enum as known cases plus `other(string)`; +this breaking interface change increments the pre-1.0 WIT minor version and +requires plugins to rebuild. + +Dates, versions, transition history and configurable stage classifications are +future lifecycle metadata and are outside this RFC. From 5c1bada9cefdd7272d3b659bae5674bbbe5e4e64 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 2 Nov 2026 16:56:06 -0500 Subject: [PATCH 5/7] docs: split stability into its own RFC --- rfcs/0004-open-stability.md | 64 ------------------------------------- 1 file changed, 64 deletions(-) delete mode 100644 rfcs/0004-open-stability.md diff --git a/rfcs/0004-open-stability.md b/rfcs/0004-open-stability.md deleted file mode 100644 index 8a08fce4f..000000000 --- a/rfcs/0004-open-stability.md +++ /dev/null @@ -1,64 +0,0 @@ -# RFC 0004: Open stability stages - -## Summary - -Fern describes API maturity with `x-fern-availability`; existing specs also use -`x-stability`. Their vocabularies overlap but are not complete: stages such as a -release candidate do not fit Forge's current closed availability enum. - -Forge replaces that enum with the native extension `x-forge-stability`. Its -value resolves to an open enum: - -```yaml -x-forge-stability: release-candidate -``` - -```text -alpha -beta -preview -release-candidate -stable -deprecated -other(string) -``` - -Known values let plugins handle common stages exhaustively. Any other nonempty -string becomes `other` with its original value, so new vocabulary does not -require a Forge or plugin-interface change. Stages have no ordering and do not -filter the model. - -## Resolution - -Forge recognizes these exact, case-sensitive aliases: - -| Written value | Resolved stage | -| ---------------------- | ------------------- | -| `rc` | `release-candidate` | -| `release-candidate` | `release-candidate` | -| `ga` | `stable` | -| `generally-available` | `stable` | -| `stable` | `stable` | -| another nonempty value | `other(value)` | - -`deprecated` is a stability stage, not a second source of truth. OpenAPI -`deprecated: true` resolves to that stage where the field exists; -`x-forge-stability: deprecated` works on every Forge entity that supports -stability metadata. Writing `deprecated: true` with a different stability is an -error. APIs may keep an `is_deprecated` convenience derived from the resolved -stage. - -## Compatibility - -- `x-forge-stability` wins over compatibility sources. -- `x-forge-availability` remains a deprecated native alias. -- `x-fern-availability` maps through the aliases above. -- `x-stability` maps through the same resolver. - -The Rust, TypeScript and WIT plugin APIs replace `availability` with -`stability`. WIT represents the open enum as known cases plus `other(string)`; -this breaking interface change increments the pre-1.0 WIT minor version and -requires plugins to rebuild. - -Dates, versions, transition history and configurable stage classifications are -future lifecycle metadata and are outside this RFC. From 698240a55756363164c2f3188cb2d1ed24ff5cb0 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 9 Nov 2026 17:01:02 -0500 Subject: [PATCH 6/7] docs: tighten conditional skip contract --- rfcs/0003-conditional-skips.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/rfcs/0003-conditional-skips.md b/rfcs/0003-conditional-skips.md index 3a45cd009..f4e650072 100644 --- a/rfcs/0003-conditional-skips.md +++ b/rfcs/0003-conditional-skips.md @@ -23,7 +23,7 @@ x-forge-skip: A generation has an unordered set of active condition names, such as `sdk` and `typescript`. `when` skips when any named condition is active. `unless` skips -when none is active. The two keys are mutually exclusive. +when none is active. A condition rule contains exactly one of the two keys. | Value | Result | | ------------------- | -------------------------------------------- | @@ -32,15 +32,15 @@ when none is active. The two keys are mutually exclusive. | `{ when: names }` | skip when the active set intersects `names` | | `{ unless: names }` | skip when the active set misses every `name` | -Names are exact, case-sensitive strings. Their order and duplicates have no -effect. `when: []` is equivalent to `false`; `unless: []` is equivalent to -`true`. +Each rule lists at least one condition. Names are non-empty, exact, +case-sensitive strings. Their order and duplicates have no effect. An empty +list is invalid rather than an implicit always or never rule; use a boolean for +those cases. ## Conditions -The name follows Node's conditional exports: several independent conditions can -describe one output. For example, a TypeScript SDK can activate both `sdk` and -`typescript`. +Several independent conditions can describe one output. For example, a +TypeScript SDK can activate both `sdk` and `typescript`. The CLI accepts additional conditions as a comma-separated set: @@ -48,10 +48,13 @@ The CLI accepts additional conditions as a comma-separated set: forge openapi.json --target wasm://plugin.wasm --conditions=sdk,typescript --out sdk ``` -Built-in targets provide their own conditions. The host resolves skips before -building the Forge model, so plugins cannot disagree about filtering and never -receive skipped definitions or skip metadata. A future `--include-skipped` -option may bypass filtering for inspection; it is not part of this RFC. +Built-in targets provide their own conditions. A custom plugin starts with no +active conditions unless the CLI supplies them. With no active conditions, +`when` never skips and `unless` always skips. + +The host resolves skips before building the Forge model, so plugins cannot +disagree about filtering and never receive skipped definitions or skip +metadata. ## Compatibility adapters From dcb9a7aa4f2bc512dd15529640b700fcf3a29f21 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 9 Nov 2026 17:29:22 -0500 Subject: [PATCH 7/7] docs: resolve conditional skip semantics --- rfcs/0003-conditional-skips.md | 47 ++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/rfcs/0003-conditional-skips.md b/rfcs/0003-conditional-skips.md index f4e650072..6547cac32 100644 --- a/rfcs/0003-conditional-skips.md +++ b/rfcs/0003-conditional-skips.md @@ -9,7 +9,7 @@ interpret it. Both should normalize into one Forge-native policy so specs can migrate away from the vendor extensions. Forge filters skipped operations and fields before a plugin receives its model. -`x-forge-skip` accepts a boolean or one condition rule: +`x-forge-skip` accepts a boolean or a condition rule: ```yaml x-forge-skip: true @@ -19,23 +19,30 @@ x-forge-skip: x-forge-skip: unless: [sdk] + +x-forge-skip: + when: [terraform] + unless: [sdk] ``` A generation has an unordered set of active condition names, such as `sdk` and `typescript`. `when` skips when any named condition is active. `unless` skips -when none is active. A condition rule contains exactly one of the two keys. - -| Value | Result | -| ------------------- | -------------------------------------------- | -| missing or `false` | keep | -| `true` | skip | -| `{ when: names }` | skip when the active set intersects `names` | -| `{ unless: names }` | skip when the active set misses every `name` | - -Each rule lists at least one condition. Names are non-empty, exact, +when none is active. A condition rule contains at least one of the two keys and +may contain both. When both are present, the element is skipped if `when` +matches **or** `unless` does not match, so exclusion wins. + +| Value | Result | +| ------------------------ | ----------------------------------------------------------- | +| missing or `false` | keep | +| `true` | skip | +| `{ when: names }` | skip when the active set intersects `names` | +| `{ unless: names }` | skip when the active set misses every `name` | +| `{ when: a, unless: b }` | skip when the active set intersects `a` or misses every `b` | + +Each supplied list contains at least one condition. Names are non-empty, exact, case-sensitive strings. Their order and duplicates have no effect. An empty -list is invalid rather than an implicit always or never rule; use a boolean for -those cases. +rule or list is invalid rather than an implicit always or never rule; use a +boolean for those cases. ## Conditions @@ -56,6 +63,13 @@ The host resolves skips before building the Forge model, so plugins cannot disagree about filtering and never receive skipped definitions or skip metadata. +After filtering, the host validates the resulting document. Straightforward +local bookkeeping is repaired, such as removing a skipped property from its +schema's `required` list. If a retained element references a skipped component, +generation fails with a diagnostic identifying both the reference and the +skipped target. Forge neither cascades the skip to the referencing element nor +retains the skipped dependency. + ## Compatibility adapters The compatibility layer maps vendor intent instead of copying vendor shapes: @@ -66,6 +80,13 @@ The compatibility layer maps vendor intent instead of copying vendor shapes: `x-forge-skip: { unless: [sdk] }`. - Boolean vendor skips remain boolean. +An explicit `x-forge-skip`, including `false`, is authoritative. When it is +present, Forge ignores compatibility extensions on the same element. When it is +absent, normalization combines recognized compatibility inputs into one native +rule: target skips contribute `when`, audiences contribute `unless`, and names +for the same key are unioned. A boolean `true` dominates and becomes an +unconditional skip; `false` contributes nothing. + `x-forge-availability` remains independent lifecycle metadata and is not a filter. This design adds no audience field to the plugin API and requires no WIT change.