diff --git a/.quixos-subtree-source.json b/.quixos-subtree-source.json index e4239c0..14bf34c 100644 --- a/.quixos-subtree-source.json +++ b/.quixos-subtree-source.json @@ -1,7 +1,7 @@ { "version": 1, "sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos", - "sourceCommit": "a45fd9a85b541ce6b449b28e8b47954bf04de96b", + "sourceCommit": "db6d7990a4ffec273258ac313b7d2aeac57b261a", "sourcePath": "quixos-protocol", "exportName": "quixos-protocol", "mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" diff --git a/src/bindings/index.ts b/src/bindings/index.ts index 00d20a6..bce2140 100644 --- a/src/bindings/index.ts +++ b/src/bindings/index.ts @@ -285,19 +285,35 @@ export const generateTypeScriptBindings = ( genericImplementationType(definition, [...schema.interfaces, ...(schema.interfaceTemplates ?? [])], type), ]); } - const contracts = schema.interfaces.map((iface) => { - const generated = port({ - id: capabilityId.dependencyPort("contract"), - displayName: iface.displayName, - requirement: { kind: "interface", interfaceRevisionId: iface.revisionId }, + // Unused imported interfaces must not introduce new message-codec obligations. + // A used port still fails above if its own required codec is missing. + const hasCodec = (value: ValueType): boolean => { + if (value.kind === "message") return !!options.messages?.[value.descriptorId]; + if (value.kind === "record") return Object.values(value.fields).every(hasCodec); + if (value.kind === "list" || value.kind === "optional") return hasCodec(value.value); + return true; + }; + const contracts = schema.interfaces + .filter((iface) => + iface.members.every((member) => + member.operations + .filter((operation) => operation.mode === "call" && operation.scope !== "class") + .every((operation) => hasCodec(operation.inputType) && hasCodec(operation.outputType)), + ), + ) + .map((iface) => { + const generated = port({ + id: capabilityId.dependencyPort("contract"), + displayName: iface.displayName, + requirement: { kind: "interface", interfaceRevisionId: iface.revisionId }, + }); + const spec = generated.spec as { operations: unknown }; + return { + iface, + type: generated.type, + code: `defineQxInterfaceContract<${generated.type}>(${q(iface.revisionId)}, ${JSON.stringify(spec.operations)})`, + }; }); - const spec = generated.spec as { operations: unknown }; - return { - iface, - type: generated.type, - code: `defineQxInterfaceContract<${generated.type}>(${q(iface.revisionId)}, ${JSON.stringify(spec.operations)})`, - }; - }); const imports = [...messages].map(([id, alias]) => { const binding = options.messages![id]!; if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(binding.export)) diff --git a/src/bindings/react.ts b/src/bindings/react.ts index 020f71f..4622560 100644 --- a/src/bindings/react.ts +++ b/src/bindings/react.ts @@ -78,21 +78,44 @@ export function generateReactBindings( throw new Error("React component check must name a local module relative to generated bindings"); return `type Component${i} = CheckedComponent<${q(component.propsExport)}, typeof import(${q(component.module)})["default"]>;`; }); - const contracts = schema.interfaces.map((iface) => { - const reference = type({ - kind: "object-ref", - expectation: { kind: "interface", interfaceRevisionId: iface.revisionId }, - }); - const calls = iface.members.flatMap((member) => + // Emit optional discovery descriptors only when the browser has all codecs. + // Unrelated opaque-message imports must not break an otherwise checked UI. + const browserValue = (value: ValueType, seen: Set): boolean => { + if (value.kind === "message") return false; + if (value.kind === "builtin") return value.name === "unit"; + if (value.kind === "record") return Object.values(value.fields).every((field) => browserValue(field, seen)); + if (value.kind === "list" || value.kind === "optional") return browserValue(value.value, seen); + if (value.kind === "object-ref" && value.expectation.kind === "interface") { + const id = value.expectation.interfaceRevisionId; + if (seen.has(id)) return true; + const contract = schema.interfaces.find((entry) => entry.revisionId === id); + return !!contract && browserInterface(contract, new Set([...seen, id])); + } + return true; + }; + const browserInterface = (iface: (typeof schema.interfaces)[number], seen: Set): boolean => + iface.members.every((member) => member.operations - .filter((operation) => operation.mode === "call" && operation.scope !== "class") - .map( - (operation) => - `${q(`${member.displayName}.${operation.displayName}`)}: (${operation.inputType.kind === "builtin" && operation.inputType.name === "unit" ? "" : `input: ${type(operation.inputType)}`}) => Promise<${type(operation.outputType)}>`, - ), + .filter((op) => op.mode === "call" && op.scope !== "class") + .every((op) => browserValue(op.inputType, seen) && browserValue(op.outputType, seen)), ); - return { iface, view: `${reference} & {call: {${calls.join(";")}}}` }; - }); + const contracts = schema.interfaces + .filter((iface) => browserInterface(iface, new Set([iface.revisionId]))) + .map((iface) => { + const reference = type({ + kind: "object-ref", + expectation: { kind: "interface", interfaceRevisionId: iface.revisionId }, + }); + const calls = iface.members.flatMap((member) => + member.operations + .filter((operation) => operation.mode === "call" && operation.scope !== "class") + .map( + (operation) => + `${q(`${member.displayName}.${operation.displayName}`)}: (${operation.inputType.kind === "builtin" && operation.inputType.name === "unit" ? "" : `input: ${type(operation.inputType)}`}) => Promise<${type(operation.outputType)}>`, + ), + ); + return { iface, view: `${reference} & {call: {${calls.join(";")}}}` }; + }); return ( `// Generated from checked QX contracts. Do not edit.\nimport {defineReactInterfaceContract} from "@quixos/web-studio-react-runtime";\nimport type {ObjectRef, InterfaceReference, ReadableField, WritableField} from "@quixos/web-studio-react-runtime";\n${declarations.join("\n")}\nexport type ReactResults = {${results.join(";\n")}};\n` + `const contractsData = ${JSON.stringify(schema.interfaces)};\n` + diff --git a/test/react-fields.test.ts b/test/react-fields.test.ts index c0bfc4b..f83a704 100644 --- a/test/react-fields.test.ts +++ b/test/react-fields.test.ts @@ -33,6 +33,18 @@ test("React bindings preserve read-only, writable and nested reference contracts interfaces: [iface.resource.revision], packages: [pkg.resource.revision], } as const; + const opaque = structuredClone(iface.resource.revision); + opaque.revisionId = "opaque@1" as typeof opaque.revisionId; + opaque.displayName = "Opaque"; + for (const member of opaque.members) + for (const operation of member.operations) + operation.outputType = { kind: "message", descriptorId: "UnsupportedBrowserMessage" }; + const withUnusedOpaque = generateReactBindings( + { ...schema, interfaces: [...schema.interfaces, opaque], packages: [...schema.packages] }, + "p@1", + ["props"], + ); + assert.doesNotMatch(withUnusedOpaque, /"opaque@1": defineReactInterfaceContract/); const generated = generateReactBindings( { ...schema, interfaces: [...schema.interfaces], packages: [...schema.packages] }, "p@1",