compatra 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/dist/classify.js +19 -0
- package/dist/faults.js +49 -0
- package/dist/fix.js +30 -0
- package/dist/index.js +114 -0
- package/dist/inject.js +107 -0
- package/dist/report.js +145 -0
- package/dist/scan.js +94 -0
- package/dist/verify.js +110 -0
- package/node_modules/@compatra/core/dist/apply-migration.d.ts +29 -0
- package/node_modules/@compatra/core/dist/apply-migration.js +139 -0
- package/node_modules/@compatra/core/dist/deprecation-match.d.ts +2 -0
- package/node_modules/@compatra/core/dist/deprecation-match.js +86 -0
- package/node_modules/@compatra/core/dist/extract-usages.d.ts +14 -0
- package/node_modules/@compatra/core/dist/extract-usages.js +100 -0
- package/node_modules/@compatra/core/dist/index.d.ts +7 -0
- package/node_modules/@compatra/core/dist/index.js +11 -0
- package/node_modules/@compatra/core/dist/migrations.d.ts +22 -0
- package/node_modules/@compatra/core/dist/migrations.js +49 -0
- package/node_modules/@compatra/core/dist/resource-match.d.ts +24 -0
- package/node_modules/@compatra/core/dist/resource-match.js +84 -0
- package/node_modules/@compatra/core/dist/shopify-fetcher.d.ts +3 -0
- package/node_modules/@compatra/core/dist/shopify-fetcher.js +99 -0
- package/node_modules/@compatra/core/dist/spec-fetcher.d.ts +15 -0
- package/node_modules/@compatra/core/dist/spec-fetcher.js +129 -0
- package/node_modules/@compatra/core/dist/spec-sources.d.ts +13 -0
- package/node_modules/@compatra/core/dist/spec-sources.js +109 -0
- package/node_modules/@compatra/core/dist/vendor-registry.d.ts +12 -0
- package/node_modules/@compatra/core/dist/vendor-registry.js +36 -0
- package/node_modules/@compatra/core/package.json +17 -0
- package/package.json +48 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Migration recipes: deprecated SDK call -> replacement, for the cases where the replacement is
|
|
2
|
+
// provably equivalent. A vendor spec says an endpoint is deprecated but never what to use instead,
|
|
3
|
+
// so every recipe here was checked by hand against the vendor's spec and the real SDK. Anything
|
|
4
|
+
// without a recipe is annotated for a human, never guessed at.
|
|
5
|
+
//
|
|
6
|
+
// Pure data, no ts-morph, so it is safe to import from the main barrel. The code that applies a
|
|
7
|
+
// recipe lives in apply-migration.ts (subpath `@compatra/core/migrate`).
|
|
8
|
+
const STRIPE_SPEC = "https://github.com/stripe/openapi/blob/master/openapi/spec3.json";
|
|
9
|
+
export const MIGRATION_RECIPES = [
|
|
10
|
+
{
|
|
11
|
+
id: "stripe-customer-cards-list",
|
|
12
|
+
vendor: "stripe",
|
|
13
|
+
endpoint: "GET /v1/customers/{customer}/cards",
|
|
14
|
+
from: "customers.listCards",
|
|
15
|
+
to: "customers.listSources",
|
|
16
|
+
addParams: { object: "card" },
|
|
17
|
+
evidence: "Stripe's OpenAPI spec marks GET /v1/customers/{customer}/cards as deprecated and offers " +
|
|
18
|
+
"GET /v1/customers/{customer}/sources, which takes an `object` filter; `object: 'card'` returns the same Card objects.",
|
|
19
|
+
caveat: "Same objects at runtime. In TypeScript the return type widens from `Card` to `BankAccount | Card | Source`, " +
|
|
20
|
+
"so code that reads card fields may need narrowing (`item.object === 'card'`). Stripe's newer recommendation is the " +
|
|
21
|
+
"PaymentMethods API, which is a larger change and was not applied.",
|
|
22
|
+
docs: [
|
|
23
|
+
{ label: "Stripe API: Cards (deprecated)", url: "https://docs.stripe.com/api/cards/list" },
|
|
24
|
+
{ label: "Stripe API: Sources", url: "https://docs.stripe.com/api/sources" },
|
|
25
|
+
{ label: "Stripe OpenAPI spec", url: STRIPE_SPEC },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "stripe-customer-cards-retrieve",
|
|
30
|
+
vendor: "stripe",
|
|
31
|
+
endpoint: "GET /v1/customers/{customer}/cards/{id}",
|
|
32
|
+
from: "customers.retrieveCard",
|
|
33
|
+
to: "customers.retrieveSource",
|
|
34
|
+
evidence: "Stripe's OpenAPI spec marks GET /v1/customers/{customer}/cards/{id} as deprecated; " +
|
|
35
|
+
"GET /v1/customers/{customer}/sources/{id} takes the same customer and id and returns the same object for a card id.",
|
|
36
|
+
caveat: "Same object at runtime. In TypeScript the return type widens from `Card` to a `Card | BankAccount | Source` union, " +
|
|
37
|
+
"so code that reads card fields may need narrowing. Stripe's newer recommendation is the PaymentMethods API, " +
|
|
38
|
+
"which is a larger change and was not applied.",
|
|
39
|
+
docs: [
|
|
40
|
+
{ label: "Stripe API: Cards (deprecated)", url: "https://docs.stripe.com/api/cards/retrieve" },
|
|
41
|
+
{ label: "Stripe API: Sources", url: "https://docs.stripe.com/api/sources" },
|
|
42
|
+
{ label: "Stripe OpenAPI spec", url: STRIPE_SPEC },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
/** The recipe for a flagged call, or null when there is no provably equivalent replacement. */
|
|
47
|
+
export function findRecipe(vendor, endpoint, callText) {
|
|
48
|
+
return (MIGRATION_RECIPES.find((r) => r.vendor === vendor && r.endpoint === endpoint && (callText === r.from || callText.endsWith(`.${r.from}`))) ?? null);
|
|
49
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const RESOURCE_MATCHING_SUPPORTED: Set<string>;
|
|
2
|
+
/**
|
|
3
|
+
* Extract the "resource" segment from a REST path, e.g. Stripe's
|
|
4
|
+
* `/v1/payment_intents/{id}/confirm` -> "payment_intents", GitHub's
|
|
5
|
+
* `/repos/{owner}/{repo}/issues` -> "issues".
|
|
6
|
+
*
|
|
7
|
+
* Known imperfection (documented, not silently hidden): GitHub's REST paths don't
|
|
8
|
+
* always line up 1:1 with octokit's SDK namespace grouping (e.g. `/notifications/*`
|
|
9
|
+
* is under octokit's `activity.*`, not a `notifications.*` namespace; `/app/*` is
|
|
10
|
+
* singular but octokit groups it under `apps.*`). Real cases like these won't match
|
|
11
|
+
* and fall back to today's vendor-wide behavior for that finding — a missed
|
|
12
|
+
* attribution, not a wrong one.
|
|
13
|
+
*/
|
|
14
|
+
export declare function extractResourceFromPath(vendor: string, path: string): string | null;
|
|
15
|
+
/**
|
|
16
|
+
* Extract the "resource" segment from a raw SDK call-chain snippet (e.g.
|
|
17
|
+
* `"stripe.paymentIntents.confirm"` -> "payment_intents",
|
|
18
|
+
* `"octokit.rest.issues.create"` -> "issues"). The chain's first token is whichever
|
|
19
|
+
* local variable name the developer chose for the client (not necessarily "stripe"
|
|
20
|
+
* or "octokit") — always the second token, after dropping an optional literal
|
|
21
|
+
* `.rest.` wrapper some octokit call styles include.
|
|
22
|
+
*/
|
|
23
|
+
export declare function extractResourceFromUsage(vendor: string, snippet: string): string | null;
|
|
24
|
+
export declare function resourcesMatch(vendor: string, path: string, snippet: string): boolean;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Heuristic, resource-level matching — not an exact SDK-method -> REST-operation
|
|
2
|
+
// resolver (that's a much harder, vendor-specific problem; see README's scope-limits
|
|
3
|
+
// section). Only attempted for vendors whose SDK conventions and REST paths both
|
|
4
|
+
// follow a clean `resource.action()` / `/{resource}/...` shape.
|
|
5
|
+
export const RESOURCE_MATCHING_SUPPORTED = new Set(["stripe", "github"]);
|
|
6
|
+
// GitHub paths are scoped under an owner/repo/org/etc. prefix before the actual
|
|
7
|
+
// resource segment (e.g. /repos/{owner}/{repo}/issues) — these are pure scoping
|
|
8
|
+
// prefixes, never resources themselves, so they get stripped along with the
|
|
9
|
+
// path-parameter segments that follow them. Words like "gists" or "notifications"
|
|
10
|
+
// are deliberately NOT here — they're resources in their own right (octokit's
|
|
11
|
+
// `gists.*` namespace), not containers to skip past.
|
|
12
|
+
const GITHUB_SCOPE_PREFIXES = new Set(["repos", "orgs", "enterprises", "user", "users", "teams"]);
|
|
13
|
+
// Normalizes both sides to the same shape: SDK chains are camelCase
|
|
14
|
+
// ("codeScanning"), REST path segments are hyphenated ("code-scanning") — without
|
|
15
|
+
// this, e.g. `octokit.rest.codeScanning.*` would never match `/code-scanning/*`.
|
|
16
|
+
function normalize(s) {
|
|
17
|
+
return s.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().replace(/-/g, "_");
|
|
18
|
+
}
|
|
19
|
+
function isPathParam(segment) {
|
|
20
|
+
return segment.startsWith("{") && segment.endsWith("}");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Extract the "resource" segment from a REST path, e.g. Stripe's
|
|
24
|
+
* `/v1/payment_intents/{id}/confirm` -> "payment_intents", GitHub's
|
|
25
|
+
* `/repos/{owner}/{repo}/issues` -> "issues".
|
|
26
|
+
*
|
|
27
|
+
* Known imperfection (documented, not silently hidden): GitHub's REST paths don't
|
|
28
|
+
* always line up 1:1 with octokit's SDK namespace grouping (e.g. `/notifications/*`
|
|
29
|
+
* is under octokit's `activity.*`, not a `notifications.*` namespace; `/app/*` is
|
|
30
|
+
* singular but octokit groups it under `apps.*`). Real cases like these won't match
|
|
31
|
+
* and fall back to today's vendor-wide behavior for that finding — a missed
|
|
32
|
+
* attribution, not a wrong one.
|
|
33
|
+
*/
|
|
34
|
+
export function extractResourceFromPath(vendor, path) {
|
|
35
|
+
// path is "METHOD /some/path" (diffPaths' format) or a bare "/some/path" — handle both.
|
|
36
|
+
const rawPath = path.includes(" ") ? path.split(" ")[1] : path;
|
|
37
|
+
const segments = rawPath.split("/").filter(Boolean);
|
|
38
|
+
if (vendor === "stripe") {
|
|
39
|
+
const withoutVersion = segments[0] === "v1" ? segments.slice(1) : segments;
|
|
40
|
+
return withoutVersion[0] ? normalize(withoutVersion[0]) : null;
|
|
41
|
+
}
|
|
42
|
+
if (vendor === "github") {
|
|
43
|
+
let i = 0;
|
|
44
|
+
while (i < segments.length) {
|
|
45
|
+
const seg = segments[i];
|
|
46
|
+
if (GITHUB_SCOPE_PREFIXES.has(seg)) {
|
|
47
|
+
i++;
|
|
48
|
+
while (i < segments.length && isPathParam(segments[i]))
|
|
49
|
+
i++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (isPathParam(seg)) {
|
|
53
|
+
i++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
return segments[i] ? normalize(segments[i]) : null;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Extract the "resource" segment from a raw SDK call-chain snippet (e.g.
|
|
64
|
+
* `"stripe.paymentIntents.confirm"` -> "payment_intents",
|
|
65
|
+
* `"octokit.rest.issues.create"` -> "issues"). The chain's first token is whichever
|
|
66
|
+
* local variable name the developer chose for the client (not necessarily "stripe"
|
|
67
|
+
* or "octokit") — always the second token, after dropping an optional literal
|
|
68
|
+
* `.rest.` wrapper some octokit call styles include.
|
|
69
|
+
*/
|
|
70
|
+
export function extractResourceFromUsage(vendor, snippet) {
|
|
71
|
+
if (!RESOURCE_MATCHING_SUPPORTED.has(vendor))
|
|
72
|
+
return null;
|
|
73
|
+
const parts = snippet.split(".").filter((p) => p !== "rest");
|
|
74
|
+
if (parts.length < 2)
|
|
75
|
+
return null;
|
|
76
|
+
return normalize(parts[1]);
|
|
77
|
+
}
|
|
78
|
+
export function resourcesMatch(vendor, path, snippet) {
|
|
79
|
+
const pathResource = extractResourceFromPath(vendor, path);
|
|
80
|
+
const usageResource = extractResourceFromUsage(vendor, snippet);
|
|
81
|
+
if (!pathResource || !usageResource)
|
|
82
|
+
return false;
|
|
83
|
+
return pathResource === usageResource;
|
|
84
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
// Only what's needed to detect the breaking changes we care about: a root
|
|
3
|
+
// Query/Mutation operation appearing/disappearing, or a type's field appearing/
|
|
4
|
+
// disappearing/changing NON_NULL-ness or deprecated-ness — not full type resolution,
|
|
5
|
+
// so this stays shallow (one level of `type.kind`) rather than the classic 7-level
|
|
6
|
+
// introspection TypeRef fragment other tools use to fully resolve
|
|
7
|
+
// NonNull(List(NonNull(...))). `includeDeprecated: true` is required — by default
|
|
8
|
+
// Shopify's introspection omits deprecated fields entirely, which would make a
|
|
9
|
+
// deprecation invisible as a "field removed" rather than the more specific,
|
|
10
|
+
// earlier-warning "field deprecated" signal we actually want.
|
|
11
|
+
const INTROSPECTION_QUERY = `
|
|
12
|
+
query IntrospectionQuery {
|
|
13
|
+
__schema {
|
|
14
|
+
queryType { name }
|
|
15
|
+
mutationType { name }
|
|
16
|
+
types {
|
|
17
|
+
kind
|
|
18
|
+
name
|
|
19
|
+
fields(includeDeprecated: true) { name isDeprecated deprecationReason type { kind } }
|
|
20
|
+
inputFields { name isDeprecated deprecationReason type { kind } }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
`;
|
|
25
|
+
// GraphQL's nullability is a NON_NULL type-wrapper, not a separate `required` array
|
|
26
|
+
// like OpenAPI — a field is "required-ish" only when its outermost kind is NON_NULL.
|
|
27
|
+
// (For an output field this means "never null in a response," not "the caller must
|
|
28
|
+
// supply it" the way it does for an input field — both are still meaningful,
|
|
29
|
+
// real breaking-change signals, treated uniformly here as a documented simplification.)
|
|
30
|
+
function isRequired(field) {
|
|
31
|
+
return field.type.kind === "NON_NULL";
|
|
32
|
+
}
|
|
33
|
+
function normalizeType(type) {
|
|
34
|
+
const fields = [...(type.fields ?? []), ...(type.inputFields ?? [])];
|
|
35
|
+
const deprecationReasons = {};
|
|
36
|
+
for (const field of fields) {
|
|
37
|
+
if (field.isDeprecated && field.deprecationReason) {
|
|
38
|
+
deprecationReasons[field.name] = field.deprecationReason;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
properties: fields.map((f) => f.name).sort(),
|
|
43
|
+
required: fields.filter(isRequired).map((f) => f.name).sort(),
|
|
44
|
+
deprecated: fields.filter((f) => f.isDeprecated).map((f) => f.name).sort(),
|
|
45
|
+
deprecationReasons: Object.keys(deprecationReasons).length > 0 ? deprecationReasons : undefined,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export async function fetchShopifySchema(source) {
|
|
49
|
+
const url = source.urls[0];
|
|
50
|
+
const res = await fetch(url, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "content-type": "application/json" },
|
|
53
|
+
body: JSON.stringify({ query: INTROSPECTION_QUERY }),
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
throw new Error(`Failed to fetch ${source.vendor} GraphQL schema (${url}): ${res.status} ${res.statusText}`);
|
|
57
|
+
}
|
|
58
|
+
const raw = await res.text();
|
|
59
|
+
const hash = createHash("sha256").update(raw).digest("hex");
|
|
60
|
+
const body = JSON.parse(raw);
|
|
61
|
+
if (body.errors?.length) {
|
|
62
|
+
throw new Error(`${source.vendor} GraphQL introspection returned errors: ${body.errors.map((e) => e.message).join("; ")}`);
|
|
63
|
+
}
|
|
64
|
+
const schema = body.data?.__schema;
|
|
65
|
+
if (!schema) {
|
|
66
|
+
throw new Error(`${source.vendor} GraphQL introspection returned no __schema`);
|
|
67
|
+
}
|
|
68
|
+
const typesByName = new Map(schema.types.map((t) => [t.name, t]));
|
|
69
|
+
const paths = [];
|
|
70
|
+
const deprecatedPaths = [];
|
|
71
|
+
const queryType = schema.queryType ? typesByName.get(schema.queryType.name) : undefined;
|
|
72
|
+
for (const field of queryType?.fields ?? []) {
|
|
73
|
+
const key = `QUERY ${field.name}`;
|
|
74
|
+
paths.push(key);
|
|
75
|
+
if (field.isDeprecated)
|
|
76
|
+
deprecatedPaths.push(key);
|
|
77
|
+
}
|
|
78
|
+
const mutationType = schema.mutationType ? typesByName.get(schema.mutationType.name) : undefined;
|
|
79
|
+
for (const field of mutationType?.fields ?? []) {
|
|
80
|
+
const key = `MUTATION ${field.name}`;
|
|
81
|
+
paths.push(key);
|
|
82
|
+
if (field.isDeprecated)
|
|
83
|
+
deprecatedPaths.push(key);
|
|
84
|
+
}
|
|
85
|
+
paths.sort();
|
|
86
|
+
deprecatedPaths.sort();
|
|
87
|
+
const schemas = {};
|
|
88
|
+
for (const type of schema.types) {
|
|
89
|
+
if (type.name.startsWith("__"))
|
|
90
|
+
continue; // introspection meta-types, not real API surface
|
|
91
|
+
if (type.kind !== "OBJECT" && type.kind !== "INPUT_OBJECT")
|
|
92
|
+
continue;
|
|
93
|
+
schemas[type.name] = normalizeType(type);
|
|
94
|
+
}
|
|
95
|
+
// GraphQL has no separate "request body" concept the way REST does — a
|
|
96
|
+
// mutation/query's arguments aren't a named, diffable schema the way an OpenAPI
|
|
97
|
+
// requestBody is. Left empty rather than attempting a forced analog.
|
|
98
|
+
return { paths, deprecatedPaths, schemas, requestSchemas: {}, hash };
|
|
99
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { OpenApiSpecSource } from "./spec-sources.js";
|
|
2
|
+
export interface NormalizedSchema {
|
|
3
|
+
properties: string[];
|
|
4
|
+
required: string[];
|
|
5
|
+
deprecated: string[];
|
|
6
|
+
deprecationReasons?: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export interface FetchedSpec {
|
|
9
|
+
paths: string[];
|
|
10
|
+
deprecatedPaths: string[];
|
|
11
|
+
schemas: Record<string, NormalizedSchema>;
|
|
12
|
+
requestSchemas: Record<string, NormalizedSchema>;
|
|
13
|
+
hash: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function fetchVendorSpec(source: OpenApiSpecSource): Promise<FetchedSpec>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { parse as parseYaml } from "yaml";
|
|
3
|
+
const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
4
|
+
// Tried in this order — first one present in `requestBody.content` wins. Covers every
|
|
5
|
+
// real shape observed across tracked vendors: Stripe (form-urlencoded), GitHub/OpenAI
|
|
6
|
+
// (json). multipart is a lower-priority fallback for completeness, not confirmed
|
|
7
|
+
// against any tracked vendor's real usage.
|
|
8
|
+
const REQUEST_BODY_CONTENT_TYPES = ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"];
|
|
9
|
+
function directPropsRequired(schema) {
|
|
10
|
+
const properties = Object.entries(schema.properties ?? {});
|
|
11
|
+
return {
|
|
12
|
+
properties: properties.map(([name]) => name).sort(),
|
|
13
|
+
required: [...(schema.required ?? [])].sort(),
|
|
14
|
+
deprecated: properties.filter(([, prop]) => prop.deprecated).map(([name]) => name).sort(),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function resolveRef(ref, components) {
|
|
18
|
+
return components[ref.replace(/^#\/components\/schemas\//, "")];
|
|
19
|
+
}
|
|
20
|
+
// One level of $ref resolution, and a flat allOf property/required merge (each
|
|
21
|
+
// member also $ref-resolved one level) — covers every real request-body shape
|
|
22
|
+
// observed across tracked vendors (Stripe/GitHub: inline; OpenAI: bare $ref wrapped
|
|
23
|
+
// in allOf). A member needing further $ref/allOf resolution beyond that is left
|
|
24
|
+
// out — a known, documented gap, not a crash (mirrors the response-shape polymorphic
|
|
25
|
+
// schema gap in fetchAndParseOne below).
|
|
26
|
+
function mergeAllOf(members, components) {
|
|
27
|
+
const properties = new Set();
|
|
28
|
+
const required = new Set();
|
|
29
|
+
const deprecated = new Set();
|
|
30
|
+
for (const member of members) {
|
|
31
|
+
const resolved = "$ref" in member ? resolveRef(member.$ref, components) : member;
|
|
32
|
+
if (!resolved)
|
|
33
|
+
continue;
|
|
34
|
+
for (const [prop, propSchema] of Object.entries(resolved.properties ?? {})) {
|
|
35
|
+
properties.add(prop);
|
|
36
|
+
if (propSchema.deprecated)
|
|
37
|
+
deprecated.add(prop);
|
|
38
|
+
}
|
|
39
|
+
for (const req of resolved.required ?? [])
|
|
40
|
+
required.add(req);
|
|
41
|
+
}
|
|
42
|
+
return { properties: [...properties].sort(), required: [...required].sort(), deprecated: [...deprecated].sort() };
|
|
43
|
+
}
|
|
44
|
+
function resolveRequestSchema(raw, components) {
|
|
45
|
+
if ("$ref" in raw) {
|
|
46
|
+
const resolved = resolveRef(raw.$ref, components);
|
|
47
|
+
if (!resolved)
|
|
48
|
+
return { properties: [], required: [], deprecated: [] };
|
|
49
|
+
if (resolved.allOf)
|
|
50
|
+
return mergeAllOf(resolved.allOf, components);
|
|
51
|
+
return directPropsRequired(resolved);
|
|
52
|
+
}
|
|
53
|
+
if (raw.allOf)
|
|
54
|
+
return mergeAllOf(raw.allOf, components);
|
|
55
|
+
return directPropsRequired(raw);
|
|
56
|
+
}
|
|
57
|
+
function extractRequestSchemas(paths, components) {
|
|
58
|
+
const requestSchemas = {};
|
|
59
|
+
for (const [path, methods] of Object.entries(paths)) {
|
|
60
|
+
for (const [method, operation] of Object.entries(methods)) {
|
|
61
|
+
if (!HTTP_METHODS.includes(method))
|
|
62
|
+
continue;
|
|
63
|
+
const content = operation.requestBody?.content;
|
|
64
|
+
if (!content)
|
|
65
|
+
continue;
|
|
66
|
+
const contentType = REQUEST_BODY_CONTENT_TYPES.find((ct) => content[ct]?.schema);
|
|
67
|
+
const rawSchema = contentType ? content[contentType]?.schema : undefined;
|
|
68
|
+
if (!rawSchema)
|
|
69
|
+
continue;
|
|
70
|
+
requestSchemas[`${method.toUpperCase()} ${path}`] = resolveRequestSchema(rawSchema, components);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return requestSchemas;
|
|
74
|
+
}
|
|
75
|
+
async function fetchAndParseOne(url, vendor, format) {
|
|
76
|
+
const res = await fetch(url);
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
throw new Error(`Failed to fetch ${vendor} OpenAPI spec (${url}): ${res.status} ${res.statusText}`);
|
|
79
|
+
}
|
|
80
|
+
const raw = await res.text();
|
|
81
|
+
const spec = (format === "yaml" ? parseYaml(raw) : JSON.parse(raw));
|
|
82
|
+
const paths = [];
|
|
83
|
+
const deprecatedPaths = [];
|
|
84
|
+
for (const [path, methods] of Object.entries(spec.paths ?? {})) {
|
|
85
|
+
for (const [method, operation] of Object.entries(methods)) {
|
|
86
|
+
if (!HTTP_METHODS.includes(method))
|
|
87
|
+
continue;
|
|
88
|
+
const key = `${method.toUpperCase()} ${path}`;
|
|
89
|
+
paths.push(key);
|
|
90
|
+
if (operation.deprecated)
|
|
91
|
+
deprecatedPaths.push(key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Only property presence + required-ness + deprecated-ness, not full type info —
|
|
95
|
+
// enough to catch the breaking changes that matter (a field disappearing, a field
|
|
96
|
+
// becoming required, a field/operation getting marked deprecated as advance
|
|
97
|
+
// warning before it's actually removed) without needing to resolve every vendor's
|
|
98
|
+
// different $ref/allOf/anyOf composition style. A schema that's itself an
|
|
99
|
+
// allOf/anyOf/oneOf composition (no direct `properties` key) extracts as an empty
|
|
100
|
+
// property list — a known false-negative gap, not a crash (see README's
|
|
101
|
+
// scope-limits section).
|
|
102
|
+
const schemas = {};
|
|
103
|
+
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
|
|
104
|
+
schemas[name] = directPropsRequired(schema);
|
|
105
|
+
}
|
|
106
|
+
const requestSchemas = extractRequestSchemas(spec.paths ?? {}, spec.components?.schemas ?? {});
|
|
107
|
+
return { raw, paths, deprecatedPaths, schemas, requestSchemas };
|
|
108
|
+
}
|
|
109
|
+
export async function fetchVendorSpec(source) {
|
|
110
|
+
// Most vendors have exactly one spec file; Twilio has ~60 per-product files that
|
|
111
|
+
// all get merged into one logical "twilio" spec — fetched in the source's fixed
|
|
112
|
+
// array order so the combined hash is deterministic across runs.
|
|
113
|
+
const files = await Promise.all(source.urls.map((url) => fetchAndParseOne(url, source.vendor, source.format)));
|
|
114
|
+
const hash = createHash("sha256")
|
|
115
|
+
.update(files.map((f) => f.raw).join("\n"))
|
|
116
|
+
.digest("hex");
|
|
117
|
+
const paths = [...new Set(files.flatMap((f) => f.paths))].sort();
|
|
118
|
+
const deprecatedPaths = [...new Set(files.flatMap((f) => f.deprecatedPaths))].sort();
|
|
119
|
+
// Merge by schema/operation-key name across files. A genuine name collision across
|
|
120
|
+
// distinct per-product spec files is expected to be rare — last-fetched wins, not
|
|
121
|
+
// specially reconciled beyond that.
|
|
122
|
+
const schemas = {};
|
|
123
|
+
const requestSchemas = {};
|
|
124
|
+
for (const file of files) {
|
|
125
|
+
Object.assign(schemas, file.schemas);
|
|
126
|
+
Object.assign(requestSchemas, file.requestSchemas);
|
|
127
|
+
}
|
|
128
|
+
return { paths, deprecatedPaths, schemas, requestSchemas, hash };
|
|
129
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
interface VendorSpecSourceBase {
|
|
2
|
+
vendor: string;
|
|
3
|
+
urls: string[];
|
|
4
|
+
}
|
|
5
|
+
export type OpenApiSpecSource = VendorSpecSourceBase & {
|
|
6
|
+
format: "json" | "yaml";
|
|
7
|
+
};
|
|
8
|
+
export type GraphqlSpecSource = VendorSpecSourceBase & {
|
|
9
|
+
format: "graphql";
|
|
10
|
+
};
|
|
11
|
+
export type VendorSpecSource = OpenApiSpecSource | GraphqlSpecSource;
|
|
12
|
+
export declare const VENDOR_SPEC_SOURCES: VendorSpecSource[];
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Shopify's Admin API is GraphQL-first with no OpenAPI/REST spec at all. A real,
|
|
2
|
+
// unauthenticated introspection endpoint exists per API version
|
|
3
|
+
// (shopify.dev/admin-graphql-direct-proxy/<version>). Pinned to a real, current
|
|
4
|
+
// version rather than the "unstable" alias — "unstable" would avoid ever needing a
|
|
5
|
+
// manual bump, but surfaces in-development changes a real pinned integration
|
|
6
|
+
// wouldn't see yet, undermining the point of a tool about real breaking changes.
|
|
7
|
+
// Shopify releases quarterly (Jan/Apr/Jul/Oct); this needs bumping roughly yearly as
|
|
8
|
+
// old versions drop out of the proxy's rolling window — same maintenance tradeoff
|
|
9
|
+
// already accepted for Twilio's hardcoded file list above.
|
|
10
|
+
const SHOPIFY_API_VERSION = "2026-07";
|
|
11
|
+
const TWILIO_BASE = "https://raw.githubusercontent.com/twilio/twilio-oai/main/spec/json";
|
|
12
|
+
// The real, current list of Twilio's per-product spec files (fetched from GitHub's
|
|
13
|
+
// API — twilio/twilio-oai has 60 files under spec/json today, all confirmed same
|
|
14
|
+
// OpenAPI 3.0.1 shape as the one already parsed). Hardcoded rather than fetched live
|
|
15
|
+
// at scan time — avoids an extra GitHub API call/rate-limit dependency in the scan
|
|
16
|
+
// path. A new Twilio product added later needs this list updated manually — a stated
|
|
17
|
+
// limitation, not an oversight.
|
|
18
|
+
const TWILIO_SPEC_FILES = [
|
|
19
|
+
"twilio_accounts_v1.json",
|
|
20
|
+
"twilio_api_v2010.json",
|
|
21
|
+
"twilio_bulkexports_v1.json",
|
|
22
|
+
"twilio_chat_v1.json",
|
|
23
|
+
"twilio_chat_v2.json",
|
|
24
|
+
"twilio_chat_v3.json",
|
|
25
|
+
"twilio_content_v1.json",
|
|
26
|
+
"twilio_content_v2.json",
|
|
27
|
+
"twilio_conversations_v1.json",
|
|
28
|
+
"twilio_conversations_v2.json",
|
|
29
|
+
"twilio_events_v1.json",
|
|
30
|
+
"twilio_flex_v1.json",
|
|
31
|
+
"twilio_flex_v2.json",
|
|
32
|
+
"twilio_frontline_v1.json",
|
|
33
|
+
"twilio_iam_organizations.json",
|
|
34
|
+
"twilio_iam_v1.json",
|
|
35
|
+
"twilio_insights_v1.json",
|
|
36
|
+
"twilio_insights_v2.json",
|
|
37
|
+
"twilio_insights_v3.json",
|
|
38
|
+
"twilio_intelligence_v2.json",
|
|
39
|
+
"twilio_intelligence_v3.json",
|
|
40
|
+
"twilio_ip_messaging_v1.json",
|
|
41
|
+
"twilio_ip_messaging_v2.json",
|
|
42
|
+
"twilio_knowledge_v1.json",
|
|
43
|
+
"twilio_knowledge_v2.json",
|
|
44
|
+
"twilio_lookups_v1.json",
|
|
45
|
+
"twilio_lookups_v2.json",
|
|
46
|
+
"twilio_marketplace_v1.json",
|
|
47
|
+
"twilio_memory_v1.json",
|
|
48
|
+
"twilio_messaging_v1.json",
|
|
49
|
+
"twilio_messaging_v2.json",
|
|
50
|
+
"twilio_messaging_v3.json",
|
|
51
|
+
"twilio_monitor_v1.json",
|
|
52
|
+
"twilio_monitor_v2.json",
|
|
53
|
+
"twilio_notify_v1.json",
|
|
54
|
+
"twilio_numbers_v1.json",
|
|
55
|
+
"twilio_numbers_v2.json",
|
|
56
|
+
"twilio_numbers_v3.json",
|
|
57
|
+
"twilio_oauth_v1.json",
|
|
58
|
+
"twilio_oauth_v2.json",
|
|
59
|
+
"twilio_preview.json",
|
|
60
|
+
"twilio_pricing_v1.json",
|
|
61
|
+
"twilio_pricing_v2.json",
|
|
62
|
+
"twilio_proxy_v1.json",
|
|
63
|
+
"twilio_routes_v2.json",
|
|
64
|
+
"twilio_routes_v3.json",
|
|
65
|
+
"twilio_serverless_v1.json",
|
|
66
|
+
"twilio_studio_v1.json",
|
|
67
|
+
"twilio_studio_v2.json",
|
|
68
|
+
"twilio_supersim_v1.json",
|
|
69
|
+
"twilio_sync_v1.json",
|
|
70
|
+
"twilio_taskrouter_v1.json",
|
|
71
|
+
"twilio_trunking_v1.json",
|
|
72
|
+
"twilio_trusthub_v1.json",
|
|
73
|
+
"twilio_verify_v2.json",
|
|
74
|
+
"twilio_video_v1.json",
|
|
75
|
+
"twilio_voice_v1.json",
|
|
76
|
+
"twilio_voice_v2.json",
|
|
77
|
+
"twilio_voice_v3.json",
|
|
78
|
+
"twilio_wireless_v1.json",
|
|
79
|
+
];
|
|
80
|
+
export const VENDOR_SPEC_SOURCES = [
|
|
81
|
+
{
|
|
82
|
+
vendor: "stripe",
|
|
83
|
+
urls: ["https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json"],
|
|
84
|
+
format: "json",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
vendor: "openai",
|
|
88
|
+
urls: ["https://raw.githubusercontent.com/openai/openai-openapi/master/openapi.yaml"],
|
|
89
|
+
format: "yaml",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
vendor: "github",
|
|
93
|
+
urls: [
|
|
94
|
+
"https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json",
|
|
95
|
+
],
|
|
96
|
+
format: "json",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
// All ~60 of Twilio's real per-product spec files, not just the core one.
|
|
100
|
+
vendor: "twilio",
|
|
101
|
+
urls: TWILIO_SPEC_FILES.map((file) => `${TWILIO_BASE}/${file}`),
|
|
102
|
+
format: "json",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
vendor: "shopify",
|
|
106
|
+
urls: [`https://shopify.dev/admin-graphql-direct-proxy/${SHOPIFY_API_VERSION}`],
|
|
107
|
+
format: "graphql",
|
|
108
|
+
},
|
|
109
|
+
];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface VendorMatch {
|
|
2
|
+
vendor: string;
|
|
3
|
+
packageName: string;
|
|
4
|
+
versionRange: string;
|
|
5
|
+
}
|
|
6
|
+
interface PackageJsonDependencies {
|
|
7
|
+
dependencies?: Record<string, string>;
|
|
8
|
+
devDependencies?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
export declare function moduleMatchesVendor(moduleSpecifier: string, vendor: string): boolean;
|
|
11
|
+
export declare function detectVendorDependencies(packageJson: PackageJsonDependencies): VendorMatch[];
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const VENDOR_PATTERNS = [
|
|
2
|
+
{ vendor: "stripe", test: (name) => name === "stripe" },
|
|
3
|
+
{ vendor: "openai", test: (name) => name === "openai" },
|
|
4
|
+
{ vendor: "twilio", test: (name) => name === "twilio" },
|
|
5
|
+
{
|
|
6
|
+
vendor: "shopify",
|
|
7
|
+
test: (name) => name.startsWith("@shopify/") || name === "shopify-api-node",
|
|
8
|
+
},
|
|
9
|
+
{ vendor: "github", test: (name) => name.startsWith("@octokit/") },
|
|
10
|
+
];
|
|
11
|
+
// package.json dependency keys are never subpaths, but an import/require specifier can
|
|
12
|
+
// be (e.g. "stripe/esm", "@octokit/rest/dist/foo") — check both the full specifier and
|
|
13
|
+
// its first path segment so vendor patterns written for plain package names still match.
|
|
14
|
+
export function moduleMatchesVendor(moduleSpecifier, vendor) {
|
|
15
|
+
const pattern = VENDOR_PATTERNS.find((p) => p.vendor === vendor);
|
|
16
|
+
if (!pattern)
|
|
17
|
+
return false;
|
|
18
|
+
const firstSegment = moduleSpecifier.startsWith("@")
|
|
19
|
+
? moduleSpecifier.split("/").slice(0, 2).join("/")
|
|
20
|
+
: moduleSpecifier.split("/")[0];
|
|
21
|
+
return pattern.test(moduleSpecifier) || pattern.test(firstSegment);
|
|
22
|
+
}
|
|
23
|
+
export function detectVendorDependencies(packageJson) {
|
|
24
|
+
const allDependencies = {
|
|
25
|
+
...packageJson.dependencies,
|
|
26
|
+
...packageJson.devDependencies,
|
|
27
|
+
};
|
|
28
|
+
const matches = [];
|
|
29
|
+
for (const [packageName, versionRange] of Object.entries(allDependencies)) {
|
|
30
|
+
const pattern = VENDOR_PATTERNS.find((p) => p.test(packageName));
|
|
31
|
+
if (pattern) {
|
|
32
|
+
matches.push({ vendor: pattern.vendor, packageName, versionRange });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return matches;
|
|
36
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@compatra/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./ast": "./dist/extract-usages.js",
|
|
11
|
+
"./migrate": "./dist/apply-migration.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"ts-morph": "^28.0.0",
|
|
15
|
+
"yaml": "^2.9.1"
|
|
16
|
+
}
|
|
17
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "compatra",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Find third-party API calls in your code that use endpoints the vendor has already deprecated, migrate the ones with a safe replacement, and check whether your tests would notice.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"api",
|
|
8
|
+
"deprecation",
|
|
9
|
+
"stripe",
|
|
10
|
+
"openai",
|
|
11
|
+
"twilio",
|
|
12
|
+
"github",
|
|
13
|
+
"migration",
|
|
14
|
+
"ci"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/apps/compatra",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"bin": {
|
|
19
|
+
"compatra": "dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"prebuild": "cd ../core && npm run build",
|
|
31
|
+
"build": "tsc -p tsconfig.json",
|
|
32
|
+
"prepack": "npm run build && node scripts/vendor-core.mjs add",
|
|
33
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
34
|
+
"lint": "eslint .",
|
|
35
|
+
"pretest": "npm run build",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"scan": "node dist/index.js",
|
|
38
|
+
"postpack": "node scripts/vendor-core.mjs remove"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@compatra/core": "0.1.0",
|
|
42
|
+
"ts-morph": "^28.0.0",
|
|
43
|
+
"yaml": "^2.9.1"
|
|
44
|
+
},
|
|
45
|
+
"bundleDependencies": [
|
|
46
|
+
"@compatra/core"
|
|
47
|
+
]
|
|
48
|
+
}
|