shapi-cli 0.1.0 → 0.3.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.
Files changed (55) hide show
  1. package/README.md +7 -4
  2. package/advisories/anthropic-api-version-2026-10-21-sampling-params-400.json +35 -0
  3. package/advisories/google-ads-api-v22-sunset.json +27 -0
  4. package/advisories/google-custom-search-json-api-shutdown.json +27 -0
  5. package/advisories/google-gemini-2-0-flash-shutdown.json +27 -0
  6. package/advisories/google-gemini-2-5-flash-image-shutdown.json +27 -0
  7. package/advisories/google-gemini-2-5-preview-tts-shutdown.json +27 -0
  8. package/advisories/hubspot-contact-lists-v1-sunset.json +28 -0
  9. package/advisories/hubspot-contacts-v1-list-memberships-removed.json +30 -0
  10. package/advisories/hubspot-marketing-emails-folderid-removed.json +39 -0
  11. package/advisories/hubspot-marketing-emails-v1-sunset.json +24 -0
  12. package/advisories/hubspot-pipelines-v1-sunset.json +22 -2
  13. package/advisories/meta-graph-commerce-order-management-removed.json +26 -0
  14. package/advisories/meta-graph-date-format-param-removed.json +30 -0
  15. package/advisories/meta-graph-delivery-estimate-fields-removed.json +30 -0
  16. package/advisories/meta-graph-instagram-oembed-fields-removed.json +33 -0
  17. package/advisories/meta-graph-marketing-api-v24-sunset.json +34 -0
  18. package/advisories/meta-graph-marketing-api-v25-sunset.json +35 -0
  19. package/advisories/meta-graph-page-insights-impressions-fans-removed.json +51 -0
  20. package/advisories/meta-graph-page-insights-reach-video-views-removed.json +57 -0
  21. package/advisories/meta-graph-root-ids-lookup-removed.json +30 -0
  22. package/advisories/meta-graph-v20-sunset.json +10 -1
  23. package/advisories/meta-graph-v21-sunset.json +10 -1
  24. package/advisories/openai-assistants-api-shutdown.json +27 -0
  25. package/advisories/openai-audio-translations-endpoint-shutdown.json +27 -0
  26. package/advisories/openai-evals-api-shutdown.json +27 -0
  27. package/advisories/quickbooks-companyinfo-neoenabled-removed.json +29 -0
  28. package/advisories/quickbooks-minorversion-below-75-ignored.json +13 -1
  29. package/advisories/quickbooks-refresh-token-five-year-hard-expiry.json +29 -0
  30. package/advisories/salesforce-soap-login-broad-ip-range-token-required.json +30 -0
  31. package/advisories/salesforce-soap-login-disabled-when-unused.json +30 -0
  32. package/advisories/salesforce-soap-login-use-any-api-auth-required.json +31 -0
  33. package/advisories/shopify-admin-api-2026-01-unsupported.json +33 -0
  34. package/advisories/shopify-admin-api-2026-04-unsupported.json +33 -0
  35. package/advisories/shopify-global-catalog-rest-api-shutdown.json +24 -0
  36. package/advisories/shopify-non-expiring-offline-tokens-rejected.json +27 -0
  37. package/advisories/shopify-script-tags-stop-running.json +27 -0
  38. package/advisories/slack-files-upload-retired.json +28 -0
  39. package/advisories/stripe-checkout-session-ui-mode-values-renamed.json +53 -0
  40. package/advisories/twilio-conference-list-default-in-progress.json +27 -0
  41. package/advisories/twilio-notify-end-of-life.json +28 -0
  42. package/advisories/twilio-rcs-undelivered-error-code-30046.json +27 -0
  43. package/advisories/twilio-voice-insights-conference-participant-removed.json +28 -0
  44. package/advisories/x-activity-api-pascalcase-event-types-removed.json +31 -0
  45. package/advisories/x-self-serve-follow-writes-removed.json +27 -0
  46. package/advisories/x-self-serve-like-writes-removed.json +27 -0
  47. package/advisories/xero-allocations-contact-mismatch-rejected.json +27 -0
  48. package/advisories/xero-au-payroll-isqualifyingearnings-required.json +28 -0
  49. package/advisories/xero-finance-accounting-activities-decommissioned.json +28 -0
  50. package/advisories/xero-invoices-email-daily-limit-429.json +32 -0
  51. package/advisories/xero-nz-payroll-employee-tax-kiwisaverstatus-required.json +27 -0
  52. package/advisories/xero-organisation-class-ultra-enum.json +31 -0
  53. package/dist/index.js +1527 -161
  54. package/dist/tree-sitter-python.wasm +0 -0
  55. package/package.json +7 -9
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { Command as Command2, CommanderError as CommanderError2, Option } from "
3
3
 
4
4
  // packages/cli/src/fix.ts
5
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";
6
+ import { existsSync as existsSync5, mkdirSync as mkdirSync5, mkdtempSync as mkdtempSync3, readdirSync as readdirSync3, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
7
7
  import os3 from "os";
8
8
  import path12 from "path";
9
9
 
@@ -51,10 +51,34 @@ var providers_default = [
51
51
  "subscriptions.update": "POST /v1/subscriptions/{id}",
52
52
  "invoices.create": "POST /v1/invoices",
53
53
  "checkout.sessions.create": "POST /v1/checkout/sessions",
54
+ "checkout.sessions.retrieve": "GET /v1/checkout/sessions/{session}",
54
55
  "refunds.create": "POST /v1/refunds"
55
56
  },
56
57
  docsUrl: "https://docs.stripe.com/api",
57
- changelogUrl: "https://docs.stripe.com/changelog"
58
+ changelogUrl: "https://docs.stripe.com/changelog",
59
+ python: {
60
+ packages: [
61
+ { pypi: "stripe", module: "stripe" }
62
+ ],
63
+ methods: {
64
+ "checkout.Session.create": "POST /v1/checkout/sessions",
65
+ "checkout.Session.retrieve": "GET /v1/checkout/sessions/{session}",
66
+ "PaymentIntent.create": "POST /v1/payment_intents",
67
+ "PaymentIntent.retrieve": "GET /v1/payment_intents/{intent}",
68
+ "PaymentIntent.confirm": "POST /v1/payment_intents/{intent}/confirm",
69
+ "SetupIntent.create": "POST /v1/setup_intents",
70
+ "Customer.create": "POST /v1/customers",
71
+ "Customer.list": "GET /v1/customers",
72
+ "Subscription.create": "POST /v1/subscriptions",
73
+ "Balance.retrieve": "GET /v1/balance",
74
+ "checkout.sessions.create": "POST /v1/checkout/sessions",
75
+ "checkout.sessions.retrieve": "GET /v1/checkout/sessions/{session}",
76
+ "payment_intents.create": "POST /v1/payment_intents",
77
+ "payment_intents.retrieve": "GET /v1/payment_intents/{intent}",
78
+ "customers.create": "POST /v1/customers",
79
+ "customers.list": "GET /v1/customers"
80
+ }
81
+ }
58
82
  },
59
83
  {
60
84
  id: "twilio",
@@ -66,12 +90,23 @@ var providers_default = [
66
90
  "calls.create": "POST /2010-04-01/Accounts/{AccountSid}/Calls.json"
67
91
  },
68
92
  docsUrl: "https://www.twilio.com/docs/usage/api",
69
- changelogUrl: "https://www.twilio.com/en-us/changelog"
93
+ changelogUrl: "https://www.twilio.com/en-us/changelog",
94
+ python: {
95
+ packages: [
96
+ { pypi: "twilio", module: "twilio" }
97
+ ],
98
+ methods: {
99
+ "messages.create": "POST /2010-04-01/Accounts/{AccountSid}/Messages.json",
100
+ "messages.list": "GET /2010-04-01/Accounts/{AccountSid}/Messages.json",
101
+ "calls.create": "POST /2010-04-01/Accounts/{AccountSid}/Calls.json",
102
+ "conferences.list": "GET /2010-04-01/Accounts/{AccountSid}/Conferences.json"
103
+ }
104
+ }
70
105
  },
71
106
  {
72
107
  id: "shopify",
73
108
  name: "Shopify",
74
- hosts: ["*.myshopify.com"],
109
+ hosts: ["*.myshopify.com", "discover.shopifyapps.com"],
75
110
  sdkPackages: ["@shopify/shopify-api", "@shopify/admin-api-client", "shopify-api-node"],
76
111
  docsUrl: "https://shopify.dev/docs/api/admin",
77
112
  changelogUrl: "https://shopify.dev/changelog"
@@ -98,7 +133,18 @@ var providers_default = [
98
133
  hosts: ["api.hubapi.com", "api.hubspot.com"],
99
134
  sdkPackages: ["@hubspot/api-client"],
100
135
  docsUrl: "https://developers.hubspot.com/docs/api/overview",
101
- changelogUrl: "https://developers.hubspot.com/changelog"
136
+ changelogUrl: "https://developers.hubspot.com/changelog",
137
+ python: {
138
+ packages: [
139
+ { pypi: "hubspot-api-client", module: "hubspot" }
140
+ ],
141
+ methods: {
142
+ "crm.contacts.basic_api.get_page": "GET /crm/v3/objects/contacts",
143
+ "crm.contacts.basic_api.create": "POST /crm/v3/objects/contacts",
144
+ "crm.deals.basic_api.get_page": "GET /crm/v3/objects/deals",
145
+ "crm.pipelines.pipelines_api.get_all": "GET /crm/v3/pipelines/{objectType}"
146
+ }
147
+ }
102
148
  },
103
149
  {
104
150
  id: "slack",
@@ -114,7 +160,12 @@ var providers_default = [
114
160
  "files.uploadV2": "POST /api/files.uploadV2"
115
161
  },
116
162
  docsUrl: "https://api.slack.com/methods",
117
- changelogUrl: "https://api.slack.com/changelog"
163
+ changelogUrl: "https://api.slack.com/changelog",
164
+ python: {
165
+ packages: [
166
+ { pypi: "slack-sdk", module: "slack_sdk" }
167
+ ]
168
+ }
118
169
  },
119
170
  {
120
171
  id: "openai",
@@ -132,7 +183,27 @@ var providers_default = [
132
183
  "files.create": "POST /v1/files"
133
184
  },
134
185
  docsUrl: "https://platform.openai.com/docs/api-reference",
135
- changelogUrl: "https://platform.openai.com/docs/changelog"
186
+ changelogUrl: "https://platform.openai.com/docs/changelog",
187
+ python: {
188
+ packages: [
189
+ { pypi: "openai", module: "openai" }
190
+ ],
191
+ methods: {
192
+ "chat.completions.create": "POST /v1/chat/completions",
193
+ "responses.create": "POST /v1/responses",
194
+ "models.list": "GET /v1/models",
195
+ "embeddings.create": "POST /v1/embeddings",
196
+ "audio.translations.create": "POST /v1/audio/translations",
197
+ "audio.transcriptions.create": "POST /v1/audio/transcriptions",
198
+ "images.generate": "POST /v1/images/generations",
199
+ "beta.assistants.create": "POST /v1/assistants",
200
+ "beta.assistants.list": "GET /v1/assistants",
201
+ "beta.threads.create": "POST /v1/threads",
202
+ "beta.threads.runs.create": "POST /v1/threads/{thread_id}/runs",
203
+ "evals.create": "POST /v1/evals",
204
+ "evals.list": "GET /v1/evals"
205
+ }
206
+ }
136
207
  },
137
208
  {
138
209
  id: "anthropic",
@@ -146,7 +217,19 @@ var providers_default = [
146
217
  "models.list": "GET /v1/models"
147
218
  },
148
219
  docsUrl: "https://docs.claude.com/en/api",
149
- changelogUrl: "https://docs.claude.com/en/release-notes/api"
220
+ changelogUrl: "https://docs.claude.com/en/release-notes/api",
221
+ python: {
222
+ packages: [
223
+ { pypi: "anthropic", module: "anthropic" }
224
+ ],
225
+ methods: {
226
+ "messages.create": "POST /v1/messages",
227
+ "messages.stream": "POST /v1/messages",
228
+ "messages.count_tokens": "POST /v1/messages/count_tokens",
229
+ "messages.batches.create": "POST /v1/messages/batches",
230
+ "models.list": "GET /v1/models"
231
+ }
232
+ }
150
233
  },
151
234
  {
152
235
  id: "salesforce",
@@ -357,7 +440,12 @@ var ProviderSchema = z.object({
357
440
  /** SDK method chain (after the client, without a leading "rest.") to the HTTP endpoint it calls. */
358
441
  sdkMethods: z.record(z.string(), EndpointRefSchema).optional(),
359
442
  docsUrl: z.url(),
360
- changelogUrl: z.url().optional()
443
+ changelogUrl: z.url().optional(),
444
+ /** The provider's Python SDK: PyPI package and import module, and SDK method chain → HTTP endpoint. */
445
+ python: z.object({
446
+ packages: z.array(z.object({ pypi: z.string().min(1), module: z.string().min(1) })),
447
+ methods: z.record(z.string(), EndpointRefSchema).optional()
448
+ }).optional()
361
449
  });
362
450
  var ProviderRegistrySchema = z.array(ProviderSchema).refine((ps) => new Set(ps.map((p) => p.id)).size === ps.length, "provider ids must be unique");
363
451
  var cached;
@@ -365,6 +453,9 @@ function loadProviders() {
365
453
  cached ??= ProviderRegistrySchema.parse(providers_default);
366
454
  return cached;
367
455
  }
456
+ function getProvider(id) {
457
+ return loadProviders().find((p) => p.id === id);
458
+ }
368
459
  function providerForHost(host) {
369
460
  const h = host.toLowerCase().replace(/:\d+$/, "");
370
461
  let best;
@@ -378,9 +469,25 @@ function providerForHost(host) {
378
469
  }
379
470
  return best?.provider;
380
471
  }
472
+ var PATH_SIGNATURES = [
473
+ { provider: "shopify", path: /^\/admin\/api\/\d{4}-\d{2}(\/|$)/ },
474
+ { provider: "hubspot", path: /^\/crm-pipelines\/v\d+(\/|$)/ }
475
+ ];
476
+ function providerForPath(path13) {
477
+ const hit = PATH_SIGNATURES.find((s) => s.path.test(path13));
478
+ return hit ? getProvider(hit.provider) : void 0;
479
+ }
381
480
  function providerForPackage(name) {
382
481
  return loadProviders().find((p) => p.sdkPackages.includes(name));
383
482
  }
483
+ function providerForPythonModule(module) {
484
+ const top = module.split(".")[0];
485
+ return loadProviders().find((p) => p.python?.packages.some((pkg) => pkg.module === top));
486
+ }
487
+ function providerForPythonPackage(name) {
488
+ const norm = (s) => s.toLowerCase().replace(/[-_.]+/g, "-");
489
+ return loadProviders().find((p) => p.python?.packages.some((pkg) => norm(pkg.pypi) === norm(name)));
490
+ }
384
491
  function sdkEndpoint(provider, chain) {
385
492
  const methods = provider.sdkMethods ?? {};
386
493
  const key = chain.replace(/^rest\./, "");
@@ -516,11 +623,26 @@ var TriggerSchema = z3.object({
516
623
  enums: z3.array(z3.object({ field: z3.string() })).optional(),
517
624
  /** Affected when the request sets this header (optionally to one of these values). */
518
625
  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(),
626
+ /**
627
+ * Affected when the request sets this query parameter: optionally to a number below `below`, or to
628
+ * one of `values` (a comma-separated value such as `metric=a,b` matches when any item does).
629
+ */
630
+ query: z3.array(z3.object({ name: z3.string(), below: z3.number().optional(), values: z3.array(z3.string()).optional() })).optional(),
521
631
  /** Every call to the endpoint is affected (sunsets, auth and scope changes). */
522
632
  any: z3.boolean().optional()
523
633
  }).refine((t) => Object.values(t).some((v) => v !== void 0 && v !== false), "trigger needs at least one condition");
634
+ var FixSchema = z3.object({
635
+ /** Replace this part of the request path, for example "/v21.0/" with "/v26.0/". */
636
+ path: z3.object({ from: z3.string().min(1), to: z3.string().min(1) }).optional(),
637
+ /** Send this query parameter with this value, for example minorversion=75. */
638
+ query: z3.object({ name: z3.string().min(1), value: z3.string() }).optional(),
639
+ /** Send this request header with this value, for example Stripe-Version. */
640
+ header: z3.object({ name: z3.string().min(1), value: z3.string() }).optional(),
641
+ /** Response fields that were renamed: the code reads the new name, falling back to the old one. */
642
+ fields: z3.array(z3.object({ from: z3.string().min(1), to: z3.string().min(1) })).optional(),
643
+ /** Enum values that were renamed: comparisons with an old value also accept the new one. */
644
+ enums: z3.array(z3.object({ field: z3.string().min(1), values: z3.array(z3.object({ from: z3.string(), to: z3.string() })).min(1) })).optional()
645
+ }).refine((f) => Object.values(f).some((v) => v !== void 0), "fix needs at least one change");
524
646
  var AdvisorySchema = z3.object({
525
647
  id: z3.string().regex(/^[a-z0-9-]+$/),
526
648
  provider: z3.string(),
@@ -538,6 +660,8 @@ var AdvisorySchema = z3.object({
538
660
  severity: z3.enum(SEVERITIES),
539
661
  migrationNote: z3.string().min(1),
540
662
  recipeId: z3.string().optional(),
663
+ /** What `recipeId: "apply-fix"` changes. */
664
+ fix: FixSchema.optional(),
541
665
  sources: z3.array(z3.url()).min(1),
542
666
  /** "verified": checked against a primary or two independent sources. "unverified": secondary reports only. */
543
667
  status: z3.enum(["verified", "unverified"]),
@@ -573,10 +697,16 @@ function matchAdvisories(lock, advisories, options = {}) {
573
697
  const horizon = options.horizonDays ?? 90;
574
698
  const affected = [];
575
699
  const exposed = [];
700
+ const possible = [];
576
701
  const hitsPerAdvisory = /* @__PURE__ */ new Map();
577
702
  for (const advisory of advisories) {
578
703
  for (const callSite of lock.callSites) {
579
- if (!endpointMatches(callSite, advisory)) continue;
704
+ if (!endpointMatches(callSite, advisory)) {
705
+ if (endpointMayMatch(callSite, advisory) && triggerReasons(callSite, advisory.trigger).length > 0) {
706
+ possible.push({ advisory, callSite, reasons: [`builds the path at runtime (${describe(callSite)}); affected if it reaches ${advisory.endpoint.path}`] });
707
+ }
708
+ continue;
709
+ }
580
710
  hitsPerAdvisory.set(advisory.id, (hitsPerAdvisory.get(advisory.id) ?? 0) + 1);
581
711
  const reasons = triggerReasons(callSite, advisory.trigger);
582
712
  if (reasons.length > 0) affected.push({ advisory, callSite, reasons });
@@ -590,7 +720,7 @@ function matchAdvisories(lock, advisories, options = {}) {
590
720
  daysUntil: Math.round((Date.parse(advisory.effectiveDate) - today) / DAY_MS),
591
721
  callSites: hitsPerAdvisory.get(advisory.id) ?? 0
592
722
  })).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 };
723
+ return { affected: affected.sort(byFinding), exposed: exposed.sort(byFinding), possible: possible.sort(byFinding), upcoming };
594
724
  }
595
725
  function endpointMatches(site, advisory) {
596
726
  const pattern = advisory.endpoint;
@@ -599,6 +729,30 @@ function endpointMatches(site, advisory) {
599
729
  if (pattern.hosts && !(site.host && pattern.hosts.includes(site.host))) return false;
600
730
  return pathMatches(site.endpoint, pattern);
601
731
  }
732
+ function endpointMayMatch(site, advisory) {
733
+ const pattern = advisory.endpoint;
734
+ if (site.provider !== advisory.provider || site.endpoint === null) return false;
735
+ if (pattern.method !== "*" && site.method !== null && site.method !== pattern.method) return false;
736
+ if (pattern.hosts && site.host && !pattern.hosts.includes(site.host)) return false;
737
+ if (!/\{[^}]*\}/.test(site.endpoint)) return false;
738
+ const known = segments(site.endpoint);
739
+ const runtimeAt = known.findIndex(isRuntime);
740
+ const literals = segments(pattern.path).filter((p) => !/[*{]/.test(p));
741
+ if (!literals.some((l) => known.slice(0, runtimeAt < 0 ? known.length : runtimeAt).includes(l))) return false;
742
+ return segmentsMayMatch(known, segments(pattern.path));
743
+ }
744
+ var segments = (p) => p.split("/").filter(Boolean);
745
+ var isRuntime = (seg) => /^\{[^}]*\}$/.test(seg);
746
+ function segmentsMayMatch(site, pattern) {
747
+ if (pattern.length === 0) return site.length === 0;
748
+ const [p, ...prest] = pattern;
749
+ if (p === "**") return segmentsMayMatch(site, prest) || site.length > 0 && segmentsMayMatch(site.slice(1), pattern);
750
+ if (site.length === 0) return false;
751
+ const [s, ...srest] = site;
752
+ if (isRuntime(s)) return srest.length === 0 || segmentsMayMatch(srest, prest);
753
+ if (!pathMatches(`/${s}`, { path: `/${p}` })) return false;
754
+ return segmentsMayMatch(srest, prest);
755
+ }
602
756
  function pathMatches(endpoint, pattern) {
603
757
  const sample = endpoint.replace(/\{[^}]*\}/g, "\0");
604
758
  let re = "";
@@ -646,8 +800,12 @@ function triggerReasons(site, trigger) {
646
800
  for (const q of trigger.query ?? []) {
647
801
  const p = s.query.find((x) => x.name === q.name);
648
802
  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})`);
803
+ if (q.below !== void 0) {
804
+ if (p.value !== null && Number(p.value) < q.below) reasons.push(`sends ${p.name}=${p.value} (below ${q.below})`);
805
+ } else if (q.values) {
806
+ const hit = p.value === null ? [] : p.value.split(",").map((v) => v.trim()).filter((v) => q.values.includes(v));
807
+ if (hit.length) reasons.push(`sends ${p.name}=${hit.join(",")}`);
808
+ } else reasons.push(`sends ${p.name}=${p.value ?? "(dynamic)"}`);
651
809
  }
652
810
  if (trigger.any) reasons.push(`calls ${describe(site)}`);
653
811
  return reasons;
@@ -737,7 +895,11 @@ function collectConsts(ctx) {
737
895
  for (const decl of ctx.sf.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
738
896
  const list = decl.getVariableStatement()?.getDeclarationKind();
739
897
  const init = decl.getInitializer();
740
- if (list === "const" && init && Node.isIdentifier(decl.getNameNode())) ctx.consts.set(decl.getName(), init);
898
+ if (list === "const" && init && Node.isIdentifier(decl.getNameNode())) {
899
+ ctx.consts.set(decl.getName(), init);
900
+ ctx.declared ??= /* @__PURE__ */ new Map();
901
+ ctx.declared.set(decl.getName(), [...ctx.declared.get(decl.getName()) ?? [], init]);
902
+ }
741
903
  }
742
904
  }
743
905
  function collectClients(ctx) {
@@ -843,7 +1005,8 @@ function fetchStyle(ctx, call, lib, urlArg, optsArg, verb, baseURL) {
843
1005
  if (opts) method = methodFrom(ctx, propertyValue(opts, "method")) ?? (propertyValue(opts, "method") ? null : "GET");
844
1006
  else method = null;
845
1007
  }
846
- return httpSite(ctx, call, lib, joinUrl(baseURL, evalString(ctx, urlArg)), method, opts ? optsArg : void 0);
1008
+ const params = opts && (lib === "got" || lib === "ky") ? resolveObject(ctx, propertyValue(opts, "searchParams")) : void 0;
1009
+ return httpSite(ctx, call, lib, joinUrl(baseURL, evalString(ctx, urlArg)), method, opts ? optsArg : void 0, params);
847
1010
  }
848
1011
  function axiosStyle(ctx, call, verb, baseURL) {
849
1012
  const args = call.getArguments();
@@ -869,7 +1032,7 @@ function httpSite(ctx, call, lib, url, method, options, params) {
869
1032
  const surface = emptySurface();
870
1033
  surface.headers = headersOf(ctx, options);
871
1034
  surface.query = [...query, ...objectEntries(ctx, params)];
872
- const provider = host ? providerForHost(host)?.id ?? null : null;
1035
+ const provider = providerOf(host, path13);
873
1036
  return {
874
1037
  call,
875
1038
  options,
@@ -1051,9 +1214,16 @@ function evalString(ctx, node, depth = 0) {
1051
1214
  }
1052
1215
  }
1053
1216
  if (Node.isIdentifier(expr)) {
1054
- const init = ctx.consts.get(expr.getText());
1217
+ const init = nearestConst(ctx, expr) ?? ctx.consts.get(expr.getText());
1055
1218
  return init && init !== node ? evalString(ctx, init, depth + 1) : void 0;
1056
1219
  }
1220
+ if (Node.isCallExpression(expr)) {
1221
+ const callee = expr.getExpression();
1222
+ const args = expr.getArguments();
1223
+ if (Node.isPropertyAccessExpression(callee) && callee.getName() === "toString" && args.length === 0) return evalString(ctx, callee.getExpression(), depth + 1);
1224
+ if (Node.isIdentifier(callee) && callee.getText() === "String" && args.length === 1) return evalString(ctx, args[0], depth + 1);
1225
+ }
1226
+ if (Node.isPropertyAccessExpression(expr) && expr.getName() === "href") return evalString(ctx, expr.getExpression(), depth + 1);
1057
1227
  if (Node.isNewExpression(expr) && expr.getExpression().getText() === "URL") {
1058
1228
  const [p, base] = expr.getArguments();
1059
1229
  const pathPart = evalString(ctx, p, depth + 1);
@@ -1062,6 +1232,18 @@ function evalString(ctx, node, depth = 0) {
1062
1232
  }
1063
1233
  return void 0;
1064
1234
  }
1235
+ function nearestConst(ctx, id) {
1236
+ const inits = ctx.declared?.get(id.getText());
1237
+ if (!inits || inits.length < 2) return void 0;
1238
+ let best;
1239
+ for (const init of inits) {
1240
+ const scope = init.getFirstAncestor((a) => Node.isBlock(a) || Node.isSourceFile(a));
1241
+ if (!scope || id.getPos() < scope.getPos() || id.getEnd() > scope.getEnd()) continue;
1242
+ const size = scope.getEnd() - scope.getPos();
1243
+ if (!best || size < best.size) best = { init, size };
1244
+ }
1245
+ return best?.init;
1246
+ }
1065
1247
  function placeholder(node) {
1066
1248
  const expr = unwrap(node);
1067
1249
  if (Node.isIdentifier(expr)) return `{${expr.getText()}}`;
@@ -1069,6 +1251,53 @@ function placeholder(node) {
1069
1251
  if (Node.isCallExpression(expr) && expr.getArguments().length === 1) return placeholder(expr.getArguments()[0]);
1070
1252
  return "{}";
1071
1253
  }
1254
+ function providerOf(host, path13) {
1255
+ if (host) return providerForHost(host)?.id ?? null;
1256
+ return path13 ? providerForPath(path13)?.id ?? null : null;
1257
+ }
1258
+ function urlParts(value2) {
1259
+ const v = value2.trim().replace(/\{\{\s*([^{}]*?)\s*\}\}/g, "{$1}");
1260
+ if (/^(?:https?:)?\/\//i.test(v)) return parseUrl(v);
1261
+ const asPath = "/" + v.replace(/^\{[^}]*\}(?=\/)/, "").replace(/^\/+/, "");
1262
+ if (providerForPath(asPath.split(/[?#]/)[0])) return parseUrl(asPath);
1263
+ return void 0;
1264
+ }
1265
+ function looseUrlSites(sf, file, found) {
1266
+ const ctx = { sf, imports: /* @__PURE__ */ new Map(), consts: /* @__PURE__ */ new Map(), http: /* @__PURE__ */ new Map(), sdk: /* @__PURE__ */ new Map() };
1267
+ collectConsts(ctx);
1268
+ const covered = new Set(found.map((f) => `${f.site.provider} ${f.site.endpoint}`));
1269
+ const inFoundCall = (node) => found.some((f) => node.getPos() >= f.call.getPos() && node.getEnd() <= f.call.getEnd());
1270
+ const candidates = [];
1271
+ for (const node of sf.getDescendants()) {
1272
+ if (!Node.isStringLiteral(node) && !Node.isNoSubstitutionTemplateLiteral(node) && !Node.isTemplateExpression(node) && !isConcat(node)) continue;
1273
+ const parent = node.getParent();
1274
+ if (parent && (Node.isTemplateSpan(parent) || isConcat(parent) || Node.isImportDeclaration(parent) || Node.isExportDeclaration(parent))) continue;
1275
+ if (parent && Node.isPropertyAssignment(parent) && parent.getNameNode() === node) continue;
1276
+ if (inFoundCall(node)) continue;
1277
+ const value2 = evalString(ctx, node);
1278
+ const parts = value2 === void 0 ? void 0 : urlParts(value2);
1279
+ if (!parts?.path || parts.path === "/") continue;
1280
+ const provider = providerOf(parts.host, parts.path);
1281
+ if (provider) candidates.push({ node, host: parts.host, path: parts.path, query: parts.query, provider });
1282
+ }
1283
+ const sites = [];
1284
+ const seen = /* @__PURE__ */ new Set();
1285
+ for (const c of candidates) {
1286
+ const key = `${c.provider} ${c.path}`;
1287
+ if (covered.has(key) || seen.has(key)) continue;
1288
+ if (candidates.some((o) => o.provider === c.provider && o.path !== c.path && o.path.startsWith(`${c.path}/`))) continue;
1289
+ if (found.some((f) => f.site.provider === c.provider && f.site.endpoint?.startsWith(`${c.path}/`))) continue;
1290
+ seen.add(key);
1291
+ const { line, column } = sf.getLineAndColumnAtPos(c.node.getStart());
1292
+ const surface = emptySurface();
1293
+ surface.query = c.query;
1294
+ sites.push({ file, line, column, provider: c.provider, kind: "http", client: "url", method: null, host: c.host, endpoint: c.path, resolved: true, surface });
1295
+ }
1296
+ return sites;
1297
+ }
1298
+ function isConcat(node) {
1299
+ return Node.isBinaryExpression(node) && node.getOperatorToken().getKind() === SyntaxKind.PlusToken;
1300
+ }
1072
1301
  function joinUrl(base, url) {
1073
1302
  if (url === void 0) return void 0;
1074
1303
  if (!base || /^[a-z]+:\/\//i.test(url)) return url;
@@ -1136,6 +1365,7 @@ var NOT_FIELDS = /* @__PURE__ */ new Set([
1136
1365
  "valueOf",
1137
1366
  "toJSON"
1138
1367
  ]);
1368
+ var ITEM_CALLBACKS = { map: 0, flatMap: 0, filter: 0, forEach: 0, find: 0, findIndex: 0, some: 0, every: 0, reduce: 1 };
1139
1369
  var MAX_DEPTH = 4;
1140
1370
  var Collector = class {
1141
1371
  fields = /* @__PURE__ */ new Set();
@@ -1250,7 +1480,7 @@ function use(ref, loc, env, c, depth) {
1250
1480
  function interpret(env, loc, access, top, c, depth, compare) {
1251
1481
  const full = [...loc.path, ...access];
1252
1482
  if (loc.space === "body" || env === "body") {
1253
- body(full, top, c, compare);
1483
+ body(full, top, c, compare, env, depth);
1254
1484
  return;
1255
1485
  }
1256
1486
  const [first, ...rest] = full;
@@ -1276,20 +1506,37 @@ function interpret(env, loc, access, top, c, depth, compare) {
1276
1506
  } else if (h && h !== "get") c.responseHeaders.add(h.toLowerCase());
1277
1507
  return;
1278
1508
  }
1279
- if (env === "axios" && first === "data") return body(rest, top, c, compare);
1280
- if (env === "got" && first === "body") return body(rest, top, c, compare);
1509
+ if (env === "axios" && first === "data") return body(rest, top, c, compare, env, depth);
1510
+ if (env === "got" && first === "body") return body(rest, top, c, compare, env, depth);
1281
1511
  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
1512
  if (bodyCall && isCall()) {
1283
1513
  bindFrom(top.getParent(), { space: "body", path: [] }, env, c, depth + 1);
1284
1514
  }
1285
1515
  }
1286
- function body(path13, top, c, compare) {
1516
+ function body(path13, top, c, compare, env, depth) {
1287
1517
  const cut = path13.findIndex((p) => NOT_FIELDS.has(p));
1288
1518
  const fieldPath = cut >= 0 ? path13.slice(0, cut) : path13;
1289
1519
  if (fieldPath.length === 0) return;
1290
1520
  const field = fieldPath.join(".").replace(/\.\[\]/g, "[]");
1291
1521
  c.fields.add(field);
1292
1522
  if (compare && cut < 0) for (const s of comparedLiterals(top).strs) c.addEnum(field, s);
1523
+ if (depth >= MAX_DEPTH) return;
1524
+ const items = { space: "body", path: [...fieldPath, "[]"] };
1525
+ const method = cut >= 0 && cut === path13.length - 1 ? path13[cut] : void 0;
1526
+ const call = top.getParent();
1527
+ if (method && method in ITEM_CALLBACKS && Node2.isCallExpression(call) && call.getExpression() === top) {
1528
+ const cb = call.getArguments()[0];
1529
+ const param = cb && (Node2.isArrowFunction(cb) || Node2.isFunctionExpression(cb)) ? cb.getParameters()[ITEM_CALLBACKS[method]] : void 0;
1530
+ if (param) bindName(param.getNameNode(), items, param, env, c, depth + 1);
1531
+ if (method === "find") bindFrom(call, items, env, c, depth + 1);
1532
+ return;
1533
+ }
1534
+ const loop = top.getParent();
1535
+ if (cut < 0 && Node2.isForOfStatement(loop) && loop.getExpression() === top) {
1536
+ const list = loop.getInitializer();
1537
+ const decl = Node2.isVariableDeclarationList(list) ? list.getDeclarations()[0] : void 0;
1538
+ if (decl) bindName(decl.getNameNode(), items, decl, env, c, depth + 1);
1539
+ }
1293
1540
  }
1294
1541
  function comparedLiterals(node) {
1295
1542
  const nums = [];
@@ -1323,10 +1570,14 @@ function comparedLiterals(node) {
1323
1570
  import { readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
1324
1571
  import path3 from "path";
1325
1572
  var SOURCE_FILE = /\.(c|m)?[jt]sx?$/;
1326
- var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "out", "coverage", "vendor", "bower_components"]);
1573
+ var PYTHON_FILE = /\.py$/;
1574
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "out", "coverage", "vendor", "bower_components", "venv", "__pycache__", "site-packages"]);
1575
+ var PYTHON_MANIFEST = /^(requirements[\w.-]*\.txt|pyproject\.toml)$/;
1327
1576
  function listRepoFiles(root) {
1328
1577
  const sources = [];
1329
1578
  const manifests = [];
1579
+ const python = [];
1580
+ const pythonManifests = [];
1330
1581
  const walk2 = (dir) => {
1331
1582
  for (const entry of readdirSync2(dir, { withFileTypes: true })) {
1332
1583
  if (entry.name.startsWith(".")) continue;
@@ -1336,11 +1587,13 @@ function listRepoFiles(root) {
1336
1587
  } else if (entry.isFile()) {
1337
1588
  if (entry.name === "package.json") manifests.push(full);
1338
1589
  else if (SOURCE_FILE.test(entry.name) && !entry.name.endsWith(".d.ts")) sources.push(full);
1590
+ else if (PYTHON_FILE.test(entry.name)) python.push(full);
1591
+ else if (PYTHON_MANIFEST.test(entry.name)) pythonManifests.push(full);
1339
1592
  }
1340
1593
  }
1341
1594
  };
1342
1595
  walk2(root);
1343
- return { sources: sources.sort(), manifests: manifests.sort() };
1596
+ return { sources: sources.sort(), manifests: manifests.sort(), python: python.sort(), pythonManifests: pythonManifests.sort() };
1344
1597
  }
1345
1598
  function declaredProviders(root, manifests) {
1346
1599
  const found = [];
@@ -1366,6 +1619,578 @@ function declaredProviders(root, manifests) {
1366
1619
  function toPosix(p) {
1367
1620
  return p.split(path3.sep).join("/");
1368
1621
  }
1622
+ function declaredPythonProviders(root, manifests) {
1623
+ const found = [];
1624
+ for (const manifest of manifests) {
1625
+ let text;
1626
+ try {
1627
+ text = readFileSync2(manifest, "utf8");
1628
+ } catch {
1629
+ continue;
1630
+ }
1631
+ const names = /* @__PURE__ */ new Set();
1632
+ if (manifest.endsWith(".txt")) {
1633
+ for (const line of text.split(/\r?\n/)) {
1634
+ const m = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(line.replace(/#.*/, ""));
1635
+ if (m && !line.trim().startsWith("-")) names.add(m[1]);
1636
+ }
1637
+ } else {
1638
+ let inTable = false;
1639
+ let inList = false;
1640
+ for (const line of text.split(/\r?\n/)) {
1641
+ const t = line.replace(/#.*/, "").trim();
1642
+ if (/^\[/.test(t)) {
1643
+ inTable = /dependencies\]$/.test(t);
1644
+ inList = false;
1645
+ continue;
1646
+ }
1647
+ if (/^(dependencies|optional-dependencies|dev-dependencies)\s*=\s*\[/.test(t) || /^[\w-]+\s*=\s*\[/.test(t)) inList = true;
1648
+ if (inList) for (const m of t.matchAll(/["']([A-Za-z0-9][A-Za-z0-9._-]*)/g)) names.add(m[1]);
1649
+ if (inList && t.includes("]")) inList = false;
1650
+ const kv = inTable ? /^([A-Za-z0-9][A-Za-z0-9._-]*)\s*=/.exec(t) : null;
1651
+ if (kv) names.add(kv[1]);
1652
+ }
1653
+ }
1654
+ for (const name of [...names].sort()) {
1655
+ const provider = providerForPythonPackage(name);
1656
+ if (provider) found.push({ provider: provider.id, package: name, manifest: toPosix(path3.relative(root, manifest)) });
1657
+ }
1658
+ }
1659
+ return found;
1660
+ }
1661
+
1662
+ // packages/extractor/src/python.ts
1663
+ import { existsSync } from "fs";
1664
+ import { createRequire } from "module";
1665
+ import { fileURLToPath } from "url";
1666
+ import { Language, Parser } from "web-tree-sitter";
1667
+ var parser;
1668
+ async function loadPythonParser() {
1669
+ if (parser) return;
1670
+ await Parser.init();
1671
+ const bundled = fileURLToPath(new URL("./tree-sitter-python.wasm", import.meta.url));
1672
+ const wasm = existsSync(bundled) ? bundled : createRequire(import.meta.url).resolve("tree-sitter-python/tree-sitter-python.wasm");
1673
+ const language = await Language.load(wasm);
1674
+ const p = new Parser();
1675
+ p.setLanguage(language);
1676
+ parser = p;
1677
+ }
1678
+ function pythonParserLoaded() {
1679
+ return parser !== void 0;
1680
+ }
1681
+ var HTTP_LIBS = /* @__PURE__ */ new Set(["requests", "httpx", "aiohttp"]);
1682
+ var VERBS2 = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
1683
+ function extractPython(source, file) {
1684
+ if (!parser) throw new Error("call loadPythonParser() before extracting Python");
1685
+ const tree = parser.parse(source);
1686
+ if (!tree) return [];
1687
+ try {
1688
+ const root = tree.rootNode;
1689
+ const ctx = { names: /* @__PURE__ */ new Map(), consts: /* @__PURE__ */ new Map() };
1690
+ collectImports2(root, ctx);
1691
+ collectConsts2(root, ctx);
1692
+ collectClients2(root, ctx);
1693
+ const sites = [];
1694
+ const calls = [];
1695
+ for (const call of root.descendantsOfType("call")) {
1696
+ if (!call) continue;
1697
+ const site = httpCall(ctx, call) ?? sdkCall(ctx, call);
1698
+ if (!site) continue;
1699
+ site.file = file;
1700
+ site.line = call.startPosition.row + 1;
1701
+ site.column = call.startPosition.column + 1;
1702
+ addResponseSurface2(call, site);
1703
+ sites.push(site);
1704
+ calls.push(call);
1705
+ }
1706
+ return [...sites, ...looseUrls(ctx, root, file, sites, calls)];
1707
+ } finally {
1708
+ tree.delete();
1709
+ }
1710
+ }
1711
+ function collectImports2(root, ctx) {
1712
+ for (const st of root.descendantsOfType(["import_statement", "import_from_statement"])) {
1713
+ if (!st) continue;
1714
+ if (st.type === "import_statement") {
1715
+ for (const item of st.namedChildren) {
1716
+ if (!item) continue;
1717
+ const dotted = item.type === "aliased_import" ? item.childForFieldName("name") : item;
1718
+ const alias = item.type === "aliased_import" ? item.childForFieldName("alias")?.text : dotted?.text.split(".")[0];
1719
+ if (!dotted || !alias) continue;
1720
+ bindModule(ctx, alias, dotted.text);
1721
+ }
1722
+ } else {
1723
+ const module = st.childForFieldName("module_name")?.text;
1724
+ if (!module) continue;
1725
+ for (const item of st.childrenForFieldName("name")) {
1726
+ if (!item) continue;
1727
+ const name = item.type === "aliased_import" ? item.childForFieldName("name")?.text : item.text;
1728
+ const alias = item.type === "aliased_import" ? item.childForFieldName("alias")?.text : item.text;
1729
+ if (name && alias) bindModule(ctx, alias, `${module}.${name}`);
1730
+ }
1731
+ }
1732
+ }
1733
+ }
1734
+ function bindModule(ctx, alias, dotted) {
1735
+ const top = dotted.split(".")[0];
1736
+ if (HTTP_LIBS.has(top)) {
1737
+ ctx.names.set(alias, { kind: "http-module", lib: top });
1738
+ return;
1739
+ }
1740
+ const provider = providerForPythonModule(top);
1741
+ if (provider) ctx.names.set(alias, { kind: "sdk-module", provider, module: dotted });
1742
+ }
1743
+ function collectConsts2(root, ctx) {
1744
+ for (const st of root.namedChildren) {
1745
+ const assign = st?.type === "expression_statement" ? st.namedChildren[0] : void 0;
1746
+ if (assign?.type !== "assignment") continue;
1747
+ const left = assign.childForFieldName("left");
1748
+ const right = assign.childForFieldName("right");
1749
+ if (left?.type === "identifier" && right) ctx.consts.set(left.text, right);
1750
+ }
1751
+ }
1752
+ function collectClients2(root, ctx) {
1753
+ const visit = (target, value2) => {
1754
+ const call = unwrap2(value2);
1755
+ if (call.type !== "call") return;
1756
+ const fn = call.childForFieldName("function");
1757
+ const chain = fn ? chainOf2(fn) : void 0;
1758
+ if (!chain) return;
1759
+ const base = ctx.names.get(chain.root);
1760
+ if (!base) return;
1761
+ if (base.kind === "http-module" || base.kind === "http-client") {
1762
+ const ctor = chain.names.at(-1);
1763
+ if (!ctor || !/^(Session|Client|AsyncClient|ClientSession)$/.test(ctor)) return;
1764
+ const args = call.childForFieldName("arguments");
1765
+ const baseUrl = evalString2(ctx, keyword(args, "base_url"));
1766
+ ctx.names.set(target, { kind: "http-client", lib: base.lib, ...baseUrl ? { baseUrl } : {}, headers: dictEntries(ctx, keyword(args, "headers")) });
1767
+ return;
1768
+ }
1769
+ if (base.kind === "sdk-module" && !sdkEndpoint2(base.provider, [...modulePath(base), ...chain.names])) {
1770
+ ctx.names.set(target, { kind: "sdk-client", provider: base.provider, module: base.module });
1771
+ }
1772
+ };
1773
+ for (const assign of root.descendantsOfType("assignment")) {
1774
+ const left = assign?.childForFieldName("left");
1775
+ const right = assign?.childForFieldName("right");
1776
+ if (left && right && (left.type === "identifier" || left.type === "attribute")) visit(left.text, right);
1777
+ }
1778
+ for (const item of root.descendantsOfType("as_pattern")) {
1779
+ const value2 = item?.namedChildren[0];
1780
+ const alias = item?.childForFieldName("alias")?.namedChildren[0] ?? item?.namedChildren.at(-1);
1781
+ if (value2 && alias && alias.type === "identifier") visit(alias.text, value2);
1782
+ }
1783
+ }
1784
+ function httpCall(ctx, call) {
1785
+ const fn = call.childForFieldName("function");
1786
+ const chain = fn ? chainOf2(fn) : void 0;
1787
+ if (!chain || chain.names.length === 0) {
1788
+ return void 0;
1789
+ }
1790
+ const binding = ctx.names.get(chain.root) ?? ctx.names.get(chain.prefix(chain.names.length - 1));
1791
+ const member = chain.names.at(-1);
1792
+ if (!binding || binding.kind !== "http-module" && binding.kind !== "http-client") return void 0;
1793
+ if (binding.kind === "http-module" && chain.names.length !== 1) return void 0;
1794
+ const args = call.childForFieldName("arguments");
1795
+ const positional = (args?.namedChildren ?? []).filter((a) => a && a.type !== "keyword_argument");
1796
+ let method;
1797
+ let urlNode;
1798
+ if (VERBS2.has(member)) {
1799
+ method = member.toUpperCase();
1800
+ urlNode = positional[0] ?? keyword(args, "url");
1801
+ } else if (member === "request") {
1802
+ const m = evalString2(ctx, positional[0] ?? keyword(args, "method"))?.toUpperCase();
1803
+ method = m && HTTP_METHODS.includes(m) ? m : null;
1804
+ urlNode = positional[1] ?? keyword(args, "url");
1805
+ } else return void 0;
1806
+ const base = binding.kind === "http-client" ? binding.baseUrl : void 0;
1807
+ const { host, path: path13, query } = parseUrl(joinUrl2(base, evalString2(ctx, urlNode)));
1808
+ const surface = emptySurface2();
1809
+ surface.headers = [...binding.kind === "http-client" ? binding.headers : [], ...dictEntries(ctx, keyword(args, "headers"))].map((h) => ({ ...h, name: h.name.toLowerCase() }));
1810
+ surface.query = [...query, ...dictEntries(ctx, keyword(args, "params"))];
1811
+ const provider = providerOf(host, path13);
1812
+ return { file: "", line: 0, column: 0, provider, kind: "http", client: binding.lib, method, host, endpoint: path13, resolved: Boolean(provider && path13 && method), surface };
1813
+ }
1814
+ function sdkCall(ctx, call) {
1815
+ const fn = call.childForFieldName("function");
1816
+ const chain = fn ? chainOf2(fn) : void 0;
1817
+ if (!chain || chain.names.length === 0) return void 0;
1818
+ const binding = ctx.names.get(chain.root);
1819
+ if (!binding || binding.kind !== "sdk-module" && binding.kind !== "sdk-client") return void 0;
1820
+ const names = binding.kind === "sdk-module" ? [...modulePath(binding), ...chain.names] : chain.names;
1821
+ const ep = sdkEndpoint2(binding.provider, names);
1822
+ if (!ep && (binding.kind === "sdk-module" || names.length < 2)) return void 0;
1823
+ const surface = emptySurface2();
1824
+ const host = binding.provider.hosts.find((h) => !h.startsWith("*.")) ?? null;
1825
+ return {
1826
+ file: "",
1827
+ line: 0,
1828
+ column: 0,
1829
+ provider: binding.provider.id,
1830
+ kind: "sdk",
1831
+ client: binding.module.split(".")[0],
1832
+ operation: names.join("."),
1833
+ method: ep?.method ?? null,
1834
+ host,
1835
+ endpoint: ep?.path ?? null,
1836
+ resolved: Boolean(ep),
1837
+ surface
1838
+ };
1839
+ }
1840
+ function modulePath(b) {
1841
+ return b.module.split(".").slice(1);
1842
+ }
1843
+ function sdkEndpoint2(provider, names) {
1844
+ let op = names.join(".");
1845
+ if (provider.id === "stripe") op = op.replace(/^v1\./, "");
1846
+ if (provider.id === "slack") {
1847
+ const last = names.at(-1);
1848
+ if (names.length !== 1 || !last || !/^[a-z]+(_[a-zA-Z0-9]+)+$/.test(last)) return void 0;
1849
+ if (last === "files_upload_v2") return { method: "POST", path: "/api/files.completeUploadExternal" };
1850
+ return { method: "POST", path: `/api/${last.replace(/_/g, ".")}` };
1851
+ }
1852
+ const ref = getProvider(provider.id)?.python?.methods?.[op];
1853
+ if (!ref) return void 0;
1854
+ const [method, path13] = ref.split(" ");
1855
+ return { method, path: path13 };
1856
+ }
1857
+ var NOT_FIELDS2 = /* @__PURE__ */ new Set([
1858
+ "keys",
1859
+ "values",
1860
+ "items",
1861
+ "append",
1862
+ "extend",
1863
+ "lower",
1864
+ "upper",
1865
+ "strip",
1866
+ "split",
1867
+ "startswith",
1868
+ "endswith",
1869
+ "replace",
1870
+ "format",
1871
+ "encode",
1872
+ "decode",
1873
+ "count",
1874
+ "index",
1875
+ "copy",
1876
+ "pop",
1877
+ "update",
1878
+ "sort"
1879
+ ]);
1880
+ var SAME_DATA = /* @__PURE__ */ new Set(["to_dict", "to_dict_recursive", "model_dump", "dict"]);
1881
+ var MAX_DEPTH2 = 4;
1882
+ var Collector2 = class {
1883
+ fields = /* @__PURE__ */ new Set();
1884
+ statusCodes = /* @__PURE__ */ new Set();
1885
+ checksOk = false;
1886
+ enums = /* @__PURE__ */ new Map();
1887
+ responseHeaders = /* @__PURE__ */ new Set();
1888
+ };
1889
+ function addResponseSurface2(call, site) {
1890
+ const c = new Collector2();
1891
+ const envelope = site.kind === "sdk" ? "body" : "response";
1892
+ bindFrom2(call, { space: envelope, path: [] }, site.client, c, 0);
1893
+ const s = site.surface;
1894
+ s.fields = [...c.fields].sort();
1895
+ s.statusCodes = [...c.statusCodes].sort((a, b) => a - b);
1896
+ s.checksOk = c.checksOk;
1897
+ s.enums = [...c.enums].map(([field, values]) => ({ field, values: [...values].sort() })).sort((a, b) => a.field.localeCompare(b.field));
1898
+ s.responseHeaders = [...c.responseHeaders].sort();
1899
+ }
1900
+ function bindFrom2(expr, loc, lib, c, depth) {
1901
+ if (depth > MAX_DEPTH2) return;
1902
+ let cur = expr;
1903
+ while (cur.parent && (cur.parent.type === "await" || cur.parent.type === "parenthesized_expression")) cur = cur.parent;
1904
+ const parent = cur.parent;
1905
+ if (!parent) return;
1906
+ if (parent.type === "assignment" && parent.childForFieldName("right")?.id === cur.id) {
1907
+ const left = parent.childForFieldName("left");
1908
+ if (left?.type === "identifier") bindName2(left.text, parent, loc, lib, c, depth);
1909
+ return;
1910
+ }
1911
+ if (parent.type === "as_pattern" && parent.namedChildren[0]?.id === cur.id) {
1912
+ const alias = parent.childForFieldName("alias")?.namedChildren[0] ?? parent.namedChildren.at(-1);
1913
+ if (alias?.type === "identifier") bindName2(alias.text, parent, loc, lib, c, depth);
1914
+ return;
1915
+ }
1916
+ use2(cur, loc, lib, c, depth);
1917
+ }
1918
+ function bindName2(name, decl, loc, lib, c, depth) {
1919
+ for (const ref of referencesOf2(name, decl)) use2(ref, loc, lib, c, depth);
1920
+ }
1921
+ function referencesOf2(name, decl) {
1922
+ let scope = decl.parent;
1923
+ while (scope && scope.type !== "function_definition" && scope.type !== "lambda" && scope.type !== "module") scope = scope.parent;
1924
+ if (!scope) return [];
1925
+ return scope.descendantsOfType("identifier").filter((id) => {
1926
+ if (!id || id.text !== name || id.startIndex < decl.endIndex) return false;
1927
+ const p = id.parent;
1928
+ if (p?.type === "attribute" && p.childForFieldName("attribute")?.id === id.id) return false;
1929
+ if (p?.type === "keyword_argument" && p.childForFieldName("name")?.id === id.id) return false;
1930
+ if (p?.type === "assignment" && p.childForFieldName("left")?.id === id.id) return false;
1931
+ return true;
1932
+ });
1933
+ }
1934
+ function use2(ref, loc, lib, c, depth) {
1935
+ const access = [];
1936
+ let cur = ref;
1937
+ for (; ; ) {
1938
+ const p = cur.parent;
1939
+ if (!p) break;
1940
+ if (p.type === "attribute" && p.childForFieldName("object")?.id === cur.id) {
1941
+ const name = p.childForFieldName("attribute")?.text ?? "";
1942
+ const call = p.parent?.type === "call" && p.parent.childForFieldName("function")?.id === p.id ? p.parent : void 0;
1943
+ if (call && name === "get" && (loc.space === "body" || access.length > 0)) {
1944
+ const key = stringValue(call.childForFieldName("arguments")?.namedChildren[0] ?? void 0);
1945
+ if (key === void 0) break;
1946
+ access.push(key);
1947
+ cur = call;
1948
+ continue;
1949
+ }
1950
+ if (call && SAME_DATA.has(name) && loc.space === "body") {
1951
+ cur = call;
1952
+ continue;
1953
+ }
1954
+ access.push(name);
1955
+ cur = p;
1956
+ } else if (p.type === "subscript" && p.childForFieldName("value")?.id === cur.id) {
1957
+ const key = stringValue(p.childForFieldName("subscript") ?? void 0);
1958
+ access.push(key ?? "[]");
1959
+ cur = p;
1960
+ } else if (p.type === "parenthesized_expression" || p.type === "await") {
1961
+ cur = p;
1962
+ } else break;
1963
+ }
1964
+ interpret2(loc, access, cur, lib, c, depth);
1965
+ }
1966
+ function interpret2(loc, access, top, lib, c, depth) {
1967
+ const full = [...loc.path, ...access];
1968
+ if (loc.space === "body") return body2(full, top, lib, c, depth);
1969
+ const [first, ...rest] = full;
1970
+ if (first === void 0) return;
1971
+ const isCall = top.parent?.type === "call" && top.parent.childForFieldName("function")?.id === top.id;
1972
+ const statusKey = lib === "aiohttp" ? "status" : "status_code";
1973
+ if (first === statusKey && rest.length === 0) {
1974
+ for (const n of comparedLiterals2(top).nums) c.statusCodes.add(n);
1975
+ return;
1976
+ }
1977
+ if (first === "ok" && rest.length === 0 || first === "raise_for_status" && isCall) {
1978
+ c.checksOk = true;
1979
+ return;
1980
+ }
1981
+ if (first === "headers") {
1982
+ if (rest[0]) c.responseHeaders.add(rest[0].toLowerCase());
1983
+ return;
1984
+ }
1985
+ if (first === "json" && rest.length === 0 && isCall) bindFrom2(top.parent, { space: "body", path: [] }, lib, c, depth + 1);
1986
+ }
1987
+ function body2(path13, top, lib, c, depth) {
1988
+ const cut = path13.findIndex((p) => NOT_FIELDS2.has(p));
1989
+ const fieldPath = cut >= 0 ? path13.slice(0, cut) : path13;
1990
+ if (fieldPath.length === 0) {
1991
+ if (path13.length === 0 && depth < MAX_DEPTH2) iterate(top, { space: "body", path: [] }, lib, c, depth);
1992
+ return;
1993
+ }
1994
+ const field = fieldPath.join(".").replace(/\.\[\]/g, "[]");
1995
+ c.fields.add(field);
1996
+ if (cut < 0) {
1997
+ for (const s of comparedLiterals2(top).strs) {
1998
+ const set = c.enums.get(field) ?? /* @__PURE__ */ new Set();
1999
+ set.add(s);
2000
+ c.enums.set(field, set);
2001
+ }
2002
+ if (depth < MAX_DEPTH2) {
2003
+ const parent = top.parent;
2004
+ if (parent?.type === "assignment" && parent.childForFieldName("right")?.id === top.id) {
2005
+ const left = parent.childForFieldName("left");
2006
+ if (left?.type === "identifier") bindName2(left.text, parent, { space: "body", path: fieldPath }, lib, c, depth + 1);
2007
+ }
2008
+ iterate(top, { space: "body", path: fieldPath }, lib, c, depth);
2009
+ }
2010
+ }
2011
+ }
2012
+ function iterate(top, loc, lib, c, depth) {
2013
+ const p = top.parent;
2014
+ const items = { space: "body", path: [...loc.path, "[]"] };
2015
+ if (p?.type === "for_statement" && p.childForFieldName("right")?.id === top.id) {
2016
+ const left = p.childForFieldName("left");
2017
+ if (left?.type === "identifier") bindName2(left.text, left, items, lib, c, depth + 1);
2018
+ } else if (p?.type === "for_in_clause" && p.childForFieldName("right")?.id === top.id) {
2019
+ const left = p.childForFieldName("left");
2020
+ const comp = p.parent;
2021
+ if (left?.type === "identifier" && comp) {
2022
+ for (const id of comp.descendantsOfType("identifier")) {
2023
+ if (id && id.text === left.text && id.id !== left.id) use2(id, items, lib, c, depth + 1);
2024
+ }
2025
+ }
2026
+ }
2027
+ }
2028
+ function comparedLiterals2(node) {
2029
+ const nums = [];
2030
+ const strs = [];
2031
+ let cur = node;
2032
+ while (cur.parent?.type === "parenthesized_expression") cur = cur.parent;
2033
+ const p = cur.parent;
2034
+ if (p?.type !== "comparison_operator") return { nums, strs };
2035
+ const take = (n) => {
2036
+ if (!n) return;
2037
+ if (n.type === "integer") nums.push(Number(n.text));
2038
+ else if (n.type === "string") {
2039
+ const v = stringValue(n);
2040
+ if (v !== void 0) strs.push(v);
2041
+ } else if (n.type === "tuple" || n.type === "list" || n.type === "set") n.namedChildren.forEach(take);
2042
+ };
2043
+ for (const operand of p.namedChildren) if (operand && operand.id !== cur.id) take(operand);
2044
+ return { nums, strs };
2045
+ }
2046
+ function chainOf2(node) {
2047
+ const names = [];
2048
+ let cur = node;
2049
+ while (cur.type === "attribute") {
2050
+ names.unshift(cur.childForFieldName("attribute").text);
2051
+ cur = cur.childForFieldName("object");
2052
+ }
2053
+ if (cur.type !== "identifier") return void 0;
2054
+ const root = cur.text;
2055
+ return { root, names, prefix: (n) => [root, ...names.slice(0, n)].join(".") };
2056
+ }
2057
+ function unwrap2(n) {
2058
+ let cur = n;
2059
+ while (cur.type === "await" || cur.type === "parenthesized_expression") cur = cur.namedChildren[0] ?? cur;
2060
+ return cur;
2061
+ }
2062
+ function keyword(args, name) {
2063
+ for (const a of args?.namedChildren ?? []) {
2064
+ if (a?.type === "keyword_argument" && a.childForFieldName("name")?.text === name) return a.childForFieldName("value") ?? void 0;
2065
+ }
2066
+ return void 0;
2067
+ }
2068
+ function stringValue(n) {
2069
+ if (!n || n.type !== "string") return void 0;
2070
+ if (n.namedChildren.some((c) => c?.type === "interpolation")) return void 0;
2071
+ return n.namedChildren.filter((c) => c?.type === "string_content" || c?.type === "escape_sequence").map((c) => c.text).join("");
2072
+ }
2073
+ function evalString2(ctx, node, depth = 0) {
2074
+ if (!node || depth > 8) return void 0;
2075
+ const n = unwrap2(node);
2076
+ if (n.type === "string") {
2077
+ let out = "";
2078
+ for (const part of n.namedChildren) {
2079
+ if (!part) continue;
2080
+ if (part.type === "string_content" || part.type === "escape_sequence") out += part.text;
2081
+ else if (part.type === "interpolation") {
2082
+ const inner = part.childForFieldName("expression") ?? part.namedChildren[0];
2083
+ out += (inner && evalString2(ctx, inner, depth + 1)) ?? placeholder2(inner ?? void 0);
2084
+ }
2085
+ }
2086
+ return out;
2087
+ }
2088
+ if (n.type === "concatenated_string") return n.namedChildren.map((s) => evalString2(ctx, s ?? void 0, depth + 1) ?? "").join("");
2089
+ if (n.type === "binary_operator" && n.childForFieldName("operator")?.text === "+") {
2090
+ const l = evalString2(ctx, n.childForFieldName("left") ?? void 0, depth + 1);
2091
+ const r = evalString2(ctx, n.childForFieldName("right") ?? void 0, depth + 1);
2092
+ if (l === void 0 && r === void 0) return void 0;
2093
+ return (l ?? placeholder2(n.childForFieldName("left") ?? void 0)) + (r ?? placeholder2(n.childForFieldName("right") ?? void 0));
2094
+ }
2095
+ if (n.type === "identifier") {
2096
+ const init = localAssignment(n) ?? ctx.consts.get(n.text);
2097
+ return init && init.id !== node.id ? evalString2(ctx, init, depth + 1) : void 0;
2098
+ }
2099
+ if (n.type === "call") {
2100
+ const fn = n.childForFieldName("function")?.text;
2101
+ const args = (n.childForFieldName("arguments")?.namedChildren ?? []).filter(Boolean);
2102
+ if ((fn === "os.getenv" || fn === "getenv" || fn === "os.environ.get") && args[1]) return evalString2(ctx, args[1], depth + 1);
2103
+ }
2104
+ return void 0;
2105
+ }
2106
+ function localAssignment(use3) {
2107
+ let scope = use3.parent;
2108
+ while (scope && scope.type !== "function_definition" && scope.type !== "module") scope = scope.parent;
2109
+ if (!scope || scope.type === "module") return void 0;
2110
+ let last;
2111
+ for (const assign of scope.descendantsOfType("assignment")) {
2112
+ if (!assign || assign.endIndex > use3.startIndex) continue;
2113
+ const left = assign.childForFieldName("left");
2114
+ if (left?.type !== "identifier" || left.text !== use3.text) continue;
2115
+ let owner = assign.parent;
2116
+ while (owner && owner.type !== "function_definition") owner = owner.parent;
2117
+ if (owner?.id !== scope.id) continue;
2118
+ last = assign.childForFieldName("right") ?? void 0;
2119
+ }
2120
+ return last;
2121
+ }
2122
+ function looseUrls(ctx, root, file, sites, calls) {
2123
+ const covered = new Set(sites.map((s) => `${s.provider} ${s.endpoint}`));
2124
+ const inCall = (n) => calls.some((c) => n.startIndex >= c.startIndex && n.endIndex <= c.endIndex);
2125
+ const candidates = [];
2126
+ for (const node of root.descendantsOfType(["string", "concatenated_string", "binary_operator"])) {
2127
+ if (!node) continue;
2128
+ if (node.type === "binary_operator" && node.childForFieldName("operator")?.text !== "+") continue;
2129
+ const p = node.parent;
2130
+ if (p && (p.type === "concatenated_string" || p.type === "interpolation" || p.type === "expression_statement" || p.type === "binary_operator" && p.childForFieldName("operator")?.text === "+")) continue;
2131
+ if (inCall(node)) continue;
2132
+ const value2 = evalString2(ctx, node);
2133
+ const parts = value2 === void 0 ? void 0 : urlParts(value2);
2134
+ if (!parts?.path || parts.path === "/") continue;
2135
+ const provider = providerOf(parts.host, parts.path);
2136
+ if (provider) candidates.push({ node, host: parts.host, path: parts.path, query: parts.query, provider });
2137
+ }
2138
+ const out = [];
2139
+ const seen = /* @__PURE__ */ new Set();
2140
+ for (const c of candidates) {
2141
+ const key = `${c.provider} ${c.path}`;
2142
+ if (covered.has(key) || seen.has(key)) continue;
2143
+ if (candidates.some((o) => o.provider === c.provider && o.path !== c.path && o.path.startsWith(`${c.path}/`))) continue;
2144
+ if (sites.some((s) => s.provider === c.provider && s.endpoint?.startsWith(`${c.path}/`))) continue;
2145
+ seen.add(key);
2146
+ const surface = emptySurface2();
2147
+ surface.query = c.query;
2148
+ out.push({ file, line: c.node.startPosition.row + 1, column: c.node.startPosition.column + 1, provider: c.provider, kind: "http", client: "url", method: null, host: c.host, endpoint: c.path, resolved: true, surface });
2149
+ }
2150
+ return out;
2151
+ }
2152
+ function placeholder2(n) {
2153
+ if (!n) return "{}";
2154
+ const e = unwrap2(n);
2155
+ if (e.type === "identifier") return `{${e.text}}`;
2156
+ if (e.type === "attribute") return `{${e.childForFieldName("attribute")?.text ?? ""}}`;
2157
+ const args = e.type === "call" ? (e.childForFieldName("arguments")?.namedChildren ?? []).filter(Boolean) : [];
2158
+ if (args.length === 1) return placeholder2(args[0]);
2159
+ return "{}";
2160
+ }
2161
+ function dictEntries(ctx, node, depth = 0) {
2162
+ if (!node || depth > 3) return [];
2163
+ const n = unwrap2(node);
2164
+ if (n.type === "identifier") {
2165
+ const init = ctx.consts.get(n.text);
2166
+ return init ? dictEntries(ctx, init, depth + 1) : [];
2167
+ }
2168
+ if (n.type === "call" && n.childForFieldName("function")?.text === "dict") {
2169
+ return (n.childForFieldName("arguments")?.namedChildren ?? []).filter((a) => a?.type === "keyword_argument").map((a) => ({ name: a.childForFieldName("name").text, value: literal(ctx, a.childForFieldName("value") ?? void 0) }));
2170
+ }
2171
+ if (n.type !== "dictionary") return [];
2172
+ const out = [];
2173
+ for (const pair of n.namedChildren) {
2174
+ if (pair?.type !== "pair") continue;
2175
+ const key = stringValue(pair.childForFieldName("key") ?? void 0);
2176
+ if (key !== void 0) out.push({ name: key, value: literal(ctx, pair.childForFieldName("value") ?? void 0) });
2177
+ }
2178
+ return out;
2179
+ }
2180
+ function literal(ctx, n) {
2181
+ if (!n) return null;
2182
+ if (n.type === "integer" || n.type === "float") return n.text;
2183
+ if (n.type === "true" || n.type === "false") return n.text.toLowerCase();
2184
+ return evalString2(ctx, n) ?? null;
2185
+ }
2186
+ function joinUrl2(base, url) {
2187
+ if (url === void 0) return base;
2188
+ if (!base || /^[a-z]+:\/\//i.test(url)) return url;
2189
+ return base.replace(/\/+$/, "") + "/" + url.replace(/^\/+/, "");
2190
+ }
2191
+ function emptySurface2() {
2192
+ return { fields: [], statusCodes: [], checksOk: false, enums: [], headers: [], query: [], responseHeaders: [] };
2193
+ }
1369
2194
 
1370
2195
  // packages/extractor/src/index.ts
1371
2196
  var SCRIPT_KINDS = {
@@ -1380,31 +2205,50 @@ var SCRIPT_KINDS = {
1380
2205
  };
1381
2206
  function extractCallSites(rootDir) {
1382
2207
  const root = path4.resolve(rootDir);
1383
- const { sources, manifests } = listRepoFiles(root);
2208
+ const { sources, manifests, python, pythonManifests } = listRepoFiles(root);
1384
2209
  const project = new Project({
1385
2210
  useInMemoryFileSystem: true,
1386
2211
  skipFileDependencyResolution: true,
1387
2212
  compilerOptions: { allowJs: true }
1388
2213
  });
1389
2214
  const found = [];
2215
+ const loose = [];
1390
2216
  for (const abs of sources) {
1391
2217
  const rel = toPosix(path4.relative(root, abs));
1392
2218
  const sf = project.createSourceFile(`/${rel}`, readFileSync3(abs, "utf8"), {
1393
2219
  overwrite: true,
1394
2220
  scriptKind: SCRIPT_KINDS[path4.extname(abs)] ?? ScriptKind.TS
1395
2221
  });
1396
- for (const f of extractFromFile(sf, rel)) {
2222
+ const inFile = extractFromFile(sf, rel);
2223
+ for (const f of inFile) {
1397
2224
  addResponseSurface(f);
1398
2225
  found.push(f);
1399
2226
  }
2227
+ loose.push(...looseUrlSites(sf, rel, inFile));
1400
2228
  }
1401
2229
  found.sort((a, b) => a.site.file.localeCompare(b.site.file) || a.site.line - b.site.line || a.site.column - b.site.column);
2230
+ const skipped = [];
2231
+ const pythonSites = [];
2232
+ for (const abs of python) {
2233
+ const rel = toPosix(path4.relative(root, abs));
2234
+ if (!pythonParserLoaded()) {
2235
+ skipped.push({ file: rel, reason: "the Python parser was not loaded" });
2236
+ continue;
2237
+ }
2238
+ try {
2239
+ pythonSites.push(...extractPython(readFileSync3(abs, "utf8"), rel));
2240
+ } catch (err) {
2241
+ skipped.push({ file: rel, reason: err instanceof Error ? err.message : String(err) });
2242
+ }
2243
+ }
2244
+ const callSites = [...found.map((f) => f.site), ...loose, ...pythonSites].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column);
1402
2245
  return {
1403
2246
  root,
1404
- filesScanned: sources.length,
1405
- callSites: found.map((f) => f.site),
1406
- declared: declaredProviders(root, manifests),
1407
- found
2247
+ filesScanned: sources.length + python.length - skipped.length,
2248
+ callSites,
2249
+ declared: [...declaredProviders(root, manifests), ...declaredPythonProviders(root, pythonManifests)],
2250
+ found,
2251
+ skipped
1408
2252
  };
1409
2253
  }
1410
2254
 
@@ -1520,9 +2364,9 @@ function inferShape(res, opts) {
1520
2364
  else if (!VOLATILE_HEADERS.has(name) && !name.startsWith("x-ratelimit") && !name.startsWith("ratelimit")) headerNames.push(name);
1521
2365
  }
1522
2366
  const text = res.body ?? "";
1523
- let body2;
2367
+ let body3;
1524
2368
  if (text.trim() === "") {
1525
- body2 = { kind: "empty", fields: [] };
2369
+ body3 = { kind: "empty", fields: [] };
1526
2370
  } else {
1527
2371
  let parsed;
1528
2372
  let isJson = true;
@@ -1534,16 +2378,16 @@ function inferShape(res, opts) {
1534
2378
  if (isJson) {
1535
2379
  const acc = /* @__PURE__ */ new Map();
1536
2380
  walk(parsed, "", acc);
1537
- body2 = { kind: "json", rootType: typeOf(parsed), fields: finish(acc, opts.salt) };
2381
+ body3 = { kind: "json", rootType: typeOf(parsed), fields: finish(acc, opts.salt) };
1538
2382
  } else {
1539
- body2 = { kind: "text", fields: [] };
2383
+ body3 = { kind: "text", fields: [] };
1540
2384
  }
1541
2385
  }
1542
2386
  return {
1543
2387
  status: res.status,
1544
2388
  headers: Object.fromEntries(Object.entries(headers).sort(([a], [b]) => cmp3(a, b))),
1545
2389
  headerNames: [...new Set(headerNames)].sort(),
1546
- body: body2
2390
+ body: body3
1547
2391
  };
1548
2392
  }
1549
2393
  function walk(value2, path13, acc) {
@@ -1939,8 +2783,181 @@ function replay(recording, store, secret) {
1939
2783
  return { recording, observations, after, before, ...before ? { diff: diffShapes(before.shape, after.shape) } : {} };
1940
2784
  }
1941
2785
 
1942
- // packages/repair/src/recipes/status-code-accept-set.ts
2786
+ // packages/repair/src/recipes/apply-fix.ts
1943
2787
  import { Node as Node3, SyntaxKind as SyntaxKind3 } from "ts-morph";
2788
+ var applyFix = {
2789
+ id: "apply-fix",
2790
+ changeClass: "version-sunset",
2791
+ description: "Apply the advisory's fix: the new path, query value or header, and the renamed fields and enum values",
2792
+ edits({ sourceFile, call, advisory }) {
2793
+ const fix2 = advisory.fix;
2794
+ if (!fix2) return [];
2795
+ const where = (n) => `${sourceFile.getFilePath().slice(1)}:${n.getStartLineNumber()}`;
2796
+ const edits = [...requestEdits(call, sourceFile, fix2, where), ...headerEdits(call, fix2, where)];
2797
+ const scope = enclosingFunction(call) ?? sourceFile;
2798
+ edits.push(...fieldEdits(scope, fix2, where, edits), ...enumEdits(scope, fix2, where));
2799
+ return edits;
2800
+ }
2801
+ };
2802
+ var TEXT_KINDS = [SyntaxKind3.StringLiteral, SyntaxKind3.NoSubstitutionTemplateLiteral, SyntaxKind3.TemplateHead, SyntaxKind3.TemplateMiddle, SyntaxKind3.TemplateTail];
2803
+ function requestEdits(call, sf, fix2, where) {
2804
+ if (!fix2.path && !fix2.query) return [];
2805
+ const edits = [];
2806
+ for (const node of urlTextNodes(call, sf)) {
2807
+ const before = node.getText();
2808
+ let after = before;
2809
+ if (fix2.path) {
2810
+ after = after.split(fix2.path.from).join(fix2.path.to);
2811
+ const from = fix2.path.from.replace(/\/$/, "");
2812
+ const to = fix2.path.to.replace(/\/$/, "");
2813
+ if (from !== fix2.path.from) after = after.replace(new RegExp(`${escapeRe(from)}(?=["'\`]|\\$\\{)`, "g"), to);
2814
+ }
2815
+ if (fix2.query) {
2816
+ const re = new RegExp(`([?&])${escapeRe(fix2.query.name)}=([^&#\`'"$]+)`, "g");
2817
+ after = after.replace(re, (_m, sep) => `${sep}${fix2.query.name}=${fix2.query.value}`);
2818
+ }
2819
+ if (after !== before) edits.push({ start: node.getStart(), end: node.getEnd(), text: after, note: `${where(node)} \`${before}\` \u2192 \`${after}\`` });
2820
+ }
2821
+ if (fix2.query) {
2822
+ for (const prop of [...objectProperties(call, "params"), ...objectProperties(call, "searchParams")]) {
2823
+ if (propName2(prop) !== fix2.query.name || !Node3.isPropertyAssignment(prop)) continue;
2824
+ const init = prop.getInitializer();
2825
+ if (!init || !(Node3.isNumericLiteral(init) || Node3.isStringLiteral(init))) continue;
2826
+ const text = /^\d+(\.\d+)?$/.test(fix2.query.value) && Node3.isNumericLiteral(init) ? fix2.query.value : JSON.stringify(fix2.query.value);
2827
+ if (init.getText() !== text) edits.push({ start: init.getStart(), end: init.getEnd(), text, note: `${where(init)} ${fix2.query.name}: \`${init.getText()}\` \u2192 \`${text}\`` });
2828
+ }
2829
+ }
2830
+ return edits;
2831
+ }
2832
+ function headerEdits(call, fix2, where) {
2833
+ if (!fix2.header) return [];
2834
+ const edits = [];
2835
+ const text = JSON.stringify(fix2.header.value);
2836
+ for (const prop of objectProperties(call, "headers")) {
2837
+ if (propName2(prop)?.toLowerCase() !== fix2.header.name.toLowerCase() || !Node3.isPropertyAssignment(prop)) continue;
2838
+ const init = prop.getInitializer();
2839
+ if (!init || !(Node3.isStringLiteral(init) || Node3.isNoSubstitutionTemplateLiteral(init)) || init.getLiteralText() === fix2.header.value) continue;
2840
+ edits.push({ start: init.getStart(), end: init.getEnd(), text, note: `${where(init)} ${fix2.header.name}: \`${init.getText()}\` \u2192 \`${text}\`` });
2841
+ }
2842
+ return edits;
2843
+ }
2844
+ function fieldEdits(scope, fix2, where, taken) {
2845
+ const edits = [];
2846
+ for (const { from, to } of fix2.fields ?? []) {
2847
+ const oldName = leaf(from);
2848
+ const newName = leaf(to);
2849
+ if (oldName === newName) continue;
2850
+ for (const access of scope.getDescendantsOfKind(SyntaxKind3.PropertyAccessExpression)) {
2851
+ if (access.getName() !== oldName || isWrite(access) || overlaps(access, [...taken, ...edits])) continue;
2852
+ const target = access.getExpression().getText();
2853
+ const dot = access.hasQuestionDotToken() ? "?." : ".";
2854
+ const text = `(${target}${dot}${newName} ?? ${target}${dot}${oldName})`;
2855
+ edits.push({ start: access.getStart(), end: access.getEnd(), text, note: `${where(access)} \`${access.getText()}\` \u2192 \`${text}\`` });
2856
+ }
2857
+ }
2858
+ return edits;
2859
+ }
2860
+ function enumEdits(scope, fix2, where) {
2861
+ const edits = [];
2862
+ for (const { field, values } of fix2.enums ?? []) {
2863
+ const name = leaf(field);
2864
+ const renamed = new Map(values.map((v) => [v.from, v.to]));
2865
+ const isField = (n) => Node3.isPropertyAccessExpression(n) && n.getName() === name || Node3.isIdentifier(n) && n.getText() === name;
2866
+ for (const bin of scope.getDescendantsOfKind(SyntaxKind3.BinaryExpression)) {
2867
+ const op = bin.getOperatorToken().getKind();
2868
+ const negated = op === SyntaxKind3.ExclamationEqualsEqualsToken || op === SyntaxKind3.ExclamationEqualsToken;
2869
+ if (!negated && op !== SyntaxKind3.EqualsEqualsEqualsToken && op !== SyntaxKind3.EqualsEqualsToken) continue;
2870
+ const [lit, other] = Node3.isStringLiteral(bin.getRight()) ? [bin.getRight(), bin.getLeft()] : [bin.getLeft(), bin.getRight()];
2871
+ if (!Node3.isStringLiteral(lit) || !isField(other)) continue;
2872
+ const to = renamed.get(lit.getLiteralText());
2873
+ if (to === void 0) continue;
2874
+ const text = `${negated ? "!" : ""}${JSON.stringify([lit.getLiteralText(), to])}.includes(${other.getText()})`;
2875
+ edits.push({ start: bin.getStart(), end: bin.getEnd(), text, note: `${where(bin)} \`${bin.getText()}\` \u2192 \`${text}\`` });
2876
+ }
2877
+ for (const sw of scope.getDescendantsOfKind(SyntaxKind3.SwitchStatement)) {
2878
+ if (!isField(sw.getExpression())) continue;
2879
+ const cases = sw.getClauses().filter(Node3.isCaseClause);
2880
+ const present = new Set(cases.map((c) => c.getExpression()).filter(Node3.isStringLiteral).map((l) => l.getLiteralText()));
2881
+ for (const c of cases) {
2882
+ const e = c.getExpression();
2883
+ const to = Node3.isStringLiteral(e) ? renamed.get(e.getLiteralText()) : void 0;
2884
+ if (to === void 0 || present.has(to)) continue;
2885
+ present.add(to);
2886
+ const indent = " ".repeat(c.getSourceFile().getLineAndColumnAtPos(c.getStart()).column - 1);
2887
+ edits.push({ start: c.getStart(), end: c.getStart(), text: `case ${JSON.stringify(to)}:
2888
+ ${indent}`, note: `${where(c)} add case ${JSON.stringify(to)}` });
2889
+ }
2890
+ }
2891
+ for (const inc of scope.getDescendantsOfKind(SyntaxKind3.CallExpression)) {
2892
+ const callee = inc.getExpression();
2893
+ if (!Node3.isPropertyAccessExpression(callee) || callee.getName() !== "includes" || !isField(inc.getArguments()[0] ?? inc)) continue;
2894
+ const arr = callee.getExpression();
2895
+ if (!Node3.isArrayLiteralExpression(arr)) continue;
2896
+ const items = arr.getElements().filter(Node3.isStringLiteral).map((l) => l.getLiteralText());
2897
+ const add = items.map((v) => renamed.get(v)).filter((v) => v !== void 0 && !items.includes(v));
2898
+ if (!add.length) continue;
2899
+ const text = JSON.stringify([...items, ...new Set(add)]);
2900
+ edits.push({ start: arr.getStart(), end: arr.getEnd(), text, note: `${where(arr)} \`${arr.getText()}\` \u2192 \`${text}\`` });
2901
+ }
2902
+ }
2903
+ return edits;
2904
+ }
2905
+ function urlTextNodes(call, sf) {
2906
+ const out = /* @__PURE__ */ new Set();
2907
+ const seen = /* @__PURE__ */ new Set();
2908
+ const visit = (n, depth) => {
2909
+ if (!n || depth > 4 || seen.has(n)) return;
2910
+ seen.add(n);
2911
+ if (TEXT_KINDS.includes(n.getKind())) out.add(n);
2912
+ if (Node3.isIdentifier(n)) {
2913
+ const decl = sf.getVariableDeclaration(n.getText());
2914
+ if (decl?.getVariableStatement()?.getDeclarationKind() === "const") visit(decl.getInitializer(), depth + 1);
2915
+ return;
2916
+ }
2917
+ n.forEachChild((c) => visit(c, depth));
2918
+ };
2919
+ const [first] = call.getArguments();
2920
+ if (first && Node3.isObjectLiteralExpression(first)) {
2921
+ const url = first.getProperty("url");
2922
+ if (url && Node3.isPropertyAssignment(url)) visit(url.getInitializer(), 0);
2923
+ } else visit(first, 0);
2924
+ for (const prop of sf.getDescendantsOfKind(SyntaxKind3.PropertyAssignment)) if (propName2(prop) === "baseURL") visit(prop.getInitializer(), 0);
2925
+ return [...out];
2926
+ }
2927
+ function objectProperties(call, name) {
2928
+ const out = [];
2929
+ for (const arg of call.getArguments()) {
2930
+ if (!Node3.isObjectLiteralExpression(arg)) continue;
2931
+ const prop = arg.getProperties().find((p) => propName2(p) === name);
2932
+ const value2 = prop && Node3.isPropertyAssignment(prop) ? prop.getInitializer() : void 0;
2933
+ if (value2 && Node3.isObjectLiteralExpression(value2)) out.push(...value2.getProperties());
2934
+ }
2935
+ return out;
2936
+ }
2937
+ function propName2(p) {
2938
+ if (!(Node3.isPropertyAssignment(p) || Node3.isShorthandPropertyAssignment(p))) return void 0;
2939
+ const n = p.getNameNode();
2940
+ return Node3.isStringLiteral(n) || Node3.isNoSubstitutionTemplateLiteral(n) ? n.getLiteralText() : n.getText();
2941
+ }
2942
+ function enclosingFunction(call) {
2943
+ return call.getFirstAncestor((n) => Node3.isFunctionDeclaration(n) || Node3.isArrowFunction(n) || Node3.isFunctionExpression(n) || Node3.isMethodDeclaration(n));
2944
+ }
2945
+ function isWrite(access) {
2946
+ const p = access.getParent();
2947
+ return Node3.isBinaryExpression(p) && p.getLeft() === access && p.getOperatorToken().getKind() === SyntaxKind3.EqualsToken;
2948
+ }
2949
+ function overlaps(n, edits) {
2950
+ return edits.some((e) => n.getStart() < e.end && e.start < n.getEnd());
2951
+ }
2952
+ function leaf(path13) {
2953
+ return path13.split(".").pop().replace(/\[\]/g, "");
2954
+ }
2955
+ function escapeRe(s) {
2956
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2957
+ }
2958
+
2959
+ // packages/repair/src/recipes/status-code-accept-set.ts
2960
+ import { Node as Node4, SyntaxKind as SyntaxKind4 } from "ts-morph";
1944
2961
  var STATUS_PROPS = /* @__PURE__ */ new Set(["status", "statusCode"]);
1945
2962
  var statusCodeAcceptSet = {
1946
2963
  id: "status-code-accept-set",
@@ -1955,23 +2972,23 @@ var statusCodeAcceptSet = {
1955
2972
  const where = (n) => `${sourceFile.getFilePath().slice(1)}:${n.getStartLineNumber()}`;
1956
2973
  for (const status of statusExpressions(call)) {
1957
2974
  let cur = status;
1958
- while (Node3.isParenthesizedExpression(cur.getParent())) cur = cur.getParent();
2975
+ while (Node4.isParenthesizedExpression(cur.getParent())) cur = cur.getParent();
1959
2976
  const parent = cur.getParent();
1960
- if (Node3.isBinaryExpression(parent)) {
2977
+ if (Node4.isBinaryExpression(parent)) {
1961
2978
  const op = parent.getOperatorToken().getKind();
1962
2979
  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;
2980
+ if (!Node4.isNumericLiteral(other) || !oldCodes.includes(Number(other.getLiteralValue()))) continue;
2981
+ const negated = op === SyntaxKind4.ExclamationEqualsEqualsToken || op === SyntaxKind4.ExclamationEqualsToken;
2982
+ const equal = op === SyntaxKind4.EqualsEqualsEqualsToken || op === SyntaxKind4.EqualsEqualsToken;
1966
2983
  if (!negated && !equal) continue;
1967
2984
  const text = `${negated ? "!" : ""}${list}.includes(${status.getText()})`;
1968
2985
  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())));
2986
+ } else if (Node4.isSwitchStatement(parent) && parent.getExpression() === cur) {
2987
+ const cases = parent.getClauses().filter(Node4.isCaseClause);
2988
+ const present = new Set(cases.map((c) => c.getExpression()).filter(Node4.isNumericLiteral).map((l) => Number(l.getLiteralValue())));
1972
2989
  const anchor = cases.find((c) => {
1973
2990
  const e = c.getExpression();
1974
- return Node3.isNumericLiteral(e) && oldCodes.includes(Number(e.getLiteralValue()));
2991
+ return Node4.isNumericLiteral(e) && oldCodes.includes(Number(e.getLiteralValue()));
1975
2992
  });
1976
2993
  const missing = accept.filter((c) => !present.has(c));
1977
2994
  if (!anchor || missing.length === 0) continue;
@@ -1979,11 +2996,11 @@ var statusCodeAcceptSet = {
1979
2996
  const text = missing.map((c) => `case ${c}:
1980
2997
  ${indent}`).join("");
1981
2998
  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) {
2999
+ } else if (Node4.isCallExpression(parent) && parent.getArguments()[0] === cur) {
1983
3000
  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()));
3001
+ const arr = Node4.isPropertyAccessExpression(callee) && callee.getName() === "includes" ? callee.getExpression() : void 0;
3002
+ if (!arr || !Node4.isArrayLiteralExpression(arr)) continue;
3003
+ const values = arr.getElements().filter(Node4.isNumericLiteral).map((l) => Number(l.getLiteralValue()));
1987
3004
  if (!values.some((v) => oldCodes.includes(v))) continue;
1988
3005
  const merged = [.../* @__PURE__ */ new Set([...values, ...accept])].sort((a, b) => a - b);
1989
3006
  if (merged.length === values.length) continue;
@@ -1999,52 +3016,52 @@ function statusExpressions(call) {
1999
3016
  let cur = call;
2000
3017
  for (; ; ) {
2001
3018
  const p = cur.getParent();
2002
- if (p && (Node3.isAwaitExpression(p) || Node3.isParenthesizedExpression(p) || Node3.isAsExpression(p) || Node3.isNonNullExpression(p))) cur = p;
3019
+ if (p && (Node4.isAwaitExpression(p) || Node4.isParenthesizedExpression(p) || Node4.isAsExpression(p) || Node4.isNonNullExpression(p))) cur = p;
2003
3020
  else break;
2004
3021
  }
2005
3022
  const parent = cur.getParent();
2006
3023
  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;
3024
+ if (Node4.isPropertyAccessExpression(parent) && parent.getExpression() === cur && STATUS_PROPS.has(parent.getName())) out.push(parent);
3025
+ if (Node4.isPropertyAccessExpression(parent) && parent.getName() === "then") {
3026
+ const cb = Node4.isCallExpression(parent.getParent()) ? parent.getParent().getArguments()[0] : void 0;
3027
+ const param = cb && (Node4.isArrowFunction(cb) || Node4.isFunctionExpression(cb)) ? cb.getParameters()[0] : void 0;
2011
3028
  if (param) out.push(...fromBinding(param.getNameNode(), param, sf));
2012
3029
  }
2013
- if (Node3.isVariableDeclaration(parent) && parent.getInitializer() === cur) out.push(...fromBinding(parent.getNameNode(), parent, sf));
3030
+ if (Node4.isVariableDeclaration(parent) && parent.getInitializer() === cur) out.push(...fromBinding(parent.getNameNode(), parent, sf));
2014
3031
  return out;
2015
3032
  }
2016
3033
  function fromBinding(name, decl, sf) {
2017
3034
  const out = [];
2018
- if (Node3.isIdentifier(name)) {
3035
+ if (Node4.isIdentifier(name)) {
2019
3036
  for (const ref of references(name.getText(), decl, sf)) {
2020
3037
  const p = ref.getParent();
2021
- if (Node3.isPropertyAccessExpression(p) && p.getExpression() === ref && STATUS_PROPS.has(p.getName())) out.push(p);
3038
+ if (Node4.isPropertyAccessExpression(p) && p.getExpression() === ref && STATUS_PROPS.has(p.getName())) out.push(p);
2022
3039
  }
2023
- } else if (Node3.isObjectBindingPattern(name)) {
3040
+ } else if (Node4.isObjectBindingPattern(name)) {
2024
3041
  for (const el of name.getElements()) {
2025
3042
  const prop = el.getPropertyNameNode()?.getText() ?? el.getName();
2026
- if (STATUS_PROPS.has(prop) && Node3.isIdentifier(el.getNameNode())) out.push(...references(el.getName(), decl, sf));
3043
+ if (STATUS_PROPS.has(prop) && Node4.isIdentifier(el.getNameNode())) out.push(...references(el.getName(), decl, sf));
2027
3044
  }
2028
3045
  }
2029
3046
  return out;
2030
3047
  }
2031
3048
  function references(name, decl, sf) {
2032
3049
  const scope = decl.getFirstAncestor(
2033
- (n) => Node3.isBlock(n) || Node3.isArrowFunction(n) || Node3.isFunctionExpression(n) || Node3.isFunctionDeclaration(n) || Node3.isMethodDeclaration(n) || Node3.isCaseClause(n)
3050
+ (n) => Node4.isBlock(n) || Node4.isArrowFunction(n) || Node4.isFunctionExpression(n) || Node4.isFunctionDeclaration(n) || Node4.isMethodDeclaration(n) || Node4.isCaseClause(n)
2034
3051
  ) ?? sf;
2035
- return scope.getDescendantsOfKind(SyntaxKind3.Identifier).filter((id) => {
3052
+ return scope.getDescendantsOfKind(SyntaxKind4.Identifier).filter((id) => {
2036
3053
  if (id.getText() !== name || id.getStart() < decl.getEnd()) return false;
2037
3054
  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));
3055
+ if (Node4.isPropertyAccessExpression(p) && p.getNameNode() === id) return false;
3056
+ if (Node4.isPropertyAssignment(p) && p.getNameNode() === id) return false;
3057
+ return !(Node4.isBindingElement(p) || Node4.isVariableDeclaration(p) || Node4.isParameterDeclaration(p));
2041
3058
  });
2042
3059
  }
2043
3060
 
2044
3061
  // packages/repair/src/recipe.ts
2045
- import { Project as Project2, ScriptKind as ScriptKind2, SyntaxKind as SyntaxKind4 } from "ts-morph";
3062
+ import { Project as Project2, ScriptKind as ScriptKind2, SyntaxKind as SyntaxKind5 } from "ts-morph";
2046
3063
  function findCall(sf, site) {
2047
- return sf.getDescendantsOfKind(SyntaxKind4.CallExpression).filter((c) => {
3064
+ return sf.getDescendantsOfKind(SyntaxKind5.CallExpression).filter((c) => {
2048
3065
  const pos = sf.getLineAndColumnAtPos(c.getStart());
2049
3066
  return pos.line === site.line && pos.column === site.column;
2050
3067
  }).sort((a, b) => a.getWidth() - b.getWidth())[0];
@@ -2073,7 +3090,7 @@ function runRecipe(recipes, advisory, callSite, text) {
2073
3090
 
2074
3091
  // packages/repair/src/contract.ts
2075
3092
  import path7 from "path";
2076
- import { Node as Node4, Project as Project3, ScriptKind as ScriptKind3, SyntaxKind as SyntaxKind5 } from "ts-morph";
3093
+ import { Node as Node5, Project as Project3, ScriptKind as ScriptKind3, SyntaxKind as SyntaxKind6 } from "ts-morph";
2077
3094
 
2078
3095
  // packages/repair/src/synth.ts
2079
3096
  function synthesizeBody(shape) {
@@ -2132,70 +3149,339 @@ function sampleString(classes) {
2132
3149
  if (classes.includes("token")) return "shapi_value";
2133
3150
  return "shapi text";
2134
3151
  }
3152
+ function bodyFromPaths(paths, values = {}) {
3153
+ const all = [.../* @__PURE__ */ new Set([...paths, ...Object.keys(values)])];
3154
+ const isLeaf = (p) => !all.some((o) => o !== p && (o.startsWith(`${p}.`) || o.startsWith(`${p}[]`)));
3155
+ const shape = {
3156
+ status: 200,
3157
+ headers: {},
3158
+ headerNames: [],
3159
+ body: {
3160
+ kind: "json",
3161
+ rootType: all.some((p) => p.startsWith("[]")) ? "array" : "object",
3162
+ fields: all.map((path13) => ({ path: path13, pathHash: path13, types: isLeaf(path13) ? ["string"] : [], classes: [], occurrences: 1, nullRate: 0 }))
3163
+ }
3164
+ };
3165
+ const body3 = synthesizeBody(shape);
3166
+ for (const [path13, value2] of Object.entries(values)) setPath(body3, tokens(path13), value2);
3167
+ return body3;
3168
+ }
3169
+ function setPath(target, path13, value2) {
3170
+ let cur = target;
3171
+ for (let i = 0; i < path13.length; i++) {
3172
+ const t = path13[i];
3173
+ const last = i === path13.length - 1;
3174
+ if (t === "[]") {
3175
+ const arr = cur;
3176
+ if (last) arr[0] = value2;
3177
+ else cur = arr[0];
3178
+ } else {
3179
+ const obj = cur;
3180
+ if (last) obj[t] = value2;
3181
+ else cur = obj[t];
3182
+ }
3183
+ if (cur === void 0 || cur === null) return;
3184
+ }
3185
+ }
2135
3186
 
2136
3187
  // packages/repair/src/contract.ts
2137
3188
  var CONTRACT_DIR = "test/shapi";
3189
+ var RETIRED_STATUS = 410;
2138
3190
  function generateContractTest(input) {
2139
3191
  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
- }
3192
+ const fix2 = advisory.fix;
2143
3193
  const project = new Project3({ useInMemoryFileSystem: true, skipFileDependencyResolution: true, compilerOptions: { allowJs: true } });
2144
3194
  const sf = project.createSourceFile(`/${callSite.file}`, input.sourceText, {
2145
3195
  scriptKind: /\.(c|m)?jsx?$/.test(callSite.file) ? ScriptKind3.JS : ScriptKind3.TS
2146
3196
  });
2147
3197
  const call = findCall(sf, callSite);
2148
3198
  if (!call) return { status: "unsupported", reason: `no call at ${callSite.file}:${callSite.line}:${callSite.column}` };
3199
+ const transport = transportFor(callSite, sf);
3200
+ if ("reason" in transport) return { status: "unsupported", reason: transport.reason };
2149
3201
  const fn = exportedFunction(call);
2150
3202
  if (!fn) return { status: "unsupported", reason: "the call site is not inside an exported function" };
2151
- let responses;
3203
+ const file = input.file ?? `${CONTRACT_DIR}/${advisory.id}.test.ts`;
3204
+ const importPath = toImport(path7.posix.relative(path7.posix.dirname(file), callSite.file));
3205
+ const parts = {
3206
+ header: `// Generated by SHAPI for advisory ${advisory.id}: ${advisory.title}
3207
+ // Call site: ${callSite.file}:${callSite.line} (${callSite.method} ${callSite.endpoint}, via ${callSite.client})`,
3208
+ fn: fn.name,
3209
+ importPath,
3210
+ args: fn.params.map((p) => argument(p, sf)).join(", "),
3211
+ advisory,
3212
+ transport
3213
+ };
3214
+ if (fix2 && (fix2.path || fix2.query || fix2.header)) {
3215
+ if (callSite.kind === "sdk") return { status: "unsupported", reason: `the fix changes the request, which ${callSite.client} builds itself` };
3216
+ return { status: "generated", file, content: requestTest({ ...parts, fix: fix2, body: renamePair(callSite, fix2).after }), functionName: fn.name };
3217
+ }
3218
+ if (fix2 && (fix2.fields || fix2.enums)) {
3219
+ return { status: "generated", file, content: sameResultTest({ ...parts, ...renamePair(callSite, fix2) }), functionName: fn.name };
3220
+ }
3221
+ if (!before || !after) return { status: "unsupported", reason: "no before/after responses for this endpoint, and the advisory has no fix to build a test from" };
2152
3222
  try {
2153
- responses = [response(`before (${before.status})`, before), response(`after (${after.status})`, after)].join(",\n");
3223
+ return { status: "generated", file, content: recordedTest({ ...parts, before, after }), functionName: fn.name };
2154
3224
  } catch (err) {
2155
3225
  return { status: "unsupported", reason: err instanceof Error ? err.message : String(err) };
2156
3226
  }
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}";
3227
+ }
3228
+ function transportFor(site, sf) {
3229
+ if (site.kind === "http") {
3230
+ if (site.client === "fetch" || site.client === "ky") return { mock: "", stubFetch: true, name: "fetch" };
3231
+ if (site.client === "node-fetch") {
3232
+ return { mock: `vi.mock("node-fetch", () => ({ default: (input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, init) }));
3233
+ `, stubFetch: true, name: "node-fetch" };
3234
+ }
3235
+ if (site.client === "axios") return { mock: AXIOS_FAKE, stubFetch: false, name: "axios" };
3236
+ if (site.client === "got") return { mock: GOT_FAKE, stubFetch: false, name: "got" };
3237
+ return { reason: `contract tests support fetch, node-fetch, ky, axios, got and SDK call sites; this one uses ${site.client}` };
3238
+ }
3239
+ const decl = sf.getImportDeclarations().find((d) => d.getModuleSpecifierValue() === site.client);
3240
+ if (!decl) return { reason: `${site.file} does not import ${site.client} with an import statement` };
3241
+ const names = decl.getNamedImports().map((n) => n.getName());
3242
+ const exports = [`default: client`, ...names.map((n) => `${JSON.stringify(n)}: client`)].join(", ");
3243
+ const url = `https://${site.host ?? "sdk.invalid"}${site.endpoint ?? "/"}`;
3244
+ const envelope = site.provider === "github" ? `{ status: res.status, data: res.body, headers: res.headers }` : `res.body`;
3245
+ return {
3246
+ mock: `vi.mock(${JSON.stringify(site.client)}, () => {
3247
+ const call = async () => {
3248
+ const res = shapi.respond({ method: ${JSON.stringify(site.method ?? "GET")}, url: ${JSON.stringify(url)}, headers: {} });
3249
+ if (res.status >= 400) throw Object.assign(new Error(\`${site.client} request failed with \${res.status}\`), { status: res.status, statusCode: res.status, response: { status: res.status, data: res.body } });
3250
+ return ${envelope};
3251
+ };
3252
+ const client: any = new Proxy(function () {}, {
3253
+ get: (_t, prop) => (prop === "then" || typeof prop === "symbol" ? undefined : client),
3254
+ apply: () => call(),
3255
+ construct: () => client,
3256
+ });
3257
+ return { ${exports} };
3258
+ });
3259
+ `,
3260
+ stubFetch: false,
3261
+ name: `a fake ${site.client} client`
3262
+ };
3263
+ }
3264
+ function preamble(p) {
3265
+ return `import { afterEach, describe, expect, it, vi } from "vitest";
3266
+
3267
+ type ShapiRequest = { method: string; url: string; headers: Record<string, string> };
3268
+ type ShapiResponse = { status: number; body: unknown; headers: Record<string, string> };
3269
+ const shapi = vi.hoisted(() => ({
3270
+ respond: (_req: { method: string; url: string; headers: Record<string, string> }): { status: number; body: unknown; headers: Record<string, string> } => ({ status: 200, body: null, headers: {} }),
3271
+ lower: (h: unknown): Record<string, string> =>
3272
+ Object.fromEntries(Object.entries((h ?? {}) as Record<string, unknown>).filter(([, v]) => v !== undefined).map(([k, v]) => [k.toLowerCase(), String(v)])),
3273
+ }));
3274
+ ${p.transport.mock}
3275
+ import { ${p.fn} } from "${p.importPath}";
3276
+ ${p.transport.stubFetch ? `
3277
+ const shapiFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
3278
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
3279
+ const headers = Object.fromEntries(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)).entries());
3280
+ const res = shapi.respond({ method: (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(), url, headers });
3281
+ return new Response(res.body === null ? null : JSON.stringify(res.body), { status: res.status, headers: res.headers });
3282
+ });
3283
+ ` : ""}
3284
+ afterEach(() => {
3285
+ vi.unstubAllGlobals();
3286
+ });
2166
3287
 
3288
+ async function run(respond: (req: ShapiRequest) => ShapiResponse): Promise<unknown> {
3289
+ shapi.respond = respond;${p.transport.stubFetch ? `
3290
+ vi.stubGlobal("fetch", shapiFetch);` : ""}
3291
+ return ${p.fn}(${p.args});
3292
+ }
3293
+ `;
3294
+ }
3295
+ var JSON_HEADERS = `{ "content-type": "application/json" }`;
3296
+ function requestTest(p) {
3297
+ const checks = [];
3298
+ if (p.fix.path) checks.push(`url.href.includes(${JSON.stringify(p.fix.path.from)})`);
3299
+ if (p.fix.query) {
3300
+ const name = JSON.stringify(p.fix.query.name);
3301
+ const below = p.advisory.trigger.query?.find((q) => q.name === p.fix.query.name)?.below;
3302
+ checks.push(
3303
+ below === void 0 ? `(url.searchParams.has(${name}) && url.searchParams.get(${name}) !== ${JSON.stringify(p.fix.query.value)})` : `(url.searchParams.has(${name}) && Number(url.searchParams.get(${name})) < ${below})`
3304
+ );
3305
+ }
3306
+ if (p.fix.header) {
3307
+ const name = JSON.stringify(p.fix.header.name.toLowerCase());
3308
+ checks.push(`(${name} in req.headers && req.headers[${name}] !== ${JSON.stringify(p.fix.header.value)})`);
3309
+ }
3310
+ return `${p.header}
3311
+ // The provider no longer accepts the old request: ${p.transport.name} is replaced by a stub that answers it
3312
+ // with ${RETIRED_STATUS}, and answers anything else with a body holding the fields the code reads. The test
3313
+ // fails until ${p.fn}() sends the new request (${describeFix(p.fix)}) and still works.
3314
+ ${preamble(p)}
3315
+ const body = ${JSON.stringify(p.body)};
3316
+
3317
+ function retired(req: ShapiRequest): boolean {
3318
+ const url = new URL(req.url, "https://shapi.invalid");
3319
+ return ${checks.join(" || ")};
3320
+ }
3321
+
3322
+ describe("SHAPI contract: ${p.advisory.id}", () => {
3323
+ it("${p.fn} sends the new request and handles the response", async () => {
3324
+ const sent: boolean[] = [];
3325
+ await run((req) => {
3326
+ const old = retired(req);
3327
+ sent.push(old);
3328
+ return old ? { status: ${RETIRED_STATUS}, body: { error: "retired request (SHAPI contract test)" }, headers: ${JSON_HEADERS} } : { status: 200, body, headers: ${JSON_HEADERS} };
3329
+ });
3330
+ expect(sent.length).toBeGreaterThan(0);
3331
+ expect(sent).not.toContain(true);
3332
+ });
3333
+ });
3334
+ `;
3335
+ }
3336
+ function sameResultTest(p) {
3337
+ return `${p.header}
3338
+ // The provider renamed fields or enum values. This test calls ${p.fn}() once with the old response and
3339
+ // once with the new one (${p.transport.name} replaced by a stub), and expects the same result: it fails
3340
+ // while the code only reads the old names.
3341
+ ${preamble(p)}
2167
3342
  const responses = [
2168
- ${responses},
3343
+ { name: "old", body: ${JSON.stringify(p.before)} },
3344
+ { name: "new", body: ${JSON.stringify(p.after)} },
2169
3345
  ];
2170
3346
 
2171
- describe("SHAPI contract: ${advisory.id}", () => {
2172
- afterEach(() => {
2173
- vi.unstubAllGlobals();
3347
+ describe("SHAPI contract: ${p.advisory.id}", () => {
3348
+ it("${p.fn} returns the same result for the old and the new response", async () => {
3349
+ const results: unknown[] = [];
3350
+ for (const r of responses) results.push(await run(() => ({ status: 200, body: r.body, headers: ${JSON_HEADERS} })));
3351
+ expect(results[1]).toEqual(results[0]);
2174
3352
  });
3353
+ });
3354
+ `;
3355
+ }
3356
+ function recordedTest(p) {
3357
+ const responses = [response(`before (${p.before.status})`, p.before), response(`after (${p.after.status})`, p.after)].join(",\n");
3358
+ return `${p.header}
3359
+ // Calls ${p.fn}() with ${p.transport.name} returning the response the code was written for and the one
3360
+ // ${p.advisory.provider} sends now. It fails while the code accepts only the old response.
3361
+ ${preamble(p)}
3362
+ const responses = [
3363
+ ${responses},
3364
+ ];
2175
3365
 
3366
+ describe("SHAPI contract: ${p.advisory.id}", () => {
2176
3367
  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();
3368
+ it(\`${p.fn} handles the \${r.name} response\`, async () => {
3369
+ const seen: ShapiRequest[] = [];
3370
+ await run((req) => (seen.push(req), { status: r.status, body: r.body, headers: r.headers }));
3371
+ expect(seen.length).toBeGreaterThan(0);
2182
3372
  });
2183
3373
  }
2184
3374
  });
2185
3375
  `;
2186
- return { status: "generated", file, content, functionName: fn.name };
2187
3376
  }
3377
+ function renamePair(site, fix2) {
3378
+ const paths = site.surface.fields;
3379
+ const rename = (p) => {
3380
+ for (const { from, to } of fix2.fields ?? []) {
3381
+ if (!/[.[]/.test(from)) {
3382
+ const segs = p.split(".");
3383
+ const i = segs.findIndex((seg) => seg.replace(/\[\]/g, "") === from);
3384
+ if (i >= 0) return [...segs.slice(0, i), segs[i].replace(from, to), ...segs.slice(i + 1)].join(".");
3385
+ } else if (p === from || p.startsWith(`${from}.`) || p.startsWith(`${from}[]`)) return to + p.slice(from.length);
3386
+ }
3387
+ return p;
3388
+ };
3389
+ const oldValues = {};
3390
+ const newValues = {};
3391
+ for (const { field, values } of fix2.enums ?? []) {
3392
+ const compared = site.surface.enums.find((e) => e.field === field)?.values ?? [];
3393
+ const pick = values.find((v) => compared.includes(v.from)) ?? values[0];
3394
+ oldValues[field] = pick.from;
3395
+ newValues[rename(field)] = pick.to;
3396
+ }
3397
+ return { before: bodyFromPaths(paths, oldValues), after: bodyFromPaths(paths.map(rename), newValues) };
3398
+ }
3399
+ function describeFix(fix2) {
3400
+ const parts = [];
3401
+ if (fix2.path) parts.push(`${fix2.path.to} instead of ${fix2.path.from}`);
3402
+ if (fix2.query) parts.push(`${fix2.query.name}=${fix2.query.value}`);
3403
+ if (fix2.header) parts.push(`${fix2.header.name}: ${fix2.header.value}`);
3404
+ return parts.join(", ");
3405
+ }
3406
+ var AXIOS_FAKE = `vi.mock("axios", () => {
3407
+ class AxiosError extends Error {
3408
+ isAxiosError = true;
3409
+ constructor(message: string, readonly response?: unknown, readonly status?: number) {
3410
+ super(message);
3411
+ }
3412
+ }
3413
+ const build = (defaults: any = {}): any => {
3414
+ const send = async (config: any) => {
3415
+ const c = { ...defaults, ...config, headers: { ...defaults.headers, ...config.headers }, params: { ...defaults.params, ...config.params } };
3416
+ let url = String(c.url ?? "");
3417
+ if (c.baseURL && !/^[a-z]+:\\/\\//i.test(url)) url = String(c.baseURL).replace(/\\/+$/, "") + "/" + url.replace(/^\\/+/, "");
3418
+ const qs = new URLSearchParams(Object.entries(c.params).filter(([, v]) => v !== undefined && v !== null).map(([k, v]) => [k, String(v)])).toString();
3419
+ if (qs) url += (url.includes("?") ? "&" : "?") + qs;
3420
+ const res = shapi.respond({ method: String(c.method ?? "get").toUpperCase(), url, headers: shapi.lower(c.headers) });
3421
+ const response = { status: res.status, statusText: "", headers: res.headers, data: res.body, config: c };
3422
+ if (res.status < 200 || res.status >= 300) throw new AxiosError(\`Request failed with status code \${res.status}\`, response, res.status);
3423
+ return response;
3424
+ };
3425
+ const instance: any = (a: any, b?: any) => send(typeof a === "string" ? { ...b, url: a } : a);
3426
+ instance.request = (c: any) => send(c);
3427
+ for (const m of ["get", "delete", "head", "options"]) instance[m] = (url: string, c: any = {}) => send({ ...c, url, method: m });
3428
+ for (const m of ["post", "put", "patch"]) instance[m] = (url: string, data: unknown, c: any = {}) => send({ ...c, url, data, method: m });
3429
+ instance.create = (d: any = {}) => build({ ...defaults, ...d, headers: { ...defaults.headers, ...d.headers } });
3430
+ instance.defaults = defaults;
3431
+ instance.interceptors = { request: { use: () => 0, eject: () => {} }, response: { use: () => 0, eject: () => {} } };
3432
+ instance.isAxiosError = (e: any) => Boolean(e?.isAxiosError);
3433
+ return instance;
3434
+ };
3435
+ const axios = build();
3436
+ return { default: axios, create: axios.create, isAxiosError: axios.isAxiosError, AxiosError };
3437
+ });
3438
+ `;
3439
+ var GOT_FAKE = `vi.mock("got", () => {
3440
+ class HTTPError extends Error {
3441
+ constructor(readonly response: any) {
3442
+ super(\`Response code \${response.statusCode}\`);
3443
+ }
3444
+ }
3445
+ const build = (defaults: any = {}): any => {
3446
+ const call = (input: any, options: any = {}) => {
3447
+ const fromObject = typeof input === "object" && !(input instanceof URL);
3448
+ const o = { ...defaults, ...(fromObject ? input : options), headers: { ...defaults.headers, ...(fromObject ? input.headers : options.headers) } };
3449
+ let url = fromObject ? String(o.url ?? "") : String(input);
3450
+ if (o.prefixUrl) url = String(o.prefixUrl).replace(/\\/+$/, "") + "/" + url.replace(/^\\/+/, "");
3451
+ const sp = o.searchParams;
3452
+ const qs = sp ? new URLSearchParams(typeof sp === "string" || sp instanceof URLSearchParams ? sp : Object.entries(sp).map(([k, v]) => [k, String(v)])).toString() : "";
3453
+ if (qs) url += (url.includes("?") ? "&" : "?") + qs;
3454
+ const res = shapi.respond({ method: String(o.method ?? "GET").toUpperCase(), url, headers: shapi.lower(o.headers) });
3455
+ const text = res.body === null ? "" : JSON.stringify(res.body);
3456
+ const response = { statusCode: res.status, headers: res.headers, body: o.responseType === "json" ? res.body : text, url };
3457
+ const failed = (o.throwHttpErrors ?? true) && res.status >= 400;
3458
+ const settle = <T,>(value: T): Promise<T> => (failed ? Promise.reject(new HTTPError(response)) : Promise.resolve(value));
3459
+ const promise: any = settle(response);
3460
+ promise.catch(() => {});
3461
+ promise.json = () => settle(res.body);
3462
+ promise.text = () => settle(text);
3463
+ promise.buffer = () => settle(Buffer.from(text));
3464
+ return promise;
3465
+ };
3466
+ const got: any = (input: any, options?: any) => call(input, options);
3467
+ for (const m of ["get", "post", "put", "patch", "delete", "head"]) got[m] = (input: any, options: any = {}) => call(input, { ...options, method: m.toUpperCase() });
3468
+ got.extend = (...opts: any[]) => build(Object.assign({}, defaults, ...opts));
3469
+ return got;
3470
+ };
3471
+ return { default: build(), HTTPError };
3472
+ });
3473
+ `;
2188
3474
  function response(name, shape) {
2189
- const body2 = synthesizeBody(shape);
3475
+ const body3 = synthesizeBody(shape);
2190
3476
  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)} }`;
3477
+ return ` { name: ${JSON.stringify(name)}, status: ${shape.status}, headers: ${JSON.stringify(headers)}, body: ${JSON.stringify(body3)} }`;
2192
3478
  }
2193
3479
  function exportedFunction(call) {
2194
3480
  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)) {
3481
+ if (Node5.isFunctionDeclaration(anc) && anc.isExported() && anc.getName()) return { name: anc.getName(), params: anc.getParameters() };
3482
+ if (Node5.isArrowFunction(anc) || Node5.isFunctionExpression(anc)) {
2197
3483
  const decl = anc.getParent();
2198
- if (Node4.isVariableDeclaration(decl) && decl.getVariableStatement()?.isExported()) return { name: decl.getName(), params: anc.getParameters() };
3484
+ if (Node5.isVariableDeclaration(decl) && decl.getVariableStatement()?.isExported()) return { name: decl.getName(), params: anc.getParameters() };
2199
3485
  }
2200
3486
  }
2201
3487
  return void 0;
@@ -2207,22 +3493,22 @@ function argument(p, sf) {
2207
3493
  function valueFor(type, name, sf, depth) {
2208
3494
  if (depth > 4) return "undefined as never";
2209
3495
  switch (type.getKind()) {
2210
- case SyntaxKind5.StringKeyword:
3496
+ case SyntaxKind6.StringKeyword:
2211
3497
  return JSON.stringify(`shapi-${name}`);
2212
- case SyntaxKind5.NumberKeyword:
3498
+ case SyntaxKind6.NumberKeyword:
2213
3499
  return "1";
2214
- case SyntaxKind5.BooleanKeyword:
3500
+ case SyntaxKind6.BooleanKeyword:
2215
3501
  return "true";
2216
3502
  }
2217
- if (Node4.isTypeLiteral(type)) return objectFrom(type.getProperties(), sf, depth);
2218
- if (Node4.isTypeReference(type)) {
3503
+ if (Node5.isTypeLiteral(type)) return objectFrom(type.getProperties(), sf, depth);
3504
+ if (Node5.isTypeReference(type)) {
2219
3505
  const ref = type.getTypeName().getText();
2220
3506
  const iface = sf.getInterface(ref);
2221
3507
  if (iface) return objectFrom(iface.getProperties(), sf, depth);
2222
3508
  const alias = sf.getTypeAlias(ref)?.getTypeNode();
2223
3509
  if (alias) return valueFor(alias, name, sf, depth + 1);
2224
3510
  }
2225
- if (Node4.isArrayTypeNode(type)) return "[]";
3511
+ if (Node5.isArrayTypeNode(type)) return "[]";
2226
3512
  return "undefined as never";
2227
3513
  }
2228
3514
  function objectFrom(props, sf, depth) {
@@ -2242,13 +3528,13 @@ import path10 from "path";
2242
3528
 
2243
3529
  // packages/repair/src/runner.ts
2244
3530
  import { spawnSync } from "child_process";
2245
- import { existsSync } from "fs";
2246
- import { createRequire } from "module";
3531
+ import { existsSync as existsSync2 } from "fs";
3532
+ import { createRequire as createRequire2 } from "module";
2247
3533
  import path8 from "path";
2248
3534
  function vitestCli(dir) {
2249
3535
  const local = path8.join(dir, "node_modules", "vitest", "vitest.mjs");
2250
- if (existsSync(local)) return local;
2251
- const require2 = createRequire(import.meta.url);
3536
+ if (existsSync2(local)) return local;
3537
+ const require2 = createRequire2(import.meta.url);
2252
3538
  return path8.join(path8.dirname(require2.resolve("vitest/package.json")), "vitest.mjs");
2253
3539
  }
2254
3540
  function runTestFile(dir, file, timeoutMs = 12e4) {
@@ -2272,7 +3558,7 @@ function runFailure(output) {
2272
3558
  }
2273
3559
 
2274
3560
  // packages/repair/src/workspace.ts
2275
- import { cpSync, existsSync as existsSync2, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync5, symlinkSync, writeFileSync as writeFileSync3 } from "fs";
3561
+ import { cpSync, existsSync as existsSync3, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync5, symlinkSync, writeFileSync as writeFileSync3 } from "fs";
2276
3562
  import os from "os";
2277
3563
  import path9 from "path";
2278
3564
  var COPY_SKIP = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "coverage", ".shapi"]);
@@ -2283,12 +3569,12 @@ function localWorkspace(repoDir) {
2283
3569
  filter: (src) => !COPY_SKIP.has(path9.basename(src)) || path9.resolve(src) === path9.resolve(repoDir)
2284
3570
  });
2285
3571
  const modules = path9.join(repoDir, "node_modules");
2286
- if (existsSync2(modules)) symlinkSync(modules, path9.join(dir, "node_modules"), "junction");
3572
+ if (existsSync3(modules)) symlinkSync(modules, path9.join(dir, "node_modules"), "junction");
2287
3573
  return {
2288
3574
  location: dir,
2289
3575
  async read(file) {
2290
3576
  const abs = path9.join(dir, file);
2291
- return existsSync2(abs) ? readFileSync5(abs, "utf8") : void 0;
3577
+ return existsSync3(abs) ? readFileSync5(abs, "utf8") : void 0;
2292
3578
  },
2293
3579
  async write(file, content) {
2294
3580
  const abs = path9.join(dir, file);
@@ -2306,10 +3592,13 @@ var PROTECTED = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "covera
2306
3592
  async function repairCallSite(input) {
2307
3593
  const { advisory, callSite } = input;
2308
3594
  if (!input.workspace && !input.repoDir) throw new Error("repairCallSite needs a workspace or a repoDir");
3595
+ if (!/\.(c|m)?[jt]sx?$/.test(callSite.file)) {
3596
+ return { status: "unsupported", changes: [], attempts: [], reason: `fixes support TypeScript and JavaScript; ${callSite.file} is scanned only` };
3597
+ }
2309
3598
  const ws = input.workspace ?? localWorkspace(input.repoDir);
2310
3599
  const original = await ws.read(callSite.file);
2311
3600
  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 });
3601
+ const test = generateContractTest({ callSite, advisory, before: input.before, after: input.after, sourceText: original, file: input.testFile });
2313
3602
  if (test.status !== "generated") return { status: "unsupported", changes: [], attempts: [], reason: test.reason };
2314
3603
  await ws.write(test.file, test.content);
2315
3604
  const testChange = { file: test.file, before: "", after: test.content, notes: ["generated contract test"] };
@@ -2398,6 +3687,42 @@ function rejected(ws, test, baseline, attempts) {
2398
3687
  async function restore(ws, changes) {
2399
3688
  for (const c of changes) await ws.write(c.file, c.before);
2400
3689
  }
3690
+ async function repairAdvisory(input) {
3691
+ if (!input.workspace && !input.repoDir) throw new Error("repairAdvisory needs a workspace or a repoDir");
3692
+ const ws = input.workspace ?? localWorkspace(input.repoDir);
3693
+ const seen = /* @__PURE__ */ new Set();
3694
+ const order = input.callSites.filter((s) => {
3695
+ const key = `${s.file}:${s.line}:${s.column}`;
3696
+ return !seen.has(key) && Boolean(seen.add(key));
3697
+ }).sort((a, b) => a.file.localeCompare(b.file) || b.line - a.line || b.column - a.column);
3698
+ const originals = /* @__PURE__ */ new Map();
3699
+ const sites = [];
3700
+ for (const [i, callSite] of order.entries()) {
3701
+ if (!originals.has(callSite.file)) originals.set(callSite.file, await ws.read(callSite.file) ?? "");
3702
+ const testFile = `test/shapi/${input.advisory.id}${i === 0 ? "" : `.${i + 1}`}.test.ts`;
3703
+ const result = await repairCallSite({ ...input, workspace: ws, callSite, testFile });
3704
+ for (const c of result.changes) if (c.before && !originals.has(c.file)) originals.set(c.file, c.before);
3705
+ sites.push({ callSite, result });
3706
+ }
3707
+ const fixed = sites.filter((s) => s.result.status === "fixed");
3708
+ const needed = sites.filter((s) => s.result.status !== "already-handled");
3709
+ const tests = new Set(fixed.map((s) => s.result.test?.file));
3710
+ const sourceFiles = [...new Set(fixed.flatMap((s) => s.result.changes.filter((c) => !tests.has(c.file)).map((c) => c.file)))].sort();
3711
+ const changes = [];
3712
+ for (const file of sourceFiles) {
3713
+ const notes = fixed.flatMap((s) => s.result.changes.filter((c) => c.file === file).flatMap((c) => c.notes));
3714
+ changes.push({ file, before: originals.get(file) ?? "", after: await ws.read(file) ?? "", notes });
3715
+ }
3716
+ for (const s of fixed) if (s.result.test) changes.push({ file: s.result.test.file, before: "", after: s.result.test.content, notes: ["generated contract test"] });
3717
+ const methods = new Set(fixed.map((s) => s.result.method));
3718
+ return {
3719
+ status: fixed.length === 0 ? "none" : fixed.length === needed.length ? "fixed" : "partial",
3720
+ sites,
3721
+ changes,
3722
+ ...fixed.length ? { method: methods.has("llm") ? "llm" : "recipe" } : {},
3723
+ sandbox: ws.location
3724
+ };
3725
+ }
2401
3726
 
2402
3727
  // packages/repair/src/llm.ts
2403
3728
  import Anthropic from "@anthropic-ai/sdk";
@@ -2510,7 +3835,8 @@ ${req.failingOutput.slice(-6e3)}
2510
3835
 
2511
3836
  // packages/repair/src/index.ts
2512
3837
  var RECIPES = {
2513
- [statusCodeAcceptSet.id]: statusCodeAcceptSet
3838
+ [statusCodeAcceptSet.id]: statusCodeAcceptSet,
3839
+ [applyFix.id]: applyFix
2514
3840
  };
2515
3841
 
2516
3842
  // packages/github-app/src/pr.ts
@@ -2651,29 +3977,31 @@ function prTitle(advisory) {
2651
3977
  function prBranch(advisory) {
2652
3978
  return `shapi/${advisory.id}`;
2653
3979
  }
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";
3980
+ function renderPrBody({ advisory, repair, shapeSource }) {
3981
+ const fixed = repair.sites.filter((s) => s.result.status === "fixed");
3982
+ const tests = new Set(fixed.map((s) => s.result.test?.file));
3983
+ const notes = repair.changes.filter((c) => !tests.has(c.file)).flatMap((c) => (c.notes.length ? c.notes : [`\`${c.file}\``]).map((n) => `- ${n}`));
3984
+ const method = repair.method === "llm" ? "Claude, verified by the contract tests" : `deterministic recipe \`${advisory.recipeId}\``;
2658
3985
  const evidence = advisory.evidence;
3986
+ const where = (s) => `\`${s.callSite.file}:${s.callSite.line}\` (${s.callSite.method} ${s.callSite.endpoint})`;
3987
+ const siteLine = (s) => s.result.status === "fixed" ? `- [x] ${where(s)}: fixed, test \`${s.result.test?.file}\`` : s.result.status === "already-handled" ? `- [x] ${where(s)}: already handles the change` : `- [ ] ${where(s)}: not fixed here (${s.result.reason ?? s.result.status}); needs a manual change`;
2659
3988
  const lines = [
2660
3989
  `**${advisory.provider}** changed \`${advisory.endpoint.method} ${advisory.endpoint.path}\`: ${advisory.title}.`,
2661
3990
  "",
2662
3991
  advisory.summary,
2663
3992
  "",
2664
- "### Affected call site",
2665
- `\`${callSite.file}:${callSite.line}\` (${callSite.method} ${callSite.endpoint})`,
3993
+ `### Affected call sites (${fixed.length} of ${repair.sites.length} fixed here)`,
3994
+ ...repair.sites.map(siteLine),
2666
3995
  "",
2667
3996
  `### Change (${method})`,
2668
- ...notes.length ? notes : source.map((c) => `- \`${c.file}\``),
3997
+ ...notes,
2669
3998
  "",
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.`,
3999
+ `### Contract test${fixed.length === 1 ? "" : "s"}`,
4000
+ "Each test calls the code with the response it was written for and the one the provider sends now. Each failed before this change and passes after it.",
2672
4001
  "",
2673
- "| Run | Result |",
2674
- "|---|---|",
2675
- `| Before this change | ${summary(result.baseline)} |`,
2676
- `| After this change | ${summary(result.attempts.find((a) => a.outcome === "passed")?.run)} |`,
4002
+ "| Test | Before this change | After this change |",
4003
+ "|---|---|---|",
4004
+ ...fixed.map((s) => `| \`${s.result.test?.file}\` | ${summary(s.result.baseline)} | ${summary(s.result.attempts.find((a) => a.outcome === "passed")?.run)} |`),
2677
4005
  "",
2678
4006
  "### Evidence",
2679
4007
  "| | |",
@@ -2685,13 +4013,13 @@ function renderPrBody({ advisory, callSite, result, affectedInRepo, shapeSource
2685
4013
  `| Provider notice | ${advisory.publishedNotice ? `[${advisory.publishedNotice.date ?? "link"}](${advisory.publishedNotice.url})` : "none (undocumented change)"} |`,
2686
4014
  `| Shapes from | ${shapeSource} |`,
2687
4015
  ...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 |`,
4016
+ `| Blast radius | ${repair.sites.length} affected call site${repair.sites.length === 1 ? "" : "s"} in this repo |`,
2689
4017
  `| Sources | ${advisory.sources.map((s, i) => `[${i + 1}](${s})`).join(" ")} |`,
2690
4018
  "",
2691
4019
  `Migration note: ${advisory.migrationNote}`,
2692
4020
  "",
2693
4021
  "---",
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."
4022
+ "Opened by [SHAPI](https://shapi.dev). A change is kept only when its contract test failed before it and passes after it."
2695
4023
  ];
2696
4024
  return lines.join("\n") + "\n";
2697
4025
  }
@@ -2712,7 +4040,7 @@ import { Octokit as Octokit2 } from "@octokit/rest";
2712
4040
  var ResilientOctokit = Octokit2.plugin(throttling, retry);
2713
4041
 
2714
4042
  // packages/github-app/src/snapshot.ts
2715
- import { Parser } from "tar";
4043
+ import { Parser as Parser2 } from "tar";
2716
4044
 
2717
4045
  // packages/cli/src/scan.ts
2718
4046
  import { execFileSync as execFileSync2 } from "child_process";
@@ -2721,16 +4049,19 @@ import os2 from "os";
2721
4049
  import path11 from "path";
2722
4050
 
2723
4051
  // packages/cli/src/paths.ts
2724
- import { existsSync as existsSync3 } from "fs";
2725
- import { fileURLToPath } from "url";
4052
+ import { existsSync as existsSync4 } from "fs";
4053
+ import { fileURLToPath as fileURLToPath2 } from "url";
2726
4054
  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];
4055
+ const candidates = [new URL(`../${published}`, import.meta.url), new URL(`../../../${monorepo}`, import.meta.url)].map((u) => fileURLToPath2(u));
4056
+ return candidates.find((p) => existsSync4(p)) ?? candidates[candidates.length - 1];
2729
4057
  }
2730
4058
 
2731
4059
  // packages/cli/src/scan.ts
2732
- var VERSION = "0.1.0";
4060
+ var VERSION = "0.3.0";
2733
4061
  var DEFAULT_ADVISORIES = bundledDir("advisories", "advisories");
4062
+ async function preparePython() {
4063
+ await loadPythonParser().catch(() => void 0);
4064
+ }
2734
4065
  function scan(target, opts) {
2735
4066
  const started = performance.now();
2736
4067
  const failOn = parseFailOn(opts.failOn ?? "critical");
@@ -2753,9 +4084,12 @@ function scan(target, opts) {
2753
4084
  const advisories = loadAdvisories(opts.advisories ?? DEFAULT_ADVISORIES);
2754
4085
  const match = matchAdvisories(lock, advisories, { now });
2755
4086
  const failing = failOn === "none" ? [] : match.affected.filter((f) => SEVERITY_RANK[f.advisory.severity] <= SEVERITY_RANK[failOn]);
4087
+ const where = opts.repo ? `--repo ${opts.repo}` : label2;
2756
4088
  return {
2757
4089
  label: label2,
4090
+ fixTarget: /\s/.test(where) ? JSON.stringify(where) : where,
2758
4091
  filesScanned: extracted.filesScanned,
4092
+ skipped: extracted.skipped,
2759
4093
  lock,
2760
4094
  match,
2761
4095
  elapsedMs: Math.round(performance.now() - started),
@@ -2787,6 +4121,7 @@ function toJsonReport(o) {
2787
4121
  filesScanned: o.filesScanned,
2788
4122
  affected: o.match.affected.map(finding),
2789
4123
  exposed: o.match.exposed.map(finding),
4124
+ possible: o.match.possible.map(finding),
2790
4125
  upcoming: o.match.upcoming.map((u) => ({
2791
4126
  advisory: u.advisory.id,
2792
4127
  provider: u.advisory.provider,
@@ -2822,24 +4157,31 @@ async function fix(target, opts, deps) {
2822
4157
  if (affected.length === 0) deps.log("No affected call sites: nothing to fix.");
2823
4158
  const llm = opts.llm ? claudeFixer() : void 0;
2824
4159
  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);
4160
+ const byAdvisory = /* @__PURE__ */ new Map();
4161
+ for (const f of affected) byAdvisory.set(f.advisory.id, [...byAdvisory.get(f.advisory.id) ?? [], f]);
4162
+ for (const findings of byAdvisory.values()) {
4163
+ const advisory = findings[0].advisory;
4164
+ const shapes = advisory.fix ? { source: FIX_SOURCE } : findShapes(advisory, opts);
2829
4165
  if (!shapes) {
2830
4166
  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 });
4167
+ for (const finding of findings) {
4168
+ deps.log(`SKIP ${finding.callSite.file}:${finding.callSite.line} ${advisory.id}: ${skipped}`);
4169
+ outcomes.push({ finding, skipped });
4170
+ }
2833
4171
  continue;
2834
4172
  }
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 }));
4173
+ const repair = await repairAdvisory({ workspace: localWorkspace(dir), advisory, callSites: findings.map((f) => f.callSite), before: shapes.before, after: shapes.after, recipes: RECIPES, llm });
4174
+ const mine = [];
4175
+ for (const { callSite, result } of repair.sites) {
4176
+ const finding = findings.find((f) => f.callSite.file === callSite.file && f.callSite.line === callSite.line && f.callSite.column === callSite.column);
4177
+ mine.push({ finding, shapeSource: shapes.source, result });
4178
+ deps.log(`${result.status.toUpperCase().padEnd(15)} ${callSite.file}:${callSite.line} ${advisory.id}${result.method ? ` via ${result.method}` : ""}${result.reason ? `: ${result.reason}` : ""}`);
4179
+ for (const a of result.attempts) deps.log(` ${a.method}: ${a.outcome} (${a.detail})`);
4180
+ }
4181
+ outcomes.push(...mine);
4182
+ if (!repair.changes.length) continue;
4183
+ for (const c of repair.changes) deps.log(` ${c.before ? "M" : "A"} ${c.file}`);
4184
+ const files = repair.changes.map((c) => ({ path: c.file, content: c.after }));
2843
4185
  if (opts.write) {
2844
4186
  for (const f of files) {
2845
4187
  mkdirSync5(path12.dirname(path12.join(dir, f.path)), { recursive: true });
@@ -2848,22 +4190,18 @@ async function fix(target, opts, deps) {
2848
4190
  deps.log(` wrote ${files.length} files into ${dir}`);
2849
4191
  }
2850
4192
  if (opts.pr && remote) {
4193
+ const fixed = repair.sites.filter((s) => s.result.status === "fixed");
2851
4194
  const meta = {
2852
4195
  title: prTitle(advisory),
2853
4196
  branch: prBranch(advisory),
2854
4197
  commitMessage: `fix: ${advisory.title}
2855
4198
 
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
- })
4199
+ Advisory ${advisory.id}, ${fixed.length} of ${repair.sites.length} call sites. Contract tests ${fixed.map((s) => s.result.test?.file).join(", ")} fail before this change and pass after.`,
4200
+ body: renderPrBody({ advisory, repair, shapeSource: shapes.source })
2864
4201
  };
2865
- outcome.pr = await (deps.deliver ?? deliverToGitHub)(remote, files, meta);
2866
- deps.log(` pull request: ${outcome.pr.url}`);
4202
+ const pr = await (deps.deliver ?? deliverToGitHub)(remote, files, meta);
4203
+ for (const o of mine) if (o.result?.status === "fixed") o.pr = pr;
4204
+ deps.log(` pull request: ${pr.url}`);
2867
4205
  }
2868
4206
  }
2869
4207
  return outcomes;
@@ -2878,8 +4216,9 @@ async function deliverToGitHub(repo, files, meta) {
2878
4216
  if (outcome.action === "skipped") throw new Error(`not delivered: ${outcome.reason}${outcome.url ? ` (${outcome.url})` : ""}`);
2879
4217
  return { url: outcome.url, number: outcome.number, branch: outcome.branch, commit: outcome.commit };
2880
4218
  }
4219
+ var FIX_SOURCE = "built from the advisory's fix and the fields the code reads";
2881
4220
  function findShapes(advisory, opts) {
2882
- if (opts.db && existsSync4(opts.db)) {
4221
+ if (opts.db && existsSync5(opts.db)) {
2883
4222
  const store = ShapeStore.open(opts.db);
2884
4223
  try {
2885
4224
  for (const e of store.endpoints()) {
@@ -2894,7 +4233,7 @@ function findShapes(advisory, opts) {
2894
4233
  }
2895
4234
  }
2896
4235
  const dir = opts.recordings ?? DEFAULT_RECORDINGS;
2897
- if (!existsSync4(dir)) return void 0;
4236
+ if (!existsSync5(dir)) return void 0;
2898
4237
  for (const name of readdirSync3(dir).filter((f) => f.endsWith(".json")).sort()) {
2899
4238
  const recording = loadRecording(path12.join(dir, name));
2900
4239
  if (recording.provider !== advisory.provider || !pathMatches(recording.endpoint, advisory.endpoint)) continue;
@@ -2916,19 +4255,26 @@ function originRepo(dir) {
2916
4255
 
2917
4256
  // packages/cli/src/report.ts
2918
4257
  import { styleText } from "util";
4258
+ var APP_URL = "https://app.shapi.dev";
4259
+ function title(color) {
4260
+ const paint = (style, s) => color ? styleText(style, s) : s;
4261
+ return `${paint("bold", "SHAPI")} ${paint("dim", "third-party API changes, caught and fixed")}`;
4262
+ }
2919
4263
  function renderText(o, color) {
2920
4264
  const paint = (style, s) => color ? styleText(style, s) : s;
2921
4265
  const out = [];
2922
4266
  const { lock, match } = o;
2923
4267
  const providers = lock.providers;
2924
4268
  const unresolved = lock.callSites.filter((c) => !c.resolved).length;
2925
- out.push(`${paint("bold", "SHAPI scan")} ${o.label}`);
4269
+ out.push(title(color));
4270
+ out.push(`${paint("bold", "scan")} ${o.label}`);
2926
4271
  out.push(
2927
4272
  paint(
2928
4273
  "dim",
2929
4274
  [plural(o.filesScanned, "file"), plural(lock.callSites.length, "call site"), plural(providers.length, "provider"), `${o.elapsedMs} ms`].join(" \xB7 ")
2930
4275
  )
2931
4276
  );
4277
+ if (o.skipped.length) out.push(paint("dim", `${plural(o.skipped.length, "file")} not scanned: ${[...new Set(o.skipped.map((s) => s.reason))].join("; ")}`));
2932
4278
  out.push("");
2933
4279
  out.push(paint("bold", "Providers"));
2934
4280
  if (providers.length === 0) out.push(" none found");
@@ -2942,6 +4288,11 @@ function renderText(o, color) {
2942
4288
  if (match.affected.length === 0) out.push(" none");
2943
4289
  for (const f of match.affected) out.push(...renderFinding(f, paint));
2944
4290
  out.push("");
4291
+ if (match.possible.length > 0) {
4292
+ out.push(paint("bold", `Possibly affected, check these (${match.possible.length})`));
4293
+ for (const f of match.possible) out.push(...renderFinding(f, paint));
4294
+ out.push("");
4295
+ }
2945
4296
  if (match.exposed.length > 0) {
2946
4297
  out.push(paint("bold", `Exposed, not affected (${match.exposed.length})`));
2947
4298
  for (const f of match.exposed) {
@@ -2959,6 +4310,10 @@ function renderText(o, color) {
2959
4310
  out.push("");
2960
4311
  out.push(paint("dim", `${plural(unresolved, "call site")} could not be fully resolved (see --json or --out api.lock).`));
2961
4312
  }
4313
+ out.push("");
4314
+ out.push(paint("bold", "Next"));
4315
+ if (match.affected.length > 0) out.push(` npx shapi-cli fix ${o.fixTarget} ${paint("dim", "fix the affected call sites test-first (nothing changes until you add --write or --pr)")}`);
4316
+ out.push(` ${APP_URL} ${paint("dim", "install the GitHub App: every push scanned, verified fix PRs, and a dashboard")}`);
2962
4317
  return out.join("\n") + "\n";
2963
4318
  }
2964
4319
  var SEVERITY_STYLE = { critical: "red", high: "yellow", medium: "cyan", low: "gray" };
@@ -2988,7 +4343,12 @@ var processIo = {
2988
4343
  };
2989
4344
  async function run(argv, io = processIo, fixDeps = {}) {
2990
4345
  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 });
4346
+ await preparePython();
4347
+ 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).addHelpText("beforeAll", `${title(Boolean(io.isTTY))}
4348
+ `).addHelpText("afterAll", `
4349
+ Run it without installing: npx shapi-cli scan .
4350
+ Fix PRs and a dashboard on every push: install the GitHub App at ${APP_URL}
4351
+ `).exitOverride().configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
2992
4352
  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
4353
  const outcome = scan(opts.repo ? void 0 : target, opts);
2994
4354
  if (opts.json) io.stdout(JSON.stringify(toJsonReport(outcome), null, 2) + "\n");
@@ -2996,8 +4356,13 @@ async function run(argv, io = processIo, fixDeps = {}) {
2996
4356
  exitCode = outcome.exitCode;
2997
4357
  });
2998
4358
  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) => {
4359
+ io.stdout(`${title(Boolean(io.isTTY))}
4360
+ `);
2999
4361
  const outcomes = await fix(opts.repo ? void 0 : target, opts, { log: (l) => io.stdout(`${l}
3000
4362
  `), ...fixDeps });
4363
+ if (outcomes.some((o) => o.result?.status === "fixed") && !opts.write && !opts.pr) io.stdout("\nNothing was changed. Add --write to keep these fixes, or --pr to open pull requests.\n");
4364
+ io.stdout(`Fix PRs and a dashboard on every push: install the GitHub App at ${APP_URL}
4365
+ `);
3001
4366
  exitCode = outcomes.every((o) => o.result?.status === "fixed" || o.result?.status === "already-handled") ? 0 : 1;
3002
4367
  });
3003
4368
  try {
@@ -3019,6 +4384,7 @@ export {
3019
4384
  VERSION,
3020
4385
  fix,
3021
4386
  main,
4387
+ preparePython,
3022
4388
  renderText,
3023
4389
  run,
3024
4390
  scan,