Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve

## [Unreleased]

- fix(compiler): preserve wrapping sequence occurrence bounds on element particles

## [1.1.0] - 2026-09-22

- feat(generation): add automatic TypeScript ESLint preambles and document generated-path exclusions for restrictive lint policies
Expand Down
3 changes: 3 additions & 0 deletions docs/supported-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The rows below are backed by committed conformance fixtures under `test/conforma
<!-- support-matrix:start -->
| Capability ID | Status | Public contract |
|---|---|---|
| `sequence-occurrence-wrappers` | supported | Wrapping sequences with nonzero maxima propagate element bounds and optionality without crossing choice or all boundaries. |
| `weather-document-literal-soap` | supported | The canonical weather WSDL compiles through client, OpenAPI, gateway, generated-test, and app artifacts with document-literal SOAP operations. |
| `sequence-baseline-complex` | supported | Complex type sequences support nested complex references, repeated elements, optional fields, and all-optional request wrappers. |
| `simple-restriction-list` | supported | Named simple type restrictions, enumerations, and `xs:list` declarations emit aligned TypeScript aliases and OpenAPI schemas. |
Expand All @@ -32,6 +33,8 @@ The rows below are backed by committed conformance fixtures under `test/conforma

These patterns are handled end-to-end: WSDL parsing, TypeScript type generation, OpenAPI schema output, and gateway code generation.

Wrapping sequence bounds propagate through uninterrupted sequence ancestry to element array types and optionality. Traversal through `xs:choice`, `xs:all`, or a sequence with `maxOccurs="0"` retains the previous flattened behavior; wildcard and disabled-element handling are unchanged. This does not enforce sequence ordering or finite array lengths at runtime.

- Complex types with `<xs:sequence>`, `<xs:all>`, and `<xs:choice>` compositors, including recursive nesting
- Simple content with attributes using the `$value` pattern to preserve text content alongside attribute properties
- Named simple type restrictions and enumerations emitted as TypeScript aliases and OpenAPI scalar schemas
Expand Down
18 changes: 18 additions & 0 deletions scripts/smoke-npm-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ try {
run(["--input-type=module", "-e", "import {createRequire} from 'node:module'; const require=createRequire(import.meta.url); try {require.resolve('typescript'); process.exit(2);} catch(e) {if(e.code !== 'MODULE_NOT_FOUND') throw e;}"]);
const pipeline = ["pipeline", "--wsdl-source", "weather.wsdl", "--client-dir", ".generated/client", "--openapi-file", ".generated/openapi.json", "--gateway-dir", ".generated/gateway", "--gateway-service-name", "weather", "--gateway-version-prefix", "v1", "--openapi-format", "json", "--init-app", "--test-dir", ".generated/tests"];
command(pipeline);
fs.copyFileSync(path.join(root, "test/conformance/fixtures/xsd/sequences/sequence-occurrence-wrappers.wsdl"), path.join(consumer, "occurrence.wsdl"));
command(["client", "--wsdl-source", "occurrence.wsdl", "--client-dir", ".generated/occurrence"]);
const occurrenceTypes = fs.readFileSync(path.join(consumer, ".generated/occurrence/types.ts"), "utf8");
for (const declaration of ["address: AddressType[];", "address?: AddressType[];", "bounded?: string[];", "unbounded?: string[];"]) {
assert.ok(occurrenceTypes.includes(declaration), `Installed CLI must emit ${declaration}`);
}
command(["client", "--wsdl-source", "weather.wsdl", "--client-dir", "from-wsdl"]);
command(["client", "--catalog-file", ".generated/client/catalog.json", "--client-dir", "from-catalog"]);
command(["gateway", "--openapi-file", ".generated/openapi.json", "--client-dir", ".generated/client", "--gateway-dir", ".generated/standalone", "--gateway-service-name", "weather", "--gateway-version-prefix", "v1"]);
Expand All @@ -86,6 +92,18 @@ try {
const appPackage = JSON.parse(fs.readFileSync(path.join(consumer, ".generated/app/package.json"), "utf8"));
npm(["install", ...Object.keys(appPackage.dependencies).map(exact)]);
npm(["install", "--save-dev", ...["typescript", "@types/node", "vitest"].map(exact)]);
write(".generated/occurrence/usage.ts", [
'import type {Addresses, OptionalAddresses, SubmitOccurrence} from "./types.js";',
'const addresses: Addresses = {address: [{street: "First"}, {street: "Second"}]};',
'const optional: OptionalAddresses = {};',
'const request: SubmitOccurrence = {requestId: "test", bounded: ["a", "b"]};',
'void addresses; void optional; void request;',
'// @ts-expect-error A repeated address cannot be a scalar.',
'const scalar: Addresses = {address: {street: "Wrong"}};',
'void scalar;',
].join("\n"));
json(".generated/occurrence/tsconfig.json", {compilerOptions: {strict: true, noEmit: true, target: "ES2022", module: "NodeNext", moduleResolution: "NodeNext", skipLibCheck: true}, include: ["*.ts"]});
run(["node_modules/typescript/bin/tsc", "-p", ".generated/occurrence/tsconfig.json"]);
run(["node_modules/typescript/bin/tsc", "--noEmit", "-p", ".generated/app/tsconfig.json"]);
run([path.join(consumer, "node_modules/vitest/vitest.mjs"), "run", "--config", "vitest.config.ts"], path.join(consumer, ".generated/tests"));

Expand Down
23 changes: 17 additions & 6 deletions src/compiler/schemaCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -917,17 +917,28 @@ export function compileCatalog(

const collectParticles = (ownerTypeName: string, node: any): CompiledType["elems"] => {
const out: CompiledType["elems"] = [];
// process a compositor or element container recursively
const recurse = (groupNode: any) => {
// handle direct element children
type Occurrence = {min: number; max: number | "unbounded"};
const one: Occurrence = {min: 1, max: 1};
const combine = (a: Occurrence, b: Occurrence): Occurrence => ({
min: a.min * b.min,
max: a.max === "unbounded" || b.max === "unbounded" ? "unbounded" : a.max * b.max,
});
const recurse = (groupNode: any, inherited: Occurrence = one, inheritSequenceOccurs = true) => {
for (const e of getChildrenWithLocalName(groupNode, "element")) {
const particle = compileElementParticle(ownerTypeName, e);
if (particle) out.push(particle);
if (!particle) continue;
// @constraints Preserve disabled particles until the content model can represent their absence (#145).
out.push(particle.max === 0 ? particle : {...particle, ...combine(inherited, particle)});
}
// recurse into nested compositor groups
for (const comp of ["sequence", "all", "choice"]) {
for (const sub of getChildrenWithLocalName(groupNode, comp)) {
recurse(sub);
const own = readOccurrence(sub);
if (inheritSequenceOccurs && comp === "sequence" && own.max !== 0) {
recurse(sub, combine(inherited, own), true);
} else {
// @constraints Choice, all, and disabled subtrees keep their previous flattened bounds (#145).
recurse(sub, one, false);
}
}
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<wsdl:definitions xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:tns="urn:conformance:sequence-occurrence" targetNamespace="urn:conformance:sequence-occurrence">
<wsdl:types>
<xs:schema targetNamespace="urn:conformance:sequence-occurrence" elementFormDefault="qualified">
<xs:complexType name="AddressType">
<xs:sequence><xs:element name="street" type="xs:string"/></xs:sequence>
</xs:complexType>
<xs:complexType name="Addresses">
<xs:sequence maxOccurs="2"><xs:element name="address" type="tns:AddressType"/></xs:sequence>
</xs:complexType>
<xs:complexType name="OptionalAddresses">
<xs:sequence minOccurs="0" maxOccurs="5"><xs:element name="address" type="tns:AddressType"/></xs:sequence>
</xs:complexType>
<xs:element name="SubmitOccurrence">
<xs:complexType><xs:sequence>
<xs:element name="requestId" type="xs:string"/>
<xs:sequence minOccurs="0" maxOccurs="5"><xs:element name="bounded" type="xs:string"/></xs:sequence>
<xs:sequence minOccurs="0" maxOccurs="unbounded"><xs:element name="unbounded" type="xs:string"/></xs:sequence>
</xs:sequence></xs:complexType>
</xs:element>
<xs:element name="SubmitOccurrenceResponse">
<xs:complexType><xs:sequence>
<xs:element name="addresses" type="tns:Addresses"/>
<xs:element name="optionalAddresses" type="tns:OptionalAddresses" minOccurs="0"/>
</xs:sequence></xs:complexType>
</xs:element>
</xs:schema>
</wsdl:types>
<wsdl:message name="SubmitOccurrenceInput"><wsdl:part name="parameters" element="tns:SubmitOccurrence"/></wsdl:message>
<wsdl:message name="SubmitOccurrenceOutput"><wsdl:part name="parameters" element="tns:SubmitOccurrenceResponse"/></wsdl:message>
<wsdl:portType name="OccurrencePortType"><wsdl:operation name="SubmitOccurrence">
<wsdl:input message="tns:SubmitOccurrenceInput"/><wsdl:output message="tns:SubmitOccurrenceOutput"/>
</wsdl:operation></wsdl:portType>
<wsdl:binding name="OccurrenceBinding" type="tns:OccurrencePortType">
<soap:binding style="document" transport="http://schemas.xmlsoap.org/soap/http"/>
<wsdl:operation name="SubmitOccurrence"><soap:operation soapAction="urn:submit-occurrence"/>
<wsdl:input><soap:body use="literal"/></wsdl:input><wsdl:output><soap:body use="literal"/></wsdl:output>
</wsdl:operation>
</wsdl:binding>
<wsdl:service name="OccurrenceService"><wsdl:port name="OccurrencePort" binding="tns:OccurrenceBinding">
<soap:address location="http://example.com/occurrence"/>
</wsdl:port></wsdl:service>
</wsdl:definitions>
74 changes: 74 additions & 0 deletions test/conformance/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,80 @@ function assertSuccessEnvelope(body: any, expectedData: unknown): void {
}

export const capabilities: CapabilityCase[] = [
{
id: "sequence-occurrence-wrappers",
title: "Occurrence bounds through uninterrupted sequence ancestry",
status: "supported",
featureTags: ["xsd", "sequence", "occurrence"],
fixture: "xsd/sequences/sequence-occurrence-wrappers.wsdl",
docsAnchor: "fully-supported",
publicContract: "Wrapping sequences with nonzero maxima propagate element bounds and optionality without crossing choice or all boundaries.",
decision: "support",
decisionReason: "Regression evidence for #141 covers catalog bounds, client arrays, and flattened gateway responses.",
authority: "XML Schema 1.0",
provenance: "Synthetic fixture based on the public sequence wrapper example in issue #141; no private WSDL content.",
license: "MIT",
fixtureKind: "real-world-compatible",
compile: {
outcome: "success",
typeNames: ["Addresses", "OptionalAddresses", "AddressType", "SubmitOccurrence", "SubmitOccurrenceResponse"],
operationNames: ["SubmitOccurrence"],
assert: compiled => {
for (const [type, name, min, max] of [
["Addresses", "address", 1, 2],
["OptionalAddresses", "address", 0, 5],
["SubmitOccurrence", "bounded", 0, 5],
["SubmitOccurrence", "unbounded", 0, "unbounded"],
] as const) {
const particle = compiled.types.find(t => t.name === type)?.elems.find(e => e.name === name);
assertJsonEqual({min: particle?.min, max: particle?.max}, {min, max}, `${type}.${name} should inherit sequence bounds.`);
}
},
},
client: {
outcome: "success",
sourceIncludes: [
{file: "types", text: "address: AddressType[];"},
{file: "types", text: "address?: AddressType[];"},
{file: "types", text: "bounded?: string[];"},
{file: "types", text: "unbounded?: string[];"},
],
},
openapi: {
outcome: "success",
assert: ({doc}) => {
for (const name of ["Addresses", "OptionalAddresses"]) {
assertJsonEqual(requireSchema(doc, name).type, "array", `${name} should flatten to an array.`);
}
const request = requireSchema(doc, "SubmitOccurrence");
assertJsonEqual(request.required, ["requestId"], "Only requestId should be required.");
for (const name of ["bounded", "unbounded"]) {
assertJsonEqual(request.properties[name].type, "array", `${name} should be an array.`);
}
},
},
gateway: {
outcome: "success",
requests: [
{requestId: "omitted"},
{requestId: "repeated", bounded: ["a", "b"], unbounded: ["c", "d"]},
].map(payload => ({
operationId: "SubmitOccurrence",
payload,
mockClient: {
SubmitOccurrence: async () => ({
response: {addresses: {address: [{street: "First"}, {street: "Second"}]}},
headers: {},
}),
},
expectedStatus: 200,
assertBody: body => assertSuccessEnvelope(body, {addresses: [{street: "First"}, {street: "Second"}]}),
assertClientArgs: args => assertJsonEqual(args, payload, "Gateway should retain repeated and omitted request fields."),
})),
},
generatedTests: {outcome: "success"},
app: {outcome: "success"},
},
{
id: "weather-document-literal-soap",
title: "Weather document-literal SOAP baseline",
Expand Down
Loading
Loading