shapi-cli 0.0.0-stage → 0.1.0
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.
- package/LICENSE +202 -0
- package/README.md +48 -2
- package/advisories/github-workflow-dispatch-204-to-200.json +26 -0
- package/advisories/hubspot-pipelines-v1-sunset.json +24 -0
- package/advisories/meta-graph-v20-sunset.json +24 -0
- package/advisories/meta-graph-v21-sunset.json +24 -0
- package/advisories/quickbooks-minorversion-below-75-ignored.json +28 -0
- package/advisories/reddit-logged-out-json-403.json +25 -0
- package/advisories/xero-accounting-transactions-scope-split.json +27 -0
- package/bin/shapi.js +4 -0
- package/dist/index.js +3026 -0
- package/package.json +51 -4
- package/recordings/github-workflow-dispatch-204-to-200.json +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3026 @@
|
|
|
1
|
+
// packages/cli/src/index.ts
|
|
2
|
+
import { Command as Command2, CommanderError as CommanderError2, Option } from "commander";
|
|
3
|
+
|
|
4
|
+
// packages/cli/src/fix.ts
|
|
5
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
6
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync5, mkdtempSync as mkdtempSync3, readdirSync as readdirSync3, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
7
|
+
import os3 from "os";
|
|
8
|
+
import path12 from "path";
|
|
9
|
+
|
|
10
|
+
// packages/core/src/providers.ts
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
// packages/core/providers.json
|
|
14
|
+
var providers_default = [
|
|
15
|
+
{
|
|
16
|
+
id: "github",
|
|
17
|
+
name: "GitHub",
|
|
18
|
+
hosts: ["api.github.com", "uploads.github.com"],
|
|
19
|
+
sdkPackages: ["@octokit/rest", "@octokit/core", "octokit", "@octokit/action", "@actions/github"],
|
|
20
|
+
sdkMethods: {
|
|
21
|
+
"actions.createWorkflowDispatch": "POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches",
|
|
22
|
+
"actions.listWorkflowRuns": "GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs",
|
|
23
|
+
"actions.getWorkflowRun": "GET /repos/{owner}/{repo}/actions/runs/{run_id}",
|
|
24
|
+
"repos.get": "GET /repos/{owner}/{repo}",
|
|
25
|
+
"repos.getContent": "GET /repos/{owner}/{repo}/contents/{path}",
|
|
26
|
+
"repos.createDispatchEvent": "POST /repos/{owner}/{repo}/dispatches",
|
|
27
|
+
"pulls.create": "POST /repos/{owner}/{repo}/pulls",
|
|
28
|
+
"pulls.get": "GET /repos/{owner}/{repo}/pulls/{pull_number}",
|
|
29
|
+
"pulls.merge": "PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge",
|
|
30
|
+
"issues.create": "POST /repos/{owner}/{repo}/issues",
|
|
31
|
+
"issues.createComment": "POST /repos/{owner}/{repo}/issues/{issue_number}/comments",
|
|
32
|
+
"checks.create": "POST /repos/{owner}/{repo}/check-runs"
|
|
33
|
+
},
|
|
34
|
+
docsUrl: "https://docs.github.com/en/rest",
|
|
35
|
+
changelogUrl: "https://github.blog/changelog/"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "stripe",
|
|
39
|
+
name: "Stripe",
|
|
40
|
+
hosts: ["api.stripe.com", "files.stripe.com"],
|
|
41
|
+
sdkPackages: ["stripe"],
|
|
42
|
+
sdkMethods: {
|
|
43
|
+
"customers.create": "POST /v1/customers",
|
|
44
|
+
"customers.retrieve": "GET /v1/customers/{id}",
|
|
45
|
+
"customers.update": "POST /v1/customers/{id}",
|
|
46
|
+
"customers.list": "GET /v1/customers",
|
|
47
|
+
"paymentIntents.create": "POST /v1/payment_intents",
|
|
48
|
+
"paymentIntents.retrieve": "GET /v1/payment_intents/{id}",
|
|
49
|
+
"paymentIntents.confirm": "POST /v1/payment_intents/{id}/confirm",
|
|
50
|
+
"subscriptions.create": "POST /v1/subscriptions",
|
|
51
|
+
"subscriptions.update": "POST /v1/subscriptions/{id}",
|
|
52
|
+
"invoices.create": "POST /v1/invoices",
|
|
53
|
+
"checkout.sessions.create": "POST /v1/checkout/sessions",
|
|
54
|
+
"refunds.create": "POST /v1/refunds"
|
|
55
|
+
},
|
|
56
|
+
docsUrl: "https://docs.stripe.com/api",
|
|
57
|
+
changelogUrl: "https://docs.stripe.com/changelog"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "twilio",
|
|
61
|
+
name: "Twilio",
|
|
62
|
+
hosts: ["api.twilio.com", "*.twilio.com"],
|
|
63
|
+
sdkPackages: ["twilio"],
|
|
64
|
+
sdkMethods: {
|
|
65
|
+
"messages.create": "POST /2010-04-01/Accounts/{AccountSid}/Messages.json",
|
|
66
|
+
"calls.create": "POST /2010-04-01/Accounts/{AccountSid}/Calls.json"
|
|
67
|
+
},
|
|
68
|
+
docsUrl: "https://www.twilio.com/docs/usage/api",
|
|
69
|
+
changelogUrl: "https://www.twilio.com/en-us/changelog"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "shopify",
|
|
73
|
+
name: "Shopify",
|
|
74
|
+
hosts: ["*.myshopify.com"],
|
|
75
|
+
sdkPackages: ["@shopify/shopify-api", "@shopify/admin-api-client", "shopify-api-node"],
|
|
76
|
+
docsUrl: "https://shopify.dev/docs/api/admin",
|
|
77
|
+
changelogUrl: "https://shopify.dev/changelog"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "quickbooks",
|
|
81
|
+
name: "QuickBooks Online",
|
|
82
|
+
hosts: ["quickbooks.api.intuit.com", "sandbox-quickbooks.api.intuit.com", "oauth.platform.intuit.com"],
|
|
83
|
+
sdkPackages: ["node-quickbooks", "intuit-oauth"],
|
|
84
|
+
docsUrl: "https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/account",
|
|
85
|
+
changelogUrl: "https://blogs.intuit.com/category/intuit-developer/"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "xero",
|
|
89
|
+
name: "Xero",
|
|
90
|
+
hosts: ["api.xero.com", "identity.xero.com", "login.xero.com"],
|
|
91
|
+
sdkPackages: ["xero-node"],
|
|
92
|
+
docsUrl: "https://developer.xero.com/documentation/api/accounting/overview",
|
|
93
|
+
changelogUrl: "https://developer.xero.com/changelog"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "hubspot",
|
|
97
|
+
name: "HubSpot",
|
|
98
|
+
hosts: ["api.hubapi.com", "api.hubspot.com"],
|
|
99
|
+
sdkPackages: ["@hubspot/api-client"],
|
|
100
|
+
docsUrl: "https://developers.hubspot.com/docs/api/overview",
|
|
101
|
+
changelogUrl: "https://developers.hubspot.com/changelog"
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "slack",
|
|
105
|
+
name: "Slack",
|
|
106
|
+
hosts: ["slack.com", "hooks.slack.com"],
|
|
107
|
+
sdkPackages: ["@slack/web-api", "@slack/bolt", "@slack/webhook"],
|
|
108
|
+
sdkMethods: {
|
|
109
|
+
"chat.postMessage": "POST /api/chat.postMessage",
|
|
110
|
+
"chat.update": "POST /api/chat.update",
|
|
111
|
+
"conversations.list": "POST /api/conversations.list",
|
|
112
|
+
"conversations.history": "POST /api/conversations.history",
|
|
113
|
+
"users.info": "POST /api/users.info",
|
|
114
|
+
"files.uploadV2": "POST /api/files.uploadV2"
|
|
115
|
+
},
|
|
116
|
+
docsUrl: "https://api.slack.com/methods",
|
|
117
|
+
changelogUrl: "https://api.slack.com/changelog"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: "openai",
|
|
121
|
+
name: "OpenAI",
|
|
122
|
+
hosts: ["api.openai.com"],
|
|
123
|
+
sdkPackages: ["openai"],
|
|
124
|
+
sdkMethods: {
|
|
125
|
+
"chat.completions.create": "POST /v1/chat/completions",
|
|
126
|
+
"responses.create": "POST /v1/responses",
|
|
127
|
+
"embeddings.create": "POST /v1/embeddings",
|
|
128
|
+
"images.generate": "POST /v1/images/generations",
|
|
129
|
+
"beta.assistants.create": "POST /v1/assistants",
|
|
130
|
+
"beta.threads.create": "POST /v1/threads",
|
|
131
|
+
"beta.threads.runs.create": "POST /v1/threads/{thread_id}/runs",
|
|
132
|
+
"files.create": "POST /v1/files"
|
|
133
|
+
},
|
|
134
|
+
docsUrl: "https://platform.openai.com/docs/api-reference",
|
|
135
|
+
changelogUrl: "https://platform.openai.com/docs/changelog"
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: "anthropic",
|
|
139
|
+
name: "Anthropic",
|
|
140
|
+
hosts: ["api.anthropic.com"],
|
|
141
|
+
sdkPackages: ["@anthropic-ai/sdk"],
|
|
142
|
+
sdkMethods: {
|
|
143
|
+
"messages.create": "POST /v1/messages",
|
|
144
|
+
"messages.stream": "POST /v1/messages",
|
|
145
|
+
"messages.countTokens": "POST /v1/messages/count_tokens",
|
|
146
|
+
"models.list": "GET /v1/models"
|
|
147
|
+
},
|
|
148
|
+
docsUrl: "https://docs.claude.com/en/api",
|
|
149
|
+
changelogUrl: "https://docs.claude.com/en/release-notes/api"
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: "salesforce",
|
|
153
|
+
name: "Salesforce",
|
|
154
|
+
hosts: ["*.salesforce.com", "*.force.com"],
|
|
155
|
+
sdkPackages: ["jsforce", "@jsforce/jsforce-node"],
|
|
156
|
+
docsUrl: "https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/",
|
|
157
|
+
changelogUrl: "https://help.salesforce.com/s/articleView?id=release-notes.salesforce_release_notes.htm"
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: "zendesk",
|
|
161
|
+
name: "Zendesk",
|
|
162
|
+
hosts: ["*.zendesk.com"],
|
|
163
|
+
sdkPackages: ["node-zendesk"],
|
|
164
|
+
docsUrl: "https://developer.zendesk.com/api-reference/",
|
|
165
|
+
changelogUrl: "https://developer.zendesk.com/api-reference/changelog/changelog/"
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: "intercom",
|
|
169
|
+
name: "Intercom",
|
|
170
|
+
hosts: ["api.intercom.io", "api.eu.intercom.io", "api.au.intercom.io"],
|
|
171
|
+
sdkPackages: ["intercom-client"],
|
|
172
|
+
docsUrl: "https://developers.intercom.com/docs/references/rest-api/api.intercom.io",
|
|
173
|
+
changelogUrl: "https://developers.intercom.com/docs/build-an-integration/learn-more/rest-apis/api-changelog"
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: "sendgrid",
|
|
177
|
+
name: "SendGrid",
|
|
178
|
+
hosts: ["api.sendgrid.com"],
|
|
179
|
+
sdkPackages: ["@sendgrid/mail", "@sendgrid/client"],
|
|
180
|
+
sdkMethods: {
|
|
181
|
+
send: "POST /v3/mail/send"
|
|
182
|
+
},
|
|
183
|
+
docsUrl: "https://www.twilio.com/docs/sendgrid/api-reference"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: "mailchimp",
|
|
187
|
+
name: "Mailchimp",
|
|
188
|
+
hosts: ["*.api.mailchimp.com"],
|
|
189
|
+
sdkPackages: ["@mailchimp/mailchimp_marketing", "@mailchimp/mailchimp_transactional"],
|
|
190
|
+
docsUrl: "https://mailchimp.com/developer/marketing/api/"
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: "plaid",
|
|
194
|
+
name: "Plaid",
|
|
195
|
+
hosts: ["production.plaid.com", "sandbox.plaid.com", "development.plaid.com"],
|
|
196
|
+
sdkPackages: ["plaid"],
|
|
197
|
+
docsUrl: "https://plaid.com/docs/api/",
|
|
198
|
+
changelogUrl: "https://plaid.com/docs/changelog/"
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "square",
|
|
202
|
+
name: "Square",
|
|
203
|
+
hosts: ["connect.squareup.com", "connect.squareupsandbox.com"],
|
|
204
|
+
sdkPackages: ["square"],
|
|
205
|
+
docsUrl: "https://developer.squareup.com/reference/square",
|
|
206
|
+
changelogUrl: "https://developer.squareup.com/docs/changelog/connect"
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: "paypal",
|
|
210
|
+
name: "PayPal",
|
|
211
|
+
hosts: ["api-m.paypal.com", "api-m.sandbox.paypal.com", "api.paypal.com", "api.sandbox.paypal.com"],
|
|
212
|
+
sdkPackages: ["@paypal/paypal-server-sdk", "@paypal/checkout-server-sdk"],
|
|
213
|
+
docsUrl: "https://developer.paypal.com/api/rest/"
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: "google-gemini",
|
|
217
|
+
name: "Google Gemini API",
|
|
218
|
+
hosts: ["generativelanguage.googleapis.com"],
|
|
219
|
+
sdkPackages: ["@google/genai", "@google/generative-ai"],
|
|
220
|
+
docsUrl: "https://ai.google.dev/api",
|
|
221
|
+
changelogUrl: "https://ai.google.dev/gemini-api/docs/changelog"
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: "google",
|
|
225
|
+
name: "Google APIs",
|
|
226
|
+
hosts: ["*.googleapis.com"],
|
|
227
|
+
sdkPackages: ["googleapis"],
|
|
228
|
+
docsUrl: "https://developers.google.com/apis-explorer"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
id: "microsoft-graph",
|
|
232
|
+
name: "Microsoft Graph",
|
|
233
|
+
hosts: ["graph.microsoft.com"],
|
|
234
|
+
sdkPackages: ["@microsoft/microsoft-graph-client", "@microsoft/msgraph-sdk"],
|
|
235
|
+
docsUrl: "https://learn.microsoft.com/en-us/graph/api/overview",
|
|
236
|
+
changelogUrl: "https://developer.microsoft.com/en-us/graph/changelog"
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: "meta-graph",
|
|
240
|
+
name: "Meta Graph API",
|
|
241
|
+
hosts: ["graph.facebook.com", "graph.instagram.com"],
|
|
242
|
+
sdkPackages: ["facebook-nodejs-business-sdk"],
|
|
243
|
+
docsUrl: "https://developers.facebook.com/docs/graph-api",
|
|
244
|
+
changelogUrl: "https://developers.facebook.com/docs/graph-api/changelog"
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: "x",
|
|
248
|
+
name: "X (Twitter)",
|
|
249
|
+
hosts: ["api.x.com", "api.twitter.com", "upload.twitter.com"],
|
|
250
|
+
sdkPackages: ["twitter-api-v2"],
|
|
251
|
+
docsUrl: "https://docs.x.com/x-api",
|
|
252
|
+
changelogUrl: "https://docs.x.com/changelog"
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: "reddit",
|
|
256
|
+
name: "Reddit",
|
|
257
|
+
hosts: ["reddit.com", "www.reddit.com", "oauth.reddit.com", "old.reddit.com"],
|
|
258
|
+
sdkPackages: ["snoowrap"],
|
|
259
|
+
docsUrl: "https://www.reddit.com/dev/api/"
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: "linkedin",
|
|
263
|
+
name: "LinkedIn",
|
|
264
|
+
hosts: ["api.linkedin.com"],
|
|
265
|
+
sdkPackages: ["linkedin-api-client"],
|
|
266
|
+
docsUrl: "https://learn.microsoft.com/en-us/linkedin/",
|
|
267
|
+
changelogUrl: "https://learn.microsoft.com/en-us/linkedin/marketing/integrations/recent-changes"
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: "notion",
|
|
271
|
+
name: "Notion",
|
|
272
|
+
hosts: ["api.notion.com"],
|
|
273
|
+
sdkPackages: ["@notionhq/client"],
|
|
274
|
+
docsUrl: "https://developers.notion.com/reference",
|
|
275
|
+
changelogUrl: "https://developers.notion.com/page/changelog"
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
id: "airtable",
|
|
279
|
+
name: "Airtable",
|
|
280
|
+
hosts: ["api.airtable.com"],
|
|
281
|
+
sdkPackages: ["airtable"],
|
|
282
|
+
docsUrl: "https://airtable.com/developers/web/api/introduction"
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: "atlassian",
|
|
286
|
+
name: "Atlassian (Jira, Confluence)",
|
|
287
|
+
hosts: ["*.atlassian.net", "api.atlassian.com"],
|
|
288
|
+
sdkPackages: ["jira.js", "jira-client", "confluence.js"],
|
|
289
|
+
docsUrl: "https://developer.atlassian.com/cloud/jira/platform/rest/v3/",
|
|
290
|
+
changelogUrl: "https://developer.atlassian.com/changelog/"
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: "linear",
|
|
294
|
+
name: "Linear",
|
|
295
|
+
hosts: ["api.linear.app"],
|
|
296
|
+
sdkPackages: ["@linear/sdk"],
|
|
297
|
+
docsUrl: "https://linear.app/developers"
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: "discord",
|
|
301
|
+
name: "Discord",
|
|
302
|
+
hosts: ["discord.com", "discordapp.com"],
|
|
303
|
+
sdkPackages: ["discord.js", "@discordjs/rest"],
|
|
304
|
+
docsUrl: "https://discord.com/developers/docs/reference",
|
|
305
|
+
changelogUrl: "https://discord.com/developers/docs/change-log"
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
id: "zoom",
|
|
309
|
+
name: "Zoom",
|
|
310
|
+
hosts: ["api.zoom.us", "zoom.us"],
|
|
311
|
+
sdkPackages: [],
|
|
312
|
+
docsUrl: "https://developers.zoom.us/docs/api/",
|
|
313
|
+
changelogUrl: "https://developers.zoom.us/changelog/"
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
id: "dropbox",
|
|
317
|
+
name: "Dropbox",
|
|
318
|
+
hosts: ["api.dropboxapi.com", "content.dropboxapi.com"],
|
|
319
|
+
sdkPackages: ["dropbox"],
|
|
320
|
+
docsUrl: "https://www.dropbox.com/developers/documentation/http/documentation"
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: "pinterest",
|
|
324
|
+
name: "Pinterest",
|
|
325
|
+
hosts: ["api.pinterest.com"],
|
|
326
|
+
sdkPackages: [],
|
|
327
|
+
docsUrl: "https://developers.pinterest.com/docs/api/v5/",
|
|
328
|
+
changelogUrl: "https://developers.pinterest.com/docs/getting-started/changelog/"
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
id: "resend",
|
|
332
|
+
name: "Resend",
|
|
333
|
+
hosts: ["api.resend.com"],
|
|
334
|
+
sdkPackages: ["resend"],
|
|
335
|
+
docsUrl: "https://resend.com/docs/api-reference/introduction"
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
id: "cloudflare",
|
|
339
|
+
name: "Cloudflare",
|
|
340
|
+
hosts: ["api.cloudflare.com"],
|
|
341
|
+
sdkPackages: ["cloudflare"],
|
|
342
|
+
docsUrl: "https://developers.cloudflare.com/api/",
|
|
343
|
+
changelogUrl: "https://developers.cloudflare.com/changelog/"
|
|
344
|
+
}
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
// packages/core/src/providers.ts
|
|
348
|
+
var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
349
|
+
var EndpointRefSchema = z.string().regex(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) \/\S*$/);
|
|
350
|
+
var ProviderSchema = z.object({
|
|
351
|
+
id: z.string().regex(/^[a-z0-9-]+$/),
|
|
352
|
+
name: z.string().min(1),
|
|
353
|
+
/** Exact hostnames, or "*.example.com" for any subdomain of example.com. */
|
|
354
|
+
hosts: z.array(z.string().regex(/^(\*\.)?[a-z0-9.-]+$/)).min(1),
|
|
355
|
+
/** npm packages that are this provider's SDKs. */
|
|
356
|
+
sdkPackages: z.array(z.string().min(1)),
|
|
357
|
+
/** SDK method chain (after the client, without a leading "rest.") to the HTTP endpoint it calls. */
|
|
358
|
+
sdkMethods: z.record(z.string(), EndpointRefSchema).optional(),
|
|
359
|
+
docsUrl: z.url(),
|
|
360
|
+
changelogUrl: z.url().optional()
|
|
361
|
+
});
|
|
362
|
+
var ProviderRegistrySchema = z.array(ProviderSchema).refine((ps) => new Set(ps.map((p) => p.id)).size === ps.length, "provider ids must be unique");
|
|
363
|
+
var cached;
|
|
364
|
+
function loadProviders() {
|
|
365
|
+
cached ??= ProviderRegistrySchema.parse(providers_default);
|
|
366
|
+
return cached;
|
|
367
|
+
}
|
|
368
|
+
function providerForHost(host) {
|
|
369
|
+
const h = host.toLowerCase().replace(/:\d+$/, "");
|
|
370
|
+
let best;
|
|
371
|
+
for (const provider of loadProviders()) {
|
|
372
|
+
for (const pattern of provider.hosts) {
|
|
373
|
+
let score = -1;
|
|
374
|
+
if (pattern === h) score = 1e4;
|
|
375
|
+
else if (pattern.startsWith("*.") && h.endsWith(pattern.slice(1))) score = pattern.length;
|
|
376
|
+
if (score > (best?.score ?? -1)) best = { provider, score };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return best?.provider;
|
|
380
|
+
}
|
|
381
|
+
function providerForPackage(name) {
|
|
382
|
+
return loadProviders().find((p) => p.sdkPackages.includes(name));
|
|
383
|
+
}
|
|
384
|
+
function sdkEndpoint(provider, chain) {
|
|
385
|
+
const methods = provider.sdkMethods ?? {};
|
|
386
|
+
const key = chain.replace(/^rest\./, "");
|
|
387
|
+
const ref = methods[key];
|
|
388
|
+
if (!ref) return void 0;
|
|
389
|
+
const [method, path13] = ref.split(" ");
|
|
390
|
+
return { method, path: path13 };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// packages/core/src/lockfile.ts
|
|
394
|
+
import { createHash } from "crypto";
|
|
395
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
396
|
+
import path from "path";
|
|
397
|
+
import { z as z2 } from "zod";
|
|
398
|
+
var NameValueSchema = z2.object({
|
|
399
|
+
name: z2.string(),
|
|
400
|
+
/** Statically known value, with unknown parts as {name}; null when unknown. */
|
|
401
|
+
value: z2.string().nullable()
|
|
402
|
+
});
|
|
403
|
+
var SurfaceSchema = z2.object({
|
|
404
|
+
/** Response body field paths read, dot-separated; array elements as "[]". */
|
|
405
|
+
fields: z2.array(z2.string()),
|
|
406
|
+
/** Status codes the code compares against. */
|
|
407
|
+
statusCodes: z2.array(z2.number().int()),
|
|
408
|
+
/** Branches on `response.ok`. */
|
|
409
|
+
checksOk: z2.boolean(),
|
|
410
|
+
/** String values response fields are compared against. */
|
|
411
|
+
enums: z2.array(z2.object({ field: z2.string(), values: z2.array(z2.string()) })),
|
|
412
|
+
/** Request headers set, names lowercased (API version pins live here). */
|
|
413
|
+
headers: z2.array(NameValueSchema),
|
|
414
|
+
/** Request query parameters. */
|
|
415
|
+
query: z2.array(NameValueSchema),
|
|
416
|
+
/** Response headers read, names lowercased. */
|
|
417
|
+
responseHeaders: z2.array(z2.string())
|
|
418
|
+
});
|
|
419
|
+
var CallSiteSchema = z2.object({
|
|
420
|
+
/** Repo-relative POSIX path. */
|
|
421
|
+
file: z2.string(),
|
|
422
|
+
line: z2.number().int().positive(),
|
|
423
|
+
column: z2.number().int().positive(),
|
|
424
|
+
provider: z2.string().nullable(),
|
|
425
|
+
kind: z2.enum(["http", "sdk"]),
|
|
426
|
+
/** HTTP library (fetch, axios, got, undici, ky, node-fetch) or SDK package name. */
|
|
427
|
+
client: z2.string(),
|
|
428
|
+
/** SDK method chain, for example "rest.actions.createWorkflowDispatch". */
|
|
429
|
+
operation: z2.string().optional(),
|
|
430
|
+
method: z2.enum(HTTP_METHODS).nullable(),
|
|
431
|
+
host: z2.string().nullable(),
|
|
432
|
+
/** Path with dynamic segments as {name} or {}, query string removed. */
|
|
433
|
+
endpoint: z2.string().nullable(),
|
|
434
|
+
/** True when provider, method and endpoint are all known. */
|
|
435
|
+
resolved: z2.boolean(),
|
|
436
|
+
surface: SurfaceSchema
|
|
437
|
+
});
|
|
438
|
+
var LockedCallSiteSchema = z2.object({ id: z2.string().regex(/^[0-9a-f]{12}$/) }).extend(CallSiteSchema.shape);
|
|
439
|
+
var ApiLockSchema = z2.object({
|
|
440
|
+
lockfileVersion: z2.literal(1),
|
|
441
|
+
generator: z2.string(),
|
|
442
|
+
providers: z2.array(
|
|
443
|
+
z2.object({
|
|
444
|
+
id: z2.string(),
|
|
445
|
+
callSites: z2.number().int().nonnegative(),
|
|
446
|
+
/** SDK packages in package.json that point at this provider. */
|
|
447
|
+
declaredBy: z2.array(z2.string())
|
|
448
|
+
})
|
|
449
|
+
),
|
|
450
|
+
callSites: z2.array(LockedCallSiteSchema)
|
|
451
|
+
});
|
|
452
|
+
var LOCKFILE_VERSION = 1;
|
|
453
|
+
function callSiteId(site) {
|
|
454
|
+
return createHash("sha256").update(`${site.file}:${site.line}:${site.column}:${site.method ?? "?"}:${site.endpoint ?? "?"}`).digest("hex").slice(0, 12);
|
|
455
|
+
}
|
|
456
|
+
function createApiLock(input, generator) {
|
|
457
|
+
const callSites = [...input.callSites].sort((a, b) => cmp(a.file, b.file) || a.line - b.line || a.column - b.column).map((site) => ({ id: callSiteId(site), ...site }));
|
|
458
|
+
const providers = /* @__PURE__ */ new Map();
|
|
459
|
+
const entry = (id) => {
|
|
460
|
+
let e = providers.get(id);
|
|
461
|
+
if (!e) providers.set(id, e = { callSites: 0, declaredBy: /* @__PURE__ */ new Set() });
|
|
462
|
+
return e;
|
|
463
|
+
};
|
|
464
|
+
for (const site of callSites) if (site.provider) entry(site.provider).callSites++;
|
|
465
|
+
for (const d of input.declared) entry(d.provider).declaredBy.add(d.package);
|
|
466
|
+
return ApiLockSchema.parse({
|
|
467
|
+
lockfileVersion: LOCKFILE_VERSION,
|
|
468
|
+
generator,
|
|
469
|
+
providers: [...providers].sort(([a], [b]) => cmp(a, b)).map(([id, e]) => ({ id, callSites: e.callSites, declaredBy: [...e.declaredBy].sort() })),
|
|
470
|
+
callSites
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
function serializeApiLock(lock) {
|
|
474
|
+
return JSON.stringify(ApiLockSchema.parse(lock), null, 2) + "\n";
|
|
475
|
+
}
|
|
476
|
+
function writeApiLock(file, lock) {
|
|
477
|
+
mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
|
478
|
+
writeFileSync(file, serializeApiLock(lock));
|
|
479
|
+
}
|
|
480
|
+
function cmp(a, b) {
|
|
481
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// packages/core/src/advisory.ts
|
|
485
|
+
import { readdirSync, readFileSync } from "fs";
|
|
486
|
+
import path2 from "path";
|
|
487
|
+
import { z as z3 } from "zod";
|
|
488
|
+
var CHANGE_CLASSES = [
|
|
489
|
+
"status-code",
|
|
490
|
+
"field-rename",
|
|
491
|
+
"enum-added",
|
|
492
|
+
"required-field",
|
|
493
|
+
"version-sunset",
|
|
494
|
+
"scope-change",
|
|
495
|
+
"auth"
|
|
496
|
+
];
|
|
497
|
+
var SEVERITIES = ["critical", "high", "medium", "low"];
|
|
498
|
+
var IsoDate = z3.iso.date();
|
|
499
|
+
var EndpointPatternSchema = z3.object({
|
|
500
|
+
method: z3.union([z3.enum(HTTP_METHODS), z3.literal("*")]),
|
|
501
|
+
path: z3.string().startsWith("/"),
|
|
502
|
+
/** Limit to these hosts (for providers whose hosts behave differently). */
|
|
503
|
+
hosts: z3.array(z3.string()).optional()
|
|
504
|
+
});
|
|
505
|
+
var TriggerSchema = z3.object({
|
|
506
|
+
/** Affected when the code compares the status against one of these (the old codes). */
|
|
507
|
+
statusCodes: z3.array(z3.number().int()).optional(),
|
|
508
|
+
/**
|
|
509
|
+
* The codes the provider answers with now. Code that already compares
|
|
510
|
+
* against all of them handles the change and is not affected.
|
|
511
|
+
*/
|
|
512
|
+
newStatusCodes: z3.array(z3.number().int()).optional(),
|
|
513
|
+
/** Affected when the code reads one of these body fields (or a field beneath it). */
|
|
514
|
+
fields: z3.array(z3.string()).optional(),
|
|
515
|
+
/** Affected when the code compares this field against string values. */
|
|
516
|
+
enums: z3.array(z3.object({ field: z3.string() })).optional(),
|
|
517
|
+
/** Affected when the request sets this header (optionally to one of these values). */
|
|
518
|
+
headers: z3.array(z3.object({ name: z3.string(), values: z3.array(z3.string()).optional() })).optional(),
|
|
519
|
+
/** Affected when the request sets this query parameter (optionally to a number below `below`). */
|
|
520
|
+
query: z3.array(z3.object({ name: z3.string(), below: z3.number().optional() })).optional(),
|
|
521
|
+
/** Every call to the endpoint is affected (sunsets, auth and scope changes). */
|
|
522
|
+
any: z3.boolean().optional()
|
|
523
|
+
}).refine((t) => Object.values(t).some((v) => v !== void 0 && v !== false), "trigger needs at least one condition");
|
|
524
|
+
var AdvisorySchema = z3.object({
|
|
525
|
+
id: z3.string().regex(/^[a-z0-9-]+$/),
|
|
526
|
+
provider: z3.string(),
|
|
527
|
+
title: z3.string().min(1),
|
|
528
|
+
summary: z3.string().min(1),
|
|
529
|
+
endpoint: EndpointPatternSchema,
|
|
530
|
+
changeClass: z3.enum(CHANGE_CLASSES),
|
|
531
|
+
trigger: TriggerSchema,
|
|
532
|
+
/** When the changed behavior was first seen in the wild. */
|
|
533
|
+
firstObserved: IsoDate.nullable(),
|
|
534
|
+
/** The provider's own notice, if there was one. */
|
|
535
|
+
publishedNotice: z3.object({ date: IsoDate.nullable(), url: z3.url() }).nullable(),
|
|
536
|
+
/** When the change takes (or took) effect. */
|
|
537
|
+
effectiveDate: IsoDate,
|
|
538
|
+
severity: z3.enum(SEVERITIES),
|
|
539
|
+
migrationNote: z3.string().min(1),
|
|
540
|
+
recipeId: z3.string().optional(),
|
|
541
|
+
sources: z3.array(z3.url()).min(1),
|
|
542
|
+
/** "verified": checked against a primary or two independent sources. "unverified": secondary reports only. */
|
|
543
|
+
status: z3.enum(["verified", "unverified"]),
|
|
544
|
+
/** Observations behind a machine-drafted advisory: what SHAPI saw before and after the change. */
|
|
545
|
+
evidence: z3.object({
|
|
546
|
+
source: z3.string(),
|
|
547
|
+
before: z3.object({ observedAt: z3.iso.datetime(), status: z3.number().int(), shapeHash: z3.string() }),
|
|
548
|
+
after: z3.object({ observedAt: z3.iso.datetime(), status: z3.number().int(), shapeHash: z3.string() }),
|
|
549
|
+
changes: z3.array(z3.string())
|
|
550
|
+
}).optional()
|
|
551
|
+
});
|
|
552
|
+
function loadAdvisories(dir) {
|
|
553
|
+
const advisories = [];
|
|
554
|
+
const seen = /* @__PURE__ */ new Set();
|
|
555
|
+
for (const name of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
|
|
556
|
+
const file = path2.join(dir, name);
|
|
557
|
+
const parsed = AdvisorySchema.safeParse(JSON.parse(readFileSync(file, "utf8")));
|
|
558
|
+
if (!parsed.success) throw new Error(`invalid advisory ${name}: ${z3.prettifyError(parsed.error)}`);
|
|
559
|
+
const advisory = parsed.data;
|
|
560
|
+
if (`${advisory.id}.json` !== name) throw new Error(`advisory ${name} must be named ${advisory.id}.json`);
|
|
561
|
+
if (seen.has(advisory.id)) throw new Error(`duplicate advisory id ${advisory.id}`);
|
|
562
|
+
seen.add(advisory.id);
|
|
563
|
+
advisories.push(advisory);
|
|
564
|
+
}
|
|
565
|
+
return advisories.sort((a, b) => a.id < b.id ? -1 : 1);
|
|
566
|
+
}
|
|
567
|
+
var SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
568
|
+
|
|
569
|
+
// packages/core/src/matcher.ts
|
|
570
|
+
var DAY_MS = 864e5;
|
|
571
|
+
function matchAdvisories(lock, advisories, options = {}) {
|
|
572
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
573
|
+
const horizon = options.horizonDays ?? 90;
|
|
574
|
+
const affected = [];
|
|
575
|
+
const exposed = [];
|
|
576
|
+
const hitsPerAdvisory = /* @__PURE__ */ new Map();
|
|
577
|
+
for (const advisory of advisories) {
|
|
578
|
+
for (const callSite of lock.callSites) {
|
|
579
|
+
if (!endpointMatches(callSite, advisory)) continue;
|
|
580
|
+
hitsPerAdvisory.set(advisory.id, (hitsPerAdvisory.get(advisory.id) ?? 0) + 1);
|
|
581
|
+
const reasons = triggerReasons(callSite, advisory.trigger);
|
|
582
|
+
if (reasons.length > 0) affected.push({ advisory, callSite, reasons });
|
|
583
|
+
else exposed.push({ advisory, callSite, reasons: [`calls ${describe(callSite)} but does not depend on the changed behavior`] });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const present = new Set(lock.providers.map((p) => p.id));
|
|
587
|
+
const today = Math.floor(now.getTime() / DAY_MS) * DAY_MS;
|
|
588
|
+
const upcoming = advisories.map((advisory) => ({
|
|
589
|
+
advisory,
|
|
590
|
+
daysUntil: Math.round((Date.parse(advisory.effectiveDate) - today) / DAY_MS),
|
|
591
|
+
callSites: hitsPerAdvisory.get(advisory.id) ?? 0
|
|
592
|
+
})).filter((u) => present.has(u.advisory.provider) && u.daysUntil >= 0 && u.daysUntil <= horizon).sort((a, b) => a.daysUntil - b.daysUntil || cmp2(a.advisory.id, b.advisory.id));
|
|
593
|
+
return { affected: affected.sort(byFinding), exposed: exposed.sort(byFinding), upcoming };
|
|
594
|
+
}
|
|
595
|
+
function endpointMatches(site, advisory) {
|
|
596
|
+
const pattern = advisory.endpoint;
|
|
597
|
+
if (site.provider !== advisory.provider || site.endpoint === null) return false;
|
|
598
|
+
if (pattern.method !== "*" && site.method !== pattern.method) return false;
|
|
599
|
+
if (pattern.hosts && !(site.host && pattern.hosts.includes(site.host))) return false;
|
|
600
|
+
return pathMatches(site.endpoint, pattern);
|
|
601
|
+
}
|
|
602
|
+
function pathMatches(endpoint, pattern) {
|
|
603
|
+
const sample = endpoint.replace(/\{[^}]*\}/g, "\0");
|
|
604
|
+
let re = "";
|
|
605
|
+
const src = pattern.path;
|
|
606
|
+
for (let i = 0; i < src.length; ) {
|
|
607
|
+
if (src.startsWith("/**", i)) {
|
|
608
|
+
re += "(?:/.*)?";
|
|
609
|
+
i += 3;
|
|
610
|
+
} else if (src.startsWith("**", i)) {
|
|
611
|
+
re += ".*";
|
|
612
|
+
i += 2;
|
|
613
|
+
} else if (src[i] === "*") {
|
|
614
|
+
re += "[^/]*";
|
|
615
|
+
i += 1;
|
|
616
|
+
} else if (src[i] === "{") {
|
|
617
|
+
const end = src.indexOf("}", i);
|
|
618
|
+
re += "[^/]+";
|
|
619
|
+
i = end < 0 ? src.length : end + 1;
|
|
620
|
+
} else {
|
|
621
|
+
re += src[i].replace(/[.+?^$()|[\]\\]/g, "\\$&");
|
|
622
|
+
i += 1;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return new RegExp(`^${re}/?$`).test(sample);
|
|
626
|
+
}
|
|
627
|
+
function triggerReasons(site, trigger) {
|
|
628
|
+
const s = site.surface;
|
|
629
|
+
const reasons = [];
|
|
630
|
+
const handlesNew = (trigger.newStatusCodes ?? []).length > 0 && trigger.newStatusCodes.every((c) => s.statusCodes.includes(c));
|
|
631
|
+
for (const code of trigger.statusCodes ?? []) {
|
|
632
|
+
if (s.statusCodes.includes(code) && !handlesNew) reasons.push(`compares the response status to ${code}`);
|
|
633
|
+
}
|
|
634
|
+
for (const field of trigger.fields ?? []) {
|
|
635
|
+
const read = s.fields.filter((f) => f === field || f.startsWith(`${field}.`) || f.startsWith(`${field}[]`));
|
|
636
|
+
if (read.length) reasons.push(`reads ${read.join(", ")}`);
|
|
637
|
+
}
|
|
638
|
+
for (const { field } of trigger.enums ?? []) {
|
|
639
|
+
const e = s.enums.find((x) => x.field === field);
|
|
640
|
+
if (e) reasons.push(`compares ${field} to ${e.values.map((v) => JSON.stringify(v)).join(", ")}`);
|
|
641
|
+
}
|
|
642
|
+
for (const h of trigger.headers ?? []) {
|
|
643
|
+
const set = s.headers.find((x) => x.name === h.name.toLowerCase());
|
|
644
|
+
if (set && (!h.values || set.value !== null && h.values.includes(set.value))) reasons.push(`sends ${set.name}: ${set.value ?? "(dynamic)"}`);
|
|
645
|
+
}
|
|
646
|
+
for (const q of trigger.query ?? []) {
|
|
647
|
+
const p = s.query.find((x) => x.name === q.name);
|
|
648
|
+
if (!p) continue;
|
|
649
|
+
if (q.below === void 0) reasons.push(`sends ${p.name}=${p.value ?? "(dynamic)"}`);
|
|
650
|
+
else if (p.value !== null && Number(p.value) < q.below) reasons.push(`sends ${p.name}=${p.value} (below ${q.below})`);
|
|
651
|
+
}
|
|
652
|
+
if (trigger.any) reasons.push(`calls ${describe(site)}`);
|
|
653
|
+
return reasons;
|
|
654
|
+
}
|
|
655
|
+
function describe(site) {
|
|
656
|
+
return `${site.method ?? "?"} ${site.endpoint ?? "?"}`;
|
|
657
|
+
}
|
|
658
|
+
function byFinding(a, b) {
|
|
659
|
+
return SEVERITY_RANK[a.advisory.severity] - SEVERITY_RANK[b.advisory.severity] || cmp2(a.callSite.file, b.callSite.file) || a.callSite.line - b.callSite.line || cmp2(a.advisory.id, b.advisory.id);
|
|
660
|
+
}
|
|
661
|
+
function cmp2(a, b) {
|
|
662
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// packages/extractor/src/index.ts
|
|
666
|
+
import path4 from "path";
|
|
667
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
668
|
+
import { Project, ScriptKind } from "ts-morph";
|
|
669
|
+
|
|
670
|
+
// packages/extractor/src/callsites.ts
|
|
671
|
+
import { Node, SyntaxKind } from "ts-morph";
|
|
672
|
+
function emptySurface() {
|
|
673
|
+
return { fields: [], statusCodes: [], checksOk: false, enums: [], headers: [], query: [], responseHeaders: [] };
|
|
674
|
+
}
|
|
675
|
+
var HTTP_MODULES = {
|
|
676
|
+
"node-fetch": "node-fetch",
|
|
677
|
+
"cross-fetch": "node-fetch",
|
|
678
|
+
"isomorphic-fetch": "node-fetch",
|
|
679
|
+
axios: "axios",
|
|
680
|
+
got: "got",
|
|
681
|
+
ky: "ky",
|
|
682
|
+
undici: "undici"
|
|
683
|
+
};
|
|
684
|
+
var VERBS = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
|
|
685
|
+
function extractFromFile(sf, file) {
|
|
686
|
+
const ctx = { sf, imports: /* @__PURE__ */ new Map(), consts: /* @__PURE__ */ new Map(), http: /* @__PURE__ */ new Map(), sdk: /* @__PURE__ */ new Map() };
|
|
687
|
+
collectImports(ctx);
|
|
688
|
+
collectConsts(ctx);
|
|
689
|
+
collectClients(ctx);
|
|
690
|
+
const found = [];
|
|
691
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
692
|
+
const hit = matchHttp(ctx, call) ?? matchSdk(ctx, call);
|
|
693
|
+
if (!hit) continue;
|
|
694
|
+
const { line, column } = sf.getLineAndColumnAtPos(call.getStart());
|
|
695
|
+
hit.site.file = file;
|
|
696
|
+
hit.site.line = line;
|
|
697
|
+
hit.site.column = column;
|
|
698
|
+
found.push(hit);
|
|
699
|
+
}
|
|
700
|
+
return found;
|
|
701
|
+
}
|
|
702
|
+
function collectImports(ctx) {
|
|
703
|
+
for (const decl of ctx.sf.getImportDeclarations()) {
|
|
704
|
+
const module = decl.getModuleSpecifierValue();
|
|
705
|
+
const def = decl.getDefaultImport();
|
|
706
|
+
if (def) ctx.imports.set(def.getText(), { module, imported: "default" });
|
|
707
|
+
const ns = decl.getNamespaceImport();
|
|
708
|
+
if (ns) ctx.imports.set(ns.getText(), { module, imported: "*" });
|
|
709
|
+
for (const named of decl.getNamedImports()) {
|
|
710
|
+
const local = named.getAliasNode()?.getText() ?? named.getName();
|
|
711
|
+
ctx.imports.set(local, { module, imported: named.getName() });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
for (const decl of ctx.sf.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
715
|
+
const module = requiredModule(decl.getInitializer());
|
|
716
|
+
if (!module) continue;
|
|
717
|
+
const name = decl.getNameNode();
|
|
718
|
+
if (Node.isIdentifier(name)) ctx.imports.set(name.getText(), { module, imported: "default" });
|
|
719
|
+
else if (Node.isObjectBindingPattern(name)) {
|
|
720
|
+
for (const el of name.getElements()) {
|
|
721
|
+
const imported = el.getPropertyNameNode()?.getText() ?? el.getName();
|
|
722
|
+
ctx.imports.set(el.getName(), { module, imported });
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
function requiredModule(node) {
|
|
728
|
+
if (!node || !Node.isCallExpression(node)) return void 0;
|
|
729
|
+
const callee = node.getExpression();
|
|
730
|
+
const arg = node.getArguments()[0];
|
|
731
|
+
if (Node.isIdentifier(callee) && callee.getText() === "require" && arg && Node.isStringLiteral(arg)) {
|
|
732
|
+
return arg.getLiteralValue();
|
|
733
|
+
}
|
|
734
|
+
return void 0;
|
|
735
|
+
}
|
|
736
|
+
function collectConsts(ctx) {
|
|
737
|
+
for (const decl of ctx.sf.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
738
|
+
const list = decl.getVariableStatement()?.getDeclarationKind();
|
|
739
|
+
const init = decl.getInitializer();
|
|
740
|
+
if (list === "const" && init && Node.isIdentifier(decl.getNameNode())) ctx.consts.set(decl.getName(), init);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
function collectClients(ctx) {
|
|
744
|
+
const bind = (key, init) => {
|
|
745
|
+
if (!init) return;
|
|
746
|
+
const expr = unwrap(init);
|
|
747
|
+
if (!Node.isNewExpression(expr) && !Node.isCallExpression(expr)) return;
|
|
748
|
+
const callee = expr.getExpression();
|
|
749
|
+
const args = expr.getArguments();
|
|
750
|
+
if (Node.isPropertyAccessExpression(callee)) {
|
|
751
|
+
const lib = httpLibOf(ctx, callee.getExpression());
|
|
752
|
+
if (lib && ["create", "extend"].includes(callee.getName())) {
|
|
753
|
+
const opts = args[0];
|
|
754
|
+
const base = Node.isObjectLiteralExpression(opts) ? evalString(ctx, propertyValue(opts, "baseURL")) ?? evalString(ctx, propertyValue(opts, "prefixUrl")) : void 0;
|
|
755
|
+
ctx.http.set(key, { lib, baseURL: base });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
const module = moduleOfCallee(ctx, callee);
|
|
760
|
+
const provider = module ? providerForPackage(module) : void 0;
|
|
761
|
+
if (module && provider) {
|
|
762
|
+
const options = [...args].reverse().find((a) => Node.isObjectLiteralExpression(a));
|
|
763
|
+
ctx.sdk.set(key, { provider, pkg: module, options });
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
for (const decl of ctx.sf.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
767
|
+
if (Node.isIdentifier(decl.getNameNode())) bind(decl.getName(), decl.getInitializer());
|
|
768
|
+
}
|
|
769
|
+
for (const prop of ctx.sf.getDescendantsOfKind(SyntaxKind.PropertyDeclaration)) {
|
|
770
|
+
bind(`this.${prop.getName()}`, prop.getInitializer());
|
|
771
|
+
}
|
|
772
|
+
for (const bin of ctx.sf.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
|
|
773
|
+
if (bin.getOperatorToken().getKind() !== SyntaxKind.EqualsToken) continue;
|
|
774
|
+
const left = bin.getLeft();
|
|
775
|
+
if (Node.isPropertyAccessExpression(left) && left.getExpression().getKind() === SyntaxKind.ThisKeyword) {
|
|
776
|
+
bind(`this.${left.getName()}`, bin.getRight());
|
|
777
|
+
} else if (Node.isIdentifier(left)) {
|
|
778
|
+
bind(left.getText(), bin.getRight());
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
function moduleOfCallee(ctx, callee) {
|
|
783
|
+
const root = chainOf(callee)?.root;
|
|
784
|
+
if (root) return ctx.imports.get(root)?.module;
|
|
785
|
+
return requiredModule(callee);
|
|
786
|
+
}
|
|
787
|
+
function httpLibOf(ctx, node) {
|
|
788
|
+
if (!Node.isIdentifier(node)) return void 0;
|
|
789
|
+
const ref = ctx.imports.get(node.getText());
|
|
790
|
+
if (ref) {
|
|
791
|
+
const lib = HTTP_MODULES[ref.module];
|
|
792
|
+
if (lib === "undici") return ref.imported === "*" ? "undici" : void 0;
|
|
793
|
+
return lib && (ref.imported === "default" || ref.imported === "*") ? lib : void 0;
|
|
794
|
+
}
|
|
795
|
+
return void 0;
|
|
796
|
+
}
|
|
797
|
+
function matchHttp(ctx, call) {
|
|
798
|
+
const callee = call.getExpression();
|
|
799
|
+
const args = call.getArguments();
|
|
800
|
+
if (Node.isIdentifier(callee)) {
|
|
801
|
+
const name = callee.getText();
|
|
802
|
+
const ref = ctx.imports.get(name);
|
|
803
|
+
if (!ref && name === "fetch") return fetchStyle(ctx, call, "fetch", args[0], args[1]);
|
|
804
|
+
if (ref) {
|
|
805
|
+
const lib = HTTP_MODULES[ref.module];
|
|
806
|
+
if (lib === "node-fetch") return fetchStyle(ctx, call, lib, args[0], args[1]);
|
|
807
|
+
if (lib === "undici" && ref.imported === "fetch") return fetchStyle(ctx, call, lib, args[0], args[1]);
|
|
808
|
+
if (lib === "undici" && ref.imported === "request") return fetchStyle(ctx, call, lib, args[0], args[1]);
|
|
809
|
+
if (lib === "axios" && ref.imported === "default") return axiosStyle(ctx, call, void 0, void 0);
|
|
810
|
+
if ((lib === "got" || lib === "ky") && ref.imported === "default") return fetchStyle(ctx, call, lib, args[0], args[1]);
|
|
811
|
+
}
|
|
812
|
+
const inst = ctx.http.get(name);
|
|
813
|
+
if (inst?.lib === "axios") return axiosStyle(ctx, call, void 0, inst.baseURL);
|
|
814
|
+
if (inst) return fetchStyle(ctx, call, inst.lib, args[0], args[1], void 0, inst.baseURL);
|
|
815
|
+
return void 0;
|
|
816
|
+
}
|
|
817
|
+
if (Node.isPropertyAccessExpression(callee)) {
|
|
818
|
+
const target = callee.getExpression();
|
|
819
|
+
const member = callee.getName();
|
|
820
|
+
const lib = httpLibOf(ctx, target);
|
|
821
|
+
const inst = Node.isIdentifier(target) ? ctx.http.get(target.getText()) : void 0;
|
|
822
|
+
const effective = lib ?? inst?.lib;
|
|
823
|
+
if (!effective) return void 0;
|
|
824
|
+
const base = inst?.baseURL;
|
|
825
|
+
if (effective === "undici") {
|
|
826
|
+
return member === "fetch" || member === "request" ? fetchStyle(ctx, call, "undici", args[0], args[1]) : void 0;
|
|
827
|
+
}
|
|
828
|
+
if (effective === "axios") {
|
|
829
|
+
if (member === "request") return axiosStyle(ctx, call, void 0, base);
|
|
830
|
+
if (VERBS.has(member)) return axiosStyle(ctx, call, member.toUpperCase(), base);
|
|
831
|
+
return void 0;
|
|
832
|
+
}
|
|
833
|
+
if (VERBS.has(member)) {
|
|
834
|
+
return fetchStyle(ctx, call, effective, args[0], args[1], member.toUpperCase(), base);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return void 0;
|
|
838
|
+
}
|
|
839
|
+
function fetchStyle(ctx, call, lib, urlArg, optsArg, verb, baseURL) {
|
|
840
|
+
const opts = resolveObject(ctx, optsArg);
|
|
841
|
+
let method = verb ?? "GET";
|
|
842
|
+
if (!verb && optsArg) {
|
|
843
|
+
if (opts) method = methodFrom(ctx, propertyValue(opts, "method")) ?? (propertyValue(opts, "method") ? null : "GET");
|
|
844
|
+
else method = null;
|
|
845
|
+
}
|
|
846
|
+
return httpSite(ctx, call, lib, joinUrl(baseURL, evalString(ctx, urlArg)), method, opts ? optsArg : void 0);
|
|
847
|
+
}
|
|
848
|
+
function axiosStyle(ctx, call, verb, baseURL) {
|
|
849
|
+
const args = call.getArguments();
|
|
850
|
+
const first = args[0];
|
|
851
|
+
const firstObj = resolveObject(ctx, first);
|
|
852
|
+
if (!verb && firstObj && !isStringish(first)) {
|
|
853
|
+
const url = evalString(ctx, propertyValue(firstObj, "url"));
|
|
854
|
+
const base = evalString(ctx, propertyValue(firstObj, "baseURL")) ?? baseURL;
|
|
855
|
+
const method2 = methodFrom(ctx, propertyValue(firstObj, "method")) ?? (propertyValue(firstObj, "method") ? null : "GET");
|
|
856
|
+
const params2 = resolveObject(ctx, propertyValue(firstObj, "params"));
|
|
857
|
+
return httpSite(ctx, call, "axios", joinUrl(base, url), method2, first, params2);
|
|
858
|
+
}
|
|
859
|
+
const hasBody = verb === "POST" || verb === "PUT" || verb === "PATCH";
|
|
860
|
+
const config = hasBody ? args[2] : args[1];
|
|
861
|
+
const cfg = resolveObject(ctx, config);
|
|
862
|
+
let method = verb ?? "GET";
|
|
863
|
+
if (!verb && cfg) method = methodFrom(ctx, propertyValue(cfg, "method")) ?? "GET";
|
|
864
|
+
const params = cfg ? resolveObject(ctx, propertyValue(cfg, "params")) : void 0;
|
|
865
|
+
return httpSite(ctx, call, "axios", joinUrl(baseURL, evalString(ctx, first)), method, config, params);
|
|
866
|
+
}
|
|
867
|
+
function httpSite(ctx, call, lib, url, method, options, params) {
|
|
868
|
+
const { host, path: path13, query } = parseUrl(url);
|
|
869
|
+
const surface = emptySurface();
|
|
870
|
+
surface.headers = headersOf(ctx, options);
|
|
871
|
+
surface.query = [...query, ...objectEntries(ctx, params)];
|
|
872
|
+
const provider = host ? providerForHost(host)?.id ?? null : null;
|
|
873
|
+
return {
|
|
874
|
+
call,
|
|
875
|
+
options,
|
|
876
|
+
site: {
|
|
877
|
+
file: "",
|
|
878
|
+
line: 0,
|
|
879
|
+
column: 0,
|
|
880
|
+
provider,
|
|
881
|
+
kind: "http",
|
|
882
|
+
client: lib,
|
|
883
|
+
method,
|
|
884
|
+
host,
|
|
885
|
+
endpoint: path13,
|
|
886
|
+
resolved: Boolean(provider && path13 && method),
|
|
887
|
+
surface
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
function matchSdk(ctx, call) {
|
|
892
|
+
const chain = chainOf(call.getExpression());
|
|
893
|
+
if (!chain || chain.names.length === 0) return void 0;
|
|
894
|
+
let client = ctx.sdk.get(chain.root);
|
|
895
|
+
if (!client) {
|
|
896
|
+
const ref = ctx.imports.get(chain.root);
|
|
897
|
+
const provider = ref ? providerForPackage(ref.module) : void 0;
|
|
898
|
+
if (!ref || !provider || ref.imported !== "default") return void 0;
|
|
899
|
+
client = { provider, pkg: ref.module };
|
|
900
|
+
}
|
|
901
|
+
const operation = chain.names.join(".");
|
|
902
|
+
const args = call.getArguments();
|
|
903
|
+
let method = null;
|
|
904
|
+
let endpoint = null;
|
|
905
|
+
let options = args[0];
|
|
906
|
+
const route = operation === "request" ? evalString(ctx, args[0]) : void 0;
|
|
907
|
+
if (route) {
|
|
908
|
+
const m = /^([A-Z]+)\s+(\S+)$/.exec(route.trim());
|
|
909
|
+
if (m && HTTP_METHODS.includes(m[1])) {
|
|
910
|
+
method = m[1];
|
|
911
|
+
endpoint = parseUrl(m[2]).path;
|
|
912
|
+
} else if (route.startsWith("/")) {
|
|
913
|
+
method = "GET";
|
|
914
|
+
endpoint = parseUrl(route).path;
|
|
915
|
+
}
|
|
916
|
+
options = args[1];
|
|
917
|
+
} else if (operation === "graphql") {
|
|
918
|
+
method = "POST";
|
|
919
|
+
endpoint = "/graphql";
|
|
920
|
+
} else {
|
|
921
|
+
const ep = sdkEndpoint(client.provider, operation);
|
|
922
|
+
if (ep) {
|
|
923
|
+
method = ep.method;
|
|
924
|
+
endpoint = ep.path;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
const host = client.provider.hosts.find((h) => !h.startsWith("*.")) ?? null;
|
|
928
|
+
const surface = emptySurface();
|
|
929
|
+
surface.headers = [...headersOf(ctx, client.options, "defaultHeaders"), ...headersOf(ctx, options)];
|
|
930
|
+
const apiVersion = client.options ? propertyValue(client.options, "apiVersion") : void 0;
|
|
931
|
+
if (apiVersion) surface.headers.push({ name: `${client.provider.id}-version`, value: evalString(ctx, apiVersion) ?? null });
|
|
932
|
+
surface.headers.sort((a, b) => a.name.localeCompare(b.name));
|
|
933
|
+
return {
|
|
934
|
+
call,
|
|
935
|
+
options,
|
|
936
|
+
clientOptions: client.options,
|
|
937
|
+
site: {
|
|
938
|
+
file: "",
|
|
939
|
+
line: 0,
|
|
940
|
+
column: 0,
|
|
941
|
+
provider: client.provider.id,
|
|
942
|
+
kind: "sdk",
|
|
943
|
+
client: client.pkg,
|
|
944
|
+
operation,
|
|
945
|
+
method,
|
|
946
|
+
host,
|
|
947
|
+
endpoint,
|
|
948
|
+
resolved: Boolean(method && endpoint),
|
|
949
|
+
surface
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
function chainOf(node) {
|
|
954
|
+
const names = [];
|
|
955
|
+
let cur = node;
|
|
956
|
+
while (Node.isPropertyAccessExpression(cur)) {
|
|
957
|
+
names.unshift(cur.getName());
|
|
958
|
+
const inner = cur.getExpression();
|
|
959
|
+
if (inner.getKind() === SyntaxKind.ThisKeyword) {
|
|
960
|
+
const first = names.shift();
|
|
961
|
+
return { root: `this.${first}`, names };
|
|
962
|
+
}
|
|
963
|
+
cur = inner;
|
|
964
|
+
}
|
|
965
|
+
if (Node.isIdentifier(cur)) return { root: cur.getText(), names };
|
|
966
|
+
return void 0;
|
|
967
|
+
}
|
|
968
|
+
function unwrap(node) {
|
|
969
|
+
let cur = node;
|
|
970
|
+
for (; ; ) {
|
|
971
|
+
if (Node.isAwaitExpression(cur) || Node.isParenthesizedExpression(cur) || Node.isAsExpression(cur) || Node.isNonNullExpression(cur)) {
|
|
972
|
+
cur = cur.getExpression();
|
|
973
|
+
} else if (Node.isSatisfiesExpression(cur)) {
|
|
974
|
+
cur = cur.getExpression();
|
|
975
|
+
} else {
|
|
976
|
+
return cur;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
function propertyValue(obj, name) {
|
|
981
|
+
if (!Node.isObjectLiteralExpression(obj)) return void 0;
|
|
982
|
+
for (const prop of obj.getProperties()) {
|
|
983
|
+
if (Node.isPropertyAssignment(prop) && propName(prop.getNameNode()) === name) return prop.getInitializer();
|
|
984
|
+
if (Node.isShorthandPropertyAssignment(prop) && prop.getName() === name) return prop.getNameNode();
|
|
985
|
+
}
|
|
986
|
+
return void 0;
|
|
987
|
+
}
|
|
988
|
+
function propName(node) {
|
|
989
|
+
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) return node.getLiteralValue();
|
|
990
|
+
return node.getText();
|
|
991
|
+
}
|
|
992
|
+
function headersOf(ctx, options, key = "headers") {
|
|
993
|
+
const obj = resolveObject(ctx, options);
|
|
994
|
+
return obj ? objectEntries(ctx, resolveObject(ctx, propertyValue(obj, key))).map((h) => ({ ...h, name: h.name.toLowerCase() })) : [];
|
|
995
|
+
}
|
|
996
|
+
function objectEntries(ctx, obj) {
|
|
997
|
+
if (!obj || !Node.isObjectLiteralExpression(obj)) return [];
|
|
998
|
+
const out = [];
|
|
999
|
+
for (const prop of obj.getProperties()) {
|
|
1000
|
+
if (Node.isPropertyAssignment(prop)) {
|
|
1001
|
+
out.push({ name: propName(prop.getNameNode()), value: evalString(ctx, prop.getInitializer()) ?? null });
|
|
1002
|
+
} else if (Node.isShorthandPropertyAssignment(prop)) {
|
|
1003
|
+
out.push({ name: prop.getName(), value: evalString(ctx, prop.getNameNode()) ?? null });
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
return out;
|
|
1007
|
+
}
|
|
1008
|
+
function resolveObject(ctx, node) {
|
|
1009
|
+
if (!node) return void 0;
|
|
1010
|
+
const expr = unwrap(node);
|
|
1011
|
+
if (Node.isObjectLiteralExpression(expr)) return expr;
|
|
1012
|
+
if (Node.isIdentifier(expr)) {
|
|
1013
|
+
const init = ctx.consts.get(expr.getText());
|
|
1014
|
+
if (init && Node.isObjectLiteralExpression(unwrap(init))) return unwrap(init);
|
|
1015
|
+
}
|
|
1016
|
+
return void 0;
|
|
1017
|
+
}
|
|
1018
|
+
function isStringish(node) {
|
|
1019
|
+
if (!node) return false;
|
|
1020
|
+
const expr = unwrap(node);
|
|
1021
|
+
return Node.isStringLiteral(expr) || Node.isTemplateExpression(expr) || Node.isNoSubstitutionTemplateLiteral(expr);
|
|
1022
|
+
}
|
|
1023
|
+
function methodFrom(ctx, node) {
|
|
1024
|
+
const value2 = evalString(ctx, node)?.toUpperCase();
|
|
1025
|
+
return value2 && HTTP_METHODS.includes(value2) ? value2 : void 0;
|
|
1026
|
+
}
|
|
1027
|
+
function evalString(ctx, node, depth = 0) {
|
|
1028
|
+
if (!node || depth > 8) return void 0;
|
|
1029
|
+
const expr = unwrap(node);
|
|
1030
|
+
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) return expr.getLiteralValue();
|
|
1031
|
+
if (Node.isNumericLiteral(expr)) return expr.getLiteralText();
|
|
1032
|
+
if (Node.isTemplateExpression(expr)) {
|
|
1033
|
+
let out = expr.getHead().getLiteralText();
|
|
1034
|
+
for (const span of expr.getTemplateSpans()) {
|
|
1035
|
+
const inner = span.getExpression();
|
|
1036
|
+
out += evalString(ctx, inner, depth + 1) ?? placeholder(inner);
|
|
1037
|
+
out += span.getLiteral().getLiteralText();
|
|
1038
|
+
}
|
|
1039
|
+
return out;
|
|
1040
|
+
}
|
|
1041
|
+
if (Node.isBinaryExpression(expr)) {
|
|
1042
|
+
const op = expr.getOperatorToken().getKind();
|
|
1043
|
+
if (op === SyntaxKind.PlusToken) {
|
|
1044
|
+
const l = evalString(ctx, expr.getLeft(), depth + 1);
|
|
1045
|
+
const r = evalString(ctx, expr.getRight(), depth + 1);
|
|
1046
|
+
if (l === void 0 && r === void 0) return void 0;
|
|
1047
|
+
return (l ?? placeholder(expr.getLeft())) + (r ?? placeholder(expr.getRight()));
|
|
1048
|
+
}
|
|
1049
|
+
if (op === SyntaxKind.QuestionQuestionToken || op === SyntaxKind.BarBarToken) {
|
|
1050
|
+
return evalString(ctx, expr.getLeft(), depth + 1) ?? evalString(ctx, expr.getRight(), depth + 1);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (Node.isIdentifier(expr)) {
|
|
1054
|
+
const init = ctx.consts.get(expr.getText());
|
|
1055
|
+
return init && init !== node ? evalString(ctx, init, depth + 1) : void 0;
|
|
1056
|
+
}
|
|
1057
|
+
if (Node.isNewExpression(expr) && expr.getExpression().getText() === "URL") {
|
|
1058
|
+
const [p, base] = expr.getArguments();
|
|
1059
|
+
const pathPart = evalString(ctx, p, depth + 1);
|
|
1060
|
+
const basePart = evalString(ctx, base, depth + 1);
|
|
1061
|
+
return pathPart !== void 0 && basePart !== void 0 ? joinUrl(basePart, pathPart) : pathPart ?? void 0;
|
|
1062
|
+
}
|
|
1063
|
+
return void 0;
|
|
1064
|
+
}
|
|
1065
|
+
function placeholder(node) {
|
|
1066
|
+
const expr = unwrap(node);
|
|
1067
|
+
if (Node.isIdentifier(expr)) return `{${expr.getText()}}`;
|
|
1068
|
+
if (Node.isPropertyAccessExpression(expr)) return `{${expr.getName()}}`;
|
|
1069
|
+
if (Node.isCallExpression(expr) && expr.getArguments().length === 1) return placeholder(expr.getArguments()[0]);
|
|
1070
|
+
return "{}";
|
|
1071
|
+
}
|
|
1072
|
+
function joinUrl(base, url) {
|
|
1073
|
+
if (url === void 0) return void 0;
|
|
1074
|
+
if (!base || /^[a-z]+:\/\//i.test(url)) return url;
|
|
1075
|
+
return base.replace(/\/+$/, "") + "/" + url.replace(/^\/+/, "");
|
|
1076
|
+
}
|
|
1077
|
+
function parseUrl(url) {
|
|
1078
|
+
if (url === void 0) return { host: null, path: null, query: [] };
|
|
1079
|
+
let host = null;
|
|
1080
|
+
let rest = url;
|
|
1081
|
+
const m = /^(?:[a-z]+:)?\/\/([^/?#]+)(.*)$/i.exec(url);
|
|
1082
|
+
if (m) {
|
|
1083
|
+
host = m[1].includes("{") ? null : m[1].toLowerCase();
|
|
1084
|
+
rest = m[2] || "/";
|
|
1085
|
+
} else if (!url.startsWith("/")) {
|
|
1086
|
+
const slash = url.indexOf("/");
|
|
1087
|
+
rest = slash >= 0 ? url.slice(slash) : "/";
|
|
1088
|
+
if (slash < 0) return { host: null, path: null, query: [] };
|
|
1089
|
+
}
|
|
1090
|
+
let path13 = rest.split(/[?#]/)[0].replace(/\/{2,}/g, "/");
|
|
1091
|
+
if (path13.length > 1) path13 = path13.replace(/\/$/, "");
|
|
1092
|
+
const search = /\?([^#]*)/.exec(rest)?.[1] ?? "";
|
|
1093
|
+
const query = search.split("&").filter(Boolean).map((pair) => {
|
|
1094
|
+
const [name, ...value2] = pair.split("=");
|
|
1095
|
+
return { name, value: value2.length ? value2.join("=") : null };
|
|
1096
|
+
});
|
|
1097
|
+
return { host, path: path13 || "/", query };
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// packages/extractor/src/surface.ts
|
|
1101
|
+
import { Node as Node2, SyntaxKind as SyntaxKind2 } from "ts-morph";
|
|
1102
|
+
var NOT_FIELDS = /* @__PURE__ */ new Set([
|
|
1103
|
+
"length",
|
|
1104
|
+
"map",
|
|
1105
|
+
"filter",
|
|
1106
|
+
"forEach",
|
|
1107
|
+
"find",
|
|
1108
|
+
"findIndex",
|
|
1109
|
+
"some",
|
|
1110
|
+
"every",
|
|
1111
|
+
"includes",
|
|
1112
|
+
"indexOf",
|
|
1113
|
+
"reduce",
|
|
1114
|
+
"slice",
|
|
1115
|
+
"join",
|
|
1116
|
+
"concat",
|
|
1117
|
+
"flatMap",
|
|
1118
|
+
"sort",
|
|
1119
|
+
"keys",
|
|
1120
|
+
"values",
|
|
1121
|
+
"entries",
|
|
1122
|
+
"at",
|
|
1123
|
+
"push",
|
|
1124
|
+
"toString",
|
|
1125
|
+
"toLowerCase",
|
|
1126
|
+
"toUpperCase",
|
|
1127
|
+
"trim",
|
|
1128
|
+
"split",
|
|
1129
|
+
"startsWith",
|
|
1130
|
+
"endsWith",
|
|
1131
|
+
"replace",
|
|
1132
|
+
"match",
|
|
1133
|
+
"then",
|
|
1134
|
+
"catch",
|
|
1135
|
+
"finally",
|
|
1136
|
+
"valueOf",
|
|
1137
|
+
"toJSON"
|
|
1138
|
+
]);
|
|
1139
|
+
var MAX_DEPTH = 4;
|
|
1140
|
+
var Collector = class {
|
|
1141
|
+
fields = /* @__PURE__ */ new Set();
|
|
1142
|
+
statusCodes = /* @__PURE__ */ new Set();
|
|
1143
|
+
checksOk = false;
|
|
1144
|
+
enums = /* @__PURE__ */ new Map();
|
|
1145
|
+
responseHeaders = /* @__PURE__ */ new Set();
|
|
1146
|
+
addEnum(field, value2) {
|
|
1147
|
+
const set = this.enums.get(field) ?? /* @__PURE__ */ new Set();
|
|
1148
|
+
set.add(value2);
|
|
1149
|
+
this.enums.set(field, set);
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1152
|
+
function addResponseSurface(found) {
|
|
1153
|
+
const c = new Collector();
|
|
1154
|
+
bindFrom(found.call, { space: "response", path: [] }, envelopeOf(found), c, 0);
|
|
1155
|
+
const surface = found.site.surface;
|
|
1156
|
+
surface.fields = [...c.fields].sort();
|
|
1157
|
+
surface.statusCodes = [...c.statusCodes].sort((a, b) => a - b);
|
|
1158
|
+
surface.checksOk = c.checksOk;
|
|
1159
|
+
surface.enums = [...c.enums].map(([field, values]) => ({ field, values: [...values].sort() })).sort((a, b) => a.field.localeCompare(b.field));
|
|
1160
|
+
surface.responseHeaders = [...c.responseHeaders].sort();
|
|
1161
|
+
}
|
|
1162
|
+
function envelopeOf({ site, call }) {
|
|
1163
|
+
if (site.kind === "sdk") return site.provider === "github" ? "axios" : "body";
|
|
1164
|
+
switch (site.client) {
|
|
1165
|
+
case "axios":
|
|
1166
|
+
return "axios";
|
|
1167
|
+
case "got":
|
|
1168
|
+
return "got";
|
|
1169
|
+
case "undici":
|
|
1170
|
+
return /request$/.test(call.getExpression().getText()) ? "undici" : "fetch";
|
|
1171
|
+
default:
|
|
1172
|
+
return "fetch";
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
function bindFrom(expr, loc, env, c, depth) {
|
|
1176
|
+
if (depth > MAX_DEPTH) return;
|
|
1177
|
+
let cur = expr;
|
|
1178
|
+
for (; ; ) {
|
|
1179
|
+
const p = cur.getParent();
|
|
1180
|
+
if (p && (Node2.isAwaitExpression(p) || Node2.isParenthesizedExpression(p) || Node2.isAsExpression(p) || Node2.isNonNullExpression(p) || Node2.isSatisfiesExpression(p))) {
|
|
1181
|
+
cur = p;
|
|
1182
|
+
} else break;
|
|
1183
|
+
}
|
|
1184
|
+
const parent = cur.getParent();
|
|
1185
|
+
if (!parent) return;
|
|
1186
|
+
if (Node2.isPropertyAccessExpression(parent) && parent.getExpression() === cur && parent.getName() === "then") {
|
|
1187
|
+
const thenCall = parent.getParent();
|
|
1188
|
+
const cb = Node2.isCallExpression(thenCall) ? thenCall.getArguments()[0] : void 0;
|
|
1189
|
+
if (cb && (Node2.isArrowFunction(cb) || Node2.isFunctionExpression(cb))) {
|
|
1190
|
+
const param = cb.getParameters()[0];
|
|
1191
|
+
if (param) bindName(param.getNameNode(), loc, param, env, c, depth);
|
|
1192
|
+
}
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
if (Node2.isVariableDeclaration(parent) && parent.getInitializer() === cur) {
|
|
1196
|
+
bindName(parent.getNameNode(), loc, parent, env, c, depth);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (Node2.isBinaryExpression(parent) && parent.getRight() === cur && parent.getOperatorToken().getKind() === SyntaxKind2.EqualsToken) {
|
|
1200
|
+
const left = parent.getLeft();
|
|
1201
|
+
if (Node2.isIdentifier(left)) bindName(left, loc, parent, env, c, depth);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
use(cur, loc, env, c, depth);
|
|
1205
|
+
}
|
|
1206
|
+
function bindName(name, loc, decl, env, c, depth) {
|
|
1207
|
+
if (Node2.isIdentifier(name)) {
|
|
1208
|
+
for (const ref of referencesOf(name.getText(), decl)) use(ref, loc, env, c, depth);
|
|
1209
|
+
} else if (Node2.isObjectBindingPattern(name)) {
|
|
1210
|
+
for (const el of name.getElements()) {
|
|
1211
|
+
const prop = el.getPropertyNameNode()?.getText() ?? el.getName();
|
|
1212
|
+
bindName(el.getNameNode(), { ...loc, path: [...loc.path, prop] }, decl, env, c, depth);
|
|
1213
|
+
interpret(env, { ...loc, path: [...loc.path, prop] }, [], el, c, depth, false);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
function referencesOf(name, decl) {
|
|
1218
|
+
const scope = decl.getFirstAncestor(
|
|
1219
|
+
(n) => Node2.isBlock(n) || Node2.isSourceFile(n) || Node2.isArrowFunction(n) || Node2.isFunctionExpression(n) || Node2.isFunctionDeclaration(n) || Node2.isMethodDeclaration(n) || Node2.isCaseClause(n)
|
|
1220
|
+
) ?? decl.getSourceFile();
|
|
1221
|
+
const after = decl.getEnd();
|
|
1222
|
+
return scope.getDescendantsOfKind(SyntaxKind2.Identifier).filter((id) => {
|
|
1223
|
+
if (id.getText() !== name || id.getStart() < after) return false;
|
|
1224
|
+
const p = id.getParent();
|
|
1225
|
+
if (Node2.isPropertyAccessExpression(p) && p.getNameNode() === id) return false;
|
|
1226
|
+
if (Node2.isPropertyAssignment(p) && p.getNameNode() === id) return false;
|
|
1227
|
+
if (Node2.isBindingElement(p) || Node2.isVariableDeclaration(p) || Node2.isParameterDeclaration(p)) return false;
|
|
1228
|
+
return true;
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
function use(ref, loc, env, c, depth) {
|
|
1232
|
+
const access = [];
|
|
1233
|
+
let cur = ref;
|
|
1234
|
+
for (; ; ) {
|
|
1235
|
+
const p = cur.getParent();
|
|
1236
|
+
if (Node2.isPropertyAccessExpression(p) && p.getExpression() === cur) {
|
|
1237
|
+
access.push(p.getName());
|
|
1238
|
+
cur = p;
|
|
1239
|
+
} else if (Node2.isElementAccessExpression(p) && p.getExpression() === cur) {
|
|
1240
|
+
const arg = p.getArgumentExpression();
|
|
1241
|
+
if (arg && (Node2.isStringLiteral(arg) || Node2.isNoSubstitutionTemplateLiteral(arg))) access.push(arg.getLiteralValue());
|
|
1242
|
+
else access.push("[]");
|
|
1243
|
+
cur = p;
|
|
1244
|
+
} else if (p && (Node2.isNonNullExpression(p) || Node2.isParenthesizedExpression(p))) {
|
|
1245
|
+
cur = p;
|
|
1246
|
+
} else break;
|
|
1247
|
+
}
|
|
1248
|
+
interpret(env, loc, access, cur, c, depth, true);
|
|
1249
|
+
}
|
|
1250
|
+
function interpret(env, loc, access, top, c, depth, compare) {
|
|
1251
|
+
const full = [...loc.path, ...access];
|
|
1252
|
+
if (loc.space === "body" || env === "body") {
|
|
1253
|
+
body(full, top, c, compare);
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
const [first, ...rest] = full;
|
|
1257
|
+
if (first === void 0) return;
|
|
1258
|
+
const isCall = () => {
|
|
1259
|
+
const p = top.getParent();
|
|
1260
|
+
return Node2.isCallExpression(p) && p.getExpression() === top;
|
|
1261
|
+
};
|
|
1262
|
+
const statusKey = env === "got" || env === "undici" ? "statusCode" : "status";
|
|
1263
|
+
if (first === statusKey && rest.length === 0) {
|
|
1264
|
+
if (compare) for (const n of comparedLiterals(top).nums) c.statusCodes.add(n);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
if (first === "ok" && rest.length === 0) {
|
|
1268
|
+
c.checksOk = true;
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
if (first === "headers") {
|
|
1272
|
+
const [h] = rest;
|
|
1273
|
+
if (h === "get" && isCall()) {
|
|
1274
|
+
const arg = top.getParent().getArguments()[0];
|
|
1275
|
+
if (arg && Node2.isStringLiteral(arg)) c.responseHeaders.add(arg.getLiteralValue().toLowerCase());
|
|
1276
|
+
} else if (h && h !== "get") c.responseHeaders.add(h.toLowerCase());
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (env === "axios" && first === "data") return body(rest, top, c, compare);
|
|
1280
|
+
if (env === "got" && first === "body") return body(rest, top, c, compare);
|
|
1281
|
+
const bodyCall = (env === "fetch" || env === "got") && rest.length === 0 && (first === "json" || first === "text") || env === "undici" && first === "body" && rest.length === 1 && (rest[0] === "json" || rest[0] === "text");
|
|
1282
|
+
if (bodyCall && isCall()) {
|
|
1283
|
+
bindFrom(top.getParent(), { space: "body", path: [] }, env, c, depth + 1);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
function body(path13, top, c, compare) {
|
|
1287
|
+
const cut = path13.findIndex((p) => NOT_FIELDS.has(p));
|
|
1288
|
+
const fieldPath = cut >= 0 ? path13.slice(0, cut) : path13;
|
|
1289
|
+
if (fieldPath.length === 0) return;
|
|
1290
|
+
const field = fieldPath.join(".").replace(/\.\[\]/g, "[]");
|
|
1291
|
+
c.fields.add(field);
|
|
1292
|
+
if (compare && cut < 0) for (const s of comparedLiterals(top).strs) c.addEnum(field, s);
|
|
1293
|
+
}
|
|
1294
|
+
function comparedLiterals(node) {
|
|
1295
|
+
const nums = [];
|
|
1296
|
+
const strs = [];
|
|
1297
|
+
const take = (n) => {
|
|
1298
|
+
if (!n) return;
|
|
1299
|
+
if (Node2.isNumericLiteral(n)) nums.push(Number(n.getLiteralValue()));
|
|
1300
|
+
else if (Node2.isStringLiteral(n) || Node2.isNoSubstitutionTemplateLiteral(n)) strs.push(n.getLiteralValue());
|
|
1301
|
+
};
|
|
1302
|
+
let cur = node;
|
|
1303
|
+
while (Node2.isParenthesizedExpression(cur.getParent())) cur = cur.getParent();
|
|
1304
|
+
const p = cur.getParent();
|
|
1305
|
+
if (Node2.isBinaryExpression(p)) {
|
|
1306
|
+
const op = p.getOperatorToken().getKind();
|
|
1307
|
+
if (op === SyntaxKind2.EqualsEqualsEqualsToken || op === SyntaxKind2.ExclamationEqualsEqualsToken || op === SyntaxKind2.EqualsEqualsToken || op === SyntaxKind2.ExclamationEqualsToken) {
|
|
1308
|
+
take(p.getLeft() === cur ? p.getRight() : p.getLeft());
|
|
1309
|
+
}
|
|
1310
|
+
} else if (Node2.isSwitchStatement(p) && p.getExpression() === cur) {
|
|
1311
|
+
for (const clause of p.getClauses()) if (Node2.isCaseClause(clause)) take(clause.getExpression());
|
|
1312
|
+
} else if (Node2.isCallExpression(p) && p.getArguments()[0] === cur) {
|
|
1313
|
+
const callee = p.getExpression();
|
|
1314
|
+
if (Node2.isPropertyAccessExpression(callee) && callee.getName() === "includes") {
|
|
1315
|
+
const arr = callee.getExpression();
|
|
1316
|
+
if (Node2.isArrayLiteralExpression(arr)) arr.getElements().forEach(take);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return { nums, strs };
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// packages/extractor/src/files.ts
|
|
1323
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
|
|
1324
|
+
import path3 from "path";
|
|
1325
|
+
var SOURCE_FILE = /\.(c|m)?[jt]sx?$/;
|
|
1326
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "out", "coverage", "vendor", "bower_components"]);
|
|
1327
|
+
function listRepoFiles(root) {
|
|
1328
|
+
const sources = [];
|
|
1329
|
+
const manifests = [];
|
|
1330
|
+
const walk2 = (dir) => {
|
|
1331
|
+
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
1332
|
+
if (entry.name.startsWith(".")) continue;
|
|
1333
|
+
const full = path3.join(dir, entry.name);
|
|
1334
|
+
if (entry.isDirectory()) {
|
|
1335
|
+
if (!SKIP_DIRS.has(entry.name)) walk2(full);
|
|
1336
|
+
} else if (entry.isFile()) {
|
|
1337
|
+
if (entry.name === "package.json") manifests.push(full);
|
|
1338
|
+
else if (SOURCE_FILE.test(entry.name) && !entry.name.endsWith(".d.ts")) sources.push(full);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
walk2(root);
|
|
1343
|
+
return { sources: sources.sort(), manifests: manifests.sort() };
|
|
1344
|
+
}
|
|
1345
|
+
function declaredProviders(root, manifests) {
|
|
1346
|
+
const found = [];
|
|
1347
|
+
for (const manifest of manifests) {
|
|
1348
|
+
let pkg;
|
|
1349
|
+
try {
|
|
1350
|
+
pkg = JSON.parse(readFileSync2(manifest, "utf8"));
|
|
1351
|
+
} catch {
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
const names = /* @__PURE__ */ new Set();
|
|
1355
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
|
|
1356
|
+
const deps = pkg[field];
|
|
1357
|
+
if (deps && typeof deps === "object") for (const name of Object.keys(deps)) names.add(name);
|
|
1358
|
+
}
|
|
1359
|
+
for (const name of [...names].sort()) {
|
|
1360
|
+
const provider = providerForPackage(name);
|
|
1361
|
+
if (provider) found.push({ provider: provider.id, package: name, manifest: toPosix(path3.relative(root, manifest)) });
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return found;
|
|
1365
|
+
}
|
|
1366
|
+
function toPosix(p) {
|
|
1367
|
+
return p.split(path3.sep).join("/");
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// packages/extractor/src/index.ts
|
|
1371
|
+
var SCRIPT_KINDS = {
|
|
1372
|
+
".ts": ScriptKind.TS,
|
|
1373
|
+
".mts": ScriptKind.TS,
|
|
1374
|
+
".cts": ScriptKind.TS,
|
|
1375
|
+
".tsx": ScriptKind.TSX,
|
|
1376
|
+
".js": ScriptKind.JS,
|
|
1377
|
+
".mjs": ScriptKind.JS,
|
|
1378
|
+
".cjs": ScriptKind.JS,
|
|
1379
|
+
".jsx": ScriptKind.JSX
|
|
1380
|
+
};
|
|
1381
|
+
function extractCallSites(rootDir) {
|
|
1382
|
+
const root = path4.resolve(rootDir);
|
|
1383
|
+
const { sources, manifests } = listRepoFiles(root);
|
|
1384
|
+
const project = new Project({
|
|
1385
|
+
useInMemoryFileSystem: true,
|
|
1386
|
+
skipFileDependencyResolution: true,
|
|
1387
|
+
compilerOptions: { allowJs: true }
|
|
1388
|
+
});
|
|
1389
|
+
const found = [];
|
|
1390
|
+
for (const abs of sources) {
|
|
1391
|
+
const rel = toPosix(path4.relative(root, abs));
|
|
1392
|
+
const sf = project.createSourceFile(`/${rel}`, readFileSync3(abs, "utf8"), {
|
|
1393
|
+
overwrite: true,
|
|
1394
|
+
scriptKind: SCRIPT_KINDS[path4.extname(abs)] ?? ScriptKind.TS
|
|
1395
|
+
});
|
|
1396
|
+
for (const f of extractFromFile(sf, rel)) {
|
|
1397
|
+
addResponseSurface(f);
|
|
1398
|
+
found.push(f);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
found.sort((a, b) => a.site.file.localeCompare(b.site.file) || a.site.line - b.site.line || a.site.column - b.site.column);
|
|
1402
|
+
return {
|
|
1403
|
+
root,
|
|
1404
|
+
filesScanned: sources.length,
|
|
1405
|
+
callSites: found.map((f) => f.site),
|
|
1406
|
+
declared: declaredProviders(root, manifests),
|
|
1407
|
+
found
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// packages/shapes/src/shape.ts
|
|
1412
|
+
import { createHash as createHash2, createHmac } from "crypto";
|
|
1413
|
+
import { z as z4 } from "zod";
|
|
1414
|
+
var JSON_TYPES = ["array", "boolean", "integer", "null", "number", "object", "string"];
|
|
1415
|
+
var VALUE_CLASSES = [
|
|
1416
|
+
"datetime",
|
|
1417
|
+
"date",
|
|
1418
|
+
"uuid",
|
|
1419
|
+
"url",
|
|
1420
|
+
"email",
|
|
1421
|
+
"id",
|
|
1422
|
+
"token",
|
|
1423
|
+
"text",
|
|
1424
|
+
"positive",
|
|
1425
|
+
"negative",
|
|
1426
|
+
"zero",
|
|
1427
|
+
"true",
|
|
1428
|
+
"false"
|
|
1429
|
+
];
|
|
1430
|
+
var MAX_ENUM_VALUES = 50;
|
|
1431
|
+
var FieldShapeSchema = z4.object({
|
|
1432
|
+
/** Field path, dot-separated, array items as "[]". Omitted when the shape is redacted. */
|
|
1433
|
+
path: z4.string().optional(),
|
|
1434
|
+
/** HMAC of the path with the provider salt; stable across observations. */
|
|
1435
|
+
pathHash: z4.string(),
|
|
1436
|
+
types: z4.array(z4.enum(JSON_TYPES)),
|
|
1437
|
+
classes: z4.array(z4.enum(VALUE_CLASSES)),
|
|
1438
|
+
/** How many times the field appeared (more than once inside arrays). */
|
|
1439
|
+
occurrences: z4.number().int().nonnegative(),
|
|
1440
|
+
/** Share of occurrences that were null. */
|
|
1441
|
+
nullRate: z4.number().min(0).max(1),
|
|
1442
|
+
/** Array lengths seen, for array fields. */
|
|
1443
|
+
arrayLength: z4.object({ min: z4.number().int(), max: z4.number().int() }).optional(),
|
|
1444
|
+
/** HMACs of distinct token values (enum candidates), never the values themselves. */
|
|
1445
|
+
valueHashes: z4.array(z4.string()).optional()
|
|
1446
|
+
});
|
|
1447
|
+
var ResponseShapeSchema = z4.object({
|
|
1448
|
+
status: z4.number().int(),
|
|
1449
|
+
/** Allowlisted headers whose values describe the contract, not the payload. */
|
|
1450
|
+
headers: z4.record(z4.string(), z4.string()),
|
|
1451
|
+
/** Names of the other non-volatile headers present. */
|
|
1452
|
+
headerNames: z4.array(z4.string()),
|
|
1453
|
+
body: z4.object({
|
|
1454
|
+
kind: z4.enum(["empty", "json", "text"]),
|
|
1455
|
+
rootType: z4.enum(JSON_TYPES).optional(),
|
|
1456
|
+
fields: z4.array(FieldShapeSchema)
|
|
1457
|
+
})
|
|
1458
|
+
});
|
|
1459
|
+
var VALUE_HEADERS = /* @__PURE__ */ new Set([
|
|
1460
|
+
"content-type",
|
|
1461
|
+
"deprecation",
|
|
1462
|
+
"sunset",
|
|
1463
|
+
"link-deprecation",
|
|
1464
|
+
"x-github-api-version-selected",
|
|
1465
|
+
"stripe-version",
|
|
1466
|
+
"x-api-version",
|
|
1467
|
+
"api-version",
|
|
1468
|
+
"openai-version",
|
|
1469
|
+
"anthropic-version",
|
|
1470
|
+
"intuit-minor-version"
|
|
1471
|
+
]);
|
|
1472
|
+
var VOLATILE_HEADERS = /* @__PURE__ */ new Set([
|
|
1473
|
+
"date",
|
|
1474
|
+
"age",
|
|
1475
|
+
"etag",
|
|
1476
|
+
"last-modified",
|
|
1477
|
+
"expires",
|
|
1478
|
+
"set-cookie",
|
|
1479
|
+
"cf-ray",
|
|
1480
|
+
"server-timing",
|
|
1481
|
+
"x-request-id",
|
|
1482
|
+
"request-id",
|
|
1483
|
+
"x-github-request-id",
|
|
1484
|
+
"x-runtime",
|
|
1485
|
+
"x-served-by",
|
|
1486
|
+
"x-cache",
|
|
1487
|
+
"x-cache-hits",
|
|
1488
|
+
"x-timer",
|
|
1489
|
+
"content-length",
|
|
1490
|
+
"connection",
|
|
1491
|
+
"keep-alive",
|
|
1492
|
+
"via",
|
|
1493
|
+
"report-to",
|
|
1494
|
+
"nel",
|
|
1495
|
+
"idempotency-key",
|
|
1496
|
+
"original-request",
|
|
1497
|
+
// transport and caching: vary with chunking, compression and proxies, not with the API contract
|
|
1498
|
+
"transfer-encoding",
|
|
1499
|
+
"accept-ranges",
|
|
1500
|
+
"content-encoding",
|
|
1501
|
+
"vary",
|
|
1502
|
+
"alt-svc",
|
|
1503
|
+
"cache-control",
|
|
1504
|
+
// token metadata: present only on authenticated calls, value changes per token
|
|
1505
|
+
"github-authentication-token-expiration"
|
|
1506
|
+
]);
|
|
1507
|
+
function providerSalt(provider, secret) {
|
|
1508
|
+
return createHmac("sha256", secret).update(provider).digest("hex");
|
|
1509
|
+
}
|
|
1510
|
+
function hmac(salt, value2) {
|
|
1511
|
+
return createHmac("sha256", salt).update(value2).digest("hex").slice(0, 16);
|
|
1512
|
+
}
|
|
1513
|
+
function inferShape(res, opts) {
|
|
1514
|
+
const headers = {};
|
|
1515
|
+
const headerNames = [];
|
|
1516
|
+
const entries = res.headers instanceof Headers ? [...res.headers.entries()] : Object.entries(res.headers);
|
|
1517
|
+
for (const [rawName, value2] of entries) {
|
|
1518
|
+
const name = rawName.toLowerCase();
|
|
1519
|
+
if (VALUE_HEADERS.has(name)) headers[name] = name === "content-type" ? value2.split(";")[0].trim() : value2;
|
|
1520
|
+
else if (!VOLATILE_HEADERS.has(name) && !name.startsWith("x-ratelimit") && !name.startsWith("ratelimit")) headerNames.push(name);
|
|
1521
|
+
}
|
|
1522
|
+
const text = res.body ?? "";
|
|
1523
|
+
let body2;
|
|
1524
|
+
if (text.trim() === "") {
|
|
1525
|
+
body2 = { kind: "empty", fields: [] };
|
|
1526
|
+
} else {
|
|
1527
|
+
let parsed;
|
|
1528
|
+
let isJson = true;
|
|
1529
|
+
try {
|
|
1530
|
+
parsed = JSON.parse(text);
|
|
1531
|
+
} catch {
|
|
1532
|
+
isJson = false;
|
|
1533
|
+
}
|
|
1534
|
+
if (isJson) {
|
|
1535
|
+
const acc = /* @__PURE__ */ new Map();
|
|
1536
|
+
walk(parsed, "", acc);
|
|
1537
|
+
body2 = { kind: "json", rootType: typeOf(parsed), fields: finish(acc, opts.salt) };
|
|
1538
|
+
} else {
|
|
1539
|
+
body2 = { kind: "text", fields: [] };
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
return {
|
|
1543
|
+
status: res.status,
|
|
1544
|
+
headers: Object.fromEntries(Object.entries(headers).sort(([a], [b]) => cmp3(a, b))),
|
|
1545
|
+
headerNames: [...new Set(headerNames)].sort(),
|
|
1546
|
+
body: body2
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
function walk(value2, path13, acc) {
|
|
1550
|
+
if (path13 !== "") record(value2, path13, acc);
|
|
1551
|
+
if (Array.isArray(value2)) {
|
|
1552
|
+
for (const item of value2) walk(item, `${path13}[]`, acc);
|
|
1553
|
+
} else if (value2 !== null && typeof value2 === "object") {
|
|
1554
|
+
for (const [key, child] of Object.entries(value2)) walk(child, path13 === "" ? key : `${path13}.${key}`, acc);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
function record(value2, path13, acc) {
|
|
1558
|
+
let a = acc.get(path13);
|
|
1559
|
+
if (!a) acc.set(path13, a = { types: /* @__PURE__ */ new Set(), classes: /* @__PURE__ */ new Set(), occurrences: 0, nulls: 0, tokens: /* @__PURE__ */ new Set(), textValues: 0 });
|
|
1560
|
+
a.occurrences++;
|
|
1561
|
+
const type = typeOf(value2);
|
|
1562
|
+
a.types.add(type);
|
|
1563
|
+
if (type === "null") a.nulls++;
|
|
1564
|
+
if (Array.isArray(value2)) {
|
|
1565
|
+
a.lengths = a.lengths ? { min: Math.min(a.lengths.min, value2.length), max: Math.max(a.lengths.max, value2.length) } : { min: value2.length, max: value2.length };
|
|
1566
|
+
}
|
|
1567
|
+
const cls = classify(value2);
|
|
1568
|
+
if (cls) a.classes.add(cls);
|
|
1569
|
+
if (cls === "token" && typeof value2 === "string") a.tokens.add(value2);
|
|
1570
|
+
}
|
|
1571
|
+
function finish(acc, salt) {
|
|
1572
|
+
return [...acc.entries()].sort(([a], [b]) => cmp3(a, b)).map(([path13, a]) => {
|
|
1573
|
+
const field = {
|
|
1574
|
+
path: path13,
|
|
1575
|
+
pathHash: hmac(salt, path13),
|
|
1576
|
+
types: [...a.types].sort(),
|
|
1577
|
+
classes: [...a.classes].sort(),
|
|
1578
|
+
occurrences: a.occurrences,
|
|
1579
|
+
nullRate: round(a.nulls / a.occurrences)
|
|
1580
|
+
};
|
|
1581
|
+
if (a.lengths) field.arrayLength = a.lengths;
|
|
1582
|
+
if (a.tokens.size > 0 && a.tokens.size <= MAX_ENUM_VALUES) {
|
|
1583
|
+
field.valueHashes = [...a.tokens].map((v) => hmac(salt, `${path13}=${v}`)).sort();
|
|
1584
|
+
}
|
|
1585
|
+
return field;
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
function typeOf(value2) {
|
|
1589
|
+
if (value2 === null) return "null";
|
|
1590
|
+
if (Array.isArray(value2)) return "array";
|
|
1591
|
+
if (typeof value2 === "number") return Number.isInteger(value2) ? "integer" : "number";
|
|
1592
|
+
if (typeof value2 === "boolean") return "boolean";
|
|
1593
|
+
if (typeof value2 === "string") return "string";
|
|
1594
|
+
return "object";
|
|
1595
|
+
}
|
|
1596
|
+
function classify(value2) {
|
|
1597
|
+
if (typeof value2 === "boolean") return value2 ? "true" : "false";
|
|
1598
|
+
if (typeof value2 === "number") return value2 > 0 ? "positive" : value2 < 0 ? "negative" : "zero";
|
|
1599
|
+
if (typeof value2 !== "string") return void 0;
|
|
1600
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/.test(value2)) return "datetime";
|
|
1601
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value2)) return "date";
|
|
1602
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value2)) return "uuid";
|
|
1603
|
+
if (/^https?:\/\//i.test(value2)) return "url";
|
|
1604
|
+
if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value2)) return "email";
|
|
1605
|
+
if (/^[a-z]{2,8}_(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{8,}$/.test(value2) || /^\d{5,}$/.test(value2) || /^[0-9a-f]{20,}$/i.test(value2)) return "id";
|
|
1606
|
+
if (/^[A-Za-z][A-Za-z0-9_.-]{0,39}$/.test(value2)) return "token";
|
|
1607
|
+
return "text";
|
|
1608
|
+
}
|
|
1609
|
+
function shapeHash(shape) {
|
|
1610
|
+
const structural = {
|
|
1611
|
+
status: shape.status,
|
|
1612
|
+
headers: shape.headers,
|
|
1613
|
+
headerNames: shape.headerNames,
|
|
1614
|
+
body: {
|
|
1615
|
+
kind: shape.body.kind,
|
|
1616
|
+
rootType: shape.body.rootType ?? null,
|
|
1617
|
+
fields: shape.body.fields.map((f) => [f.pathHash, f.types, f.classes, f.nullRate > 0, f.valueHashes ?? null])
|
|
1618
|
+
}
|
|
1619
|
+
};
|
|
1620
|
+
return createHash2("sha256").update(JSON.stringify(structural)).digest("hex");
|
|
1621
|
+
}
|
|
1622
|
+
function round(n) {
|
|
1623
|
+
return Math.round(n * 1e3) / 1e3;
|
|
1624
|
+
}
|
|
1625
|
+
function cmp3(a, b) {
|
|
1626
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// packages/shapes/src/store.ts
|
|
1630
|
+
import { mkdirSync as mkdirSync2 } from "fs";
|
|
1631
|
+
import path5 from "path";
|
|
1632
|
+
import Database from "better-sqlite3";
|
|
1633
|
+
var SCHEMA_VERSION = 1;
|
|
1634
|
+
var ShapeStore = class _ShapeStore {
|
|
1635
|
+
constructor(db) {
|
|
1636
|
+
this.db = db;
|
|
1637
|
+
}
|
|
1638
|
+
db;
|
|
1639
|
+
/** Open (and create or migrate) a store. Use ":memory:" for tests. */
|
|
1640
|
+
static open(file) {
|
|
1641
|
+
if (file !== ":memory:") mkdirSync2(path5.dirname(path5.resolve(file)), { recursive: true });
|
|
1642
|
+
const db = new Database(file);
|
|
1643
|
+
if (file !== ":memory:") db.pragma("journal_mode = WAL");
|
|
1644
|
+
const version = db.pragma("user_version", { simple: true });
|
|
1645
|
+
if (version < SCHEMA_VERSION) {
|
|
1646
|
+
db.exec(`
|
|
1647
|
+
CREATE TABLE IF NOT EXISTS observations (
|
|
1648
|
+
id INTEGER PRIMARY KEY,
|
|
1649
|
+
provider TEXT NOT NULL,
|
|
1650
|
+
method TEXT NOT NULL,
|
|
1651
|
+
endpoint TEXT NOT NULL,
|
|
1652
|
+
observed_at TEXT NOT NULL,
|
|
1653
|
+
source TEXT NOT NULL,
|
|
1654
|
+
shape_hash TEXT NOT NULL,
|
|
1655
|
+
shape_json TEXT NOT NULL
|
|
1656
|
+
);
|
|
1657
|
+
CREATE INDEX IF NOT EXISTS observations_endpoint
|
|
1658
|
+
ON observations (provider, method, endpoint, observed_at);
|
|
1659
|
+
`);
|
|
1660
|
+
db.pragma(`user_version = ${SCHEMA_VERSION}`);
|
|
1661
|
+
}
|
|
1662
|
+
return new _ShapeStore(db);
|
|
1663
|
+
}
|
|
1664
|
+
record(obs) {
|
|
1665
|
+
const shape = ResponseShapeSchema.parse(obs.shape);
|
|
1666
|
+
const hash = shapeHash(shape);
|
|
1667
|
+
const info = this.db.prepare(
|
|
1668
|
+
`INSERT INTO observations (provider, method, endpoint, observed_at, source, shape_hash, shape_json)
|
|
1669
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
1670
|
+
).run(obs.provider, obs.method, obs.endpoint, obs.observedAt, obs.source, hash, JSON.stringify(shape));
|
|
1671
|
+
return { ...obs, shape, id: Number(info.lastInsertRowid), shapeHash: hash };
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Observations of one endpoint, newest first. Pass `source` to stay within one
|
|
1675
|
+
* vantage point: the same endpoint answers differently with and without a token,
|
|
1676
|
+
* so observations from different sources must not be compared.
|
|
1677
|
+
*/
|
|
1678
|
+
history(provider, method, endpoint, opts = {}) {
|
|
1679
|
+
const rows = this.db.prepare(
|
|
1680
|
+
`SELECT * FROM observations WHERE provider = ? AND method = ? AND endpoint = ? AND (? IS NULL OR source = ?)
|
|
1681
|
+
ORDER BY observed_at DESC, id DESC LIMIT ?`
|
|
1682
|
+
).all(provider, method, endpoint, opts.source ?? null, opts.source ?? null, opts.limit ?? 100);
|
|
1683
|
+
return rows.map(fromRow);
|
|
1684
|
+
}
|
|
1685
|
+
latest(provider, method, endpoint, source) {
|
|
1686
|
+
return this.history(provider, method, endpoint, { limit: 1, source })[0];
|
|
1687
|
+
}
|
|
1688
|
+
/** The most recent observation whose shape differs from the latest one: the "before" side of the last change. */
|
|
1689
|
+
previousDistinct(provider, method, endpoint, source) {
|
|
1690
|
+
const latest = this.latest(provider, method, endpoint, source);
|
|
1691
|
+
if (!latest) return void 0;
|
|
1692
|
+
const row = this.db.prepare(
|
|
1693
|
+
`SELECT * FROM observations WHERE provider = ? AND method = ? AND endpoint = ? AND (? IS NULL OR source = ?)
|
|
1694
|
+
AND shape_hash != ?
|
|
1695
|
+
ORDER BY observed_at DESC, id DESC LIMIT 1`
|
|
1696
|
+
).get(provider, method, endpoint, source ?? null, source ?? null, latest.shapeHash);
|
|
1697
|
+
return row ? fromRow(row) : void 0;
|
|
1698
|
+
}
|
|
1699
|
+
/** One row per endpoint and source. */
|
|
1700
|
+
endpoints() {
|
|
1701
|
+
return this.db.prepare(
|
|
1702
|
+
`SELECT provider, method, endpoint, source, COUNT(*) AS observations, COUNT(DISTINCT shape_hash) AS distinctShapes,
|
|
1703
|
+
MAX(observed_at) AS lastObservedAt
|
|
1704
|
+
FROM observations GROUP BY provider, method, endpoint, source ORDER BY provider, endpoint, method, source`
|
|
1705
|
+
).all();
|
|
1706
|
+
}
|
|
1707
|
+
close() {
|
|
1708
|
+
this.db.close();
|
|
1709
|
+
}
|
|
1710
|
+
};
|
|
1711
|
+
function fromRow(row) {
|
|
1712
|
+
return {
|
|
1713
|
+
id: row.id,
|
|
1714
|
+
provider: row.provider,
|
|
1715
|
+
method: row.method,
|
|
1716
|
+
endpoint: row.endpoint,
|
|
1717
|
+
observedAt: row.observed_at,
|
|
1718
|
+
source: row.source,
|
|
1719
|
+
shapeHash: row.shape_hash,
|
|
1720
|
+
shape: ResponseShapeSchema.parse(JSON.parse(row.shape_json))
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// packages/shapes/src/diff.ts
|
|
1725
|
+
var IMPACT_ORDER = { breaking: 0, "non-breaking": 1, informational: 2 };
|
|
1726
|
+
var CONTRACT_HEADERS = /* @__PURE__ */ new Set(["content-type"]);
|
|
1727
|
+
var SUNSET_HEADERS = /* @__PURE__ */ new Set(["deprecation", "sunset", "link-deprecation"]);
|
|
1728
|
+
function diffShapes(before, after) {
|
|
1729
|
+
const changes = [];
|
|
1730
|
+
const ok = (s) => s >= 200 && s < 300;
|
|
1731
|
+
if (before.status !== after.status) changes.push(statusChange(before.status, after.status));
|
|
1732
|
+
if (before.body.kind !== after.body.kind) {
|
|
1733
|
+
const lost = before.body.kind === "json" && after.body.kind !== "json";
|
|
1734
|
+
changes.push({
|
|
1735
|
+
kind: "body-kind",
|
|
1736
|
+
impact: lost && ok(after.status) ? "breaking" : "informational",
|
|
1737
|
+
...lost && ok(after.status) ? { changeClass: "field-rename" } : {},
|
|
1738
|
+
from: before.body.kind,
|
|
1739
|
+
to: after.body.kind,
|
|
1740
|
+
detail: `body ${before.body.kind} \u2192 ${after.body.kind}`
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
if (ok(before.status) === ok(after.status) && before.body.kind === "json" && after.body.kind === "json") {
|
|
1744
|
+
changes.push(...fieldChanges(before.body.fields, after.body.fields));
|
|
1745
|
+
}
|
|
1746
|
+
changes.push(...headerChanges(before, after));
|
|
1747
|
+
changes.sort((a, b) => IMPACT_ORDER[a.impact] - IMPACT_ORDER[b.impact] || (a.path ?? "").localeCompare(b.path ?? ""));
|
|
1748
|
+
return { changes, breaking: changes.some((c) => c.impact === "breaking") };
|
|
1749
|
+
}
|
|
1750
|
+
function statusChange(from, to) {
|
|
1751
|
+
const ok = (s) => s >= 200 && s < 300;
|
|
1752
|
+
const base = { kind: "status-code", from, to, detail: `status ${from} \u2192 ${to}` };
|
|
1753
|
+
if (ok(from) && ok(to)) return { ...base, impact: "breaking", changeClass: "status-code" };
|
|
1754
|
+
if (ok(from) && (to === 401 || to === 403)) return { ...base, impact: "breaking", changeClass: "auth" };
|
|
1755
|
+
if (ok(from) && (to === 404 || to === 410)) return { ...base, impact: "breaking", changeClass: "version-sunset" };
|
|
1756
|
+
return { ...base, impact: "informational" };
|
|
1757
|
+
}
|
|
1758
|
+
function label(f) {
|
|
1759
|
+
return f.path ?? `#${f.pathHash}`;
|
|
1760
|
+
}
|
|
1761
|
+
function fieldChanges(before, after) {
|
|
1762
|
+
const out = [];
|
|
1763
|
+
const prev = new Map(before.map((f) => [f.pathHash, f]));
|
|
1764
|
+
const next = new Map(after.map((f) => [f.pathHash, f]));
|
|
1765
|
+
const removedPaths = [];
|
|
1766
|
+
const addedPaths = [];
|
|
1767
|
+
const under = (path13, roots) => path13 !== void 0 && roots.some((r) => path13.startsWith(`${r}.`) || path13.startsWith(`${r}[]`));
|
|
1768
|
+
for (const f of before) {
|
|
1769
|
+
if (next.has(f.pathHash)) continue;
|
|
1770
|
+
if (under(f.path, removedPaths)) continue;
|
|
1771
|
+
if (f.path) removedPaths.push(f.path);
|
|
1772
|
+
out.push({
|
|
1773
|
+
kind: "field-removed",
|
|
1774
|
+
impact: "breaking",
|
|
1775
|
+
changeClass: "field-rename",
|
|
1776
|
+
path: f.path,
|
|
1777
|
+
pathHash: f.pathHash,
|
|
1778
|
+
from: f.types,
|
|
1779
|
+
detail: `field ${label(f)} removed`
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
for (const f of after) {
|
|
1783
|
+
const old = prev.get(f.pathHash);
|
|
1784
|
+
if (!old) {
|
|
1785
|
+
if (under(f.path, addedPaths)) continue;
|
|
1786
|
+
if (f.path) addedPaths.push(f.path);
|
|
1787
|
+
out.push({
|
|
1788
|
+
kind: "field-added",
|
|
1789
|
+
impact: "non-breaking",
|
|
1790
|
+
path: f.path,
|
|
1791
|
+
pathHash: f.pathHash,
|
|
1792
|
+
to: f.types,
|
|
1793
|
+
detail: `field ${label(f)} added (${f.types.join(" | ")})`
|
|
1794
|
+
});
|
|
1795
|
+
continue;
|
|
1796
|
+
}
|
|
1797
|
+
const where = { path: f.path ?? old.path, pathHash: f.pathHash };
|
|
1798
|
+
const name = f.path ?? old.path ?? `#${f.pathHash}`;
|
|
1799
|
+
const newTypes = f.types.filter((t) => !old.types.includes(t));
|
|
1800
|
+
const goneTypes = old.types.filter((t) => !f.types.includes(t));
|
|
1801
|
+
const nonNullNew = newTypes.filter((t) => t !== "null");
|
|
1802
|
+
if (nonNullNew.length > 0) {
|
|
1803
|
+
out.push({
|
|
1804
|
+
kind: "type-changed",
|
|
1805
|
+
impact: "breaking",
|
|
1806
|
+
changeClass: "field-rename",
|
|
1807
|
+
...where,
|
|
1808
|
+
from: old.types,
|
|
1809
|
+
to: f.types,
|
|
1810
|
+
detail: `field ${name} type ${old.types.join(" | ")} \u2192 ${f.types.join(" | ")}`
|
|
1811
|
+
});
|
|
1812
|
+
} else if (newTypes.includes("null")) {
|
|
1813
|
+
out.push({
|
|
1814
|
+
kind: "became-nullable",
|
|
1815
|
+
impact: "breaking",
|
|
1816
|
+
changeClass: "required-field",
|
|
1817
|
+
...where,
|
|
1818
|
+
from: old.types,
|
|
1819
|
+
to: f.types,
|
|
1820
|
+
detail: `field ${name} can now be null`
|
|
1821
|
+
});
|
|
1822
|
+
} else if (goneTypes.includes("null") && goneTypes.length === 1) {
|
|
1823
|
+
out.push({ kind: "no-longer-nullable", impact: "informational", ...where, detail: `field ${name} no longer seen as null` });
|
|
1824
|
+
}
|
|
1825
|
+
const added = (f.valueHashes ?? []).filter((h) => !(old.valueHashes ?? []).includes(h));
|
|
1826
|
+
if (enumLike(old) && added.length > 0) {
|
|
1827
|
+
out.push({
|
|
1828
|
+
kind: "enum-value-added",
|
|
1829
|
+
impact: "breaking",
|
|
1830
|
+
changeClass: "enum-added",
|
|
1831
|
+
...where,
|
|
1832
|
+
to: added,
|
|
1833
|
+
detail: `field ${name} has ${added.length} value${added.length === 1 ? "" : "s"} not seen before`
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
const newClasses = f.classes.filter((c) => !old.classes.includes(c));
|
|
1837
|
+
if (nonNullNew.length === 0 && newClasses.length > 0 && old.classes.length > 0 && !newClasses.every((c) => ["positive", "negative", "zero", "true", "false"].includes(c))) {
|
|
1838
|
+
out.push({
|
|
1839
|
+
kind: "class-changed",
|
|
1840
|
+
impact: "informational",
|
|
1841
|
+
...where,
|
|
1842
|
+
from: old.classes,
|
|
1843
|
+
to: f.classes,
|
|
1844
|
+
detail: `field ${name} values look like ${f.classes.join(", ")} (were ${old.classes.join(", ")})`
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
return out;
|
|
1849
|
+
}
|
|
1850
|
+
function enumLike(f) {
|
|
1851
|
+
const distinct = f.valueHashes?.length ?? 0;
|
|
1852
|
+
return distinct > 0 && f.occurrences >= 2 * distinct;
|
|
1853
|
+
}
|
|
1854
|
+
function headerChanges(before, after) {
|
|
1855
|
+
const out = [];
|
|
1856
|
+
const names = /* @__PURE__ */ new Set([...Object.keys(before.headers), ...Object.keys(after.headers)]);
|
|
1857
|
+
for (const name of [...names].sort()) {
|
|
1858
|
+
const from = before.headers[name];
|
|
1859
|
+
const to = after.headers[name];
|
|
1860
|
+
if (from === to) continue;
|
|
1861
|
+
if (SUNSET_HEADERS.has(name) && from === void 0) {
|
|
1862
|
+
out.push({ kind: "header-added", impact: "informational", changeClass: "version-sunset", path: name, to, detail: `response now carries ${name}: ${to}` });
|
|
1863
|
+
} else if (from === void 0 || to === void 0) {
|
|
1864
|
+
out.push({
|
|
1865
|
+
kind: from === void 0 ? "header-added" : "header-removed",
|
|
1866
|
+
impact: "informational",
|
|
1867
|
+
path: name,
|
|
1868
|
+
...from !== void 0 ? { from } : {},
|
|
1869
|
+
...to !== void 0 ? { to } : {},
|
|
1870
|
+
detail: from === void 0 ? `header ${name}: ${to} added` : `header ${name} removed`
|
|
1871
|
+
});
|
|
1872
|
+
} else {
|
|
1873
|
+
const contract = CONTRACT_HEADERS.has(name) && before.status === after.status;
|
|
1874
|
+
out.push({
|
|
1875
|
+
kind: "header-changed",
|
|
1876
|
+
impact: contract ? "breaking" : "informational",
|
|
1877
|
+
...contract ? { changeClass: "field-rename" } : {},
|
|
1878
|
+
path: name,
|
|
1879
|
+
from,
|
|
1880
|
+
to,
|
|
1881
|
+
detail: `header ${name}: ${from} \u2192 ${to}`
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
const prevNames = new Set(before.headerNames);
|
|
1886
|
+
const nextNames = new Set(after.headerNames);
|
|
1887
|
+
for (const name of after.headerNames) {
|
|
1888
|
+
if (!prevNames.has(name)) out.push({ kind: "header-added", impact: "informational", path: name, detail: `header ${name} added` });
|
|
1889
|
+
}
|
|
1890
|
+
for (const name of before.headerNames) {
|
|
1891
|
+
if (!nextNames.has(name)) out.push({ kind: "header-removed", impact: "informational", path: name, detail: `header ${name} removed` });
|
|
1892
|
+
}
|
|
1893
|
+
return out;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// packages/canary/src/index.ts
|
|
1897
|
+
import { Command, CommanderError } from "commander";
|
|
1898
|
+
|
|
1899
|
+
// packages/canary/src/draft.ts
|
|
1900
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
1901
|
+
import path6 from "path";
|
|
1902
|
+
|
|
1903
|
+
// packages/canary/src/replay.ts
|
|
1904
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
1905
|
+
import { z as z5 } from "zod";
|
|
1906
|
+
var RecordingSchema = z5.object({
|
|
1907
|
+
provider: z5.string(),
|
|
1908
|
+
method: z5.enum(HTTP_METHODS),
|
|
1909
|
+
endpoint: z5.string().startsWith("/"),
|
|
1910
|
+
note: z5.string().optional(),
|
|
1911
|
+
sources: z5.array(z5.url()).default([]),
|
|
1912
|
+
observations: z5.array(
|
|
1913
|
+
z5.object({
|
|
1914
|
+
observedAt: z5.iso.datetime(),
|
|
1915
|
+
status: z5.number().int(),
|
|
1916
|
+
headers: z5.record(z5.string(), z5.string()).default({}),
|
|
1917
|
+
body: z5.string().default("")
|
|
1918
|
+
})
|
|
1919
|
+
).min(2)
|
|
1920
|
+
});
|
|
1921
|
+
function loadRecording(file) {
|
|
1922
|
+
return RecordingSchema.parse(JSON.parse(readFileSync4(file, "utf8")));
|
|
1923
|
+
}
|
|
1924
|
+
function replay(recording, store, secret) {
|
|
1925
|
+
const salt = providerSalt(recording.provider, secret);
|
|
1926
|
+
const { provider, method, endpoint } = recording;
|
|
1927
|
+
const observations = recording.observations.map(
|
|
1928
|
+
(o) => store.record({
|
|
1929
|
+
provider,
|
|
1930
|
+
method,
|
|
1931
|
+
endpoint,
|
|
1932
|
+
observedAt: o.observedAt,
|
|
1933
|
+
source: "replay",
|
|
1934
|
+
shape: inferShape({ status: o.status, headers: o.headers, body: o.body }, { salt })
|
|
1935
|
+
})
|
|
1936
|
+
);
|
|
1937
|
+
const after = store.latest(provider, method, endpoint, "replay");
|
|
1938
|
+
const before = store.previousDistinct(provider, method, endpoint, "replay");
|
|
1939
|
+
return { recording, observations, after, before, ...before ? { diff: diffShapes(before.shape, after.shape) } : {} };
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// packages/repair/src/recipes/status-code-accept-set.ts
|
|
1943
|
+
import { Node as Node3, SyntaxKind as SyntaxKind3 } from "ts-morph";
|
|
1944
|
+
var STATUS_PROPS = /* @__PURE__ */ new Set(["status", "statusCode"]);
|
|
1945
|
+
var statusCodeAcceptSet = {
|
|
1946
|
+
id: "status-code-accept-set",
|
|
1947
|
+
changeClass: "status-code",
|
|
1948
|
+
description: "Accept the new status codes alongside the old ones wherever the response status is compared",
|
|
1949
|
+
edits({ sourceFile, call, advisory }) {
|
|
1950
|
+
const oldCodes = advisory.trigger.statusCodes ?? [];
|
|
1951
|
+
const accept = [.../* @__PURE__ */ new Set([...oldCodes, ...advisory.trigger.newStatusCodes ?? []])].sort((a, b) => a - b);
|
|
1952
|
+
if (oldCodes.length === 0 || accept.length === oldCodes.length) return [];
|
|
1953
|
+
const list = `[${accept.join(", ")}]`;
|
|
1954
|
+
const edits = [];
|
|
1955
|
+
const where = (n) => `${sourceFile.getFilePath().slice(1)}:${n.getStartLineNumber()}`;
|
|
1956
|
+
for (const status of statusExpressions(call)) {
|
|
1957
|
+
let cur = status;
|
|
1958
|
+
while (Node3.isParenthesizedExpression(cur.getParent())) cur = cur.getParent();
|
|
1959
|
+
const parent = cur.getParent();
|
|
1960
|
+
if (Node3.isBinaryExpression(parent)) {
|
|
1961
|
+
const op = parent.getOperatorToken().getKind();
|
|
1962
|
+
const other = parent.getLeft() === cur ? parent.getRight() : parent.getLeft();
|
|
1963
|
+
if (!Node3.isNumericLiteral(other) || !oldCodes.includes(Number(other.getLiteralValue()))) continue;
|
|
1964
|
+
const negated = op === SyntaxKind3.ExclamationEqualsEqualsToken || op === SyntaxKind3.ExclamationEqualsToken;
|
|
1965
|
+
const equal = op === SyntaxKind3.EqualsEqualsEqualsToken || op === SyntaxKind3.EqualsEqualsToken;
|
|
1966
|
+
if (!negated && !equal) continue;
|
|
1967
|
+
const text = `${negated ? "!" : ""}${list}.includes(${status.getText()})`;
|
|
1968
|
+
edits.push({ start: parent.getStart(), end: parent.getEnd(), text, note: `${where(parent)} \`${parent.getText()}\` \u2192 \`${text}\`` });
|
|
1969
|
+
} else if (Node3.isSwitchStatement(parent) && parent.getExpression() === cur) {
|
|
1970
|
+
const cases = parent.getClauses().filter(Node3.isCaseClause);
|
|
1971
|
+
const present = new Set(cases.map((c) => c.getExpression()).filter(Node3.isNumericLiteral).map((l) => Number(l.getLiteralValue())));
|
|
1972
|
+
const anchor = cases.find((c) => {
|
|
1973
|
+
const e = c.getExpression();
|
|
1974
|
+
return Node3.isNumericLiteral(e) && oldCodes.includes(Number(e.getLiteralValue()));
|
|
1975
|
+
});
|
|
1976
|
+
const missing = accept.filter((c) => !present.has(c));
|
|
1977
|
+
if (!anchor || missing.length === 0) continue;
|
|
1978
|
+
const indent = " ".repeat(sourceFile.getLineAndColumnAtPos(anchor.getStart()).column - 1);
|
|
1979
|
+
const text = missing.map((c) => `case ${c}:
|
|
1980
|
+
${indent}`).join("");
|
|
1981
|
+
edits.push({ start: anchor.getStart(), end: anchor.getStart(), text, note: `${where(anchor)} add ${missing.map((c) => `case ${c}`).join(", ")}` });
|
|
1982
|
+
} else if (Node3.isCallExpression(parent) && parent.getArguments()[0] === cur) {
|
|
1983
|
+
const callee = parent.getExpression();
|
|
1984
|
+
const arr = Node3.isPropertyAccessExpression(callee) && callee.getName() === "includes" ? callee.getExpression() : void 0;
|
|
1985
|
+
if (!arr || !Node3.isArrayLiteralExpression(arr)) continue;
|
|
1986
|
+
const values = arr.getElements().filter(Node3.isNumericLiteral).map((l) => Number(l.getLiteralValue()));
|
|
1987
|
+
if (!values.some((v) => oldCodes.includes(v))) continue;
|
|
1988
|
+
const merged = [.../* @__PURE__ */ new Set([...values, ...accept])].sort((a, b) => a - b);
|
|
1989
|
+
if (merged.length === values.length) continue;
|
|
1990
|
+
const text = `[${merged.join(", ")}]`;
|
|
1991
|
+
edits.push({ start: arr.getStart(), end: arr.getEnd(), text, note: `${where(arr)} \`${arr.getText()}\` \u2192 \`${text}\`` });
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
return edits;
|
|
1995
|
+
}
|
|
1996
|
+
};
|
|
1997
|
+
function statusExpressions(call) {
|
|
1998
|
+
const out = [];
|
|
1999
|
+
let cur = call;
|
|
2000
|
+
for (; ; ) {
|
|
2001
|
+
const p = cur.getParent();
|
|
2002
|
+
if (p && (Node3.isAwaitExpression(p) || Node3.isParenthesizedExpression(p) || Node3.isAsExpression(p) || Node3.isNonNullExpression(p))) cur = p;
|
|
2003
|
+
else break;
|
|
2004
|
+
}
|
|
2005
|
+
const parent = cur.getParent();
|
|
2006
|
+
const sf = call.getSourceFile();
|
|
2007
|
+
if (Node3.isPropertyAccessExpression(parent) && parent.getExpression() === cur && STATUS_PROPS.has(parent.getName())) out.push(parent);
|
|
2008
|
+
if (Node3.isPropertyAccessExpression(parent) && parent.getName() === "then") {
|
|
2009
|
+
const cb = Node3.isCallExpression(parent.getParent()) ? parent.getParent().getArguments()[0] : void 0;
|
|
2010
|
+
const param = cb && (Node3.isArrowFunction(cb) || Node3.isFunctionExpression(cb)) ? cb.getParameters()[0] : void 0;
|
|
2011
|
+
if (param) out.push(...fromBinding(param.getNameNode(), param, sf));
|
|
2012
|
+
}
|
|
2013
|
+
if (Node3.isVariableDeclaration(parent) && parent.getInitializer() === cur) out.push(...fromBinding(parent.getNameNode(), parent, sf));
|
|
2014
|
+
return out;
|
|
2015
|
+
}
|
|
2016
|
+
function fromBinding(name, decl, sf) {
|
|
2017
|
+
const out = [];
|
|
2018
|
+
if (Node3.isIdentifier(name)) {
|
|
2019
|
+
for (const ref of references(name.getText(), decl, sf)) {
|
|
2020
|
+
const p = ref.getParent();
|
|
2021
|
+
if (Node3.isPropertyAccessExpression(p) && p.getExpression() === ref && STATUS_PROPS.has(p.getName())) out.push(p);
|
|
2022
|
+
}
|
|
2023
|
+
} else if (Node3.isObjectBindingPattern(name)) {
|
|
2024
|
+
for (const el of name.getElements()) {
|
|
2025
|
+
const prop = el.getPropertyNameNode()?.getText() ?? el.getName();
|
|
2026
|
+
if (STATUS_PROPS.has(prop) && Node3.isIdentifier(el.getNameNode())) out.push(...references(el.getName(), decl, sf));
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
return out;
|
|
2030
|
+
}
|
|
2031
|
+
function references(name, decl, sf) {
|
|
2032
|
+
const scope = decl.getFirstAncestor(
|
|
2033
|
+
(n) => Node3.isBlock(n) || Node3.isArrowFunction(n) || Node3.isFunctionExpression(n) || Node3.isFunctionDeclaration(n) || Node3.isMethodDeclaration(n) || Node3.isCaseClause(n)
|
|
2034
|
+
) ?? sf;
|
|
2035
|
+
return scope.getDescendantsOfKind(SyntaxKind3.Identifier).filter((id) => {
|
|
2036
|
+
if (id.getText() !== name || id.getStart() < decl.getEnd()) return false;
|
|
2037
|
+
const p = id.getParent();
|
|
2038
|
+
if (Node3.isPropertyAccessExpression(p) && p.getNameNode() === id) return false;
|
|
2039
|
+
if (Node3.isPropertyAssignment(p) && p.getNameNode() === id) return false;
|
|
2040
|
+
return !(Node3.isBindingElement(p) || Node3.isVariableDeclaration(p) || Node3.isParameterDeclaration(p));
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// packages/repair/src/recipe.ts
|
|
2045
|
+
import { Project as Project2, ScriptKind as ScriptKind2, SyntaxKind as SyntaxKind4 } from "ts-morph";
|
|
2046
|
+
function findCall(sf, site) {
|
|
2047
|
+
return sf.getDescendantsOfKind(SyntaxKind4.CallExpression).filter((c) => {
|
|
2048
|
+
const pos = sf.getLineAndColumnAtPos(c.getStart());
|
|
2049
|
+
return pos.line === site.line && pos.column === site.column;
|
|
2050
|
+
}).sort((a, b) => a.getWidth() - b.getWidth())[0];
|
|
2051
|
+
}
|
|
2052
|
+
function applyEdits(text, edits) {
|
|
2053
|
+
let out = text;
|
|
2054
|
+
for (const e of [...edits].sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
|
2055
|
+
return out;
|
|
2056
|
+
}
|
|
2057
|
+
function runRecipe(recipes, advisory, callSite, text) {
|
|
2058
|
+
const recipe = advisory.recipeId ? recipes[advisory.recipeId] : void 0;
|
|
2059
|
+
if (!recipe) return { status: "no-recipe", reason: advisory.recipeId ? `unknown recipe ${advisory.recipeId}` : "advisory names no recipe" };
|
|
2060
|
+
const project = new Project2({ useInMemoryFileSystem: true, skipFileDependencyResolution: true, compilerOptions: { allowJs: true } });
|
|
2061
|
+
const kind = /\.(c|m)?jsx?$/.test(callSite.file) ? ScriptKind2.JS : callSite.file.endsWith("x") ? ScriptKind2.TSX : ScriptKind2.TS;
|
|
2062
|
+
const sourceFile = project.createSourceFile(`/${callSite.file}`, text, { scriptKind: kind });
|
|
2063
|
+
const call = findCall(sourceFile, callSite);
|
|
2064
|
+
if (!call) return { status: "call-not-found", recipe: recipe.id, reason: `no call at ${callSite.file}:${callSite.line}:${callSite.column}` };
|
|
2065
|
+
const edits = recipe.edits({ sourceFile, call, callSite, advisory });
|
|
2066
|
+
if (edits.length === 0) return { status: "no-change", recipe: recipe.id, reason: "recipe found nothing to change" };
|
|
2067
|
+
return {
|
|
2068
|
+
status: "applied",
|
|
2069
|
+
recipe: recipe.id,
|
|
2070
|
+
change: { file: callSite.file, before: text, after: applyEdits(text, edits), notes: edits.map((e) => e.note) }
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
// packages/repair/src/contract.ts
|
|
2075
|
+
import path7 from "path";
|
|
2076
|
+
import { Node as Node4, Project as Project3, ScriptKind as ScriptKind3, SyntaxKind as SyntaxKind5 } from "ts-morph";
|
|
2077
|
+
|
|
2078
|
+
// packages/repair/src/synth.ts
|
|
2079
|
+
function synthesizeBody(shape) {
|
|
2080
|
+
if (shape.body.kind !== "json") return null;
|
|
2081
|
+
const root = { children: /* @__PURE__ */ new Map() };
|
|
2082
|
+
for (const field of shape.body.fields) {
|
|
2083
|
+
if (field.path === void 0) throw new Error("cannot synthesize from a redacted shape (field paths removed)");
|
|
2084
|
+
let node = root;
|
|
2085
|
+
for (const token of tokens(field.path)) {
|
|
2086
|
+
if (token === "[]") node = node.item ??= { children: /* @__PURE__ */ new Map() };
|
|
2087
|
+
else {
|
|
2088
|
+
let child = node.children.get(token);
|
|
2089
|
+
if (!child) node.children.set(token, child = { children: /* @__PURE__ */ new Map() });
|
|
2090
|
+
node = child;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
node.field = field;
|
|
2094
|
+
}
|
|
2095
|
+
return value(root, shape.body.rootType === "array" ? "array" : "object");
|
|
2096
|
+
}
|
|
2097
|
+
function tokens(path13) {
|
|
2098
|
+
const out = [];
|
|
2099
|
+
for (const part of path13.split(".")) {
|
|
2100
|
+
const m = /^([^[]*)((?:\[\])*)$/.exec(part);
|
|
2101
|
+
if (m[1]) out.push(m[1]);
|
|
2102
|
+
for (let i = 0; i < m[2].length / 2; i++) out.push("[]");
|
|
2103
|
+
}
|
|
2104
|
+
return out;
|
|
2105
|
+
}
|
|
2106
|
+
function value(node, forced) {
|
|
2107
|
+
const types = node.field?.types.filter((t) => t !== "null") ?? [];
|
|
2108
|
+
const kind = forced ?? (node.item || types.includes("array") ? "array" : node.children.size > 0 || types.includes("object") ? "object" : types[0]);
|
|
2109
|
+
if (kind === "array") return node.item ? [value(node.item)] : [];
|
|
2110
|
+
if (kind === "object") return Object.fromEntries([...node.children].map(([k, child]) => [k, value(child)]));
|
|
2111
|
+
const classes = node.field?.classes ?? [];
|
|
2112
|
+
switch (kind) {
|
|
2113
|
+
case "integer":
|
|
2114
|
+
return classes.includes("negative") ? -1 : classes.includes("zero") && !classes.includes("positive") ? 0 : 1;
|
|
2115
|
+
case "number":
|
|
2116
|
+
return classes.includes("negative") ? -1.5 : 1.5;
|
|
2117
|
+
case "boolean":
|
|
2118
|
+
return !classes.includes("false") || classes.includes("true");
|
|
2119
|
+
case "string":
|
|
2120
|
+
return sampleString(classes);
|
|
2121
|
+
default:
|
|
2122
|
+
return null;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
function sampleString(classes) {
|
|
2126
|
+
if (classes.includes("datetime")) return "2026-01-01T00:00:00Z";
|
|
2127
|
+
if (classes.includes("date")) return "2026-01-01";
|
|
2128
|
+
if (classes.includes("uuid")) return "00000000-0000-4000-8000-000000000000";
|
|
2129
|
+
if (classes.includes("url")) return "https://example.com/shapi";
|
|
2130
|
+
if (classes.includes("email")) return "shapi@example.com";
|
|
2131
|
+
if (classes.includes("id")) return "id_shapi00000001";
|
|
2132
|
+
if (classes.includes("token")) return "shapi_value";
|
|
2133
|
+
return "shapi text";
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// packages/repair/src/contract.ts
|
|
2137
|
+
var CONTRACT_DIR = "test/shapi";
|
|
2138
|
+
function generateContractTest(input) {
|
|
2139
|
+
const { callSite, advisory, before, after } = input;
|
|
2140
|
+
if (callSite.kind !== "http" || callSite.client !== "fetch") {
|
|
2141
|
+
return { status: "unsupported", reason: `contract tests support global fetch call sites; this one uses ${callSite.client}` };
|
|
2142
|
+
}
|
|
2143
|
+
const project = new Project3({ useInMemoryFileSystem: true, skipFileDependencyResolution: true, compilerOptions: { allowJs: true } });
|
|
2144
|
+
const sf = project.createSourceFile(`/${callSite.file}`, input.sourceText, {
|
|
2145
|
+
scriptKind: /\.(c|m)?jsx?$/.test(callSite.file) ? ScriptKind3.JS : ScriptKind3.TS
|
|
2146
|
+
});
|
|
2147
|
+
const call = findCall(sf, callSite);
|
|
2148
|
+
if (!call) return { status: "unsupported", reason: `no call at ${callSite.file}:${callSite.line}:${callSite.column}` };
|
|
2149
|
+
const fn = exportedFunction(call);
|
|
2150
|
+
if (!fn) return { status: "unsupported", reason: "the call site is not inside an exported function" };
|
|
2151
|
+
let responses;
|
|
2152
|
+
try {
|
|
2153
|
+
responses = [response(`before (${before.status})`, before), response(`after (${after.status})`, after)].join(",\n");
|
|
2154
|
+
} catch (err) {
|
|
2155
|
+
return { status: "unsupported", reason: err instanceof Error ? err.message : String(err) };
|
|
2156
|
+
}
|
|
2157
|
+
const file = `${CONTRACT_DIR}/${advisory.id}.test.ts`;
|
|
2158
|
+
const importPath = toImport(path7.posix.relative(path7.posix.dirname(file), callSite.file));
|
|
2159
|
+
const args = fn.params.map((p) => argument(p, sf)).join(", ");
|
|
2160
|
+
const content = `// Generated by SHAPI for advisory ${advisory.id}: ${advisory.title}
|
|
2161
|
+
// Call site: ${callSite.file}:${callSite.line} (${callSite.method} ${callSite.endpoint})
|
|
2162
|
+
// Calls ${fn.name}() with fetch returning the response the code was written for and the one
|
|
2163
|
+
// ${advisory.provider} sends now. It fails while the code accepts only the old response.
|
|
2164
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2165
|
+
import { ${fn.name} } from "${importPath}";
|
|
2166
|
+
|
|
2167
|
+
const responses = [
|
|
2168
|
+
${responses},
|
|
2169
|
+
];
|
|
2170
|
+
|
|
2171
|
+
describe("SHAPI contract: ${advisory.id}", () => {
|
|
2172
|
+
afterEach(() => {
|
|
2173
|
+
vi.unstubAllGlobals();
|
|
2174
|
+
});
|
|
2175
|
+
|
|
2176
|
+
for (const r of responses) {
|
|
2177
|
+
it(\`${fn.name} handles the \${r.name} response\`, async () => {
|
|
2178
|
+
const fetchMock = vi.fn(async () => new Response(r.body === null ? null : JSON.stringify(r.body), { status: r.status, headers: r.headers }));
|
|
2179
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
2180
|
+
await ${fn.name}(${args});
|
|
2181
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
});
|
|
2185
|
+
`;
|
|
2186
|
+
return { status: "generated", file, content, functionName: fn.name };
|
|
2187
|
+
}
|
|
2188
|
+
function response(name, shape) {
|
|
2189
|
+
const body2 = synthesizeBody(shape);
|
|
2190
|
+
const headers = shape.headers["content-type"] ? { "content-type": shape.headers["content-type"] } : {};
|
|
2191
|
+
return ` { name: ${JSON.stringify(name)}, status: ${shape.status}, headers: ${JSON.stringify(headers)}, body: ${JSON.stringify(body2)} }`;
|
|
2192
|
+
}
|
|
2193
|
+
function exportedFunction(call) {
|
|
2194
|
+
for (const anc of call.getAncestors()) {
|
|
2195
|
+
if (Node4.isFunctionDeclaration(anc) && anc.isExported() && anc.getName()) return { name: anc.getName(), params: anc.getParameters() };
|
|
2196
|
+
if (Node4.isArrowFunction(anc) || Node4.isFunctionExpression(anc)) {
|
|
2197
|
+
const decl = anc.getParent();
|
|
2198
|
+
if (Node4.isVariableDeclaration(decl) && decl.getVariableStatement()?.isExported()) return { name: decl.getName(), params: anc.getParameters() };
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
return void 0;
|
|
2202
|
+
}
|
|
2203
|
+
function argument(p, sf) {
|
|
2204
|
+
const type = p.getTypeNode();
|
|
2205
|
+
return type ? valueFor(type, p.getName(), sf, 0) : JSON.stringify(`shapi-${p.getName()}`);
|
|
2206
|
+
}
|
|
2207
|
+
function valueFor(type, name, sf, depth) {
|
|
2208
|
+
if (depth > 4) return "undefined as never";
|
|
2209
|
+
switch (type.getKind()) {
|
|
2210
|
+
case SyntaxKind5.StringKeyword:
|
|
2211
|
+
return JSON.stringify(`shapi-${name}`);
|
|
2212
|
+
case SyntaxKind5.NumberKeyword:
|
|
2213
|
+
return "1";
|
|
2214
|
+
case SyntaxKind5.BooleanKeyword:
|
|
2215
|
+
return "true";
|
|
2216
|
+
}
|
|
2217
|
+
if (Node4.isTypeLiteral(type)) return objectFrom(type.getProperties(), sf, depth);
|
|
2218
|
+
if (Node4.isTypeReference(type)) {
|
|
2219
|
+
const ref = type.getTypeName().getText();
|
|
2220
|
+
const iface = sf.getInterface(ref);
|
|
2221
|
+
if (iface) return objectFrom(iface.getProperties(), sf, depth);
|
|
2222
|
+
const alias = sf.getTypeAlias(ref)?.getTypeNode();
|
|
2223
|
+
if (alias) return valueFor(alias, name, sf, depth + 1);
|
|
2224
|
+
}
|
|
2225
|
+
if (Node4.isArrayTypeNode(type)) return "[]";
|
|
2226
|
+
return "undefined as never";
|
|
2227
|
+
}
|
|
2228
|
+
function objectFrom(props, sf, depth) {
|
|
2229
|
+
const entries = props.filter((p) => !p.hasQuestionToken()).map((p) => {
|
|
2230
|
+
const t = p.getTypeNode();
|
|
2231
|
+
return `${JSON.stringify(p.getName())}: ${t ? valueFor(t, p.getName(), sf, depth + 1) : "undefined as never"}`;
|
|
2232
|
+
});
|
|
2233
|
+
return `{ ${entries.join(", ")} }`;
|
|
2234
|
+
}
|
|
2235
|
+
function toImport(rel) {
|
|
2236
|
+
const noExt = rel.replace(/\.(c|m)?[jt]sx?$/, "");
|
|
2237
|
+
return noExt.startsWith(".") ? noExt : `./${noExt}`;
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
// packages/repair/src/agent.ts
|
|
2241
|
+
import path10 from "path";
|
|
2242
|
+
|
|
2243
|
+
// packages/repair/src/runner.ts
|
|
2244
|
+
import { spawnSync } from "child_process";
|
|
2245
|
+
import { existsSync } from "fs";
|
|
2246
|
+
import { createRequire } from "module";
|
|
2247
|
+
import path8 from "path";
|
|
2248
|
+
function vitestCli(dir) {
|
|
2249
|
+
const local = path8.join(dir, "node_modules", "vitest", "vitest.mjs");
|
|
2250
|
+
if (existsSync(local)) return local;
|
|
2251
|
+
const require2 = createRequire(import.meta.url);
|
|
2252
|
+
return path8.join(path8.dirname(require2.resolve("vitest/package.json")), "vitest.mjs");
|
|
2253
|
+
}
|
|
2254
|
+
function runTestFile(dir, file, timeoutMs = 12e4) {
|
|
2255
|
+
const res = spawnSync(process.execPath, [vitestCli(dir), "run", file, "--root", dir, "--reporter", "verbose", "--no-color"], {
|
|
2256
|
+
cwd: dir,
|
|
2257
|
+
encoding: "utf8",
|
|
2258
|
+
timeout: timeoutMs,
|
|
2259
|
+
env: { ...process.env, FORCE_COLOR: "0", CI: "1" }
|
|
2260
|
+
});
|
|
2261
|
+
const output = `${res.stdout ?? ""}${res.stderr ?? ""}${res.error ? `
|
|
2262
|
+
${res.error.message}` : ""}`;
|
|
2263
|
+
const exitCode = res.status ?? 1;
|
|
2264
|
+
return { passed: exitCode === 0, exitCode, output };
|
|
2265
|
+
}
|
|
2266
|
+
function testsRan(output) {
|
|
2267
|
+
return /^\s*Tests\s+.*\d+ (?:failed|passed)/m.test(output);
|
|
2268
|
+
}
|
|
2269
|
+
function runFailure(output) {
|
|
2270
|
+
const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
2271
|
+
return lines.find((l) => /\b\w*Error: |\berror: /i.test(l)) ?? lines.find((l) => /Cannot find|not found|Error/.test(l)) ?? lines[0] ?? "no output";
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// packages/repair/src/workspace.ts
|
|
2275
|
+
import { cpSync, existsSync as existsSync2, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync5, symlinkSync, writeFileSync as writeFileSync3 } from "fs";
|
|
2276
|
+
import os from "os";
|
|
2277
|
+
import path9 from "path";
|
|
2278
|
+
var COPY_SKIP = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "coverage", ".shapi"]);
|
|
2279
|
+
function localWorkspace(repoDir) {
|
|
2280
|
+
const dir = mkdtempSync(path9.join(os.tmpdir(), "shapi-repair-"));
|
|
2281
|
+
cpSync(repoDir, dir, {
|
|
2282
|
+
recursive: true,
|
|
2283
|
+
filter: (src) => !COPY_SKIP.has(path9.basename(src)) || path9.resolve(src) === path9.resolve(repoDir)
|
|
2284
|
+
});
|
|
2285
|
+
const modules = path9.join(repoDir, "node_modules");
|
|
2286
|
+
if (existsSync2(modules)) symlinkSync(modules, path9.join(dir, "node_modules"), "junction");
|
|
2287
|
+
return {
|
|
2288
|
+
location: dir,
|
|
2289
|
+
async read(file) {
|
|
2290
|
+
const abs = path9.join(dir, file);
|
|
2291
|
+
return existsSync2(abs) ? readFileSync5(abs, "utf8") : void 0;
|
|
2292
|
+
},
|
|
2293
|
+
async write(file, content) {
|
|
2294
|
+
const abs = path9.join(dir, file);
|
|
2295
|
+
mkdirSync4(path9.dirname(abs), { recursive: true });
|
|
2296
|
+
writeFileSync3(abs, content);
|
|
2297
|
+
},
|
|
2298
|
+
async runTest(file) {
|
|
2299
|
+
return runTestFile(dir, file);
|
|
2300
|
+
}
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// packages/repair/src/agent.ts
|
|
2305
|
+
var PROTECTED = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "coverage", ".shapi"]);
|
|
2306
|
+
async function repairCallSite(input) {
|
|
2307
|
+
const { advisory, callSite } = input;
|
|
2308
|
+
if (!input.workspace && !input.repoDir) throw new Error("repairCallSite needs a workspace or a repoDir");
|
|
2309
|
+
const ws = input.workspace ?? localWorkspace(input.repoDir);
|
|
2310
|
+
const original = await ws.read(callSite.file);
|
|
2311
|
+
if (original === void 0) return { status: "unsupported", sandbox: ws.location, changes: [], attempts: [], reason: `${callSite.file} is not in the repository` };
|
|
2312
|
+
const test = generateContractTest({ callSite, advisory, before: input.before, after: input.after, sourceText: original });
|
|
2313
|
+
if (test.status !== "generated") return { status: "unsupported", changes: [], attempts: [], reason: test.reason };
|
|
2314
|
+
await ws.write(test.file, test.content);
|
|
2315
|
+
const testChange = { file: test.file, before: "", after: test.content, notes: ["generated contract test"] };
|
|
2316
|
+
const baseline = await ws.runTest(test.file);
|
|
2317
|
+
if (!baseline.passed && !testsRan(baseline.output)) {
|
|
2318
|
+
return {
|
|
2319
|
+
status: "unsupported",
|
|
2320
|
+
sandbox: ws.location,
|
|
2321
|
+
changes: [],
|
|
2322
|
+
test: { file: test.file, content: test.content },
|
|
2323
|
+
baseline,
|
|
2324
|
+
attempts: [],
|
|
2325
|
+
reason: `the contract test could not run: ${runFailure(baseline.output)}`
|
|
2326
|
+
};
|
|
2327
|
+
}
|
|
2328
|
+
if (baseline.passed) {
|
|
2329
|
+
return {
|
|
2330
|
+
status: "already-handled",
|
|
2331
|
+
sandbox: ws.location,
|
|
2332
|
+
changes: [],
|
|
2333
|
+
test: { file: test.file, content: test.content },
|
|
2334
|
+
baseline,
|
|
2335
|
+
attempts: [],
|
|
2336
|
+
reason: "the contract test already passes against the new response"
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
const attempts = [];
|
|
2340
|
+
const recipe = runRecipe(input.recipes, advisory, callSite, original);
|
|
2341
|
+
if (recipe.status === "applied") {
|
|
2342
|
+
await ws.write(callSite.file, recipe.change.after);
|
|
2343
|
+
const run3 = await ws.runTest(test.file);
|
|
2344
|
+
attempts.push({ method: "recipe", outcome: run3.passed ? "passed" : "failed", detail: recipe.recipe, run: run3 });
|
|
2345
|
+
if (run3.passed) {
|
|
2346
|
+
return { status: "fixed", method: "recipe", sandbox: ws.location, changes: [recipe.change, testChange], test: { file: test.file, content: test.content }, baseline, attempts };
|
|
2347
|
+
}
|
|
2348
|
+
await ws.write(callSite.file, original);
|
|
2349
|
+
} else {
|
|
2350
|
+
attempts.push({ method: "recipe", outcome: "skipped", detail: recipe.reason });
|
|
2351
|
+
}
|
|
2352
|
+
if (!input.llm) {
|
|
2353
|
+
attempts.push({ method: "llm", outcome: "skipped", detail: "no LLM fixer configured" });
|
|
2354
|
+
return rejected(ws, test, baseline, attempts);
|
|
2355
|
+
}
|
|
2356
|
+
const proposal = await input.llm.fix({
|
|
2357
|
+
advisory,
|
|
2358
|
+
callSite,
|
|
2359
|
+
file: { path: callSite.file, content: original },
|
|
2360
|
+
test: { path: test.file, content: test.content },
|
|
2361
|
+
failingOutput: baseline.output
|
|
2362
|
+
});
|
|
2363
|
+
if (proposal.status !== "proposed") {
|
|
2364
|
+
attempts.push({ method: "llm", outcome: "skipped", detail: proposal.reason });
|
|
2365
|
+
return rejected(ws, test, baseline, attempts);
|
|
2366
|
+
}
|
|
2367
|
+
const changes = [];
|
|
2368
|
+
for (const f of proposal.files) {
|
|
2369
|
+
const rel = path10.posix.normalize(f.path.replaceAll("\\", "/"));
|
|
2370
|
+
if (rel.startsWith("..") || path10.posix.isAbsolute(rel) || rel === test.file || rel.split("/").some((s) => PROTECTED.has(s))) {
|
|
2371
|
+
attempts.push({ method: "llm", outcome: "failed", detail: `refused to write ${f.path}: outside the repo, the test, or a protected folder` });
|
|
2372
|
+
await restore(ws, changes);
|
|
2373
|
+
return rejected(ws, test, baseline, attempts);
|
|
2374
|
+
}
|
|
2375
|
+
const before = await ws.read(rel) ?? "";
|
|
2376
|
+
await ws.write(rel, f.content);
|
|
2377
|
+
changes.push({ file: rel, before, after: f.content, notes: [proposal.explanation] });
|
|
2378
|
+
}
|
|
2379
|
+
const run2 = await ws.runTest(test.file);
|
|
2380
|
+
attempts.push({ method: "llm", outcome: run2.passed ? "passed" : "failed", detail: `${proposal.model}: ${proposal.explanation}`, run: run2 });
|
|
2381
|
+
if (run2.passed) {
|
|
2382
|
+
return { status: "fixed", method: "llm", sandbox: ws.location, changes: [...changes, testChange], test: { file: test.file, content: test.content }, baseline, attempts };
|
|
2383
|
+
}
|
|
2384
|
+
await restore(ws, changes);
|
|
2385
|
+
return rejected(ws, test, baseline, attempts);
|
|
2386
|
+
}
|
|
2387
|
+
function rejected(ws, test, baseline, attempts) {
|
|
2388
|
+
return {
|
|
2389
|
+
status: "rejected",
|
|
2390
|
+
sandbox: ws.location,
|
|
2391
|
+
changes: [],
|
|
2392
|
+
test: { file: test.file, content: test.content },
|
|
2393
|
+
baseline,
|
|
2394
|
+
attempts,
|
|
2395
|
+
reason: "no change made the contract test pass"
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
async function restore(ws, changes) {
|
|
2399
|
+
for (const c of changes) await ws.write(c.file, c.before);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// packages/repair/src/llm.ts
|
|
2403
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2404
|
+
import { z as z6 } from "zod";
|
|
2405
|
+
var DEFAULT_REPAIR_MODEL = "claude-fable-5-1";
|
|
2406
|
+
var ProposalSchema = z6.object({
|
|
2407
|
+
files: z6.array(z6.object({ path: z6.string().min(1), content: z6.string() })).min(1),
|
|
2408
|
+
explanation: z6.string()
|
|
2409
|
+
});
|
|
2410
|
+
var SUBMIT_FIX = {
|
|
2411
|
+
name: "submit_fix",
|
|
2412
|
+
description: "Submit the corrected files. Each entry is a repo-relative path and the file's complete new content (not a diff). Include only files you changed. Never include the contract test.",
|
|
2413
|
+
strict: true,
|
|
2414
|
+
input_schema: {
|
|
2415
|
+
type: "object",
|
|
2416
|
+
properties: {
|
|
2417
|
+
files: {
|
|
2418
|
+
type: "array",
|
|
2419
|
+
items: {
|
|
2420
|
+
type: "object",
|
|
2421
|
+
properties: { path: { type: "string" }, content: { type: "string" } },
|
|
2422
|
+
required: ["path", "content"],
|
|
2423
|
+
additionalProperties: false
|
|
2424
|
+
}
|
|
2425
|
+
},
|
|
2426
|
+
explanation: { type: "string", description: "One or two sentences on what changed and why, for the pull request." }
|
|
2427
|
+
},
|
|
2428
|
+
required: ["files", "explanation"],
|
|
2429
|
+
additionalProperties: false
|
|
2430
|
+
}
|
|
2431
|
+
};
|
|
2432
|
+
var SYSTEM = `You repair third-party API integrations after the provider changed its behavior.
|
|
2433
|
+
You get a breaking-change advisory, the call site that depends on the old behavior, the file around it, and a contract test that calls the code with the old response and with the new one. The test currently fails on the new response.
|
|
2434
|
+
Make the smallest change that lets the code handle both responses. Keep its behavior for the old response, keep the file's style, and do not touch unrelated code. Do not edit the test.
|
|
2435
|
+
When done, call submit_fix with the complete content of each file you changed. If no safe change exists, explain why in text and do not call the tool.`;
|
|
2436
|
+
function claudeFixer(opts = {}) {
|
|
2437
|
+
const client = opts.client ?? new Anthropic();
|
|
2438
|
+
const model = opts.model ?? process.env.SHAPI_REPAIR_MODEL ?? DEFAULT_REPAIR_MODEL;
|
|
2439
|
+
return {
|
|
2440
|
+
async fix(req) {
|
|
2441
|
+
let response2;
|
|
2442
|
+
try {
|
|
2443
|
+
response2 = await client.beta.messages.create({
|
|
2444
|
+
model,
|
|
2445
|
+
max_tokens: 16e3,
|
|
2446
|
+
// Thinking is always on for this model; effort sets its depth.
|
|
2447
|
+
output_config: { effort: "high" },
|
|
2448
|
+
// On a safety-classifier decline, retry server-side on Anthropic's recommended fallback model.
|
|
2449
|
+
betas: ["server-side-fallback-2026-07-01"],
|
|
2450
|
+
fallbacks: "default",
|
|
2451
|
+
system: SYSTEM,
|
|
2452
|
+
tools: [SUBMIT_FIX],
|
|
2453
|
+
tool_choice: { type: "auto" },
|
|
2454
|
+
messages: [{ role: "user", content: prompt(req) }]
|
|
2455
|
+
});
|
|
2456
|
+
} catch (err) {
|
|
2457
|
+
if (err instanceof Anthropic.AuthenticationError) return { status: "declined", reason: "Claude API authentication failed (set ANTHROPIC_API_KEY)" };
|
|
2458
|
+
if (err instanceof Anthropic.RateLimitError) return { status: "declined", reason: "Claude API rate limit reached; retry later" };
|
|
2459
|
+
if (err instanceof Anthropic.APIError) return { status: "declined", reason: `Claude API error ${err.status ?? ""}: ${err.message}`.trim() };
|
|
2460
|
+
throw err;
|
|
2461
|
+
}
|
|
2462
|
+
if (response2.stop_reason === "refusal") {
|
|
2463
|
+
return { status: "declined", reason: `model declined (${response2.stop_details?.category ?? "no category"})` };
|
|
2464
|
+
}
|
|
2465
|
+
if (response2.stop_reason === "max_tokens") return { status: "declined", reason: "response hit max_tokens before finishing" };
|
|
2466
|
+
const call = response2.content.find((b) => b.type === "tool_use" && b.name === "submit_fix");
|
|
2467
|
+
if (!call) {
|
|
2468
|
+
const text = response2.content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
2469
|
+
return { status: "declined", reason: text.trim() || "model did not submit a fix" };
|
|
2470
|
+
}
|
|
2471
|
+
const parsed = ProposalSchema.safeParse(call.input);
|
|
2472
|
+
if (!parsed.success) return { status: "declined", reason: `fix did not match the submit_fix schema: ${z6.prettifyError(parsed.error)}` };
|
|
2473
|
+
return { status: "proposed", ...parsed.data, model: response2.model };
|
|
2474
|
+
}
|
|
2475
|
+
};
|
|
2476
|
+
}
|
|
2477
|
+
function prompt(req) {
|
|
2478
|
+
const { advisory, callSite } = req;
|
|
2479
|
+
return `<advisory>
|
|
2480
|
+
${JSON.stringify(
|
|
2481
|
+
{
|
|
2482
|
+
id: advisory.id,
|
|
2483
|
+
provider: advisory.provider,
|
|
2484
|
+
title: advisory.title,
|
|
2485
|
+
summary: advisory.summary,
|
|
2486
|
+
endpoint: advisory.endpoint,
|
|
2487
|
+
changeClass: advisory.changeClass,
|
|
2488
|
+
trigger: advisory.trigger,
|
|
2489
|
+
migrationNote: advisory.migrationNote
|
|
2490
|
+
},
|
|
2491
|
+
null,
|
|
2492
|
+
2
|
|
2493
|
+
)}
|
|
2494
|
+
</advisory>
|
|
2495
|
+
|
|
2496
|
+
<call_site>${callSite.file}:${callSite.line}:${callSite.column} ${callSite.method ?? "?"} ${callSite.endpoint ?? "?"}</call_site>
|
|
2497
|
+
|
|
2498
|
+
<file path="${req.file.path}">
|
|
2499
|
+
${req.file.content}
|
|
2500
|
+
</file>
|
|
2501
|
+
|
|
2502
|
+
<contract_test path="${req.test.path}">
|
|
2503
|
+
${req.test.content}
|
|
2504
|
+
</contract_test>
|
|
2505
|
+
|
|
2506
|
+
<failing_test_output>
|
|
2507
|
+
${req.failingOutput.slice(-6e3)}
|
|
2508
|
+
</failing_test_output>`;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
// packages/repair/src/index.ts
|
|
2512
|
+
var RECIPES = {
|
|
2513
|
+
[statusCodeAcceptSet.id]: statusCodeAcceptSet
|
|
2514
|
+
};
|
|
2515
|
+
|
|
2516
|
+
// packages/github-app/src/pr.ts
|
|
2517
|
+
import { execFileSync } from "child_process";
|
|
2518
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
2519
|
+
import { createAppAuth } from "@octokit/auth-app";
|
|
2520
|
+
import { Octokit } from "@octokit/rest";
|
|
2521
|
+
|
|
2522
|
+
// packages/github-app/src/policy.ts
|
|
2523
|
+
var STOP_LABEL = "shapi:stop";
|
|
2524
|
+
|
|
2525
|
+
// packages/github-app/src/pr.ts
|
|
2526
|
+
function octokitFor(auth, opts = {}) {
|
|
2527
|
+
const request = opts.fetch ? { fetch: opts.fetch } : void 0;
|
|
2528
|
+
if ("token" in auth) return new Octokit({ auth: auth.token, userAgent: "shapi", request });
|
|
2529
|
+
return new Octokit({
|
|
2530
|
+
authStrategy: createAppAuth,
|
|
2531
|
+
auth: { appId: auth.appId, privateKey: auth.privateKey, installationId: auth.installationId },
|
|
2532
|
+
userAgent: "shapi",
|
|
2533
|
+
request
|
|
2534
|
+
});
|
|
2535
|
+
}
|
|
2536
|
+
function authFromEnv(env = process.env) {
|
|
2537
|
+
const key = env.SHAPI_GITHUB_APP_PRIVATE_KEY ?? (env.SHAPI_GITHUB_APP_PRIVATE_KEY_PATH ? readFileSync6(env.SHAPI_GITHUB_APP_PRIVATE_KEY_PATH, "utf8") : void 0);
|
|
2538
|
+
if (env.SHAPI_GITHUB_APP_ID && env.SHAPI_GITHUB_APP_INSTALLATION_ID && key) {
|
|
2539
|
+
return { appId: env.SHAPI_GITHUB_APP_ID, privateKey: key, installationId: Number(env.SHAPI_GITHUB_APP_INSTALLATION_ID) };
|
|
2540
|
+
}
|
|
2541
|
+
const token = env.GITHUB_TOKEN ?? env.GH_TOKEN;
|
|
2542
|
+
if (token) return { token };
|
|
2543
|
+
try {
|
|
2544
|
+
const fromGh = execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
2545
|
+
return fromGh ? { token: fromGh } : void 0;
|
|
2546
|
+
} catch {
|
|
2547
|
+
return void 0;
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
var FIX_TRAILER = "SHAPI-Branch";
|
|
2551
|
+
async function upsertFixPr(octokit, pr) {
|
|
2552
|
+
const { owner, repo } = splitRepo(pr.repo);
|
|
2553
|
+
const message = `${pr.commitMessage.trimEnd()}
|
|
2554
|
+
|
|
2555
|
+
${FIX_TRAILER}: ${pr.branch}`;
|
|
2556
|
+
const { data: prs } = await octokit.rest.pulls.list({ owner, repo, head: `${owner}:${pr.branch}`, state: "all", sort: "created", direction: "desc", per_page: 10 });
|
|
2557
|
+
const openPr = prs.find((p) => p.state === "open");
|
|
2558
|
+
const latest = prs[0];
|
|
2559
|
+
const closedByMaintainer = !openPr && latest && !latest.merged_at;
|
|
2560
|
+
if (closedByMaintainer && !pr.retryClosed) {
|
|
2561
|
+
return { action: "skipped", reason: "closed without merging", branch: pr.branch, url: latest.html_url, number: latest.number, state: "closed" };
|
|
2562
|
+
}
|
|
2563
|
+
const head = await branchHead(octokit, owner, repo, pr.branch);
|
|
2564
|
+
const ours = head ? (await octokit.rest.git.getCommit({ owner, repo, commit_sha: head })).data.message.includes(`${FIX_TRAILER}: ${pr.branch}`) : true;
|
|
2565
|
+
if (openPr) {
|
|
2566
|
+
const found = { branch: pr.branch, url: openPr.html_url, number: openPr.number, state: "open" };
|
|
2567
|
+
if (openPr.labels.some((l) => l.name === STOP_LABEL)) return { action: "skipped", reason: `labeled ${STOP_LABEL}`, ...found };
|
|
2568
|
+
if (!ours) return { action: "skipped", reason: "someone else pushed to the branch", ...found };
|
|
2569
|
+
const conflicted = (await octokit.rest.pulls.get({ owner, repo, pull_number: openPr.number })).data.mergeable === false;
|
|
2570
|
+
if (head && !conflicted && await filesMatch(octokit, owner, repo, head, pr.files)) {
|
|
2571
|
+
if (openPr.title !== pr.title || (openPr.body ?? "") !== pr.body) {
|
|
2572
|
+
await octokit.rest.pulls.update({ owner, repo, pull_number: openPr.number, title: pr.title, body: pr.body });
|
|
2573
|
+
}
|
|
2574
|
+
return { action: "unchanged", url: openPr.html_url, number: openPr.number, branch: pr.branch, commit: head };
|
|
2575
|
+
}
|
|
2576
|
+
const commit2 = await commitOnBase(octokit, owner, repo, openPr.base.ref, pr.files, message);
|
|
2577
|
+
await octokit.rest.git.updateRef({ owner, repo, ref: `heads/${pr.branch}`, sha: commit2, force: true });
|
|
2578
|
+
await octokit.rest.pulls.update({ owner, repo, pull_number: openPr.number, title: pr.title, body: pr.body });
|
|
2579
|
+
return { action: "updated", url: openPr.html_url, number: openPr.number, branch: pr.branch, commit: commit2 };
|
|
2580
|
+
}
|
|
2581
|
+
if (head && !ours && !latest?.merged_at && !closedByMaintainer) return { action: "skipped", reason: "a branch with this name exists and is not SHAPI's", branch: pr.branch };
|
|
2582
|
+
const base = pr.base ?? (await octokit.rest.repos.get({ owner, repo })).data.default_branch;
|
|
2583
|
+
const commit = await commitOnBase(octokit, owner, repo, base, pr.files, message);
|
|
2584
|
+
if (head) await octokit.rest.git.updateRef({ owner, repo, ref: `heads/${pr.branch}`, sha: commit, force: true });
|
|
2585
|
+
else await octokit.rest.git.createRef({ owner, repo, ref: `refs/heads/${pr.branch}`, sha: commit });
|
|
2586
|
+
const opened = await octokit.rest.pulls.create({ owner, repo, head: pr.branch, base, title: pr.title, body: pr.body });
|
|
2587
|
+
if (pr.labels?.length) await octokit.rest.issues.addLabels({ owner, repo, issue_number: opened.data.number, labels: pr.labels });
|
|
2588
|
+
return { action: "created", url: opened.data.html_url, number: opened.data.number, branch: pr.branch, commit };
|
|
2589
|
+
}
|
|
2590
|
+
function splitRepo(full) {
|
|
2591
|
+
const [owner, repo] = full.split("/");
|
|
2592
|
+
if (!owner || !repo) throw new Error(`repo must be owner/name, got ${full}`);
|
|
2593
|
+
return { owner, repo };
|
|
2594
|
+
}
|
|
2595
|
+
async function branchHead(octokit, owner, repo, branch) {
|
|
2596
|
+
const { data } = await octokit.rest.git.listMatchingRefs({ owner, repo, ref: `heads/${branch}` });
|
|
2597
|
+
return data.find((r) => r.ref === `refs/heads/${branch}`)?.object.sha;
|
|
2598
|
+
}
|
|
2599
|
+
async function commitOnBase(octokit, owner, repo, base, files, message) {
|
|
2600
|
+
const baseSha = (await octokit.rest.git.getRef({ owner, repo, ref: `heads/${base}` })).data.object.sha;
|
|
2601
|
+
const baseTree = (await octokit.rest.git.getCommit({ owner, repo, commit_sha: baseSha })).data.tree.sha;
|
|
2602
|
+
const existing = new Set(
|
|
2603
|
+
(await octokit.rest.git.getTree({ owner, repo, tree_sha: baseTree, recursive: "true" })).data.tree.map((e) => e.path)
|
|
2604
|
+
);
|
|
2605
|
+
const contents = await Promise.all(
|
|
2606
|
+
files.map(async (f) => ({
|
|
2607
|
+
...f,
|
|
2608
|
+
content: existing.has(f.path) ? await matchLineEndings(octokit, owner, repo, base, f.path, f.content) : f.content.replace(/\r\n/g, "\n")
|
|
2609
|
+
}))
|
|
2610
|
+
);
|
|
2611
|
+
const tree = await octokit.rest.git.createTree({
|
|
2612
|
+
owner,
|
|
2613
|
+
repo,
|
|
2614
|
+
base_tree: baseTree,
|
|
2615
|
+
tree: contents.map((f) => ({ path: f.path, mode: "100644", type: "blob", content: f.content }))
|
|
2616
|
+
});
|
|
2617
|
+
const commit = await octokit.rest.git.createCommit({ owner, repo, message, tree: tree.data.sha, parents: [baseSha] });
|
|
2618
|
+
return commit.data.sha;
|
|
2619
|
+
}
|
|
2620
|
+
async function readFile(octokit, owner, repo, ref, filePath) {
|
|
2621
|
+
try {
|
|
2622
|
+
const res = await octokit.rest.repos.getContent({ owner, repo, path: filePath, ref });
|
|
2623
|
+
return !Array.isArray(res.data) && res.data.type === "file" && "content" in res.data ? Buffer.from(res.data.content, "base64").toString("utf8") : void 0;
|
|
2624
|
+
} catch (err) {
|
|
2625
|
+
if (err.status === 404) return void 0;
|
|
2626
|
+
throw err;
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
async function filesMatch(octokit, owner, repo, ref, files) {
|
|
2630
|
+
const lf = (s) => s.replace(/\r\n/g, "\n");
|
|
2631
|
+
for (const f of files) {
|
|
2632
|
+
const current = await readFile(octokit, owner, repo, ref, f.path);
|
|
2633
|
+
if (current === void 0 || lf(current) !== lf(f.content)) return false;
|
|
2634
|
+
}
|
|
2635
|
+
return true;
|
|
2636
|
+
}
|
|
2637
|
+
async function matchLineEndings(octokit, owner, repo, ref, filePath, content) {
|
|
2638
|
+
const current = await readFile(octokit, owner, repo, ref, filePath) ?? "";
|
|
2639
|
+
const lf = content.replace(/\r\n/g, "\n");
|
|
2640
|
+
return current.includes("\r\n") ? lf.replace(/\n/g, "\r\n") : lf;
|
|
2641
|
+
}
|
|
2642
|
+
function repoFromUrl(url) {
|
|
2643
|
+
const m = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(url.trim());
|
|
2644
|
+
return m ? `${m[1]}/${m[2]}` : void 0;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
// packages/github-app/src/body.ts
|
|
2648
|
+
function prTitle(advisory) {
|
|
2649
|
+
return `SHAPI: ${advisory.title}`;
|
|
2650
|
+
}
|
|
2651
|
+
function prBranch(advisory) {
|
|
2652
|
+
return `shapi/${advisory.id}`;
|
|
2653
|
+
}
|
|
2654
|
+
function renderPrBody({ advisory, callSite, result, affectedInRepo, shapeSource }) {
|
|
2655
|
+
const source = result.changes.filter((c) => c.file !== result.test?.file);
|
|
2656
|
+
const notes = source.flatMap((c) => c.notes.map((n) => `- ${n}`));
|
|
2657
|
+
const method = result.method === "recipe" ? `deterministic recipe \`${advisory.recipeId}\`` : "Claude, verified by the contract test";
|
|
2658
|
+
const evidence = advisory.evidence;
|
|
2659
|
+
const lines = [
|
|
2660
|
+
`**${advisory.provider}** changed \`${advisory.endpoint.method} ${advisory.endpoint.path}\`: ${advisory.title}.`,
|
|
2661
|
+
"",
|
|
2662
|
+
advisory.summary,
|
|
2663
|
+
"",
|
|
2664
|
+
"### Affected call site",
|
|
2665
|
+
`\`${callSite.file}:${callSite.line}\` (${callSite.method} ${callSite.endpoint})`,
|
|
2666
|
+
"",
|
|
2667
|
+
`### Change (${method})`,
|
|
2668
|
+
...notes.length ? notes : source.map((c) => `- \`${c.file}\``),
|
|
2669
|
+
"",
|
|
2670
|
+
"### Contract test",
|
|
2671
|
+
`Added \`${result.test?.file}\`. It calls the code with the response it was written for and the response ${advisory.provider} sends now.`,
|
|
2672
|
+
"",
|
|
2673
|
+
"| Run | Result |",
|
|
2674
|
+
"|---|---|",
|
|
2675
|
+
`| Before this change | ${summary(result.baseline)} |`,
|
|
2676
|
+
`| After this change | ${summary(result.attempts.find((a) => a.outcome === "passed")?.run)} |`,
|
|
2677
|
+
"",
|
|
2678
|
+
"### Evidence",
|
|
2679
|
+
"| | |",
|
|
2680
|
+
"|---|---|",
|
|
2681
|
+
`| Advisory | \`${advisory.id}\` (${advisory.status}) |`,
|
|
2682
|
+
`| Severity | ${advisory.severity} |`,
|
|
2683
|
+
`| Change class | ${advisory.changeClass} |`,
|
|
2684
|
+
`| First observed | ${advisory.firstObserved ?? "unknown"} |`,
|
|
2685
|
+
`| Provider notice | ${advisory.publishedNotice ? `[${advisory.publishedNotice.date ?? "link"}](${advisory.publishedNotice.url})` : "none (undocumented change)"} |`,
|
|
2686
|
+
`| Shapes from | ${shapeSource} |`,
|
|
2687
|
+
...evidence ? [`| Observed | ${evidence.before.status} at ${evidence.before.observedAt} \u2192 ${evidence.after.status} at ${evidence.after.observedAt} (${evidence.source}) |`] : [],
|
|
2688
|
+
`| Blast radius | ${affectedInRepo} affected call site${affectedInRepo === 1 ? "" : "s"} in this repo |`,
|
|
2689
|
+
`| Sources | ${advisory.sources.map((s, i) => `[${i + 1}](${s})`).join(" ")} |`,
|
|
2690
|
+
"",
|
|
2691
|
+
`Migration note: ${advisory.migrationNote}`,
|
|
2692
|
+
"",
|
|
2693
|
+
"---",
|
|
2694
|
+
"Opened by [SHAPI](https://github.com/SriVarshini09/YC). The change was kept only because the contract test failed before it and passes after it."
|
|
2695
|
+
];
|
|
2696
|
+
return lines.join("\n") + "\n";
|
|
2697
|
+
}
|
|
2698
|
+
function summary(run2) {
|
|
2699
|
+
if (!run2) return "not run";
|
|
2700
|
+
const m = /Tests\s+([^\n]+?)\s*\(\d+\)/.exec(run2.output);
|
|
2701
|
+
return `${run2.passed ? "pass" : "fail"}${m ? ` (${m[1].trim()})` : ""}`;
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// packages/github-app/src/app.ts
|
|
2705
|
+
import { createHmac as createHmac2, timingSafeEqual } from "crypto";
|
|
2706
|
+
import { createAppAuth as createAppAuth2 } from "@octokit/auth-app";
|
|
2707
|
+
|
|
2708
|
+
// packages/github-app/src/client.ts
|
|
2709
|
+
import { retry } from "@octokit/plugin-retry";
|
|
2710
|
+
import { throttling } from "@octokit/plugin-throttling";
|
|
2711
|
+
import { Octokit as Octokit2 } from "@octokit/rest";
|
|
2712
|
+
var ResilientOctokit = Octokit2.plugin(throttling, retry);
|
|
2713
|
+
|
|
2714
|
+
// packages/github-app/src/snapshot.ts
|
|
2715
|
+
import { Parser } from "tar";
|
|
2716
|
+
|
|
2717
|
+
// packages/cli/src/scan.ts
|
|
2718
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
2719
|
+
import { mkdtempSync as mkdtempSync2, rmSync } from "fs";
|
|
2720
|
+
import os2 from "os";
|
|
2721
|
+
import path11 from "path";
|
|
2722
|
+
|
|
2723
|
+
// packages/cli/src/paths.ts
|
|
2724
|
+
import { existsSync as existsSync3 } from "fs";
|
|
2725
|
+
import { fileURLToPath } from "url";
|
|
2726
|
+
function bundledDir(published, monorepo) {
|
|
2727
|
+
const candidates = [new URL(`../${published}`, import.meta.url), new URL(`../../../${monorepo}`, import.meta.url)].map((u) => fileURLToPath(u));
|
|
2728
|
+
return candidates.find((p) => existsSync3(p)) ?? candidates[candidates.length - 1];
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// packages/cli/src/scan.ts
|
|
2732
|
+
var VERSION = "0.1.0";
|
|
2733
|
+
var DEFAULT_ADVISORIES = bundledDir("advisories", "advisories");
|
|
2734
|
+
function scan(target, opts) {
|
|
2735
|
+
const started = performance.now();
|
|
2736
|
+
const failOn = parseFailOn(opts.failOn ?? "critical");
|
|
2737
|
+
const now = opts.now ? new Date(opts.now) : /* @__PURE__ */ new Date();
|
|
2738
|
+
if (Number.isNaN(now.getTime())) throw new Error(`--now: not a date: ${opts.now}`);
|
|
2739
|
+
let dir = path11.resolve(target ?? ".");
|
|
2740
|
+
let label2 = target ?? ".";
|
|
2741
|
+
let cleanup;
|
|
2742
|
+
if (opts.repo) {
|
|
2743
|
+
if (opts.repo.startsWith("-")) throw new Error(`--repo: not a repository URL: ${opts.repo}`);
|
|
2744
|
+
cleanup = mkdtempSync2(path11.join(os2.tmpdir(), "shapi-scan-"));
|
|
2745
|
+
execFileSync2("git", ["-c", "core.autocrlf=false", "clone", "--depth", "1", "--quiet", "--", opts.repo, cleanup], { stdio: ["ignore", "ignore", "pipe"] });
|
|
2746
|
+
dir = cleanup;
|
|
2747
|
+
label2 = opts.repo;
|
|
2748
|
+
}
|
|
2749
|
+
try {
|
|
2750
|
+
const extracted = extractCallSites(dir);
|
|
2751
|
+
const lock = createApiLock(extracted, `shapi ${VERSION}`);
|
|
2752
|
+
if (opts.out) writeApiLock(opts.out, lock);
|
|
2753
|
+
const advisories = loadAdvisories(opts.advisories ?? DEFAULT_ADVISORIES);
|
|
2754
|
+
const match = matchAdvisories(lock, advisories, { now });
|
|
2755
|
+
const failing = failOn === "none" ? [] : match.affected.filter((f) => SEVERITY_RANK[f.advisory.severity] <= SEVERITY_RANK[failOn]);
|
|
2756
|
+
return {
|
|
2757
|
+
label: label2,
|
|
2758
|
+
filesScanned: extracted.filesScanned,
|
|
2759
|
+
lock,
|
|
2760
|
+
match,
|
|
2761
|
+
elapsedMs: Math.round(performance.now() - started),
|
|
2762
|
+
exitCode: failing.length > 0 ? 1 : 0
|
|
2763
|
+
};
|
|
2764
|
+
} finally {
|
|
2765
|
+
if (cleanup) rmSync(cleanup, { recursive: true, force: true });
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
function parseFailOn(value2) {
|
|
2769
|
+
if (value2 === "none" || SEVERITIES.includes(value2)) return value2;
|
|
2770
|
+
throw new Error(`--fail-on: expected one of ${[...SEVERITIES, "none"].join(", ")}, got ${value2}`);
|
|
2771
|
+
}
|
|
2772
|
+
function toJsonReport(o) {
|
|
2773
|
+
const finding = (f) => ({
|
|
2774
|
+
advisory: f.advisory.id,
|
|
2775
|
+
severity: f.advisory.severity,
|
|
2776
|
+
title: f.advisory.title,
|
|
2777
|
+
callSite: f.callSite.id,
|
|
2778
|
+
file: f.callSite.file,
|
|
2779
|
+
line: f.callSite.line,
|
|
2780
|
+
method: f.callSite.method,
|
|
2781
|
+
endpoint: f.callSite.endpoint,
|
|
2782
|
+
reasons: f.reasons,
|
|
2783
|
+
migrationNote: f.advisory.migrationNote
|
|
2784
|
+
});
|
|
2785
|
+
return {
|
|
2786
|
+
target: o.label,
|
|
2787
|
+
filesScanned: o.filesScanned,
|
|
2788
|
+
affected: o.match.affected.map(finding),
|
|
2789
|
+
exposed: o.match.exposed.map(finding),
|
|
2790
|
+
upcoming: o.match.upcoming.map((u) => ({
|
|
2791
|
+
advisory: u.advisory.id,
|
|
2792
|
+
provider: u.advisory.provider,
|
|
2793
|
+
title: u.advisory.title,
|
|
2794
|
+
effectiveDate: u.advisory.effectiveDate,
|
|
2795
|
+
daysUntil: u.daysUntil,
|
|
2796
|
+
callSites: u.callSites
|
|
2797
|
+
})),
|
|
2798
|
+
lock: o.lock
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
// packages/cli/src/fix.ts
|
|
2803
|
+
var DEFAULT_RECORDINGS = bundledDir("recordings", "fixtures/recordings");
|
|
2804
|
+
async function fix(target, opts, deps) {
|
|
2805
|
+
let dir = path12.resolve(target ?? ".");
|
|
2806
|
+
let cleanup;
|
|
2807
|
+
let remote = opts.repo ? repoFromUrl(opts.repo) : void 0;
|
|
2808
|
+
if (opts.repo) {
|
|
2809
|
+
if (opts.repo.startsWith("-")) throw new Error(`--repo: not a repository URL: ${opts.repo}`);
|
|
2810
|
+
cleanup = mkdtempSync3(path12.join(os3.tmpdir(), "shapi-fix-"));
|
|
2811
|
+
execFileSync3("git", ["-c", "core.autocrlf=false", "clone", "--depth", "1", "--quiet", "--", opts.repo, cleanup], { stdio: ["ignore", "ignore", "pipe"] });
|
|
2812
|
+
dir = cleanup;
|
|
2813
|
+
} else if (opts.pr) {
|
|
2814
|
+
remote = originRepo(dir);
|
|
2815
|
+
}
|
|
2816
|
+
if (opts.pr && !remote) throw new Error("--pr needs a GitHub repository: pass --repo <github url> or run inside a clone whose origin is on GitHub");
|
|
2817
|
+
try {
|
|
2818
|
+
const advisories = loadAdvisories(opts.advisories ?? DEFAULT_ADVISORIES).filter((a) => !opts.advisory || a.id === opts.advisory);
|
|
2819
|
+
const lock = createApiLock(extractCallSites(dir), "shapi fix");
|
|
2820
|
+
const now = opts.now ? new Date(opts.now) : /* @__PURE__ */ new Date();
|
|
2821
|
+
const { affected } = matchAdvisories(lock, advisories, { now });
|
|
2822
|
+
if (affected.length === 0) deps.log("No affected call sites: nothing to fix.");
|
|
2823
|
+
const llm = opts.llm ? claudeFixer() : void 0;
|
|
2824
|
+
const outcomes = [];
|
|
2825
|
+
for (const finding of affected) {
|
|
2826
|
+
const { advisory, callSite } = finding;
|
|
2827
|
+
const where = `${callSite.file}:${callSite.line}`;
|
|
2828
|
+
const shapes = findShapes(advisory, opts);
|
|
2829
|
+
if (!shapes) {
|
|
2830
|
+
const skipped = "no before/after shapes for this endpoint (record them with the canary, or add a recording)";
|
|
2831
|
+
deps.log(`SKIP ${where} ${advisory.id}: ${skipped}`);
|
|
2832
|
+
outcomes.push({ finding, skipped });
|
|
2833
|
+
continue;
|
|
2834
|
+
}
|
|
2835
|
+
const result = await repairCallSite({ repoDir: dir, advisory, callSite, before: shapes.before, after: shapes.after, recipes: RECIPES, llm });
|
|
2836
|
+
const outcome = { finding, shapeSource: shapes.source, result };
|
|
2837
|
+
outcomes.push(outcome);
|
|
2838
|
+
deps.log(`${result.status.toUpperCase().padEnd(15)} ${where} ${advisory.id}${result.method ? ` via ${result.method}` : ""}${result.reason ? `: ${result.reason}` : ""}`);
|
|
2839
|
+
for (const a of result.attempts) deps.log(` ${a.method}: ${a.outcome} (${a.detail})`);
|
|
2840
|
+
if (result.status !== "fixed") continue;
|
|
2841
|
+
for (const c of result.changes) deps.log(` ${c.before ? "M" : "A"} ${c.file}`);
|
|
2842
|
+
const files = result.changes.map((c) => ({ path: c.file, content: c.after }));
|
|
2843
|
+
if (opts.write) {
|
|
2844
|
+
for (const f of files) {
|
|
2845
|
+
mkdirSync5(path12.dirname(path12.join(dir, f.path)), { recursive: true });
|
|
2846
|
+
writeFileSync4(path12.join(dir, f.path), f.content);
|
|
2847
|
+
}
|
|
2848
|
+
deps.log(` wrote ${files.length} files into ${dir}`);
|
|
2849
|
+
}
|
|
2850
|
+
if (opts.pr && remote) {
|
|
2851
|
+
const meta = {
|
|
2852
|
+
title: prTitle(advisory),
|
|
2853
|
+
branch: prBranch(advisory),
|
|
2854
|
+
commitMessage: `fix: ${advisory.title}
|
|
2855
|
+
|
|
2856
|
+
Advisory ${advisory.id}. Contract test ${result.test?.file} fails before this change and passes after.`,
|
|
2857
|
+
body: renderPrBody({
|
|
2858
|
+
advisory,
|
|
2859
|
+
callSite,
|
|
2860
|
+
result,
|
|
2861
|
+
affectedInRepo: affected.filter((f) => f.advisory.id === advisory.id).length,
|
|
2862
|
+
shapeSource: shapes.source
|
|
2863
|
+
})
|
|
2864
|
+
};
|
|
2865
|
+
outcome.pr = await (deps.deliver ?? deliverToGitHub)(remote, files, meta);
|
|
2866
|
+
deps.log(` pull request: ${outcome.pr.url}`);
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
return outcomes;
|
|
2870
|
+
} finally {
|
|
2871
|
+
if (cleanup) rmSync2(cleanup, { recursive: true, force: true });
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
async function deliverToGitHub(repo, files, meta) {
|
|
2875
|
+
const auth = authFromEnv();
|
|
2876
|
+
if (!auth) throw new Error("no GitHub credentials: set GITHUB_TOKEN, log in with `gh auth login`, or configure the SHAPI GitHub App variables");
|
|
2877
|
+
const outcome = await upsertFixPr(octokitFor(auth), { repo, files, ...meta, labels: ["shapi"] });
|
|
2878
|
+
if (outcome.action === "skipped") throw new Error(`not delivered: ${outcome.reason}${outcome.url ? ` (${outcome.url})` : ""}`);
|
|
2879
|
+
return { url: outcome.url, number: outcome.number, branch: outcome.branch, commit: outcome.commit };
|
|
2880
|
+
}
|
|
2881
|
+
function findShapes(advisory, opts) {
|
|
2882
|
+
if (opts.db && existsSync4(opts.db)) {
|
|
2883
|
+
const store = ShapeStore.open(opts.db);
|
|
2884
|
+
try {
|
|
2885
|
+
for (const e of store.endpoints()) {
|
|
2886
|
+
if (e.provider !== advisory.provider || !pathMatches(e.endpoint, advisory.endpoint)) continue;
|
|
2887
|
+
if (advisory.endpoint.method !== "*" && e.method !== advisory.endpoint.method) continue;
|
|
2888
|
+
const after = store.latest(e.provider, e.method, e.endpoint, e.source);
|
|
2889
|
+
const before = store.previousDistinct(e.provider, e.method, e.endpoint, e.source);
|
|
2890
|
+
if (before && after) return { before: before.shape, after: after.shape, source: `${e.source} (${opts.db})` };
|
|
2891
|
+
}
|
|
2892
|
+
} finally {
|
|
2893
|
+
store.close();
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
const dir = opts.recordings ?? DEFAULT_RECORDINGS;
|
|
2897
|
+
if (!existsSync4(dir)) return void 0;
|
|
2898
|
+
for (const name of readdirSync3(dir).filter((f) => f.endsWith(".json")).sort()) {
|
|
2899
|
+
const recording = loadRecording(path12.join(dir, name));
|
|
2900
|
+
if (recording.provider !== advisory.provider || !pathMatches(recording.endpoint, advisory.endpoint)) continue;
|
|
2901
|
+
if (advisory.endpoint.method !== "*" && recording.method !== advisory.endpoint.method) continue;
|
|
2902
|
+
const store = ShapeStore.open(":memory:");
|
|
2903
|
+
const replayed = replay(recording, store, process.env.SHAPI_SHAPE_SECRET ?? "shapi-dev-secret");
|
|
2904
|
+
store.close();
|
|
2905
|
+
if (replayed.before) return { before: replayed.before.shape, after: replayed.after.shape, source: `recording ${name}` };
|
|
2906
|
+
}
|
|
2907
|
+
return void 0;
|
|
2908
|
+
}
|
|
2909
|
+
function originRepo(dir) {
|
|
2910
|
+
try {
|
|
2911
|
+
return repoFromUrl(execFileSync3("git", ["remote", "get-url", "origin"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }));
|
|
2912
|
+
} catch {
|
|
2913
|
+
return void 0;
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
// packages/cli/src/report.ts
|
|
2918
|
+
import { styleText } from "util";
|
|
2919
|
+
function renderText(o, color) {
|
|
2920
|
+
const paint = (style, s) => color ? styleText(style, s) : s;
|
|
2921
|
+
const out = [];
|
|
2922
|
+
const { lock, match } = o;
|
|
2923
|
+
const providers = lock.providers;
|
|
2924
|
+
const unresolved = lock.callSites.filter((c) => !c.resolved).length;
|
|
2925
|
+
out.push(`${paint("bold", "SHAPI scan")} ${o.label}`);
|
|
2926
|
+
out.push(
|
|
2927
|
+
paint(
|
|
2928
|
+
"dim",
|
|
2929
|
+
[plural(o.filesScanned, "file"), plural(lock.callSites.length, "call site"), plural(providers.length, "provider"), `${o.elapsedMs} ms`].join(" \xB7 ")
|
|
2930
|
+
)
|
|
2931
|
+
);
|
|
2932
|
+
out.push("");
|
|
2933
|
+
out.push(paint("bold", "Providers"));
|
|
2934
|
+
if (providers.length === 0) out.push(" none found");
|
|
2935
|
+
const idWidth = Math.max(0, ...providers.map((p) => p.id.length));
|
|
2936
|
+
for (const p of providers) {
|
|
2937
|
+
const sites = plural(p.callSites, "call site");
|
|
2938
|
+
out.push(` ${p.id.padEnd(idWidth)} ${sites.padEnd(13)} ${paint("dim", p.declaredBy.join(", "))}`.trimEnd());
|
|
2939
|
+
}
|
|
2940
|
+
out.push("");
|
|
2941
|
+
out.push(paint("bold", `Affected call sites (${match.affected.length})`));
|
|
2942
|
+
if (match.affected.length === 0) out.push(" none");
|
|
2943
|
+
for (const f of match.affected) out.push(...renderFinding(f, paint));
|
|
2944
|
+
out.push("");
|
|
2945
|
+
if (match.exposed.length > 0) {
|
|
2946
|
+
out.push(paint("bold", `Exposed, not affected (${match.exposed.length})`));
|
|
2947
|
+
for (const f of match.exposed) {
|
|
2948
|
+
out.push(` ${f.callSite.file}:${f.callSite.line} ${f.advisory.id} ${paint("dim", f.reasons.join("; "))}`);
|
|
2949
|
+
}
|
|
2950
|
+
out.push("");
|
|
2951
|
+
}
|
|
2952
|
+
out.push(paint("bold", "Deadlines in the next 90 days"));
|
|
2953
|
+
if (match.upcoming.length === 0) out.push(" none for the providers in this repo");
|
|
2954
|
+
for (const u of match.upcoming) {
|
|
2955
|
+
const where = u.callSites > 0 ? plural(u.callSites, "call site") : "declared dependency";
|
|
2956
|
+
out.push(` ${u.advisory.effectiveDate} (${u.daysUntil} days) ${u.advisory.provider} ${u.advisory.title} ${paint("dim", where)}`);
|
|
2957
|
+
}
|
|
2958
|
+
if (unresolved > 0) {
|
|
2959
|
+
out.push("");
|
|
2960
|
+
out.push(paint("dim", `${plural(unresolved, "call site")} could not be fully resolved (see --json or --out api.lock).`));
|
|
2961
|
+
}
|
|
2962
|
+
return out.join("\n") + "\n";
|
|
2963
|
+
}
|
|
2964
|
+
var SEVERITY_STYLE = { critical: "red", high: "yellow", medium: "cyan", low: "gray" };
|
|
2965
|
+
function renderFinding(f, paint) {
|
|
2966
|
+
const sev = f.advisory.severity.toUpperCase().padEnd(8);
|
|
2967
|
+
const site = f.callSite;
|
|
2968
|
+
const call = `${site.method ?? "?"} ${site.endpoint ?? "?"}`;
|
|
2969
|
+
const indent = " ".repeat(12);
|
|
2970
|
+
const lines = [
|
|
2971
|
+
` ${paint(SEVERITY_STYLE[f.advisory.severity] ?? "white", sev)} ${paint("bold", `${site.file}:${site.line}`)} ${call}`,
|
|
2972
|
+
`${indent}${f.advisory.title} ${paint("dim", `[${f.advisory.id}]`)}`,
|
|
2973
|
+
`${indent}why: ${f.reasons.join("; ")}`,
|
|
2974
|
+
`${indent}fix: ${f.advisory.migrationNote}`
|
|
2975
|
+
];
|
|
2976
|
+
if (f.advisory.status === "unverified") lines.push(`${indent}${paint("dim", "note: unverified advisory (secondary reports only)")}`);
|
|
2977
|
+
return lines;
|
|
2978
|
+
}
|
|
2979
|
+
function plural(n, noun) {
|
|
2980
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
// packages/cli/src/index.ts
|
|
2984
|
+
var processIo = {
|
|
2985
|
+
stdout: (s) => process.stdout.write(s),
|
|
2986
|
+
stderr: (s) => process.stderr.write(s),
|
|
2987
|
+
isTTY: process.stdout.isTTY
|
|
2988
|
+
};
|
|
2989
|
+
async function run(argv, io = processIo, fixDeps = {}) {
|
|
2990
|
+
let exitCode = 0;
|
|
2991
|
+
const program = new Command2().name("shapi").description("Map the third-party APIs a codebase depends on and flag the breaking changes that hit it.").version(VERSION).exitOverride().configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
|
|
2992
|
+
program.command("scan").description("Scan a repository and report affected call sites").argument("[path]", "directory to scan", ".").option("--json", "print a JSON report (includes the api.lock)").option("--out <file>", "write the api.lock to this file").option("--repo <url>", "clone a git repository (shallow) into a temp dir and scan it").option("--advisories <dir>", "advisory directory (default: the bundled advisories/)").option("--fail-on <severity>", "exit 1 when an affected call site is at least this severe (critical|high|medium|low|none)", "critical").option("--no-color", "disable color").addOption(new Option("--now <date>", "evaluate deadlines as of this date").hideHelp()).action((target, opts) => {
|
|
2993
|
+
const outcome = scan(opts.repo ? void 0 : target, opts);
|
|
2994
|
+
if (opts.json) io.stdout(JSON.stringify(toJsonReport(outcome), null, 2) + "\n");
|
|
2995
|
+
else io.stdout(renderText(outcome, opts.color !== false && Boolean(io.isTTY)));
|
|
2996
|
+
exitCode = outcome.exitCode;
|
|
2997
|
+
});
|
|
2998
|
+
program.command("fix").description("Repair affected call sites test-first: generate a contract test, apply the recipe (or Claude), keep the change only if the test passes").argument("[path]", "repository to fix", ".").option("--repo <url>", "clone a GitHub repository (shallow) and fix it; with --pr the PR goes to that repository").option("--advisory <id>", "only this advisory").option("--advisories <dir>", "advisory directory (default: the bundled advisories/)").option("--db <file>", "shape store with canary observations (checked before recordings)", ".shapi/shapes.db").option("--recordings <dir>", "recorded response sequences (default: the bundled fixtures/recordings/)").option("--llm", "ask Claude when no recipe fixes the call site (needs ANTHROPIC_API_KEY)").option("--write", "write the fix and the contract test into the local repository").option("--pr", "open a pull request with the fix and the contract test").addOption(new Option("--now <date>", "evaluate deadlines as of this date").hideHelp()).action(async (target, opts) => {
|
|
2999
|
+
const outcomes = await fix(opts.repo ? void 0 : target, opts, { log: (l) => io.stdout(`${l}
|
|
3000
|
+
`), ...fixDeps });
|
|
3001
|
+
exitCode = outcomes.every((o) => o.result?.status === "fixed" || o.result?.status === "already-handled") ? 0 : 1;
|
|
3002
|
+
});
|
|
3003
|
+
try {
|
|
3004
|
+
await program.parseAsync(argv);
|
|
3005
|
+
return exitCode;
|
|
3006
|
+
} catch (err) {
|
|
3007
|
+
if (err instanceof CommanderError2) return err.exitCode;
|
|
3008
|
+
io.stderr(`shapi: ${err instanceof Error ? err.message : String(err)}
|
|
3009
|
+
`);
|
|
3010
|
+
return 2;
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
async function main(argv) {
|
|
3014
|
+
return run(argv);
|
|
3015
|
+
}
|
|
3016
|
+
export {
|
|
3017
|
+
DEFAULT_ADVISORIES,
|
|
3018
|
+
DEFAULT_RECORDINGS,
|
|
3019
|
+
VERSION,
|
|
3020
|
+
fix,
|
|
3021
|
+
main,
|
|
3022
|
+
renderText,
|
|
3023
|
+
run,
|
|
3024
|
+
scan,
|
|
3025
|
+
toJsonReport
|
|
3026
|
+
};
|