diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bec082..0ee6018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/supported-patterns.md b/docs/supported-patterns.md index 5c7b3c6..2130ae9 100644 --- a/docs/supported-patterns.md +++ b/docs/supported-patterns.md @@ -9,6 +9,7 @@ The rows below are backed by committed conformance fixtures under `test/conforma | 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. | @@ -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 ``, ``, and `` 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 diff --git a/scripts/smoke-npm-package.mjs b/scripts/smoke-npm-package.mjs index e675e8b..81accf5 100644 --- a/scripts/smoke-npm-package.mjs +++ b/scripts/smoke-npm-package.mjs @@ -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"]); @@ -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")); diff --git a/src/compiler/schemaCompiler.ts b/src/compiler/schemaCompiler.ts index 22caf21..fd8d1e1 100644 --- a/src/compiler/schemaCompiler.ts +++ b/src/compiler/schemaCompiler.ts @@ -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); + } } } }; diff --git a/test/conformance/fixtures/xsd/sequences/sequence-occurrence-wrappers.wsdl b/test/conformance/fixtures/xsd/sequences/sequence-occurrence-wrappers.wsdl new file mode 100644 index 0000000..ab1921d --- /dev/null +++ b/test/conformance/fixtures/xsd/sequences/sequence-occurrence-wrappers.wsdl @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/conformance/registry.ts b/test/conformance/registry.ts index 6261a08..1515159 100644 --- a/test/conformance/registry.ts +++ b/test/conformance/registry.ts @@ -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", diff --git a/test/unit/compiler-compositor-occurs.test.ts b/test/unit/compiler-compositor-occurs.test.ts new file mode 100644 index 0000000..154ddfb --- /dev/null +++ b/test/unit/compiler-compositor-occurs.test.ts @@ -0,0 +1,210 @@ +import {afterAll, describe, expect, it} from "vitest"; +import {mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs"; +import {join} from "node:path"; +import {generateTypes} from "../../src/client/generateTypes.js"; +import {generateSchemas} from "../../src/openapi/generateSchemas.js"; +import {compileCatalog, type CompiledCatalog} from "../../src/compiler/schemaCompiler.js"; +import {resolveCompilerOptions} from "../../src/config.js"; +import {loadWsdl} from "../../src/loader/wsdlLoader.js"; + +const tmpParent = join(process.cwd(), "tmp", "conformance"); +mkdirSync(tmpParent, {recursive: true}); +const tmpRoot = mkdtempSync(join(tmpParent, "compiler-sequence-occurs-")); + +afterAll(() => { + rmSync(tmpRoot, {recursive: true, force: true}); +}); + +function buildWsdl(schemaBody: string): string { + return ` + + + + ${schemaBody} + + + + + + + + + + + + + + + + + + + + + + + +`; +} + +async function compileFromFixture(wsdl: string, name: string): Promise { + const wsdlPath = join(tmpRoot, `${name}.wsdl`); + writeFileSync(wsdlPath, wsdl, "utf8"); + const wsdlCatalog = await loadWsdl(wsdlPath); + return compileCatalog( + wsdlCatalog, + resolveCompilerOptions({}, {wsdl: wsdlPath, out: tmpRoot}), + ); +} + +describe("compiler: compositor-level minOccurs/maxOccurs", () => { + it("applies a wrapping xs:sequence's own maxOccurs to its element particles (issue #141)", async () => { + const schema = ` + + + + + + + + + + + + + + `; + const compiled = await compileFromFixture(buildWsdl(schema), "sequence-max-2"); + const addresses = compiled.types.find((t) => t.elems.some((e) => e.name === "address")); + expect(addresses, "type carrying the address particle must be compiled").toBeTruthy(); + const address = addresses!.elems.find((e) => e.name === "address"); + expect(address).toMatchObject({min: 1, max: 2}); + }); + + it("multiplies min and max when both the compositor and the element declare occurrence bounds", async () => { + const schema = ` + + + + + + + + `; + const compiled = await compileFromFixture(buildWsdl(schema), "sequence-min-0-elem-max-3"); + const pingResponse = compiled.types.find((t) => t.name === "PingResponse"); + const item = pingResponse!.elems.find((e) => e.name === "item"); + // outer sequence: {min:0, max:1 (default)} x element: {min:1 (default), max:3} => {min:0, max:3} + expect(item).toMatchObject({min: 0, max: 3}); + }); + + it("propagates unbounded from a nested compositor rather than multiplying it as a number", async () => { + const schema = ` + + + + + + + + + + `; + const compiled = await compileFromFixture(buildWsdl(schema), "sequence-unbounded"); + const pingResponse = compiled.types.find((t) => t.name === "PingResponse"); + const row = pingResponse!.elems.find((e) => e.name === "row"); + expect(row).toMatchObject({min: 0, max: "unbounded"}); + }); + + it("preserves existing xs:any wildcard bounds", async () => { + const schema = ` + + + + + + + + `; + const compiled = await compileFromFixture(buildWsdl(schema), "sequence-wildcard-max-2"); + const pingResponse = compiled.types.find((t) => t.name === "PingResponse"); + expect(pingResponse!.wildcards).toEqual([ + {min: 1, max: 1, namespace: "##other", processContents: "lax"}, + ]); + }); + + it("leaves element occurrence untouched when no compositor declares its own bounds", async () => { + const schema = ` + + + + + + + + `; + const compiled = await compileFromFixture(buildWsdl(schema), "no-compositor-occurs"); + const pingResponse = compiled.types.find((t) => t.name === "PingResponse"); + const ok = pingResponse!.elems.find((e) => e.name === "ok"); + expect(ok).toMatchObject({min: 1, max: 1}); + }); +}); + +describe("sequence occurrence consumer contracts", () => { + it("generates required and optional arrays from the reusable production-shaped fixture", async () => { + const wsdl = readFileSync("test/conformance/fixtures/xsd/sequences/sequence-occurrence-wrappers.wsdl", "utf8"); + const compiled = await compileFromFixture(wsdl, "consumer"); + const output = join(tmpRoot, "types.ts"); + generateTypes(output, compiled); + const source = readFileSync(output, "utf8"); + expect(source).toContain("address: AddressType[];"); + expect(source).toContain("address?: AddressType[];"); + expect(source).toContain("bounded?: string[];"); + expect(source).toContain("unbounded?: string[];"); + const flattened = generateSchemas(compiled, {}); + expect(flattened.Addresses).toMatchObject({type: "array", items: {$ref: "#/components/schemas/AddressType"}}); + const wrapped = generateSchemas(compiled, {flattenArrayWrappers: false}); + expect(wrapped.Addresses).toMatchObject({type: "object", required: ["address"], properties: {address: {type: "array"}}}); + expect(wrapped.OptionalAddresses.required ?? []).not.toContain("address"); + expect(wrapped.OptionalAddresses.properties.address.type).toBe("array"); + expect(wrapped.SubmitOccurrence.required).toEqual(["requestId"]); + }); + + it.each([ + ["finite-product", '', 8, 60], + ["choice-boundary", '', 1, 1], + ["all-boundary", '', 1, 1], + ["all-nested-sequence-legacy", '', 1, 1], + ["disabled-sequence", '', 1, 1], + ["disabled-element", '', 0, 0], + ] as const)("retains the intended boundary for %s", async (name, body, min, max) => { + const compiled = await compileFromFixture(buildWsdl(`${body}`), name); + expect(compiled.types.find(t => t.name === "PingResponse")?.elems.find(e => e.name === "item")).toMatchObject({min, max}); + }); + + it("keeps inline type scope separate and extension locals multiplied once", async () => { + const compiled = await compileFromFixture(buildWsdl(` + + + + + + `), "extension"); + const extended = compiled.types.find(t => t.name === "Extended")!; + expect(extended.elems.find(e => e.name === "baseItem")).toMatchObject({min: 1, max: 2}); + const local = extended.elems.find(e => e.name === "localItem")!; + expect(local).toMatchObject({min: 1, max: 3}); + expect(extended.localElems?.find(e => e.name === "localItem")).toMatchObject({min: 1, max: 3}); + expect(compiled.types.find(t => t.name === local.tsType)?.elems).toEqual([ + expect.objectContaining({name: "value", min: 1, max: 1}), + ]); + }); +}); diff --git a/test/unit/tsconfig.json b/test/unit/tsconfig.json index 9e8fdbb..39d9c11 100644 --- a/test/unit/tsconfig.json +++ b/test/unit/tsconfig.json @@ -10,6 +10,7 @@ "preamble.test.ts", "app-security.test.ts", "client-choice-union-types.test.ts", + "compiler-compositor-occurs.test.ts", "conformance-scripts.test.ts", "conformance-fixture-policy.test.ts", "deps-maintenance.test.ts",