@capagents/forgeqe 0.0.0-stage → 1.0.1

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.
Files changed (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +781 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +146 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,624 @@
1
+ import { XMLParser } from "fast-xml-parser"
2
+ import { existsSync, readFileSync } from "node:fs"
3
+ import path from "node:path"
4
+ import YAML from "yaml"
5
+
6
+ import { exampleFor } from "./schema.ts"
7
+ import type { Contract, ContractSet, JsonSchema, KeyField, SourceKind } from "./types.ts"
8
+
9
+ /**
10
+ * Contracts from an API description, given as a link or a file: OpenAPI 3 or Swagger 2 (JSON or YAML, or the Swagger UI
11
+ * page that shows one), WSDL for SOAP, a GraphQL endpoint (read by introspection), an introspection result or SDL file,
12
+ * and .proto files or a gRPC server with reflection (through grpcurl). One contract per operation, with an example call
13
+ * from the description's examples, and the status, schema, and fields its answer must have.
14
+ */
15
+
16
+ export type Fetcher = (url: string, init?: RequestInit) => Promise<Response>
17
+ export type LoadOptions = { headers?: Record<string, string>; fetch?: Fetcher; include?: string[]; grpcurl?: (args: string[], input?: string) => Promise<{ ok: boolean; out: string }> }
18
+
19
+ type Loaded = { kind: SourceKind; name: string; baseUrl: string; contracts: Contract[]; skipped: string[] }
20
+
21
+ const READ_NAME = /^(get|list|find|search|query|read|fetch|lookup|check|validate|describe|count|is|has)/i
22
+ const KEY_NAME = /(^|_|\b)(id|uuid|key|code|status|state|type|kind|name|title|total|count|amount|price|currency|email|success|result)$/i
23
+ const METHODS = ["get", "post", "put", "patch", "delete", "head", "options"]
24
+
25
+ export const isUrl = (s: string) => /^https?:\/\//i.test(s)
26
+
27
+ async function getText(ref: string, opts: LoadOptions): Promise<{ text: string; url?: string; type: string }> {
28
+ if (!isUrl(ref)) {
29
+ const file = path.resolve(ref)
30
+ if (!existsSync(file)) throw new Error(`No such file or link: ${ref}`)
31
+ return { text: readFileSync(file, "utf8"), type: "" }
32
+ }
33
+ const res = await (opts.fetch || fetch)(ref, { headers: { Accept: "application/json, application/yaml, text/yaml, application/xml, text/xml, */*", ...(opts.headers || {}) } })
34
+ if (!res.ok) throw new Error(`${ref} answered HTTP ${res.status}`)
35
+ return { text: await res.text(), url: res.url || ref, type: res.headers.get("content-type") || "" }
36
+ }
37
+
38
+ /** Load an API description from a link or file and write a contract for each of its operations. */
39
+ export async function loadSpec(ref: string, opts: LoadOptions = {}): Promise<Loaded> {
40
+ const src = ref.trim()
41
+ if (/^grpcs?:\/\//i.test(src)) return grpcReflection(src, opts)
42
+ if (/\.proto$/i.test(src)) return fromProto([src], opts)
43
+ if (isUrl(src) && /\/graphql\/?(\?.*)?$/i.test(src)) return graphqlEndpoint(src, opts)
44
+ const { text, url, type } = await getText(src, opts)
45
+ const base = url || src
46
+ const t = text.trim()
47
+ if (/html/i.test(type) || /^<!doctype html|^<html/i.test(t)) return fromSwaggerUi(base, t, opts)
48
+ if (t.startsWith("<")) {
49
+ if (/definitions|description/.test(t.slice(0, 2000)) && /wsdl|schemas\.xmlsoap\.org/i.test(t.slice(0, 4000))) return fromWsdl(t, base, opts)
50
+ throw new Error(`${src} is XML but not a WSDL document.`)
51
+ }
52
+ if (/^\s*(syntax\s*=|package\s+[\w.]+;|service\s+\w+\s*{)/m.test(t) && /\brpc\s+\w+/.test(t)) return fromProto([src], opts, t)
53
+ let doc: unknown
54
+ try {
55
+ doc = t.startsWith("{") || t.startsWith("[") ? JSON.parse(t) : YAML.parse(t)
56
+ } catch {
57
+ doc = undefined
58
+ }
59
+ const d = doc as Record<string, any> | undefined
60
+ if (d && (d.openapi || d.swagger)) return fromOpenApi(d, base, opts)
61
+ if (d && (d.__schema || d.data?.__schema)) return fromIntrospection(d.data?.__schema || d.__schema, "", base, opts)
62
+ if (/\btype\s+(Query|Mutation)\b|\bschema\s*{/.test(t)) return fromSdl(t, base, opts)
63
+ throw new Error(`Could not tell what kind of API description ${src} is. ForgeQE reads OpenAPI or Swagger (JSON or YAML), WSDL, GraphQL (an endpoint, an introspection result, or SDL), and .proto files.`)
64
+ }
65
+
66
+ /** A contract set from a description. */
67
+ export async function setFromSpec(ref: string, id: string, opts: LoadOptions & { name?: string; baseUrl?: string } = {}): Promise<ContractSet> {
68
+ const loaded = await loadSpec(ref, opts)
69
+ const include = (opts.include || []).map((s) => s.toLowerCase()).filter(Boolean)
70
+ const contracts = include.length ? loaded.contracts.filter((c) => include.some((i) => `${c.id} ${c.name}`.toLowerCase().includes(i))) : loaded.contracts
71
+ return {
72
+ version: 1,
73
+ id,
74
+ name: opts.name || loaded.name,
75
+ baseUrl: opts.baseUrl || loaded.baseUrl,
76
+ createdAt: new Date().toISOString(),
77
+ source: { kind: loaded.kind, ref },
78
+ contracts,
79
+ ...(loaded.skipped.length ? { skipped: loaded.skipped } : {}),
80
+ }
81
+ }
82
+
83
+ // -- OpenAPI and Swagger ------------------------------------------------------------------------
84
+
85
+ /** Swagger UI's page: find the description it shows (its config, the initializer script, or the usual paths). */
86
+ async function fromSwaggerUi(pageUrl: string, html: string, opts: LoadOptions): Promise<Loaded> {
87
+ const found = (text: string) => [...text.matchAll(/\burls?\s*[:=]\s*\[?\s*{?\s*(?:url\s*:\s*)?["'`]([^"'`]+\.(?:json|ya?ml)[^"'`]*|[^"'`]*api-docs[^"'`]*)["'`]/g)].map((m) => m[1])
88
+ const candidates = found(html)
89
+ for (const s of html.matchAll(/<script[^>]+src=["']([^"']*(?:initializer|swagger-config)[^"']*)["']/g)) {
90
+ try {
91
+ candidates.push(...found((await getText(new URL(s[1], pageUrl).href, opts)).text))
92
+ } catch {}
93
+ }
94
+ candidates.push("swagger.json", "openapi.json", "v1/swagger.json", "../swagger/v1/swagger.json", "/v3/api-docs", "/v2/api-docs", "/openapi.json", "/swagger.json")
95
+ for (const c of candidates) {
96
+ const url = new URL(c, pageUrl.endsWith("/") || /\.html?$/.test(pageUrl) ? pageUrl : `${pageUrl}/`).href
97
+ try {
98
+ const { text } = await getText(url, opts)
99
+ const d = text.trim().startsWith("{") ? JSON.parse(text) : YAML.parse(text)
100
+ if (d && (d.openapi || d.swagger)) return fromOpenApi(d, url, opts)
101
+ } catch {}
102
+ }
103
+ throw new Error(`${pageUrl} is a web page, and no OpenAPI or Swagger document was found behind it. Give the link to the JSON or YAML document itself.`)
104
+ }
105
+
106
+ function deref(node: any, root: any, seen: string[] = []): any {
107
+ if (!node || typeof node !== "object") return node
108
+ if (Array.isArray(node)) return node.map((n) => deref(n, root, seen))
109
+ if (typeof node.$ref === "string") {
110
+ const ref = node.$ref as string
111
+ if (!ref.startsWith("#/") || seen.includes(ref) || seen.length > 12) return {}
112
+ const target = ref
113
+ .slice(2)
114
+ .split("/")
115
+ .map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"))
116
+ .reduce((o: any, k) => (o ? o[k] : undefined), root)
117
+ return deref(target, root, [...seen, ref])
118
+ }
119
+ const out: any = {}
120
+ for (const [k, v] of Object.entries(node)) out[k] = k === "example" || k === "examples" ? v : deref(v, root, seen)
121
+ return out
122
+ }
123
+
124
+ /** OpenAPI's schema object as ForgeQE's schema: nullable, x-nullable, and type lists. */
125
+ function toSchema(s: any): JsonSchema | undefined {
126
+ if (!s || typeof s !== "object") return undefined
127
+ const out: JsonSchema = {}
128
+ if (s.type) out.type = s.type
129
+ if (s.nullable || s["x-nullable"]) out.nullable = true
130
+ if (Array.isArray(s.type) && s.type.includes("null")) out.nullable = true
131
+ if (s.enum) out.enum = s.enum
132
+ if (s.format) out.format = s.format
133
+ if (s.example !== undefined) out.example = s.example
134
+ if (s.required && Array.isArray(s.required)) out.required = s.required
135
+ if (s.properties) out.properties = Object.fromEntries(Object.entries(s.properties).map(([k, v]) => [k, toSchema(v) || {}]))
136
+ if (s.items) out.items = toSchema(s.items)
137
+ for (const k of ["anyOf", "oneOf", "allOf"] as const) if (Array.isArray(s[k])) out[k] = s[k].map((x: any) => toSchema(x) || {})
138
+ if (!out.type && out.properties) out.type = "object"
139
+ return out
140
+ }
141
+
142
+ /** Fields to check from a response schema: required scalars named like ids, statuses, names, totals; their type only. */
143
+ export function schemaFields(schema: JsonSchema | undefined, max = 8): KeyField[] {
144
+ if (!schema) return []
145
+ const out: KeyField[] = []
146
+ const walk = (s: JsonSchema, at: string, depth: number) => {
147
+ if (depth > 3 || out.length >= max) return
148
+ if (s.allOf) for (const x of s.allOf) walk(x, at, depth)
149
+ const t = Array.isArray(s.type) ? s.type[0] : s.type
150
+ if ((t === "array" || s.items) && s.items) return walk(s.items, `${at}[0]`, depth + 1)
151
+ const req = new Set(s.required || [])
152
+ for (const [k, p] of Object.entries(s.properties || {})) {
153
+ if (out.length >= max) break
154
+ const pt = Array.isArray(p.type) ? p.type.find((x) => x !== "null") : p.type
155
+ if (req.has(k) && pt && !["object", "array"].includes(pt) && KEY_NAME.test(k)) out.push({ path: at ? `${at}.${k}` : k, type: pt === "integer" ? "number" : pt })
156
+ }
157
+ for (const [k, p] of Object.entries(s.properties || {})) if (req.has(k) && (p.properties || p.items)) walk(p, at ? `${at}.${k}` : k, depth + 1)
158
+ }
159
+ walk(schema, "", 0)
160
+ return out
161
+ }
162
+
163
+ function firstExample(media: any): unknown {
164
+ if (!media) return undefined
165
+ if (media.example !== undefined) return media.example
166
+ const ex = media.examples && typeof media.examples === "object" ? Object.values(media.examples)[0] : undefined
167
+ if (ex && typeof ex === "object" && "value" in (ex as object)) return (ex as { value: unknown }).value
168
+ return undefined
169
+ }
170
+
171
+ function jsonMedia(content: any): [string, any] | undefined {
172
+ if (!content || typeof content !== "object") return undefined
173
+ const entries = Object.entries(content)
174
+ return (entries.find(([t]) => /json/i.test(t)) || entries.find(([t]) => /xml|text/i.test(t)) || entries[0]) as [string, any] | undefined
175
+ }
176
+
177
+ function fromOpenApi(raw: any, docUrl: string, opts: LoadOptions): Loaded {
178
+ const doc = raw
179
+ const v2 = Boolean(doc.swagger)
180
+ const skipped: string[] = []
181
+ let baseUrl = ""
182
+ if (v2) {
183
+ const scheme = (doc.schemes || []).includes("https") ? "https" : doc.schemes?.[0] || (isUrl(docUrl) ? new URL(docUrl).protocol.replace(":", "") : "https")
184
+ const host = doc.host || (isUrl(docUrl) ? new URL(docUrl).host : "")
185
+ baseUrl = host ? `${scheme}://${host}${(doc.basePath || "").replace(/\/$/, "")}` : ""
186
+ } else {
187
+ const server = doc.servers?.[0]
188
+ if (server?.url) {
189
+ let u = String(server.url)
190
+ for (const [k, v] of Object.entries(server.variables || {})) u = u.replace(`{${k}}`, String((v as { default?: string }).default ?? ""))
191
+ baseUrl = isUrl(u) ? u : isUrl(docUrl) ? new URL(u, docUrl).href : u
192
+ } else if (isUrl(docUrl)) baseUrl = new URL(docUrl).origin
193
+ }
194
+ baseUrl = baseUrl.replace(/\/+$/, "")
195
+ const schemes = (v2 ? doc.securityDefinitions : doc.components?.securitySchemes) || {}
196
+ const authOf = (security: any[] | undefined): Contract["auth"] | undefined => {
197
+ const first = (security || doc.security || [])[0]
198
+ const name = first ? Object.keys(first)[0] : undefined
199
+ const s = name ? schemes[name] : undefined
200
+ if (!s) return undefined
201
+ if (s.type === "apiKey") return { type: s.in === "header" ? "header" : s.in === "cookie" ? "cookie" : "query", name: s.name }
202
+ if (s.type === "http") return { type: String(s.scheme || "bearer").toLowerCase() }
203
+ if (s.type === "basic") return { type: "basic" }
204
+ return { type: s.type === "oauth2" || s.type === "openIdConnect" ? "bearer" : String(s.type) }
205
+ }
206
+
207
+ const contracts: Contract[] = []
208
+ for (const [p, item] of Object.entries<any>(doc.paths || {})) {
209
+ for (const m of METHODS) {
210
+ const opRaw = item?.[m]
211
+ if (!opRaw) continue
212
+ const op = deref(opRaw, doc)
213
+ const params = [...(deref(item.parameters, doc) || []), ...(op.parameters || [])].filter((x: any) => x && x.name)
214
+ const needs: string[] = []
215
+ let concrete = p
216
+ const query: Record<string, string> = {}
217
+ const headers: Record<string, string> = {}
218
+ let body: unknown
219
+ for (const prm of params) {
220
+ const sch = prm.schema || prm
221
+ const ex = prm.example ?? firstExample(prm) ?? sch.example ?? sch.default ?? sch.enum?.[0]
222
+ if (prm.in === "path") {
223
+ if (ex === undefined) needs.push(`path parameter ${prm.name}`)
224
+ concrete = concrete.replace(`{${prm.name}}`, encodeURIComponent(String(ex ?? exampleFor(toSchema(sch), 0, prm.name))))
225
+ } else if (prm.in === "query" && (prm.required || ex !== undefined)) {
226
+ if (ex === undefined) needs.push(`query parameter ${prm.name}`)
227
+ query[prm.name] = String(ex ?? exampleFor(toSchema(sch), 0, prm.name))
228
+ } else if (prm.in === "header" && prm.required && !/^(authorization|accept|content-type)$/i.test(prm.name)) {
229
+ if (ex === undefined) needs.push(`header ${prm.name}`)
230
+ headers[prm.name] = String(ex ?? "sample")
231
+ } else if (prm.in === "body" && prm.schema) {
232
+ body = prm.schema.example ?? exampleFor(toSchema(prm.schema))
233
+ headers["Content-Type"] = (op.consumes || doc.consumes || ["application/json"])[0]
234
+ if (prm.schema.example === undefined) needs.push("request body (made from the schema)")
235
+ }
236
+ }
237
+ const reqMedia = jsonMedia(op.requestBody?.content)
238
+ if (reqMedia) {
239
+ const ex = firstExample(reqMedia[1])
240
+ body = ex ?? exampleFor(toSchema(reqMedia[1].schema))
241
+ headers["Content-Type"] = reqMedia[0]
242
+ if (ex === undefined && reqMedia[1].schema?.example === undefined) needs.push("request body (made from the schema)")
243
+ }
244
+ const codes = Object.keys(op.responses || {})
245
+ const ok = codes.filter((c) => /^2\d\d$/.test(c)).sort()[0] || (codes.includes("2XX") ? "200" : codes.includes("default") ? "default" : "")
246
+ const res = ok ? op.responses[ok] : undefined
247
+ const status = ok && /^\d+$/.test(ok) ? Number(ok) : 200
248
+ let schema: JsonSchema | undefined
249
+ let contentType: string | undefined
250
+ if (v2) {
251
+ schema = toSchema(res?.schema)
252
+ if (res?.schema) contentType = (op.produces || doc.produces || ["application/json"])[0]
253
+ } else {
254
+ const media = jsonMedia(res?.content)
255
+ if (media) {
256
+ contentType = media[0]
257
+ schema = toSchema(media[1].schema)
258
+ }
259
+ }
260
+ const fields = schemaFields(schema)
261
+ const method = m.toUpperCase()
262
+ const bodySchema = reqMedia?.[1]?.schema || params.find((x: any) => x.in === "body")?.schema
263
+ const requestRequired: string[] = Array.isArray(bodySchema?.required) ? bodySchema.required : []
264
+ const links = Object.values<any>(res?.links || {})
265
+ .filter((l) => l && typeof l.operationId === "string")
266
+ .map((l) => ({ operationId: String(l.operationId), parameters: Object.fromEntries(Object.entries<any>(l.parameters || {}).map(([k, v]) => [k.replace(/^(path|query|header)\./, ""), String(v)])) }))
267
+ contracts.push({
268
+ id: `${method} ${p}`,
269
+ protocol: "rest",
270
+ name: String(op.summary || op.operationId || `${method} ${p}`).slice(0, 120),
271
+ method,
272
+ path: p,
273
+ example: { path: concrete, ...(Object.keys(query).length ? { query } : {}), ...(Object.keys(headers).length ? { headers } : {}), ...(body !== undefined ? { body } : {}) },
274
+ expect: { status, ...(contentType ? { contentType } : {}), ...(schema && Object.keys(schema).length ? { schema } : {}), ...(fields.length ? { fields } : {}) },
275
+ ...(authOf(op.security) ? { auth: authOf(op.security) } : {}),
276
+ writes: !["GET", "HEAD", "OPTIONS"].includes(method),
277
+ ...(needs.length ? { needsData: needs } : {}),
278
+ source: { kind: v2 ? "swagger" : "openapi", ref: docUrl },
279
+ ...(op.description || op.summary ? { description: String(op.description || op.summary).slice(0, 300) } : {}),
280
+ ...(op.operationId ? { operationId: String(op.operationId) } : {}),
281
+ ...(links.length ? { links } : {}),
282
+ ...(requestRequired.length ? { requestRequired } : {}),
283
+ })
284
+ }
285
+ }
286
+ if (!contracts.length) skipped.push("The description has no operations under paths.")
287
+ return { kind: v2 ? "swagger" : "openapi", name: String(doc.info?.title || "API"), baseUrl, contracts, skipped }
288
+ }
289
+
290
+ // -- WSDL (SOAP) --------------------------------------------------------------------------------
291
+
292
+ const asList = <T>(v: T | T[] | undefined): T[] => (v === undefined ? [] : Array.isArray(v) ? v : [v])
293
+ const local = (qname: unknown) => String(qname || "").split(":").pop() || ""
294
+
295
+ function fromWsdl(text: string, docUrl: string, _opts: LoadOptions): Loaded {
296
+ const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@", removeNSPrefix: true })
297
+ const root = parser.parse(text)
298
+ const defs = root.definitions || root.description
299
+ if (!defs) throw new Error("Not a WSDL document: no definitions element.")
300
+ const tns = String(defs["@targetNamespace"] || "")
301
+ const soap12 = /soap12|w3\.org\/2003\/05\/soap/.test(text)
302
+ const schemas = asList<any>(defs.types?.schema)
303
+ const elements = new Map<string, any>()
304
+ const complex = new Map<string, any>()
305
+ for (const s of schemas) {
306
+ for (const e of asList<any>(s.element)) elements.set(e["@name"], e)
307
+ for (const c of asList<any>(s.complexType)) complex.set(c["@name"], c)
308
+ }
309
+ const children = (el: any): string[] => {
310
+ const ct = el?.complexType || (el?.["@type"] ? complex.get(local(el["@type"])) : undefined)
311
+ const seq = ct?.sequence || ct?.all || ct?.complexContent?.extension?.sequence
312
+ return asList<any>(seq?.element).map((c) => c["@name"] || local(c["@ref"])).filter(Boolean)
313
+ }
314
+ const messages = new Map<string, any>(asList<any>(defs.message).map((m) => [m["@name"], m]))
315
+ const location = asList<any>(defs.service).flatMap((s) => asList<any>(s.port || s.endpoint)).map((p) => p.address?.["@location"] || p["@address"]).find(Boolean) || ""
316
+ const binding = asList<any>(defs.binding)[0]
317
+ const actions = new Map<string, string>(asList<any>(binding?.operation).map((o) => [o["@name"], String(o.operation?.["@soapAction"] || o["@soapAction"] || "")]))
318
+ const portType = asList<any>(defs.portType || defs.interface)[0]
319
+ const contracts: Contract[] = []
320
+ const base = location ? new URL(location).origin : isUrl(docUrl) ? new URL(docUrl).origin : ""
321
+ const endpointPath = location ? new URL(location).pathname : "/"
322
+ for (const op of asList<any>(portType?.operation)) {
323
+ const name = String(op["@name"])
324
+ const inMsg = messages.get(local(op.input?.["@message"]))
325
+ const outMsg = messages.get(local(op.output?.["@message"]))
326
+ const inEl = local(asList<any>(inMsg?.part)[0]?.["@element"]) || local(op.input?.["@element"]) || name
327
+ const outEl = local(asList<any>(outMsg?.part)[0]?.["@element"]) || local(op.output?.["@element"]) || `${name}Response`
328
+ const kids = children(elements.get(inEl))
329
+ const envNs = soap12 ? "http://www.w3.org/2003/05/soap-envelope" : "http://schemas.xmlsoap.org/soap/envelope/"
330
+ const body = `<soap:Envelope xmlns:soap="${envNs}" xmlns:tns="${tns}"><soap:Header/><soap:Body><tns:${inEl}>${kids.map((k) => `<tns:${k}>?</tns:${k}>`).join("")}</tns:${inEl}></soap:Body></soap:Envelope>`
331
+ const action = actions.get(name) || ""
332
+ const headers: Record<string, string> = soap12 ? { "Content-Type": `application/soap+xml; charset=utf-8${action ? `; action="${action}"` : ""}` } : { "Content-Type": "text/xml; charset=utf-8", SOAPAction: `"${action}"` }
333
+ contracts.push({
334
+ id: `soap ${name}`,
335
+ protocol: "soap",
336
+ name,
337
+ method: "POST",
338
+ path: endpointPath,
339
+ example: { path: endpointPath, headers, body },
340
+ expect: { status: 200, contentType: soap12 ? "application/soap+xml" : "text/xml", elements: [outEl] },
341
+ writes: !READ_NAME.test(name),
342
+ ...(kids.length ? { needsData: kids.map((k) => `element ${k}`) } : {}),
343
+ source: { kind: "wsdl", ref: docUrl },
344
+ ...(op.documentation ? { description: String(op.documentation).slice(0, 300) } : {}),
345
+ })
346
+ }
347
+ return { kind: "wsdl", name: String(defs["@name"] || asList<any>(defs.service)[0]?.["@name"] || "SOAP service"), baseUrl: base, contracts, skipped: contracts.length ? [] : ["The WSDL has no operations."] }
348
+ }
349
+
350
+ // -- GraphQL ------------------------------------------------------------------------------------
351
+
352
+ type GqlTypeRef = { kind: string; name?: string | null; ofType?: GqlTypeRef | null }
353
+ type GqlField = { name: string; args?: { name: string; type: GqlTypeRef; defaultValue?: string | null }[]; type: GqlTypeRef }
354
+ type GqlType = { kind: string; name: string; fields?: GqlField[] | null; enumValues?: { name: string }[] | null; inputFields?: { name: string; type: GqlTypeRef }[] | null }
355
+ type GqlSchema = { queryType?: { name: string } | null; mutationType?: { name: string } | null; types: GqlType[] }
356
+
357
+ export const INTROSPECTION = `query IntrospectionQuery { __schema { queryType { name } mutationType { name } types { kind name fields(includeDeprecated: false) { name args { name defaultValue type { ...T } } type { ...T } } inputFields { name type { ...T } } enumValues { name } } } }
358
+ fragment T on __Type { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } } }`
359
+
360
+ async function graphqlEndpoint(url: string, opts: LoadOptions): Promise<Loaded> {
361
+ const res = await (opts.fetch || fetch)(url, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", ...(opts.headers || {}) }, body: JSON.stringify({ query: INTROSPECTION }) })
362
+ const json = (await res.json().catch(() => null)) as { data?: { __schema?: GqlSchema }; errors?: { message: string }[] } | null
363
+ if (!json?.data?.__schema) throw new Error(`${url} did not answer the GraphQL introspection query${json?.errors?.length ? `: ${json.errors[0].message}` : ` (HTTP ${res.status})`}. Introspection may be off on this server; give the schema as a file instead.`)
364
+ return fromIntrospection(json.data.__schema, url, url, opts)
365
+ }
366
+
367
+ const unwrap = (t: GqlTypeRef): { name: string; list: boolean; required: boolean } => {
368
+ let required = false
369
+ let list = false
370
+ let cur: GqlTypeRef | null | undefined = t
371
+ if (cur.kind === "NON_NULL") {
372
+ required = true
373
+ cur = cur.ofType
374
+ }
375
+ while (cur && (cur.kind === "NON_NULL" || cur.kind === "LIST")) {
376
+ if (cur.kind === "LIST") list = true
377
+ cur = cur.ofType
378
+ }
379
+ return { name: cur?.name || "", list, required }
380
+ }
381
+
382
+ const typeText = (t: GqlTypeRef): string => (t.kind === "NON_NULL" ? `${typeText(t.ofType!)}!` : t.kind === "LIST" ? `[${typeText(t.ofType!)}]` : t.name || "")
383
+
384
+ const SCALAR_SCHEMA: Record<string, string> = { Int: "integer", Float: "number", String: "string", Boolean: "boolean", ID: "string" }
385
+
386
+ function fromIntrospection(schema: GqlSchema, endpoint: string, ref: string, _opts: LoadOptions): Loaded {
387
+ const types = new Map(schema.types.map((t) => [t.name, t]))
388
+ const isLeaf = (name: string) => ["SCALAR", "ENUM"].includes(types.get(name)?.kind || "SCALAR")
389
+ const selection = (typeName: string, depth: number): string => {
390
+ const t = types.get(typeName)
391
+ if (!t?.fields) return ""
392
+ const leaves = t.fields.filter((f) => !f.args?.some((a) => a.type.kind === "NON_NULL") && isLeaf(unwrap(f.type).name)).slice(0, 12)
393
+ const nested = depth < 1 ? t.fields.filter((f) => !f.args?.some((a) => a.type.kind === "NON_NULL") && !isLeaf(unwrap(f.type).name)).slice(0, 3) : []
394
+ const parts = [...leaves.map((f) => f.name), ...nested.map((f) => `${f.name} ${selection(unwrap(f.type).name, depth + 1)}`.trim()).filter((s) => s.includes("{"))]
395
+ return parts.length ? `{ ${parts.join(" ")} }` : "{ __typename }"
396
+ }
397
+ const schemaOf = (typeName: string, list: boolean, depth: number): JsonSchema => {
398
+ const t = types.get(typeName)
399
+ let s: JsonSchema
400
+ if (!t || t.kind === "SCALAR") s = SCALAR_SCHEMA[typeName] ? { type: SCALAR_SCHEMA[typeName] } : {}
401
+ else if (t.kind === "ENUM") s = { type: "string", ...(t.enumValues?.length ? { enum: t.enumValues.map((e) => e.name) } : {}) }
402
+ else s = { type: "object" }
403
+ if (s.type === "object" && t?.fields && depth < 1) {
404
+ const leaves = t.fields.filter((f) => !f.args?.some((a) => a.type.kind === "NON_NULL") && isLeaf(unwrap(f.type).name)).slice(0, 12)
405
+ s.properties = Object.fromEntries(leaves.map((f) => [f.name, { ...schemaOf(unwrap(f.type).name, unwrap(f.type).list, depth + 1), ...(unwrap(f.type).required ? {} : { nullable: true }) }]))
406
+ }
407
+ return list ? { type: "array", items: s } : s
408
+ }
409
+ const exampleArg = (t: GqlTypeRef, name: string): unknown => {
410
+ const u = unwrap(t)
411
+ const it = types.get(u.name)
412
+ let v: unknown
413
+ if (it?.kind === "ENUM") v = it.enumValues?.[0]?.name
414
+ else if (it?.kind === "INPUT_OBJECT") v = Object.fromEntries((it.inputFields || []).filter((f) => f.type.kind === "NON_NULL").map((f) => [f.name, exampleArg(f.type, f.name)]))
415
+ else v = u.name === "Int" || u.name === "Float" ? 1 : u.name === "Boolean" ? true : u.name === "ID" ? "1" : /email/i.test(name) ? "user@example.com" : `sample-${name}`
416
+ return u.list ? [v] : v
417
+ }
418
+ const contracts: Contract[] = []
419
+ const endpointPath = isUrl(endpoint) ? new URL(endpoint).pathname : "/graphql"
420
+ for (const [rootName, kind] of [[schema.queryType?.name, "query"], [schema.mutationType?.name, "mutation"]] as const) {
421
+ const root = rootName ? types.get(rootName) : undefined
422
+ for (const f of root?.fields || []) {
423
+ if (f.name.startsWith("__")) continue
424
+ const required = (f.args || []).filter((a) => a.type.kind === "NON_NULL" && a.defaultValue == null)
425
+ const ret = unwrap(f.type)
426
+ const sel = isLeaf(ret.name) ? "" : ` ${selection(ret.name, 0)}`
427
+ const opName = f.name.charAt(0).toUpperCase() + f.name.slice(1)
428
+ const vars = required.map((a) => `$${a.name}: ${typeText(a.type)}`).join(", ")
429
+ const call = required.length ? `(${required.map((a) => `${a.name}: $${a.name}`).join(", ")})` : ""
430
+ const query = `${kind} ${opName}${vars ? `(${vars})` : ""} { ${f.name}${call}${sel} }`
431
+ const variables = Object.fromEntries(required.map((a) => [a.name, exampleArg(a.type, a.name)]))
432
+ const data: JsonSchema = { type: "object", required: ret.required ? [f.name] : [], properties: { [f.name]: { ...schemaOf(ret.name, ret.list, 0), ...(ret.required ? {} : { nullable: true }) } } }
433
+ contracts.push({
434
+ id: `graphql ${kind} ${opName}`,
435
+ protocol: "graphql",
436
+ name: f.name,
437
+ method: "POST",
438
+ path: endpointPath,
439
+ example: { path: endpointPath, headers: { "Content-Type": "application/json" }, body: { query, ...(required.length ? { variables } : {}), operationName: opName } },
440
+ expect: { status: 200, contentType: "application/json", schema: { type: "object", required: ["data"], properties: { data } }, fields: [{ path: `data.${f.name}` }] },
441
+ writes: kind === "mutation",
442
+ ...(required.length ? { needsData: required.map((a) => `argument ${a.name}`) } : {}),
443
+ source: { kind: "graphql", ref },
444
+ })
445
+ }
446
+ }
447
+ return { kind: "graphql", name: "GraphQL API", baseUrl: isUrl(endpoint) ? new URL(endpoint).origin : "", contracts, skipped: [] }
448
+ }
449
+
450
+ /** GraphQL SDL as an introspection-like schema (types, fields, arguments), for the same contracts. */
451
+ export function sdlSchema(sdl: string): GqlSchema {
452
+ const text = sdl.replace(/#[^\n]*/g, "").replace(/"""[\s\S]*?"""/g, "").replace(/"[^"\n]*"/g, "")
453
+ const ref = (s: string): GqlTypeRef => {
454
+ const t = s.trim()
455
+ if (t.endsWith("!")) return { kind: "NON_NULL", ofType: ref(t.slice(0, -1)) }
456
+ if (t.startsWith("[")) return { kind: "LIST", ofType: ref(t.slice(1, t.lastIndexOf("]"))) }
457
+ return { kind: "NAMED", name: t }
458
+ }
459
+ const types: GqlType[] = []
460
+ const kinds = new Map<string, string>()
461
+ for (const m of text.matchAll(/\b(type|input|enum|interface|scalar)\s+(\w+)[^{\n]*({([^}]*)})?/g)) {
462
+ const [, kw, name, , body = ""] = m
463
+ const kind = kw === "type" || kw === "interface" ? "OBJECT" : kw === "input" ? "INPUT_OBJECT" : kw === "enum" ? "ENUM" : "SCALAR"
464
+ kinds.set(name, kind)
465
+ if (kind === "ENUM") {
466
+ types.push({ kind, name, enumValues: body.split(/\s+/).filter((v) => /^\w+$/.test(v)).map((n) => ({ name: n })) })
467
+ continue
468
+ }
469
+ const fields: GqlField[] = []
470
+ for (const f of body.matchAll(/(\w+)\s*(\(([^)]*)\))?\s*:\s*([\w[\]!]+)/g)) {
471
+ const args = (f[3] || "")
472
+ .split(/,|\n/)
473
+ .map((a) => a.match(/(\w+)\s*:\s*([\w[\]!]+)(\s*=\s*(\S+))?/))
474
+ .filter(Boolean)
475
+ .map((a) => ({ name: a![1], type: ref(a![2]), defaultValue: a![4] ?? null }))
476
+ fields.push({ name: f[1], args, type: ref(f[4]) })
477
+ }
478
+ types.push(kind === "INPUT_OBJECT" ? { kind, name, inputFields: fields.map((f) => ({ name: f.name, type: f.type })) } : { kind, name, fields })
479
+ }
480
+ for (const s of ["String", "Int", "Float", "Boolean", "ID"]) if (!kinds.has(s)) types.push({ kind: "SCALAR", name: s })
481
+ const fix = (r: GqlTypeRef): GqlTypeRef => (r.kind === "NAMED" ? { kind: kinds.get(r.name!) || "SCALAR", name: r.name } : { ...r, ofType: r.ofType ? fix(r.ofType) : null })
482
+ for (const t of types) {
483
+ for (const f of t.fields || []) {
484
+ f.type = fix(f.type)
485
+ for (const a of f.args || []) a.type = fix(a.type)
486
+ }
487
+ for (const f of t.inputFields || []) f.type = fix(f.type)
488
+ }
489
+ const root = (name: string) => text.match(new RegExp(`\\b${name}\\s*:\\s*(\\w+)`))?.[1]
490
+ return { queryType: { name: root("query") || "Query" }, mutationType: { name: root("mutation") || "Mutation" }, types }
491
+ }
492
+
493
+ function fromSdl(sdl: string, ref: string, opts: LoadOptions): Loaded {
494
+ const loaded = fromIntrospection(sdlSchema(sdl), "", ref, opts)
495
+ return { ...loaded, skipped: ["The schema file gives no endpoint: set the base URL (and the endpoint path if it is not /graphql) to run these."] }
496
+ }
497
+
498
+ // -- gRPC ---------------------------------------------------------------------------------------
499
+
500
+ type ProtoMessage = { name: string; fields: { name: string; type: string; repeated: boolean }[] }
501
+ type ProtoService = { pkg: string; name: string; rpcs: { name: string; input: string; output: string; streaming: boolean }[] }
502
+
503
+ /** Each `keyword Name { ... }` block in the text, with braces matched (rpc option blocks and nested messages inside). */
504
+ function blocks(text: string, keyword: string): { name: string; body: string }[] {
505
+ const out: { name: string; body: string }[] = []
506
+ const re = new RegExp(`\\b${keyword}\\s+([\\w.]+)\\s*{`, "g")
507
+ for (const m of text.matchAll(re)) {
508
+ let depth = 1
509
+ let i = m.index! + m[0].length
510
+ const start = i
511
+ for (; i < text.length && depth; i++) depth += text[i] === "{" ? 1 : text[i] === "}" ? -1 : 0
512
+ out.push({ name: m[1], body: text.slice(start, i - 1) })
513
+ }
514
+ return out
515
+ }
516
+
517
+ /** Services, rpcs, and messages from .proto text (or grpcurl describe output, which is written the same way). */
518
+ export function parseProto(text: string): { services: ProtoService[]; messages: Map<string, ProtoMessage> } {
519
+ const clean = text
520
+ .replace(/\/\/[^\n]*/g, "")
521
+ .replace(/\/\*[\s\S]*?\*\//g, "")
522
+ // grpcurl describe: "shop.Orders is a service:" then "service Orders {": keep the full name.
523
+ .replace(/^([\w.]+) is a service:\s*\n\s*service\s+\w+/gm, "service $1")
524
+ .replace(/^([\w.]+) is a message:\s*$/gm, "")
525
+ const pkg = clean.match(/\bpackage\s+([\w.]+)\s*;/)?.[1] || ""
526
+ const services: ProtoService[] = []
527
+ const messages = new Map<string, ProtoMessage>()
528
+ for (const s of blocks(clean, "service")) {
529
+ const name = s.name.split(".").pop()!
530
+ const sp = s.name.includes(".") ? s.name.split(".").slice(0, -1).join(".") : pkg
531
+ const rpcs = [...s.body.matchAll(/\brpc\s+(\w+)\s*\(\s*(stream\s+)?\.?([\w.]+)\s*\)\s*returns\s*\(\s*(stream\s+)?\.?([\w.]+)\s*\)/g)].map((r) => ({ name: r[1], input: r[3], output: r[5], streaming: Boolean(r[2] || r[4]) }))
532
+ services.push({ pkg: sp, name, rpcs })
533
+ }
534
+ for (const m of blocks(clean, "message")) {
535
+ const own = m.body.replace(/\b(message|enum|oneof)\s+\w+\s*{[^{}]*}/g, (b) => (b.startsWith("oneof") ? b.replace(/^oneof\s+\w+\s*{|}$/g, "") : ""))
536
+ const fields = [...own.matchAll(/(repeated\s+|optional\s+)?\.?([\w.]+)\s+(\w+)\s*=\s*\d+/g)].map((f) => ({ name: f[3], type: f[2], repeated: f[1]?.trim() === "repeated" }))
537
+ const name = m.name.replace(/^\./, "")
538
+ messages.set(name.split(".").pop()!, { name, fields })
539
+ }
540
+ return { services, messages }
541
+ }
542
+
543
+ const PROTO_EXAMPLE: Record<string, unknown> = { string: "sample", bool: true, bytes: "", double: 1, float: 1, int32: 1, int64: "1", uint32: 1, uint64: "1", sint32: 1, sint64: "1", fixed32: 1, fixed64: "1" }
544
+
545
+ function protoExample(msg: ProtoMessage | undefined, messages: Map<string, ProtoMessage>, depth = 0): Record<string, unknown> {
546
+ if (!msg || depth > 3) return {}
547
+ return Object.fromEntries(
548
+ msg.fields.map((f) => {
549
+ const nested = messages.get(f.type.split(".").pop()!)
550
+ const v = f.type in PROTO_EXAMPLE ? PROTO_EXAMPLE[f.type] : nested ? protoExample(nested, messages, depth + 1) : f.name.endsWith("id") ? "1" : 0
551
+ return [f.name, f.repeated ? [v] : v]
552
+ }),
553
+ )
554
+ }
555
+
556
+ function grpcContracts(services: ProtoService[], messages: Map<string, ProtoMessage>, ref: string, kind: SourceKind): { contracts: Contract[]; skipped: string[] } {
557
+ const contracts: Contract[] = []
558
+ const skipped: string[] = []
559
+ for (const s of services) {
560
+ if (/^grpc\.(reflection|health)/.test(s.pkg) || s.name === "ServerReflection") continue
561
+ for (const r of s.rpcs) {
562
+ const full = `${s.pkg ? `${s.pkg}.` : ""}${s.name}/${r.name}`
563
+ if (r.streaming) {
564
+ skipped.push(`${full}: streaming rpc (contracts cover unary calls)`)
565
+ continue
566
+ }
567
+ const input = messages.get(r.input.split(".").pop()!)
568
+ const output = messages.get(r.output.split(".").pop()!)
569
+ const schema: JsonSchema | undefined = output ? { type: "object", properties: Object.fromEntries(output.fields.map((f) => [f.name, f.repeated ? { type: "array" } : {}])) } : undefined
570
+ contracts.push({
571
+ id: `grpc ${full}`,
572
+ protocol: "grpc",
573
+ name: full,
574
+ method: "POST",
575
+ path: `/${full}`,
576
+ example: { path: `/${full}`, body: protoExample(input, messages) },
577
+ expect: { status: 0, ...(schema ? { schema } : {}) },
578
+ writes: !READ_NAME.test(r.name),
579
+ ...(input?.fields.length ? { needsData: input.fields.map((f) => `field ${f.name}`) } : {}),
580
+ source: { kind, ref },
581
+ })
582
+ }
583
+ }
584
+ return { contracts, skipped }
585
+ }
586
+
587
+ async function fromProto(files: string[], _opts: LoadOptions, given?: string): Promise<Loaded> {
588
+ const text = given ?? files.map((f) => readFileSync(path.resolve(f), "utf8")).join("\n")
589
+ const { services, messages } = parseProto(text)
590
+ const { contracts, skipped } = grpcContracts(services, messages, files.join(", "), "proto")
591
+ if (!services.length) skipped.push("The .proto file has no service.")
592
+ return { kind: "proto", name: services.map((s) => s.name).join(", ") || "gRPC service", baseUrl: "", contracts, skipped: [...skipped, "A .proto file gives no server address: set the base URL to grpc://host:port (plaintext) or grpcs://host:port."] }
593
+ }
594
+
595
+ export async function runGrpcurl(args: string[], input?: string): Promise<{ ok: boolean; out: string }> {
596
+ const bin = Bun.which("grpcurl")
597
+ if (!bin) throw new Error("gRPC needs grpcurl (https://github.com/fullstorydev/grpcurl) on PATH: brew install grpcurl, or go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest.")
598
+ const proc = Bun.spawn([bin, ...args], { stdin: input === undefined ? "ignore" : new Blob([input]), stdout: "pipe", stderr: "pipe" })
599
+ const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
600
+ return { ok: code === 0, out: `${out}${err}` }
601
+ }
602
+
603
+ export function grpcTarget(url: string): { address: string; plaintext: boolean } {
604
+ const m = url.match(/^grpc(s?):\/\/([^/]+)/i)
605
+ if (!m) throw new Error(`Not a gRPC address: ${url} (use grpc://host:port or grpcs://host:port)`)
606
+ return { address: m[2], plaintext: !m[1] }
607
+ }
608
+
609
+ async function grpcReflection(url: string, opts: LoadOptions): Promise<Loaded> {
610
+ const { address, plaintext } = grpcTarget(url)
611
+ const run = opts.grpcurl || runGrpcurl
612
+ const flags = [...(plaintext ? ["-plaintext"] : []), ...Object.entries(opts.headers || {}).flatMap(([k, v]) => ["-H", `${k}: ${v}`])]
613
+ const list = await run([...flags, address, "list"])
614
+ if (!list.ok) throw new Error(`grpcurl could not list the services at ${address}: ${list.out.trim().split("\n")[0]}. The server may not have reflection on; give its .proto files instead.`)
615
+ const names = list.out.split("\n").map((s) => s.trim()).filter((s) => s && !/^grpc\.reflection|^grpc\.health/.test(s))
616
+ let text = ""
617
+ for (const n of names) text += `\n${(await run([...flags, address, "describe", n])).out}`
618
+ const types = new Set([...text.matchAll(/\(\s*(?:stream\s+)?\.?([\w.]+)\s*\)/g)].map((m) => m[1]))
619
+ for (const t of types) text += `\n${(await run([...flags, address, "describe", t])).out}`
620
+ const { services, messages } = parseProto(text)
621
+ const { contracts, skipped } = grpcContracts(services, messages, url, "proto")
622
+ return { kind: "proto", name: names.join(", ") || "gRPC service", baseUrl: `${plaintext ? "grpc" : "grpcs"}://${address}`, contracts, skipped }
623
+ }
624
+