| @@ -436,6 +436,34 @@ function containsUnknownTypeToken(segment: string): boolean { · 435 unmodified lines |
| 436 | 436 | return /\bunknown\b/.test(withoutQuotedLiterals); |
| 437 | 437 | } |
| 438 | 438 | |
| 439 | +function isSchemaRefOnly(schema: Record<string, unknown>): boolean { |
| 440 | + const keys = Object.keys(schema); |
| 441 | + return keys.length > 0 |
| 442 | + && typeof schema.$ref === "string" |
| 443 | + && keys.every((key) => key === "$ref"); |
| 444 | +} |
| 445 | + |
| 446 | +function resolveExpectedInputPrimitiveFromGeneratedSchema( |
| 447 | + inputSchema: Record<string, unknown>, |
| 448 | + fieldName: string, |
| 449 | + componentSchemas: Record<string, unknown>, |
| 450 | +): PrimitiveParamType | null { |
| 451 | + const rootProps = toRecord(inputSchema.properties); |
| 452 | + const containerKeys = ["path", "query", "headers", "cookie", "body"] as const; |
| 453 | + |
| 454 | + const direct = resolveSchemaPrimitiveType(rootProps[fieldName], componentSchemas); |
| 455 | + if (direct) return direct; |
| 456 | + |
| 457 | + for (const container of containerKeys) { |
| 458 | + const containerSchema = toRecord(rootProps[container]); |
| 459 | + const containerProps = toRecord(containerSchema.properties); |
| 460 | + const resolved = resolveSchemaPrimitiveType(containerProps[fieldName], componentSchemas); |
| 461 | + if (resolved) return resolved; |
| 462 | + } |
| 463 | + |
| 464 | + return null; |
| 465 | +} |
| 466 | + |
| 439 | 467 | const SPECS: SpecFixture[] = [ |
| 440 | 468 | { |
| 441 | 469 | name: "jira", |
| @@ -696,6 +724,113 @@ describe("real-world OpenAPI specs", () => { · 254 unmodified lines |
| 696 | 724 | 300_000, |
| 697 | 725 | ); |
| 698 | 726 | |
| 727 | + test( |
| 728 | + "github: enterprise code security configurations resolves array item schema in includeDts=false mode", |
| 729 | + async () => { |
| 730 | + const githubUrl = |
| 731 | + "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml"; |
| 732 | + |
| 733 | + const prepared = await prepareOpenApiSpec(githubUrl, "github", { |
| 734 | + includeDts: false, |
| 735 | + profile: "full", |
| 736 | + }); |
| 737 | + const tools = buildOpenApiToolsFromPrepared( |
| 738 | + { |
| 739 | + type: "openapi", |
| 740 | + name: "github", |
| 741 | + spec: githubUrl, |
| 742 | + baseUrl: prepared.servers[0] || "https://api.github.com", |
| 743 | + }, |
| 744 | + prepared, |
| 745 | + ); |
| 746 | + |
| 747 | + const tool = tools.find((t) => t.path === "github.code_security.get_configurations_for_enterprise"); |
| 748 | + |
| 749 | + expect(tool).toBeDefined(); |
| 750 | + const outputSchema = toRecord(tool!.typing?.outputSchema); |
| 751 | + expect(outputSchema.type).toBe("array"); |
| 752 | + |
| 753 | + const outputItems = toRecord(outputSchema.items); |
| 754 | + expect(typeof outputItems.$ref).not.toBe("string"); |
| 755 | + expect(outputItems.type).toBe("object"); |
| 756 | + |
| 757 | + const outputProps = toRecord(outputItems.properties); |
| 758 | + expect(toRecord(outputProps.id).type).toBe("integer"); |
| 759 | + expect(toRecord(outputProps.name).type).toBe("string"); |
| 760 | + }, |
| 761 | + 300_000, |
| 762 | + ); |
| 763 | + |
| 764 | + test( |
| 765 | + "github + stripe: root generated array schemas include concrete item definitions", |
| 766 | + async () => { |
| 767 | + const fixtures = [ |
| 768 | + { |
| 769 | + name: "github", |
| 770 | + url: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", |
| 771 | + fallbackBaseUrl: "https://api.github.com", |
| 772 | + }, |
| 773 | + { |
| 774 | + name: "stripe", |
| 775 | + url: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", |
| 776 | + fallbackBaseUrl: "https://api.stripe.com", |
| 777 | + }, |
| 778 | + ] as const; |
| 779 | + |
| 780 | + for (const fixture of fixtures) { |
| 781 | + const prepared = await prepareOpenApiSpec(fixture.url, fixture.name, { |
| 782 | + includeDts: false, |
| 783 | + profile: "full", |
| 784 | + }); |
| 785 | + const tools = buildOpenApiToolsFromPrepared( |
| 786 | + { |
| 787 | + type: "openapi", |
| 788 | + name: fixture.name, |
| 789 | + spec: fixture.url, |
| 790 | + baseUrl: prepared.servers[0] || fixture.fallbackBaseUrl, |
| 791 | + }, |
| 792 | + prepared, |
| 793 | + ); |
| 794 | + |
| 795 | + const issues: string[] = []; |
| 796 | + |
| 797 | + const validateArrayItems = (schema: Record<string, unknown>, location: string) => { |
| 798 | + const items = toRecord(schema.items); |
| 799 | + const itemKeys = Object.keys(items); |
| 800 | + if (itemKeys.length === 0) { |
| 801 | + issues.push(`${location}.items has empty items schema`); |
| 802 | + return; |
| 803 | + } |
| 804 | + |
| 805 | + const isRefOnly = typeof items.$ref === "string" && itemKeys.every((key) => key === "$ref"); |
| 806 | + if (isRefOnly) { |
| 807 | + issues.push(`${location}.items is ref-only (${String(items.$ref)})`); |
| 808 | + } |
| 809 | + }; |
| 810 | + |
| 811 | + const visitTopLevelSchema = (schemaValue: unknown, location: string) => { |
| 812 | + const schema = toRecord(schemaValue); |
| 813 | + if (Object.keys(schema).length === 0) return; |
| 814 | + |
| 815 | + if (schema.type === "array") { |
| 816 | + validateArrayItems(schema, `${location}:schema`); |
| 817 | + } |
| 818 | + }; |
| 819 | + |
| 820 | + for (const tool of tools) { |
| 821 | + visitTopLevelSchema(tool.typing?.inputSchema, `${tool.path}:input`); |
| 822 | + visitTopLevelSchema(tool.typing?.outputSchema, `${tool.path}:output`); |
| 823 | + } |
| 824 | + |
| 825 | + expect( |
| 826 | + issues, |
| 827 | + `${fixture.name} has array item schema regressions:\n${issues.slice(0, 20).join("\n")}`, |
| 828 | + ).toHaveLength(0); |
| 829 | + } |
| 830 | + }, |
| 831 | + 300_000, |
| 832 | + ); |
| 833 | + |
| 699 | 834 | test( |
| 700 | 835 | "stripe: extraction keeps concrete schemas for forwarding + verification report list outputs", |
| 701 | 836 | async () => { |
| @@ -1039,4 +1174,109 @@ describe("real-world OpenAPI specs", () => { · 337 unmodified lines |
| 1039 | 1174 | }, |
| 1040 | 1175 | 1_800_000, |
| 1041 | 1176 | ); |
| 1177 | + |
| 1178 | + test( |
| 1179 | + "full profile schemas validate every github/stripe operation against parser-derived expectations", |
| 1180 | + async () => { |
| 1181 | + const fixtures = SPECS.filter((fixture) => fixture.name === "github" || fixture.name === "stripe"); |
| 1182 | + expect(fixtures.length).toBe(2); |
| 1183 | + |
| 1184 | + for (const fixture of fixtures) { |
| 1185 | + const parsed = await SwaggerParser.parse(fixture.url); |
| 1186 | + const parsedSpec = parsed as Record<string, unknown>; |
| 1187 | + const componentSchemas = toRecord(toRecord(parsedSpec.components).schemas); |
| 1188 | + const operationExpectations = collectOperationTypeExpectations(parsedSpec); |
| 1189 | + expect(operationExpectations.size).toBeGreaterThan(0); |
| 1190 | + |
| 1191 | + const prepared = await prepareOpenApiSpec(parsedSpec, fixture.name, { |
| 1192 | + includeDts: false, |
| 1193 | + profile: "full", |
| 1194 | + }); |
| 1195 | + |
| 1196 | + const tools = buildOpenApiToolsFromPrepared( |
| 1197 | + { |
| 1198 | + type: "openapi", |
| 1199 | + name: fixture.name, |
| 1200 | + spec: fixture.url, |
| 1201 | + baseUrl: prepared.servers[0] || `https://${fixture.name}.example.com`, |
| 1202 | + }, |
| 1203 | + prepared, |
| 1204 | + ); |
| 1205 | + |
| 1206 | + const toolsByOperationId = new Map<string, (typeof tools)[number]>(); |
| 1207 | + for (const tool of tools) { |
| 1208 | + const operationId = tool.typing?.typedRef?.kind === "openapi_operation" |
| 1209 | + ? tool.typing.typedRef.operationId |
| 1210 | + : ""; |
| 1211 | + if (!operationId || toolsByOperationId.has(operationId)) continue; |
| 1212 | + toolsByOperationId.set(operationId, tool); |
| 1213 | + } |
| 1214 | + |
| 1215 | + expect(toolsByOperationId.size).toBe(operationExpectations.size); |
| 1216 | + |
| 1217 | + const issues: string[] = []; |
| 1218 | + |
| 1219 | + for (const [operationId, expectations] of operationExpectations.entries()) { |
| 1220 | + const tool = toolsByOperationId.get(operationId); |
| 1221 | + if (!tool) { |
| 1222 | + issues.push(`missing tool for operationId ${operationId}`); |
| 1223 | + continue; |
| 1224 | + } |
| 1225 | + |
| 1226 | + const inputSchema = toRecord(tool.typing?.inputSchema); |
| 1227 | + const outputSchema = toRecord(tool.typing?.outputSchema); |
| 1228 | + |
| 1229 | + if (expectations.expectsInput && Object.keys(inputSchema).length === 0) { |
| 1230 | + issues.push(`${tool.path}: expected input schema but got empty`); |
| 1231 | + } |
| 1232 | + |
| 1233 | + if (expectations.expectsKnownOutput && Object.keys(outputSchema).length === 0) { |
| 1234 | + issues.push(`${tool.path}: expected output schema but got empty`); |
| 1235 | + } |
| 1236 | + |
| 1237 | + if (expectations.expectsVoidOutput && Object.keys(outputSchema).length > 0) { |
| 1238 | + issues.push(`${tool.path}: expected void output but schema was present`); |
| 1239 | + } |
| 1240 | + |
| 1241 | + if (outputSchema.type === "array") { |
| 1242 | + const items = toRecord(outputSchema.items); |
| 1243 | + if (Object.keys(items).length === 0) { |
| 1244 | + issues.push(`${tool.path}: output array has empty items schema`); |
| 1245 | + } else if (isSchemaRefOnly(items)) { |
| 1246 | + issues.push(`${tool.path}: output array items are ref-only (${String(items.$ref)})`); |
| 1247 | + } |
| 1248 | + } |
| 1249 | + |
| 1250 | + for (const expected of expectations.expectedInputFields) { |
| 1251 | + const resolved = resolveExpectedInputPrimitiveFromGeneratedSchema(inputSchema, expected.name, componentSchemas); |
| 1252 | + if (!resolved) { |
| 1253 | + issues.push(`${tool.path}: missing input field ${expected.name}`); |
| 1254 | + continue; |
| 1255 | + } |
| 1256 | + if (resolved !== expected.type) { |
| 1257 | + issues.push(`${tool.path}: input field ${expected.name} expected ${expected.type}, got ${resolved}`); |
| 1258 | + } |
| 1259 | + } |
| 1260 | + |
| 1261 | + const outputProperties = toRecord(outputSchema.properties); |
| 1262 | + for (const expected of expectations.expectedOutputFields) { |
| 1263 | + const resolved = resolveSchemaPrimitiveType(outputProperties[expected.name], componentSchemas); |
| 1264 | + if (!resolved) { |
| 1265 | + issues.push(`${tool.path}: missing output field ${expected.name}`); |
| 1266 | + continue; |
| 1267 | + } |
| 1268 | + if (resolved !== expected.type) { |
| 1269 | + issues.push(`${tool.path}: output field ${expected.name} expected ${expected.type}, got ${resolved}`); |
| 1270 | + } |
| 1271 | + } |
| 1272 | + } |
| 1273 | + |
| 1274 | + expect( |
| 1275 | + issues, |
| 1276 | + `${fixture.name} full-profile schema regressions:\n${issues.slice(0, 30).join("\n")}`, |
| 1277 | + ).toHaveLength(0); |
| 1278 | + } |
| 1279 | + }, |
| 1280 | + 1_800_000, |
| 1281 | + ); |
| 1042 | 1282 | }); |