History

Improve source type projection and inspection performance

Rhys Sullivan committed 7 months ago

Commit 747f7a31 parent 20557f610 files · +1075 −144Browse files

Unified · Split

10 files
  1. .executor/executor.jsonc +11 −1
  2. apps/executor/src/server/server.real-ingestion.test.ts +32 −0
  3. packages/platform/control-plane/src/runtime/catalog/catalog-typescript.ts +233 −126
  4. packages/platform/control-plane/src/runtime/catalog/source/runtime.test.ts +526 −0
  5. packages/platform/control-plane/src/runtime/catalog/source/runtime.ts +1 −1
  6. packages/platform/control-plane/src/runtime/catalog/source/snapshot.test.ts +156 −0
  7. packages/platform/control-plane/src/runtime/catalog/source/type-declarations.test.ts +19 −5
  8. packages/platform/control-plane/src/runtime/sources/source-inspection.ts +4 −3
  9. packages/sources/core/src/catalog-json-schema.ts +78 −2
  10. packages/sources/graphql/src/graphql-tools.ts +15 −6
.executor/executor.jsonc+11−1
@@ -1,15 +1,25 @@
1{1{
2 "sources": {2 "sources": {
3 "vercel": {3 "vercel": {
4 "kind": "openapi",
5 "name": "Vercel API",4 "name": "Vercel API",
6 "connection": {5 "connection": {
7 "endpoint": "https://api.vercel.com/"6 "endpoint": "https://api.vercel.com/"
8 },7 },
8 "kind": "openapi",
9 "binding": {9 "binding": {
10 "specUrl": "https://openapi.vercel.sh/",10 "specUrl": "https://openapi.vercel.sh/",
11 "defaultHeaders": null11 "defaultHeaders": null
12 }12 }
13 },
14 "linear": {
15 "kind": "graphql",
16 "name": "Linear GraphQL",
17 "connection": {
18 "endpoint": "https://api.linear.app/graphql"
19 },
20 "binding": {
21 "defaultHeaders": null
22 }
13 }23 }
14 }24 }
15}25}
apps/executor/src/server/server.real-ingestion.test.ts+32−0
@@ -157,6 +157,38 @@ describe("local-executor-server real ingestion", () => { · 156 unmodified lines
157 expect(connected.source.status).toBe("connected");157 expect(connected.source.status).toBe("connected");
158 expect(connected.source.namespace).toBe("linear");158 expect(connected.source.namespace).toBe("linear");
159 159
160 const inspection = yield* client.sources.inspection({
161 path: {
162 workspaceId: installation.workspaceId,
163 sourceId: connected.source.id,
164 },
165 });
166
167 expect(inspection.source.kind).toBe("graphql");
168 expect(inspection.toolCount).toBeGreaterThan(100);
169 expect(
170 inspection.tools.every((tool) => tool.inputTypePreview === undefined),
171 ).toBe(true);
172 expect(
173 inspection.tools.every((tool) => tool.outputTypePreview === undefined),
174 ).toBe(true);
175
176 const toolDetail = yield* client.sources.inspectionTool({
177 path: {
178 workspaceId: installation.workspaceId,
179 sourceId: connected.source.id,
180 toolPath: "linear.administrableTeams",
181 },
182 });
183
184 expect(toolDetail.summary.path).toBe("linear.administrableTeams");
185 expect(toolDetail.summary.inputTypePreview).toBeUndefined();
186 expect(toolDetail.summary.outputTypePreview).toBeUndefined();
187 expect(toolDetail.contract.input.typeDeclaration).toContain(
188 "type LinearAdministrableTeamsCall = {",
189 );
190 expect(toolDetail.contract.input.schemaJson).toContain("\"$defs\"");
191
160 const storedSource = yield* client.sources.get({192 const storedSource = yield* client.sources.get({
161 path: {193 path: {
162 workspaceId: installation.workspaceId,194 workspaceId: installation.workspaceId,
packages/platform/control-plane/src/runtime/catalog/catalog-typescript.ts+233−126
@@ -5,6 +5,7 @@ import type { ShapeSymbolId } from "@executor/ir/ids"; · 4 unmodified lines
5import type {5import type {
6 CatalogV1,6 CatalogV1,
7 DocumentationBlock,7 DocumentationBlock,
8 ProvenanceRef,
8 ShapeNode,9 ShapeNode,
9 ShapeSymbol,10 ShapeSymbol,
10} from "@executor/ir/model";11} from "@executor/ir/model";
@@ -52,7 +53,6 @@ export type CatalogTypeProjector = { · 41 unmodified lines
52};53};
53 54
54const VALID_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;55const VALID_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
55const TYPE_ALIAS_REFERENCE_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
56 56
57const hashSignature = (value: string): string =>57const hashSignature = (value: string): string =>
58 createHash("sha256").update(value).digest("hex").slice(0, 24);58 createHash("sha256").update(value).digest("hex").slice(0, 24);
@@ -160,6 +160,89 @@ const isSyntheticShapeLabel = (value: string): boolean => · 101 unmodified lines
160 160
161const looksLikeHumanPhrase = (value: string): boolean => /\s/.test(value.trim());161const looksLikeHumanPhrase = (value: string): boolean => /\s/.test(value.trim());
162 162
163const nominalTypeName = (shape: ShapeSymbol): string | undefined => {
164 const title = shape.title?.trim();
165 return title && !isSyntheticShapeLabel(title)
166 ? formatTypeNameSegment(title)
167 : undefined;
168};
169
170const decodePointerSegment = (value: string): string =>
171 value.replace(/~1/g, "/").replace(/~0/g, "~");
172
173const meaningfulProvenanceName = (value: string): string | undefined => {
174 const stripped = value.trim();
175 if (
176 stripped.length === 0
177 || /^\d+$/.test(stripped)
178 || [
179 "$defs",
180 "definitions",
181 "components",
182 "schemas",
183 "schema",
184 "properties",
185 "items",
186 "additionalProperties",
187 "patternProperties",
188 "allOf",
189 "anyOf",
190 "oneOf",
191 "not",
192 "if",
193 "then",
194 "else",
195 "input",
196 "output",
197 "graphql",
198 "scalars",
199 "responses",
200 "headers",
201 "parameters",
202 "requestBody",
203 ].includes(stripped)
204 ) {
205 return undefined;
206 }
207
208 return formatTypeNameSegment(stripped);
209};
210
211const typeNameFromProvenance = (provenance: readonly ProvenanceRef[]): string | undefined => {
212 for (const entry of provenance) {
213 const pointer = entry.pointer;
214 if (!pointer?.startsWith("#/")) {
215 continue;
216 }
217
218 const segments = pointer
219 .slice(2)
220 .split("/")
221 .map(decodePointerSegment);
222 const markerIndexes = segments.flatMap((segment, index) =>
223 ["$defs", "definitions", "schemas", "input", "output"].includes(segment)
224 ? [index]
225 : []
226 );
227
228 for (const markerIndex of markerIndexes) {
229 const candidate = meaningfulProvenanceName(segments[markerIndex + 1] ?? "");
230 if (candidate) {
231 return candidate;
232 }
233 }
234
235 for (let index = segments.length - 1; index >= 0; index -= 1) {
236 const candidate = meaningfulProvenanceName(segments[index] ?? "");
237 if (candidate) {
238 return candidate;
239 }
240 }
241 }
242
243 return undefined;
244};
245
163const cleanDocText = (value: string | undefined): string | null => {246const cleanDocText = (value: string | undefined): string | null => {
164 const trimmed = value?.trim();247 const trimmed = value?.trim();
165 return trimmed && trimmed.length > 0 ? trimmed : null;248 return trimmed && trimmed.length > 0 ? trimmed : null;
@@ -475,14 +558,17 @@ export const createCatalogTypeProjector = (input: { · 309 unmodified lines
475}): CatalogTypeProjector => {558}): CatalogTypeProjector => {
476 const { catalog, roots } = input;559 const { catalog, roots } = input;
477 const signatureCache = new Map<ShapeSymbolId, SignatureInfo>();560 const signatureCache = new Map<ShapeSymbolId, SignatureInfo>();
478 const recursiveSignatures = new Set<string>();561 const rootShapeIds = new Set(roots.map((root) => root.shapeId));
479 const shapeIdsBySignature = new Map<string, ShapeSymbolId[]>();562 const reachableShapeIds = new Set<ShapeSymbolId>();
480 const usageCountBySignature = new Map<string, number>();563 const recursiveShapeIds = new Set<ShapeSymbolId>();
481 const aliasNameBySignature = new Map<string, string>();564 const explicitRefTargetShapeIds = new Set<ShapeSymbolId>();
482 const canonicalTypeBySignature = new Map<string, string>();565 const aliasNameByShapeId = new Map<ShapeSymbolId, string>();
483 const rootAliasHintBySignature = new Map<string, string>();566 const rootAliasHintByShapeId = new Map<ShapeSymbolId, string>();
567 const declarationBodyByShapeId = new Map<ShapeSymbolId, string>();
568 const nominalTypeNameByShapeId = new Map<ShapeSymbolId, string>();
569 const nominalTypeNameCounts = new Map<string, number>();
484 const usedAliasNames = new Set<string>();570 const usedAliasNames = new Set<string>();
485 const usedAliasSignatures = new Set<string>();571 const usedAliasShapeIds = new Set<ShapeSymbolId>();
486 572
487 const shapeSignatureInfo = (shapeId: ShapeSymbolId, stack: readonly ShapeSymbolId[] = []): SignatureInfo => {573 const shapeSignatureInfo = (shapeId: ShapeSymbolId, stack: readonly ShapeSymbolId[] = []): SignatureInfo => {
488 const cached = signatureCache.get(shapeId);574 const cached = signatureCache.get(shapeId);
@@ -590,25 +676,13 @@ export const createCatalogTypeProjector = (input: { · 101 unmodified lines
590 recursive,676 recursive,
591 };677 };
592 678
593 if (signature.recursive) {
594 recursiveSignatures.add(signature.key);
595 }
596
597 signatureCache.set(shapeId, signature);679 signatureCache.set(shapeId, signature);
598 const group = shapeIdsBySignature.get(signature.key);
599 if (group) {
600 group.push(shapeId);
601 } else {
602 shapeIdsBySignature.set(signature.key, [shapeId]);
603 }
604 return signature;680 return signature;
605 };681 };
606 682
607 const shapeSignature = (shapeId: ShapeSymbolId, stack: readonly ShapeSymbolId[] = []): string =>683 const shapeSignature = (shapeId: ShapeSymbolId, stack: readonly ShapeSymbolId[] = []): string =>
608 shapeSignatureInfo(shapeId, stack).key;684 shapeSignatureInfo(shapeId, stack).key;
609 685
610 const reachableShapeIds = new Set<ShapeSymbolId>();
611
612 const collectReachableShapes = (shapeId: ShapeSymbolId): void => {686 const collectReachableShapes = (shapeId: ShapeSymbolId): void => {
613 if (reachableShapeIds.has(shapeId)) {687 if (reachableShapeIds.has(shapeId)) {
614 return;688 return;
@@ -624,33 +698,81 @@ export const createCatalogTypeProjector = (input: { · 9 unmodified lines
624 collectReachableShapes(root.shapeId);698 collectReachableShapes(root.shapeId);
625 }699 }
626 700
627 for (const rootShapeId of roots.map((root) => root.shapeId)) {701 for (const shapeId of reachableShapeIds) {
628 const rootShape = getShapeSymbol(catalog, rootShapeId);702 const shape = getShapeSymbol(catalog, shapeId);
629 if (isInlineShapeNode(rootShape.node)) {703 if (shape.synthetic) {
630 continue;704 continue;
631 }705 }
632 706
633 const signature = shapeSignature(rootShapeId);707 const name = nominalTypeName(shape);
634 usageCountBySignature.set(signature, (usageCountBySignature.get(signature) ?? 0) + 1);708 if (!name) {
709 continue;
710 }
711
712 nominalTypeNameByShapeId.set(shapeId, name);
713 nominalTypeNameCounts.set(name, (nominalTypeNameCounts.get(name) ?? 0) + 1);
635 }714 }
636 715
637 for (const shapeId of reachableShapeIds) {716 const uniqueNominalTypeNameForShapeId = (
717 shapeId: ShapeSymbolId,
718 ): string | undefined => {
719 const name = nominalTypeNameByShapeId.get(shapeId);
720 return name && nominalTypeNameCounts.get(name) === 1
721 ? name
722 : undefined;
723 };
724
725 const visitingShapeIds: Array<ShapeSymbolId> = [];
726 const processedShapeIds = new Set<ShapeSymbolId>();
727
728 const markRecursiveShapes = (cycleStartShapeId: ShapeSymbolId): void => {
729 const cycleStartIndex = visitingShapeIds.indexOf(cycleStartShapeId);
730 if (cycleStartIndex === -1) {
731 recursiveShapeIds.add(cycleStartShapeId);
732 return;
733 }
734
735 for (const recursiveShapeId of visitingShapeIds.slice(cycleStartIndex)) {
736 recursiveShapeIds.add(recursiveShapeId);
737 }
738 recursiveShapeIds.add(cycleStartShapeId);
739 };
740
741 const detectRecursiveShapes = (shapeId: ShapeSymbolId): void => {
742 if (processedShapeIds.has(shapeId)) {
743 return;
744 }
745
746 if (visitingShapeIds.includes(shapeId)) {
747 markRecursiveShapes(shapeId);
748 return;
749 }
750
751 visitingShapeIds.push(shapeId);
638 for (const childShapeId of childShapeIds(getShapeSymbol(catalog, shapeId).node)) {752 for (const childShapeId of childShapeIds(getShapeSymbol(catalog, shapeId).node)) {
639 const childShape = getShapeSymbol(catalog, childShapeId);753 if (visitingShapeIds.includes(childShapeId)) {
640 if (isInlineShapeNode(childShape.node)) {754 markRecursiveShapes(childShapeId);
641 continue;755 continue;
642 }756 }
643 757
644 const signature = shapeSignature(childShapeId);758 detectRecursiveShapes(childShapeId);
645 usageCountBySignature.set(signature, (usageCountBySignature.get(signature) ?? 0) + 1);
646 }759 }
647 }760 visitingShapeIds.pop();
761 processedShapeIds.add(shapeId);
762 };
648 763
649 for (const root of roots) {764 for (const root of roots) {
650 const signature = shapeSignature(root.shapeId);765 detectRecursiveShapes(root.shapeId);
651 const existing = rootAliasHintBySignature.get(signature);766 const existing = rootAliasHintByShapeId.get(root.shapeId);
652 if (!existing || root.aliasHint.length < existing.length) {767 if (!existing || root.aliasHint.length < existing.length) {
653 rootAliasHintBySignature.set(signature, root.aliasHint);768 rootAliasHintByShapeId.set(root.shapeId, root.aliasHint);
769 }
770 }
771
772 for (const shapeId of reachableShapeIds) {
773 const shape = getShapeSymbol(catalog, shapeId);
774 if (shape.node.type === "ref") {
775 explicitRefTargetShapeIds.add(shape.node.target);
654 }776 }
655 }777 }
656 778
@@ -883,60 +1005,56 @@ export const createCatalogTypeProjector = (input: { · 226 unmodified lines
883 return baseText ? `${wrapCompositeType(baseText)} & (${unionText})` : unionText;1005 return baseText ? `${wrapCompositeType(baseText)} & (${unionText})` : unionText;
884 };1006 };
885 1007
886 const representativeShapeIdForSignature = (signature: string): ShapeSymbolId => {1008 const aliasNameForShapeId = (shapeId: ShapeSymbolId, hint?: string): string => {
887 const group = shapeIdsBySignature.get(signature);1009 const existing = aliasNameByShapeId.get(shapeId);
888 if (!group || group.length === 0) {
889 throw new Error(`Missing representative shape for signature ${signature}`);
890 }
891
892 const sorted = [...group].sort((left, right) => {
893 const leftShape = getShapeSymbol(catalog, left);
894 const rightShape = getShapeSymbol(catalog, right);
895 const leftTitle = leftShape.title ?? leftShape.id;
896 const rightTitle = rightShape.title ?? rightShape.id;
897 const leftRefPenalty = leftShape.node.type === "ref" ? 1 : 0;
898 const rightRefPenalty = rightShape.node.type === "ref" ? 1 : 0;
899 const leftSynthetic = isSyntheticShapeLabel(leftTitle) ? 1 : 0;
900 const rightSynthetic = isSyntheticShapeLabel(rightTitle) ? 1 : 0;
901 return leftRefPenalty - rightRefPenalty
902 || leftSynthetic - rightSynthetic
903 || leftTitle.localeCompare(rightTitle);
904 });
905
906 return sorted[0]!;
907 };
908
909 const aliasNameForSignature = (signature: string, hint?: string): string => {
910 const existing = aliasNameBySignature.get(signature);
911 if (existing) {1010 if (existing) {
912 return existing;1011 return existing;
913 }1012 }
914 1013
915 const representative = getShapeSymbol(catalog, representativeShapeIdForSignature(signature));1014 const shape = getShapeSymbol(catalog, shapeId);
916 const representativeName = representative.title ?? representative.id;1015 const rootHint = rootAliasHintByShapeId.get(shapeId);
917 const rootHint = rootAliasHintBySignature.get(signature);1016 const contextualHint = hint ? compactAliasHint(hint) : undefined;
918 const contextualHint = hint ? compactAliasHint(joinTypeNameSegments(hint)) : undefined;1017 const uniqueNominalName = uniqueNominalTypeNameForShapeId(shapeId);
919 const representativeTypeName = !isSyntheticShapeLabel(representativeName) && !looksLikeHumanPhrase(representativeName)1018 const nameCandidates = [
920 ? formatTypeNameSegment(representativeName)1019 rootHint,
921 : undefined;1020 uniqueNominalName,
922 const baseName = rootHint1021 typeNameFromProvenance(shape.provenance),
923 ?? representativeTypeName1022 nominalTypeNameByShapeId.get(shapeId),
924 ?? contextualHint1023 contextualHint,
925 ?? formatTypeNameSegment(representative.id);1024 formatTypeNameSegment(shape.id),
926 let candidate = baseName;1025 ].filter((candidate): candidate is string => candidate !== undefined);
1026 const [baseName = formatTypeNameSegment(shape.id)] = nameCandidates;
1027 let candidate =
1028 nameCandidates.find((name) => !usedAliasNames.has(name))
1029 ?? baseName;
927 let suffix = 2;1030 let suffix = 2;
928 while (usedAliasNames.has(candidate)) {1031 while (usedAliasNames.has(candidate)) {
929 candidate = `${baseName}_${String(suffix)}`;1032 candidate = `${baseName}_${String(suffix)}`;
930 suffix += 1;1033 suffix += 1;
931 }1034 }
932 1035
933 aliasNameBySignature.set(signature, candidate);1036 aliasNameByShapeId.set(shapeId, candidate);
934 usedAliasNames.add(candidate);1037 usedAliasNames.add(candidate);
935 return candidate;1038 return candidate;
936 };1039 };
937 1040
938 const shouldAliasSignature = (signature: string): boolean =>1041 const shouldEmitDeclarationShape = (shapeId: ShapeSymbolId): boolean => {
939 recursiveSignatures.has(signature) || (usageCountBySignature.get(signature) ?? 0) > 1;1042 const shape = getShapeSymbol(catalog, shapeId);
1043 if (isInlineShapeNode(shape.node)) {
1044 return false;
1045 }
1046
1047 if (rootShapeIds.has(shapeId)) {
1048 return true;
1049 }
1050
1051 if (shape.node.type === "ref") {
1052 return false;
1053 }
1054
1055 return uniqueNominalTypeNameForShapeId(shapeId) !== undefined
1056 || explicitRefTargetShapeIds.has(shapeId);
1057 };
940 1058
941 const renderDeclarationShapeBody = (1059 const renderDeclarationShapeBody = (
942 shapeId: ShapeSymbolId,1060 shapeId: ShapeSymbolId,
@@ -965,21 +1083,15 @@ export const createCatalogTypeProjector = (input: { · 22 unmodified lines
965 }1083 }
966 };1084 };
967 1085
968 const canonicalTypeForSignature = (signature: string, hint?: string): string => {1086 const renderDeclarationBodyForShapeId = (shapeId: ShapeSymbolId): string => {
969 const existing = canonicalTypeBySignature.get(signature);1087 const existing = declarationBodyByShapeId.get(shapeId);
970 if (existing) {1088 if (existing) {
971 return existing;1089 return existing;
972 }1090 }
973 1091
974 const aliasName = aliasNameForSignature(signature, hint);1092 const body = renderDeclarationShapeBody(shapeId, [shapeId], aliasNameForShapeId(shapeId));
975 canonicalTypeBySignature.set(signature, aliasName);1093 declarationBodyByShapeId.set(shapeId, body);
976 const representativeShapeId = representativeShapeIdForSignature(signature);1094 return body;
977 const body = renderDeclarationShapeBody(representativeShapeId, [representativeShapeId], aliasName);
978 const canonical = TYPE_ALIAS_REFERENCE_PATTERN.test(body) && body !== aliasName
979 ? body
980 : aliasName;
981 canonicalTypeBySignature.set(signature, canonical);
982 return canonical;
983 };1095 };
984 1096
985 const renderDeclarationShape: RenderShape = (shapeId, options = {}) => {1097 const renderDeclarationShape: RenderShape = (shapeId, options = {}) => {
@@ -988,16 +1100,21 @@ export const createCatalogTypeProjector = (input: { · 2 unmodified lines
988 return renderInlineShapeNode(shape.node);1100 return renderInlineShapeNode(shape.node);
989 }1101 }
990 1102
991 const signature = shapeSignature(shapeId);1103 const stack = options.stack ?? [];
992 if (shouldAliasSignature(signature)) {1104 if (shape.node.type === "ref" && !rootShapeIds.has(shapeId)) {
993 usedAliasSignatures.add(signature);1105 if (stack.includes(shapeId)) {
994 return canonicalTypeForSignature(signature, options.aliasHint);1106 return renderDeclarationShape(shape.node.target, {
1107 stack,
1108 aliasHint: options.aliasHint,
1109 });
1110 }
1111
1112 return renderDeclarationShapeBody(shapeId, [...stack, shapeId], options.aliasHint);
995 }1113 }
996 1114
997 const stack = options.stack ?? [];1115 if (stack.includes(shapeId) || shouldEmitDeclarationShape(shapeId)) {
998 if (stack.includes(shapeId)) {1116 usedAliasShapeIds.add(shapeId);
999 usedAliasSignatures.add(signature);1117 return aliasNameForShapeId(shapeId, options.aliasHint);
1000 return canonicalTypeForSignature(signature, options.aliasHint);
1001 }1118 }
1002 1119
1003 return renderDeclarationShapeBody(shapeId, [...stack, shapeId], options.aliasHint);1120 return renderDeclarationShapeBody(shapeId, [...stack, shapeId], options.aliasHint);
@@ -1063,53 +1180,43 @@ export const createCatalogTypeProjector = (input: { · 59 unmodified lines
1063 };1180 };
1064 1181
1065 const supportingDeclarations = (): readonly string[] => {1182 const supportingDeclarations = (): readonly string[] => {
1066 const pending = [...usedAliasSignatures];1183 const pending = [...usedAliasShapeIds];
1067 const emitted = new Set<string>();1184 const emitted = new Set<ShapeSymbolId>();
1185 let pendingIndex = 0;
1068 1186
1069 while (pending.length > 0) {1187 while (pendingIndex < pending.length) {
1070 const signature = pending.pop()!;1188 const shapeId = pending[pendingIndex++]!;
1071 if (emitted.has(signature)) {1189 if (emitted.has(shapeId)) {
1072 continue;1190 continue;
1073 }1191 }
1074 1192
1075 emitted.add(signature);1193 emitted.add(shapeId);
1076 const representativeShapeId = representativeShapeIdForSignature(signature);1194 renderDeclarationBodyForShapeId(shapeId);
1077 renderDeclarationShapeBody(representativeShapeId, [representativeShapeId], aliasNameForSignature(signature));
1078 1195
1079 for (const discoveredSignature of usedAliasSignatures) {1196 for (const discoveredShapeId of usedAliasShapeIds) {
1080 if (!emitted.has(discoveredSignature)) {1197 if (!emitted.has(discoveredShapeId)) {
1081 pending.push(discoveredSignature);1198 pending.push(discoveredShapeId);
1082 }1199 }
1083 }1200 }
1084 }1201 }
1085 1202
1086 return [...emitted]1203 const declarations = [...emitted]
1087 .sort((left, right) => aliasNameForSignature(left).localeCompare(aliasNameForSignature(right)))1204 .sort((left, right) => aliasNameForShapeId(left).localeCompare(aliasNameForShapeId(right)))
1088 .map((signature) => {1205 .map((shapeId) => {
1089 const canonicalType = canonicalTypeForSignature(signature);1206 const aliasName = aliasNameForShapeId(shapeId);
1090 const representativeShapeId = representativeShapeIdForSignature(signature);1207 const shape = getShapeSymbol(catalog, shapeId);
1091 const aliasName = aliasNameForSignature(signature);
1092 const representativeShape = getShapeSymbol(catalog, representativeShapeId);
1093 const comment = documentationComment({1208 const comment = documentationComment({
1094 title: representativeShape.title,1209 title: shape.title,
1095 docs: representativeShape.docs,1210 docs: shape.docs,
1096 deprecated: representativeShape.deprecated,1211 deprecated: shape.deprecated,
1097 includeTitle: true,1212 includeTitle: true,
1098 });1213 });
1099 if (canonicalType !== aliasName) {1214 const body = renderDeclarationBodyForShapeId(shapeId);
1100 return comment
1101 ? `${comment}\ntype ${aliasName} = ${canonicalType};`
1102 : `type ${aliasName} = ${canonicalType};`;
1103 }
1104 const body = renderDeclarationShapeBody(representativeShapeId, [representativeShapeId], aliasName);
1105 if (body === aliasName) {
1106 return "";
1107 }
1108
1109 const declaration = `type ${aliasName} = ${body};`;1215 const declaration = `type ${aliasName} = ${body};`;
1110 return comment ? `${comment}\n${declaration}` : declaration;1216 return comment ? `${comment}\n${declaration}` : declaration;
1111 })1217 });
1112 .filter((declaration) => declaration.length > 0);1218
1219 return declarations;
1113 };1220 };
1114 1221
1115 return {1222 return {
packages/platform/control-plane/src/runtime/catalog/source/runtime.test.ts+526−0
@@ -345,6 +345,532 @@ describe("source-catalog-runtime", () => { · 344 unmodified lines
345 expect(joinTypeNameSegments("linear", "teamsSearch", "call")).toBe("LinearTeamsSearchCall");345 expect(joinTypeNameSegments("linear", "teamsSearch", "call")).toBe("LinearTeamsSearchCall");
346 });346 });
347 347
348 it("keeps nominally distinct shapes separate in declaration exports", () => {
349 const loadedCatalog = createLoadedCatalog({
350 mutateCatalog: (catalog) => {
351 const stringShapeId = ShapeSymbolIdSchema.make("shape_string");
352 const projectFilterShapeId = ShapeSymbolIdSchema.make("shape_project_filter");
353 const callShapeId = ShapeSymbolIdSchema.make("shape_team_query_args");
354 const stringShape = catalog.symbols[stringShapeId];
355 const callShape = catalog.symbols[callShapeId];
356 const resourceId = stringShape?.kind === "shape" ? stringShape.resourceId : undefined;
357
358 if (!callShape || callShape.kind !== "shape" || callShape.node.type !== "object") {
359 throw new Error("Expected object call shape in fixture");
360 }
361
362 put(catalog.symbols as Record<typeof projectFilterShapeId, CatalogV1["symbols"][typeof projectFilterShapeId]>, projectFilterShapeId, {
363 id: projectFilterShapeId,
364 kind: "shape",
365 ...(resourceId ? { resourceId } : {}),
366 title: "ProjectFilter",
367 node: {
368 type: "object",
369 fields: {
370 name: {
371 shapeId: stringShapeId,
372 },
373 },
374 additionalProperties: false,
375 },
376 synthetic: false,
377 provenance: baseProvenance("#/input/ProjectFilter"),
378 });
379
380 put(catalog.symbols as Record<typeof callShapeId, CatalogV1["symbols"][typeof callShapeId]>, callShapeId, {
381 ...callShape,
382 node: {
383 ...callShape.node,
384 fields: {
385 ...callShape.node.fields,
386 projectFilter: {
387 shapeId: projectFilterShapeId,
388 docs: {
389 description: "Filter projects by name.",
390 },
391 },
392 },
393 },
394 });
395 },
396 });
397
398 const descriptor = Object.values(loadedCatalog.projected.toolDescriptors).find((tool) =>
399 tool.toolPath.join(".") === "linear.teams"
400 );
401 if (!descriptor) {
402 throw new Error("Expected linear.teams descriptor in fixture");
403 }
404
405 const projector = createCatalogTypeProjector({
406 catalog: loadedCatalog.projected.catalog,
407 roots: [{
408 shapeId: descriptor.callShapeId,
409 aliasHint: joinTypeNameSegments(...descriptor.toolPath, "call"),
410 }],
411 });
412
413 const rootType = projector.renderDeclarationShape(descriptor.callShapeId, {
414 aliasHint: joinTypeNameSegments(...descriptor.toolPath, "call"),
415 });
416 const declarations = projector.supportingDeclarations();
417 const declarationText = declarations.join("\n\n");
418
419 expect(rootType).toBe("LinearTeamsCall");
420 expect(declarations).toHaveLength(3);
421 expect(declarationText).toContain("type LinearTeamsCall = {");
422 expect(declarationText).toContain("filter?: TeamFilter;");
423 expect(declarationText).toContain("projectFilter?: ProjectFilter;");
424 expect(declarationText).toContain("type TeamFilter = {");
425 expect(declarationText).toContain("type ProjectFilter = {");
426 expect(declarationText).not.toContain("type LinearTeamsCallProjectFilter");
427 });
428
429 it("uses provenance names for GraphQL depth variants instead of numeric suffixes", () => {
430 const loadedCatalog = createLoadedCatalog({
431 mutateCatalog: (catalog) => {
432 const stringShapeId = ShapeSymbolIdSchema.make("shape_string");
433 const callShapeId = ShapeSymbolIdSchema.make("shape_team_query_args");
434 const teamShapeId = ShapeSymbolIdSchema.make("shape_team_output");
435 const teamDepthShapeId = ShapeSymbolIdSchema.make(
436 "shape_team_output_depth_1",
437 );
438 const teamRefShapeId = ShapeSymbolIdSchema.make("shape_team_output_ref");
439 const teamDepthRefShapeId = ShapeSymbolIdSchema.make(
440 "shape_team_output_depth_1_ref",
441 );
442 const stringShape = catalog.symbols[stringShapeId];
443 const callShape = catalog.symbols[callShapeId];
444 const resourceId =
445 stringShape?.kind === "shape" ? stringShape.resourceId : undefined;
446
447 if (!callShape || callShape.kind !== "shape" || callShape.node.type !== "object") {
448 throw new Error("Expected object call shape in fixture");
449 }
450
451 put(catalog.symbols as Record<typeof teamShapeId, CatalogV1["symbols"][typeof teamShapeId]>, teamShapeId, {
452 id: teamShapeId,
453 kind: "shape",
454 ...(resourceId ? { resourceId } : {}),
455 node: {
456 type: "object",
457 fields: {
458 __typename: {
459 shapeId: stringShapeId,
460 },
461 },
462 required: ["__typename"],
463 additionalProperties: false,
464 },
465 synthetic: false,
466 provenance: baseProvenance("#/$defs/graphql/output/Team"),
467 });
468
469 put(catalog.symbols as Record<typeof teamDepthShapeId, CatalogV1["symbols"][typeof teamDepthShapeId]>, teamDepthShapeId, {
470 id: teamDepthShapeId,
471 kind: "shape",
472 ...(resourceId ? { resourceId } : {}),
473 node: {
474 type: "object",
475 fields: {
476 __typename: {
477 shapeId: stringShapeId,
478 },
479 name: {
480 shapeId: stringShapeId,
481 },
482 },
483 required: ["__typename", "name"],
484 additionalProperties: false,
485 },
486 synthetic: false,
487 provenance: baseProvenance("#/$defs/graphql/output/Team__depth1"),
488 });
489
490 put(catalog.symbols as Record<typeof teamRefShapeId, CatalogV1["symbols"][typeof teamRefShapeId]>, teamRefShapeId, {
491 id: teamRefShapeId,
492 kind: "shape",
493 ...(resourceId ? { resourceId } : {}),
494 node: {
495 type: "ref",
496 target: teamShapeId,
497 },
498 synthetic: false,
499 provenance: baseProvenance("#/query/teams/args/properties/team"),
500 });
501
502 put(catalog.symbols as Record<typeof teamDepthRefShapeId, CatalogV1["symbols"][typeof teamDepthRefShapeId]>, teamDepthRefShapeId, {
503 id: teamDepthRefShapeId,
504 kind: "shape",
505 ...(resourceId ? { resourceId } : {}),
506 node: {
507 type: "ref",
508 target: teamDepthShapeId,
509 },
510 synthetic: false,
511 provenance: baseProvenance("#/query/teams/args/properties/teamDepth"),
512 });
513
514 put(catalog.symbols as Record<typeof callShapeId, CatalogV1["symbols"][typeof callShapeId]>, callShapeId, {
515 ...callShape,
516 node: {
517 ...callShape.node,
518 fields: {
519 ...callShape.node.fields,
520 team: {
521 shapeId: teamRefShapeId,
522 },
523 teamDepth: {
524 shapeId: teamDepthRefShapeId,
525 },
526 },
527 },
528 });
529 },
530 });
531
532 const descriptor = Object.values(loadedCatalog.projected.toolDescriptors).find((tool) =>
533 tool.toolPath.join(".") === "linear.teams"
534 );
535 if (!descriptor) {
536 throw new Error("Expected linear.teams descriptor in fixture");
537 }
538
539 const projector = createCatalogTypeProjector({
540 catalog: loadedCatalog.projected.catalog,
541 roots: [{
542 shapeId: descriptor.callShapeId,
543 aliasHint: joinTypeNameSegments(...descriptor.toolPath, "call"),
544 }],
545 });
546
547 projector.renderDeclarationShape(descriptor.callShapeId, {
548 aliasHint: joinTypeNameSegments(...descriptor.toolPath, "call"),
549 });
550 const declarationText = projector.supportingDeclarations().join("\n\n");
551
552 expect(declarationText).toContain("team?: Team;");
553 expect(declarationText).toContain("teamDepth?: TeamDepth1;");
554 expect(declarationText).toContain("type Team = {");
555 expect(declarationText).toContain("type TeamDepth1 = {");
556 expect(declarationText).not.toContain("type Team_2 = {");
557 });
558
559 it("keeps recursive JSON-like wrappers inline without numeric helper aliases", () => {
560 const catalog = createEmptyCatalogV1();
561 const resourceId = ResourceIdSchema.make("res_json");
562 const stringShapeId = ShapeSymbolIdSchema.make("shape_json_string");
563 const numberShapeId = ShapeSymbolIdSchema.make("shape_json_number");
564 const booleanShapeId = ShapeSymbolIdSchema.make("shape_json_boolean");
565 const arrayShapeId = ShapeSymbolIdSchema.make("shape_json_array");
566 const objectShapeId = ShapeSymbolIdSchema.make("shape_json_object");
567 const rootShapeId = ShapeSymbolIdSchema.make("shape_json_value");
568
569 put(catalog.documents as Record<typeof docId, CatalogV1["documents"][typeof docId]>, docId, {
570 id: docId,
571 kind: "graphql-schema",
572 title: "Json Test",
573 fetchedAt: "2026-03-20T00:00:00.000Z",
574 rawRef: "memory://json-test",
575 });
576 put(catalog.resources as Record<typeof resourceId, CatalogV1["resources"][typeof resourceId]>, resourceId, {
577 id: resourceId,
578 documentId: docId,
579 canonicalUri: "memory://json-test",
580 baseUri: "memory://json-test",
581 anchors: {},
582 dynamicAnchors: {},
583 synthetic: false,
584 provenance: baseProvenance("#"),
585 });
586
587 put(catalog.symbols as Record<typeof stringShapeId, CatalogV1["symbols"][typeof stringShapeId]>, stringShapeId, {
588 id: stringShapeId,
589 kind: "shape",
590 resourceId,
591 node: { type: "scalar", scalar: "string" },
592 synthetic: false,
593 provenance: baseProvenance("#/string"),
594 });
595 put(catalog.symbols as Record<typeof numberShapeId, CatalogV1["symbols"][typeof numberShapeId]>, numberShapeId, {
596 id: numberShapeId,
597 kind: "shape",
598 resourceId,
599 node: { type: "scalar", scalar: "number" },
600 synthetic: false,
601 provenance: baseProvenance("#/number"),
602 });
603 put(catalog.symbols as Record<typeof booleanShapeId, CatalogV1["symbols"][typeof booleanShapeId]>, booleanShapeId, {
604 id: booleanShapeId,
605 kind: "shape",
606 resourceId,
607 node: { type: "scalar", scalar: "boolean" },
608 synthetic: false,
609 provenance: baseProvenance("#/boolean"),
610 });
611 put(catalog.symbols as Record<typeof arrayShapeId, CatalogV1["symbols"][typeof arrayShapeId]>, arrayShapeId, {
612 id: arrayShapeId,
613 kind: "shape",
614 resourceId,
615 node: {
616 type: "array",
617 itemShapeId: rootShapeId,
618 },
619 synthetic: false,
620 provenance: baseProvenance("#/array"),
621 });
622 put(catalog.symbols as Record<typeof objectShapeId, CatalogV1["symbols"][typeof objectShapeId]>, objectShapeId, {
623 id: objectShapeId,
624 kind: "shape",
625 resourceId,
626 node: {
627 type: "object",
628 fields: {},
629 required: [],
630 additionalProperties: rootShapeId,
631 },
632 synthetic: false,
633 provenance: baseProvenance("#/object"),
634 });
635 put(catalog.symbols as Record<typeof rootShapeId, CatalogV1["symbols"][typeof rootShapeId]>, rootShapeId, {
636 id: rootShapeId,
637 kind: "shape",
638 resourceId,
639 title: "JsonValue",
640 node: {
641 type: "oneOf",
642 items: [
643 stringShapeId,
644 numberShapeId,
645 booleanShapeId,
646 arrayShapeId,
647 objectShapeId,
648 ],
649 },
650 synthetic: false,
651 provenance: baseProvenance("#/jsonValue"),
652 });
653
654 const projector = createCatalogTypeProjector({
655 catalog,
656 roots: [{
657 shapeId: rootShapeId,
658 aliasHint: "JsonValue",
659 }],
660 });
661
662 const rootType = projector.renderDeclarationShape(rootShapeId, {
663 aliasHint: "JsonValue",
664 });
665 const declarationText = projector.supportingDeclarations().join("\n\n");
666
667 expect(rootType).toBe("JsonValue");
668 expect(declarationText).toContain("type JsonValue =");
669 expect(declarationText).toContain("Array<JsonValue>");
670 expect(declarationText).toContain("[key: string]: JsonValue;");
671 expect(declarationText).not.toContain("type JsonValue_2");
672 expect(declarationText).not.toContain("type JsonValue_3");
673 });
674
675 it("uses provenance names before numeric suffixes for duplicate titled ref targets", () => {
676 const loadedCatalog = createLoadedCatalog({
677 mutateCatalog: (catalog) => {
678 const stringShapeId = ShapeSymbolIdSchema.make("shape_string");
679 const callShapeId = ShapeSymbolIdSchema.make("shape_team_query_args");
680 const accessCentrifyShapeId = ShapeSymbolIdSchema.make("shape_access_centrify");
681 const accessSchemasCentrifyShapeId = ShapeSymbolIdSchema.make("shape_access_schemas_centrify");
682 const accessCentrifyRefShapeId = ShapeSymbolIdSchema.make("shape_access_centrify_ref");
683 const accessSchemasCentrifyRefShapeId = ShapeSymbolIdSchema.make("shape_access_schemas_centrify_ref");
684 const stringShape = catalog.symbols[stringShapeId];
685 const callShape = catalog.symbols[callShapeId];
686 const resourceId =
687 stringShape?.kind === "shape" ? stringShape.resourceId : undefined;
688
689 if (!callShape || callShape.kind !== "shape" || callShape.node.type !== "object") {
690 throw new Error("Expected object call shape in fixture");
691 }
692
693 for (const [shapeId, pointer] of [
694 [accessCentrifyShapeId, "#/components/schemas/access_centrify"],
695 [accessSchemasCentrifyShapeId, "#/components/schemas/access_schemas-centrify"],
696 ] as const) {
697 put(catalog.symbols as Record<typeof shapeId, CatalogV1["symbols"][typeof shapeId]>, shapeId, {
698 id: shapeId,
699 kind: "shape",
700 ...(resourceId ? { resourceId } : {}),
701 title: "Centrify",
702 node: {
703 type: "object",
704 fields: {
705 name: {
706 shapeId: stringShapeId,
707 },
708 },
709 required: ["name"],
710 additionalProperties: false,
711 },
712 synthetic: false,
713 provenance: baseProvenance(pointer),
714 });
715 }
716
717 put(catalog.symbols as Record<typeof accessCentrifyRefShapeId, CatalogV1["symbols"][typeof accessCentrifyRefShapeId]>, accessCentrifyRefShapeId, {
718 id: accessCentrifyRefShapeId,
719 kind: "shape",
720 ...(resourceId ? { resourceId } : {}),
721 node: {
722 type: "ref",
723 target: accessCentrifyShapeId,
724 },
725 synthetic: false,
726 provenance: baseProvenance("#/query/teams/args/properties/primaryIdp"),
727 });
728 put(catalog.symbols as Record<typeof accessSchemasCentrifyRefShapeId, CatalogV1["symbols"][typeof accessSchemasCentrifyRefShapeId]>, accessSchemasCentrifyRefShapeId, {
729 id: accessSchemasCentrifyRefShapeId,
730 kind: "shape",
731 ...(resourceId ? { resourceId } : {}),
732 node: {
733 type: "ref",
734 target: accessSchemasCentrifyShapeId,
735 },
736 synthetic: false,
737 provenance: baseProvenance("#/query/teams/args/properties/secondaryIdp"),
738 });
739
740 put(catalog.symbols as Record<typeof callShapeId, CatalogV1["symbols"][typeof callShapeId]>, callShapeId, {
741 ...callShape,
742 node: {
743 ...callShape.node,
744 fields: {
745 ...callShape.node.fields,
746 primaryIdp: {
747 shapeId: accessCentrifyRefShapeId,
748 },
749 secondaryIdp: {
750 shapeId: accessSchemasCentrifyRefShapeId,
751 },
752 },
753 },
754 });
755 },
756 });
757
758 const descriptor = Object.values(loadedCatalog.projected.toolDescriptors).find((tool) =>
759 tool.toolPath.join(".") === "linear.teams"
760 );
761 if (!descriptor) {
762 throw new Error("Expected linear.teams descriptor in fixture");
763 }
764
765 const projector = createCatalogTypeProjector({
766 catalog: loadedCatalog.projected.catalog,
767 roots: [{
768 shapeId: descriptor.callShapeId,
769 aliasHint: joinTypeNameSegments(...descriptor.toolPath, "call"),
770 }],
771 });
772
773 projector.renderDeclarationShape(descriptor.callShapeId, {
774 aliasHint: joinTypeNameSegments(...descriptor.toolPath, "call"),
775 });
776 const declarationText = projector.supportingDeclarations().join("\n\n");
777
778 expect(declarationText).toContain("primaryIdp?: AccessCentrify;");
779 expect(declarationText).toContain("secondaryIdp?: AccessSchemasCentrify;");
780 expect(declarationText).toContain("type AccessCentrify = {");
781 expect(declarationText).toContain("type AccessSchemasCentrify = {");
782 expect(declarationText).not.toContain("type Centrify_2 = {");
783 });
784
785 it("renders shared GraphQL typename-only refs through normal aliasing", () => {
786 const loadedCatalog = createLoadedCatalog({
787 mutateCatalog: (catalog) => {
788 const stringShapeId = ShapeSymbolIdSchema.make("shape_string");
789 const callShapeId = ShapeSymbolIdSchema.make("shape_team_query_args");
790 const teamDepthShapeId = ShapeSymbolIdSchema.make("shape_team_output_depth_2");
791 const teamDepthRefShapeId = ShapeSymbolIdSchema.make(
792 "shape_team_output_depth_2_ref",
793 );
794 const stringShape = catalog.symbols[stringShapeId];
795 const callShape = catalog.symbols[callShapeId];
796 const resourceId =
797 stringShape?.kind === "shape" ? stringShape.resourceId : undefined;
798
799 if (!callShape || callShape.kind !== "shape" || callShape.node.type !== "object") {
800 throw new Error("Expected object call shape in fixture");
801 }
802
803 put(catalog.symbols as Record<typeof teamDepthShapeId, CatalogV1["symbols"][typeof teamDepthShapeId]>, teamDepthShapeId, {
804 id: teamDepthShapeId,
805 kind: "shape",
806 ...(resourceId ? { resourceId } : {}),
807 node: {
808 type: "object",
809 fields: {
810 __typename: {
811 shapeId: stringShapeId,
812 },
813 },
814 required: ["__typename"],
815 additionalProperties: false,
816 },
817 synthetic: false,
818 provenance: baseProvenance("#/$defs/graphql/output/GraphqlTypenameOnly"),
819 });
820
821 put(catalog.symbols as Record<typeof teamDepthRefShapeId, CatalogV1["symbols"][typeof teamDepthRefShapeId]>, teamDepthRefShapeId, {
822 id: teamDepthRefShapeId,
823 kind: "shape",
824 ...(resourceId ? { resourceId } : {}),
825 node: {
826 type: "ref",
827 target: teamDepthShapeId,
828 },
829 synthetic: false,
830 provenance: baseProvenance("#/query/teams/args/properties/teamDepthTwo"),
831 });
832
833 put(catalog.symbols as Record<typeof callShapeId, CatalogV1["symbols"][typeof callShapeId]>, callShapeId, {
834 ...callShape,
835 node: {
836 ...callShape.node,
837 fields: {
838 ...callShape.node.fields,
839 teamDepthTwo: {
840 shapeId: teamDepthRefShapeId,
841 },
842 },
843 },
844 });
845 },
846 });
847
848 const descriptor = Object.values(loadedCatalog.projected.toolDescriptors).find((tool) =>
849 tool.toolPath.join(".") === "linear.teams"
850 );
851 if (!descriptor) {
852 throw new Error("Expected linear.teams descriptor in fixture");
853 }
854
855 const projector = createCatalogTypeProjector({
856 catalog: loadedCatalog.projected.catalog,
857 roots: [{
858 shapeId: descriptor.callShapeId,
859 aliasHint: joinTypeNameSegments(...descriptor.toolPath, "call"),
860 }],
861 });
862
863 projector.renderDeclarationShape(descriptor.callShapeId, {
864 aliasHint: joinTypeNameSegments(...descriptor.toolPath, "call"),
865 });
866 const declarationText = projector.supportingDeclarations().join("\n\n");
867
868 expect(declarationText).toContain("type GraphqlTypenameOnly = {");
869 expect(declarationText).toContain("__typename: string;");
870 expect(declarationText).toContain("teamDepthTwo?: GraphqlTypenameOnly;");
871 expect(declarationText).not.toContain("type TeamDepth2 = {");
872 });
873
348 it.effect("projects friendly schemas for discover and inspection consumers", () =>874 it.effect("projects friendly schemas for discover and inspection consumers", () =>
349 Effect.gen(function* () {875 Effect.gen(function* () {
350 const [tool] = yield* expandCatalogTools({876 const [tool] = yield* expandCatalogTools({
packages/platform/control-plane/src/runtime/catalog/source/runtime.ts+1−1
@@ -920,7 +920,7 @@ const declarationBlockForShape = (input: { · 919 unmodified lines
920 }),920 }),
921 ].join("\n\n");921 ].join("\n\n");
922 922
923 return formatWithPrettier(declarationText, "typescript-module");923 return declarationText;
924 },924 },
925 catch: (cause) =>925 catch: (cause) =>
926 cause instanceof Error ? cause : new Error(String(cause)),926 cause instanceof Error ? cause : new Error(String(cause)),
packages/platform/control-plane/src/runtime/catalog/source/snapshot.test.ts+156−0
@@ -1198,4 +1198,160 @@ describe("source-catalog-snapshot", () => { · 1197 unmodified lines
1198 ),1198 ),
1199 ).toBe(false);1199 ).toBe(false);
1200 });1200 });
1201
1202 it("collapses GraphQL typename-only output stubs to a shared ref", () => {
1203 const snapshot = createGraphqlCatalogSnapshot({
1204 source: {
1205 ...baseSource,
1206 kind: "graphql",
1207 namespace: "linear",
1208 },
1209 documents: [],
1210 operations: [
1211 {
1212 toolId: "teamDepthRefs",
1213 title: "Team Depth Refs",
1214 description: "Return depth-limited Team variants.",
1215 effect: "read",
1216 inputSchema: {
1217 type: "object",
1218 additionalProperties: false,
1219 },
1220 outputSchema: {
1221 type: "object",
1222 properties: {
1223 data: {
1224 $ref: "#/$defs/graphql/output/Team",
1225 },
1226 errors: {
1227 type: "array",
1228 items: {
1229 type: "object",
1230 properties: {
1231 message: {
1232 type: "string",
1233 },
1234 },
1235 required: ["message"],
1236 additionalProperties: false,
1237 },
1238 },
1239 },
1240 required: ["data", "errors"],
1241 additionalProperties: false,
1242 $defs: {
1243 graphql: {
1244 output: {
1245 Team: {
1246 type: "object",
1247 properties: {
1248 organization: {
1249 $ref: "#/$defs/graphql/output/GraphqlTypenameOnly",
1250 },
1251 projects: {
1252 $ref: "#/$defs/graphql/output/GraphqlTypenameOnly",
1253 },
1254 __typename: {
1255 type: "string",
1256 },
1257 },
1258 required: ["organization", "projects", "__typename"],
1259 additionalProperties: false,
1260 },
1261 GraphqlTypenameOnly: {
1262 type: "object",
1263 properties: {
1264 __typename: {
1265 type: "string",
1266 },
1267 },
1268 required: ["__typename"],
1269 additionalProperties: false,
1270 },
1271 },
1272 },
1273 },
1274 },
1275 providerData: {
1276 kind: "graphql",
1277 toolKind: "field",
1278 toolId: "teamDepthRefs",
1279 rawToolId: "teamDepthRefs",
1280 group: "query",
1281 leaf: "teamDepthRefs",
1282 fieldName: "teamDepthRefs",
1283 operationType: "query",
1284 operationName: "TeamDepthRefsQuery",
1285 operationDocument:
1286 "query TeamDepthRefsQuery { teamDepthRefs { organization { __typename } projects { __typename } __typename } }",
1287 queryTypeName: "Query",
1288 mutationTypeName: null,
1289 subscriptionTypeName: null,
1290 },
1291 },
1292 ],
1293 });
1294
1295 const executable = Object.values(snapshot.catalog.executables)[0]!;
1296 const resultShape =
1297 snapshot.catalog.symbols[executable.projection.resultDataShapeId!];
1298 expect(resultShape?.kind).toBe("shape");
1299 if (!resultShape || resultShape.kind !== "shape") {
1300 throw new Error("Expected result data shape symbol");
1301 }
1302
1303 expect(resultShape.node.type).toBe("ref");
1304 if (resultShape.node.type !== "ref") {
1305 throw new Error("Expected result data ref shape");
1306 }
1307
1308 const teamShape = snapshot.catalog.symbols[resultShape.node.target];
1309 expect(teamShape?.kind).toBe("shape");
1310 if (!teamShape || teamShape.kind !== "shape" || teamShape.node.type !== "object") {
1311 throw new Error("Expected Team object shape");
1312 }
1313
1314 const organizationFieldShape =
1315 snapshot.catalog.symbols[teamShape.node.fields.organization!.shapeId];
1316 const projectsFieldShape =
1317 snapshot.catalog.symbols[teamShape.node.fields.projects!.shapeId];
1318 expect(organizationFieldShape?.kind).toBe("shape");
1319 expect(projectsFieldShape?.kind).toBe("shape");
1320 if (
1321 !organizationFieldShape
1322 || organizationFieldShape.kind !== "shape"
1323 || organizationFieldShape.node.type !== "ref"
1324 || !projectsFieldShape
1325 || projectsFieldShape.kind !== "shape"
1326 || projectsFieldShape.node.type !== "ref"
1327 ) {
1328 throw new Error("Expected GraphQL output ref field shapes");
1329 }
1330
1331 expect(organizationFieldShape.node.target).toBe(
1332 projectsFieldShape.node.target,
1333 );
1334
1335 const organizationTarget =
1336 snapshot.catalog.symbols[organizationFieldShape.node.target];
1337 const projectsTarget =
1338 snapshot.catalog.symbols[projectsFieldShape.node.target];
1339 expect(organizationTarget?.kind).toBe("shape");
1340 expect(projectsTarget?.kind).toBe("shape");
1341 if (
1342 !organizationTarget
1343 || organizationTarget.kind !== "shape"
1344 || !projectsTarget
1345 || projectsTarget.kind !== "shape"
1346 ) {
1347 throw new Error("Expected GraphQL output target shapes");
1348 }
1349
1350 expect(organizationTarget.provenance[0]?.pointer).toBe(
1351 "#/$defs/graphql/output/GraphqlTypenameOnly",
1352 );
1353 expect(projectsTarget.provenance[0]?.pointer).toBe(
1354 "#/$defs/graphql/output/GraphqlTypenameOnly",
1355 );
1356 });
1201});1357});
packages/platform/control-plane/src/runtime/catalog/source/type-declarations.test.ts+19−5
@@ -487,6 +487,7 @@ describe("source-type-declarations", () => { · 486 unmodified lines
487 );487 );
488 expect(sourceDeclaration).toContain("type LinearTeamsCall = {");488 expect(sourceDeclaration).toContain("type LinearTeamsCall = {");
489 expect(sourceDeclaration).toContain("type LinearTeamsResult = {");489 expect(sourceDeclaration).toContain("type LinearTeamsResult = {");
490 expect(sourceDeclaration).toContain("type LinearTeamsSearchResult = {");
490 expect(sourceDeclaration.match(/type LinearTeamsCall =/g)?.length).toBe(491 expect(sourceDeclaration.match(/type LinearTeamsCall =/g)?.length).toBe(
491 1,492 1,
492 );493 );
@@ -497,8 +498,13 @@ describe("source-type-declarations", () => { · 4 unmodified lines
497 "teams: (args?: LinearTeamsCall) => Promise<LinearTeamsResult>;",498 "teams: (args?: LinearTeamsCall) => Promise<LinearTeamsResult>;",
498 );499 );
499 expect(sourceDeclaration).toContain(500 expect(sourceDeclaration).toContain(
500 "teamsSearch: (args?: LinearTeamsCall) => Promise<LinearTeamsResult>;",501 "teamsSearch: (args?: LinearTeamsCall) => Promise<LinearTeamsSearchResult>;",
501 );502 );
503 expect(sourceDeclaration).toContain("filter?: {");
504 expect(sourceDeclaration).toContain("name?: string;");
505 expect(sourceDeclaration).not.toContain("type ResultData");
506 expect(sourceDeclaration).not.toContain("type ResponseHeaders");
507 expect(sourceDeclaration).not.toContain("Member1");
502 expect(sourceDeclaration).not.toMatch(/type\s+[0-9]/);508 expect(sourceDeclaration).not.toMatch(/type\s+[0-9]/);
503 expect(aggregateDeclaration).toContain(509 expect(aggregateDeclaration).toContain(
504 'import type { SourceTools_src_linear } from "../sources/src_linear";',510 'import type { SourceTools_src_linear } from "../sources/src_linear";',
@@ -687,10 +693,15 @@ describe("source-type-declarations", () => { · 182 unmodified lines
687 "src_linear.d.ts",693 "src_linear.d.ts",
688 ),694 ),
689 );695 );
690
691 expect(sourceDeclaration).toContain("unsupportedNot?: unknown;");
692 expect(sourceDeclaration).toContain(696 expect(sourceDeclaration).toContain(
693 "unsupportedConditional?: unknown;",697 "unsupportedNot?: UnsupportedNot;",
698 );
699 expect(sourceDeclaration).toContain(
700 "unsupportedConditional?: UnsupportedConditional;",
701 );
702 expect(sourceDeclaration).toContain("type UnsupportedNot = unknown;");
703 expect(sourceDeclaration).toContain(
704 "type UnsupportedConditional = unknown;",
694 );705 );
695 }),706 }),
696 );707 );
@@ -926,7 +937,10 @@ describe("source-type-declarations", () => { · 229 unmodified lines
926 );937 );
927 938
928 expect(sourceDeclaration).toContain(939 expect(sourceDeclaration).toContain(
929 "teams: (args?: LinearTeamsCall) => Promise<{",940 "teams: (args?: LinearTeamsCall) => Promise<LinearTeamsResult>;",
941 );
942 expect(sourceDeclaration).toContain(
943 "type AbuseBlockHistoryItem = {",
930 );944 );
931 expect(sourceDeclaration).toContain("actor?: string;");945 expect(sourceDeclaration).toContain("actor?: string;");
932 expect(sourceDeclaration).toContain('action: "blocked";');946 expect(sourceDeclaration).toContain('action: "blocked";');
packages/platform/control-plane/src/runtime/sources/source-inspection.ts+4−3
@@ -301,7 +301,7 @@ const resolveSourceInspectionTool = (input: { · 300 unmodified lines
301 catalogs: [loaded.catalogEntry],301 catalogs: [loaded.catalogEntry],
302 path: input.toolPath,302 path: input.toolPath,
303 includeSchemas: true,303 includeSchemas: true,
304 includeTypePreviews: true,304 includeTypePreviews: false,
305 });305 });
306 306
307 return {307 return {
@@ -393,7 +393,8 @@ export const getSourceInspection = (input: { · 85 unmodified lines
393 const inspection = yield* resolveSourceInspection({393 const inspection = yield* resolveSourceInspection({
394 ...input,394 ...input,
395 includeSchemas: false,395 includeSchemas: false,
396 includeTypePreviews: true,396 // Keep the initial tool list lightweight; full type previews are built on demand per tool.
397 includeTypePreviews: false,
397 });398 });
398 399
399 return {400 return {
@@ -452,7 +453,7 @@ export const discoverSourceInspectionTools = (input: { · 52 unmodified lines
452 workspaceId: input.workspaceId,453 workspaceId: input.workspaceId,
453 sourceId: input.sourceId,454 sourceId: input.sourceId,
454 includeSchemas: false,455 includeSchemas: false,
455 includeTypePreviews: true,456 includeTypePreviews: false,
456 });457 });
457 const queryTokens = tokenize(input.payload.query);458 const queryTokens = tokenize(input.payload.query);
458 const results = inspection.tools459 const results = inspection.tools
packages/sources/core/src/catalog-json-schema.ts+78−2
@@ -48,6 +48,80 @@ const asStringArray = (value: unknown): string[] => · 47 unmodified lines
48 return stringValue === null ? [] : [stringValue];48 return stringValue === null ? [] : [stringValue];
49 });49 });
50 50
51const decodePointerSegment = (value: string): string =>
52 value.replace(/~1/g, "/").replace(/~0/g, "~");
53
54const isNamedSchemaPointer = (pointer: string): boolean => {
55 if (!pointer.startsWith("#/")) {
56 return false;
57 }
58
59 const segments = pointer
60 .slice(2)
61 .split("/")
62 .map(decodePointerSegment);
63
64 for (let index = 0; index < segments.length; index += 1) {
65 const segment = segments[index];
66 const next = segments[index + 1];
67 const afterNext = segments[index + 2];
68
69 if (
70 (segment === "$defs" || segment === "definitions")
71 && next !== undefined
72 ) {
73 if (index + 2 === segments.length) {
74 return true;
75 }
76 continue;
77 }
78
79 if (
80 segment === "components"
81 && next !== undefined
82 && afterNext !== undefined
83 && [
84 "schemas",
85 "responses",
86 "parameters",
87 "headers",
88 "requestBodies",
89 "examples",
90 "links",
91 "callbacks",
92 ].includes(next)
93 ) {
94 if (index + 3 === segments.length) {
95 return true;
96 }
97 continue;
98 }
99
100 if (
101 segment === "graphql"
102 && next !== undefined
103 && afterNext !== undefined
104 && ["input", "output", "scalars", "enums"].includes(next)
105 ) {
106 if (index + 3 === segments.length) {
107 return true;
108 }
109 continue;
110 }
111
112 if (
113 ["input", "output", "scalars", "enums"].includes(segment)
114 && next !== undefined
115 ) {
116 if (index + 2 === segments.length) {
117 return true;
118 }
119 }
120 }
121
122 return false;
123};
124
51const addDiagnostic = (125const addDiagnostic = (
52 catalog: CatalogFragmentBuilder,126 catalog: CatalogFragmentBuilder,
53 input: Omit<ImportDiagnostic, "id">,127 input: Omit<ImportDiagnostic, "id">,
@@ -148,6 +222,8 @@ export const createJsonSchemaImporter = (input: { · 94 unmodified lines
148 description: asString(objectSchema.description),222 description: asString(objectSchema.description),
149 });223 });
150 const deprecated = asBoolean(objectSchema.deprecated) ?? undefined;224 const deprecated = asBoolean(objectSchema.deprecated) ?? undefined;
225 const preserveNominalIdentity =
226 title !== undefined || isNamedSchemaPointer(key);
151 227
152 const register = (228 const register = (
153 node: ShapeNode,229 node: ShapeNode,
@@ -158,7 +234,7 @@ export const createJsonSchemaImporter = (input: { · 4 unmodified lines
158 ): ReturnType<typeof ShapeSymbolIdSchema.make> => {234 ): ReturnType<typeof ShapeSymbolIdSchema.make> => {
159 const signature = stableStringify(node);235 const signature = stableStringify(node);
160 const recursive = recursiveShapeIds.has(shapeId);236 const recursive = recursiveShapeIds.has(shapeId);
161 const existingShapeId = recursive237 const existingShapeId = recursive || preserveNominalIdentity
162 ? undefined238 ? undefined
163 : structuralCache.get(signature);239 : structuralCache.get(signature);
164 240
@@ -214,7 +290,7 @@ export const createJsonSchemaImporter = (input: { · 49 unmodified lines
214 : {}),290 : {}),
215 } satisfies ShapeSymbol;291 } satisfies ShapeSymbol;
216 292
217 if (!recursive) {293 if (!recursive && !preserveNominalIdentity) {
218 structuralCache.set(signature, shapeId);294 structuralCache.set(signature, shapeId);
219 }295 }
220 296
packages/sources/graphql/src/graphql-tools.ts+15−6
@@ -469,6 +469,9 @@ const graphqlInputObjectRef = (name: string): string => · 468 unmodified lines
469const graphqlOutputTypeRef = (name: string, depth: number): string =>469const graphqlOutputTypeRef = (name: string, depth: number): string =>
470 `${GRAPHQL_SCHEMA_REF_PREFIX}/output/${depth === 0 ? name : `${name}__depth${depth}`}`;470 `${GRAPHQL_SCHEMA_REF_PREFIX}/output/${depth === 0 ? name : `${name}__depth${depth}`}`;
471 471
472const graphqlTypenameOnlyOutputRef = (): string =>
473 `${GRAPHQL_SCHEMA_REF_PREFIX}/output/GraphqlTypenameOnly`;
474
472const schemaRef = (ref: string): JsonSchema => ({475const schemaRef = (ref: string): JsonSchema => ({
473 $ref: ref,476 $ref: ref,
474});477});
@@ -490,6 +493,15 @@ const createGraphqlSchemaRefTableBuilder = () => { · 15 unmodified lines
490 refTable[ref] = JSON.stringify(schema);493 refTable[ref] = JSON.stringify(schema);
491 };494 };
492 495
496 const ensureTypenameOnlyOutputRef = (): JsonSchema => {
497 const ref = graphqlTypenameOnlyOutputRef();
498 if (!(ref in refTable)) {
499 defineRefSchema(ref, typenameOnlyObjectSchema());
500 }
501
502 return schemaRef(ref);
503 };
504
493 const ensureScalarRef = (name: string): JsonSchema => {505 const ensureScalarRef = (name: string): JsonSchema => {
494 const ref = graphqlScalarRef(name);506 const ref = graphqlScalarRef(name);
495 if (!(ref in refTable)) {507 if (!(ref in refTable)) {
@@ -614,28 +626,26 @@ const createGraphqlSchemaRefTableBuilder = () => { · 118 unmodified lines
614 if (isUnionType(namedType)) {626 if (isUnionType(namedType)) {
615 const result = {627 const result = {
616 selectionSet: "{ __typename }",628 selectionSet: "{ __typename }",
617 schema: schemaRef(ref),629 schema: ensureTypenameOnlyOutputRef(),
618 } satisfies SelectedGraphqlOutput;630 } satisfies SelectedGraphqlOutput;
619 outputSelectionsByRef.set(ref, result);631 outputSelectionsByRef.set(ref, result);
620 defineRefSchema(ref, typenameOnlyObjectSchema());
621 return result;632 return result;
622 }633 }
623 634
624 if (!isObjectType(namedType) && !isInterfaceType(namedType)) {635 if (!isObjectType(namedType) && !isInterfaceType(namedType)) {
625 return {636 return {
626 selectionSet: "{ __typename }",637 selectionSet: "{ __typename }",
627 schema: typenameOnlyObjectSchema(),638 schema: ensureTypenameOnlyOutputRef(),
628 };639 };
629 }640 }
630 641
631 const fallback = {642 const fallback = {
632 selectionSet: "{ __typename }",643 selectionSet: "{ __typename }",
633 schema: schemaRef(ref),644 schema: ensureTypenameOnlyOutputRef(),
634 } satisfies SelectedGraphqlOutput;645 } satisfies SelectedGraphqlOutput;
635 outputSelectionsByRef.set(ref, fallback);646 outputSelectionsByRef.set(ref, fallback);
636 647
637 if (depth >= 2) {648 if (depth >= 2) {
638 defineRefSchema(ref, typenameOnlyObjectSchema());
639 return fallback;649 return fallback;
640 }650 }
641 651
@@ -665,7 +675,6 @@ const createGraphqlSchemaRefTableBuilder = () => { · 23 unmodified lines
665 ]);675 ]);
666 676
667 if (selectedFields.length === 0) {677 if (selectedFields.length === 0) {
668 defineRefSchema(ref, typenameOnlyObjectSchema());
669 return fallback;678 return fallback;
670 }679 }
671 680