@twin.org/tools-core 0.0.3-next.16 → 0.0.3-next.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -715,7 +715,8 @@ export class JsonSchemaBuilder {
715
715
  return mappedUnionTypes[0];
716
716
  }
717
717
  if (JsonSchemaBuilder.isNeverDiscriminatedObjectUnion(context, typeNode.types, mappedUnionTypes) ||
718
- JsonSchemaBuilder.isLiteralTagDiscriminatedObjectUnion(context, mappedUnionTypes)) {
718
+ JsonSchemaBuilder.isLiteralTagDiscriminatedObjectUnion(context, mappedUnionTypes) ||
719
+ JsonSchemaBuilder.isDisjointPrimitiveKeywordUnion(typeNode, typeNode.types, mappedUnionTypes)) {
719
720
  return {
720
721
  oneOf: mappedUnionTypes
721
722
  };
@@ -749,6 +750,52 @@ export class JsonSchemaBuilder {
749
750
  });
750
751
  return undefined;
751
752
  }
753
+ /**
754
+ * Determine whether a union of primitive keyword branches is pairwise disjoint.
755
+ * @param unionTypeNode The union node being mapped.
756
+ * @param unionTypeNodes The original union branch type nodes.
757
+ * @param unionSchemas The mapped union branch schemas.
758
+ * @returns True if every branch is a primitive keyword schema and no branches overlap.
759
+ */
760
+ static isDisjointPrimitiveKeywordUnion(unionTypeNode, unionTypeNodes, unionSchemas) {
761
+ if (unionSchemas.length < 2 || unionSchemas.length !== unionTypeNodes.length) {
762
+ return false;
763
+ }
764
+ let unionContainer = unionTypeNode;
765
+ while (ts.isParenthesizedTypeNode(unionContainer.parent)) {
766
+ unionContainer = unionContainer.parent;
767
+ }
768
+ if (!ts.isTypeAliasDeclaration(unionContainer.parent) ||
769
+ unionContainer.parent.type !== unionContainer) {
770
+ // Keep nested/property unions as anyOf; only top-level alias unions are promoted.
771
+ return false;
772
+ }
773
+ const primitiveKinds = new Set([
774
+ ts.SyntaxKind.StringKeyword,
775
+ ts.SyntaxKind.NumberKeyword,
776
+ ts.SyntaxKind.BooleanKeyword,
777
+ ts.SyntaxKind.NullKeyword
778
+ ]);
779
+ if (!unionTypeNodes.every(unionBranchType => primitiveKinds.has(unionBranchType.kind))) {
780
+ // Mixed or non-primitive unions can overlap in value space.
781
+ return false;
782
+ }
783
+ const schemaTypeDomains = unionSchemas.map(schema => {
784
+ if (schema.type === "integer") {
785
+ return "number";
786
+ }
787
+ return Is.stringValue(schema.type) ? schema.type : undefined;
788
+ });
789
+ if (schemaTypeDomains.some(schemaType => schemaType !== "string" &&
790
+ schemaType !== "number" &&
791
+ schemaType !== "boolean" &&
792
+ schemaType !== "null")) {
793
+ // Only primitive keyword domains are considered disjoint enough for oneOf.
794
+ return false;
795
+ }
796
+ // oneOf is safe when every branch maps to a unique primitive domain.
797
+ return new Set(schemaTypeDomains).size === unionSchemas.length;
798
+ }
752
799
  /**
753
800
  * Determine whether a union of schemas represents mutually exclusive object branches.
754
801
  * @param context The generation context.
@@ -760,38 +807,55 @@ export class JsonSchemaBuilder {
760
807
  if (unionSchemas.length < 2 || unionSchemas.length !== unionTypeNodes.length) {
761
808
  return false;
762
809
  }
810
+ const branchInfos = [];
763
811
  let hasNeverDiscriminator = false;
764
812
  for (const unionTypeNode of unionTypeNodes) {
765
813
  const branchMembers = JsonSchemaBuilder.resolveNeverDiscriminatorMembers(context, unionTypeNode);
766
814
  if (!branchMembers) {
767
815
  return false;
768
816
  }
769
- const hasNeverProperty = branchMembers.some(member => {
770
- if (!ts.isPropertySignature(member) || !member.type) {
771
- return false;
817
+ const branchInfo = {
818
+ requiredKeys: new Set(),
819
+ forbiddenKeys: new Set()
820
+ };
821
+ for (const member of branchMembers) {
822
+ if (ts.isPropertySignature(member) && member.type && member.name) {
823
+ const propertyName = JsonSchemaBuilder.extractPropertyName(context, member.name);
824
+ if (propertyName) {
825
+ if (member.type.kind === ts.SyntaxKind.NeverKeyword) {
826
+ // never marks a property as impossible in this branch.
827
+ branchInfo.forbiddenKeys.add(propertyName);
828
+ hasNeverDiscriminator = true;
829
+ }
830
+ else if (!member.questionToken) {
831
+ // Required non-never keys are the positive branch signals.
832
+ branchInfo.requiredKeys.add(propertyName);
833
+ }
834
+ }
772
835
  }
773
- return member.type.kind === ts.SyntaxKind.NeverKeyword;
774
- });
775
- if (hasNeverProperty) {
776
- hasNeverDiscriminator = true;
777
836
  }
837
+ branchInfos.push(branchInfo);
778
838
  }
779
839
  if (!hasNeverDiscriminator) {
780
840
  return false;
781
841
  }
782
- const requiredKeys = new Set();
783
- for (const schema of unionSchemas) {
784
- const resolvedSchema = JsonSchemaBuilder.resolveLocalSchemaReference(context, schema) ?? schema;
785
- if (resolvedSchema.type !== "object" ||
786
- !Is.array(resolvedSchema.required) ||
787
- resolvedSchema.required.length !== 1) {
842
+ for (const branchInfo of branchInfos) {
843
+ if (branchInfo.requiredKeys.size === 0 && branchInfo.forbiddenKeys.size === 0) {
844
+ // Require each branch to contribute at least one discriminating signal.
788
845
  return false;
789
846
  }
790
- const requiredKey = resolvedSchema.required[0];
791
- if (requiredKeys.has(requiredKey)) {
792
- return false;
847
+ }
848
+ for (let i = 0; i < branchInfos.length; i++) {
849
+ for (let j = i + 1; j < branchInfos.length; j++) {
850
+ const firstBranch = branchInfos[i];
851
+ const secondBranch = branchInfos[j];
852
+ const firstExcludesSecond = [...firstBranch.requiredKeys].some(requiredKey => secondBranch.forbiddenKeys.has(requiredKey));
853
+ const secondExcludesFirst = [...secondBranch.requiredKeys].some(requiredKey => firstBranch.forbiddenKeys.has(requiredKey));
854
+ if (!firstExcludesSecond && !secondExcludesFirst) {
855
+ // Branch pairs must be mutually exclusive in at least one direction.
856
+ return false;
857
+ }
793
858
  }
794
- requiredKeys.add(requiredKey);
795
859
  }
796
860
  return true;
797
861
  }
@@ -847,6 +911,19 @@ export class JsonSchemaBuilder {
847
911
  * @returns Type members for the branch when resolvable.
848
912
  */
849
913
  static resolveNeverDiscriminatorMembers(context, unionTypeNode) {
914
+ const findTypeMembersInSourceFile = (sourceFile, typeName) => {
915
+ for (const statement of sourceFile.statements) {
916
+ if (ts.isInterfaceDeclaration(statement) && statement.name.text === typeName) {
917
+ return statement.members;
918
+ }
919
+ if (ts.isTypeAliasDeclaration(statement) &&
920
+ statement.name.text === typeName &&
921
+ ts.isTypeLiteralNode(statement.type)) {
922
+ return statement.type.members;
923
+ }
924
+ }
925
+ return undefined;
926
+ };
850
927
  // { discriminator: "a"; other: string } (inline type literal as union branch)
851
928
  if (ts.isTypeLiteralNode(unionTypeNode)) {
852
929
  return unionTypeNode.members;
@@ -863,21 +940,24 @@ export class JsonSchemaBuilder {
863
940
  const typeName = ts.isIdentifier(unionTypeNode.typeName)
864
941
  ? unionTypeNode.typeName.text
865
942
  : unionTypeNode.typeName.right.text;
866
- for (const statement of sourceFile.statements) {
867
- // interface BranchType { discriminator: "a"; other: string; }
868
- if (ts.isInterfaceDeclaration(statement) && statement.name.text === typeName) {
869
- return statement.members;
870
- }
871
- if (
872
- // type BranchType = { discriminator: "a"; other: string }
873
- ts.isTypeAliasDeclaration(statement) &&
874
- statement.name.text === typeName &&
875
- // { discriminator: "a"; other: string } (must be a type literal alias)
876
- ts.isTypeLiteralNode(statement.type)) {
877
- return statement.type.members;
878
- }
943
+ const localMembers = findTypeMembersInSourceFile(sourceFile, typeName);
944
+ if (localMembers) {
945
+ return localMembers;
879
946
  }
880
- return undefined;
947
+ const importedTypeReference = JsonSchemaBuilder.findImportedTypeReference(context, typeName);
948
+ if (!importedTypeReference?.moduleSpecifier.startsWith(".")) {
949
+ return undefined;
950
+ }
951
+ const resolvedImportPath = JsonSchemaBuilder.resolveImportDeclarationSourceFile(sourceFile.fileName, importedTypeReference.moduleSpecifier);
952
+ if (!resolvedImportPath) {
953
+ return undefined;
954
+ }
955
+ const importedSource = FileUtils.readFile(resolvedImportPath);
956
+ if (!importedSource) {
957
+ return undefined;
958
+ }
959
+ const importedSourceFile = ts.createSourceFile(resolvedImportPath, importedSource, ts.ScriptTarget.Latest, true);
960
+ return findTypeMembersInSourceFile(importedSourceFile, importedTypeReference.candidateTypeName);
881
961
  }
882
962
  /**
883
963
  * Resolve a local $ref schema to its stored schema definition.