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.
Files changed (32) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +175 -0
  3. package/dist/classify.js +19 -0
  4. package/dist/faults.js +49 -0
  5. package/dist/fix.js +30 -0
  6. package/dist/index.js +114 -0
  7. package/dist/inject.js +107 -0
  8. package/dist/report.js +145 -0
  9. package/dist/scan.js +94 -0
  10. package/dist/verify.js +110 -0
  11. package/node_modules/@compatra/core/dist/apply-migration.d.ts +29 -0
  12. package/node_modules/@compatra/core/dist/apply-migration.js +139 -0
  13. package/node_modules/@compatra/core/dist/deprecation-match.d.ts +2 -0
  14. package/node_modules/@compatra/core/dist/deprecation-match.js +86 -0
  15. package/node_modules/@compatra/core/dist/extract-usages.d.ts +14 -0
  16. package/node_modules/@compatra/core/dist/extract-usages.js +100 -0
  17. package/node_modules/@compatra/core/dist/index.d.ts +7 -0
  18. package/node_modules/@compatra/core/dist/index.js +11 -0
  19. package/node_modules/@compatra/core/dist/migrations.d.ts +22 -0
  20. package/node_modules/@compatra/core/dist/migrations.js +49 -0
  21. package/node_modules/@compatra/core/dist/resource-match.d.ts +24 -0
  22. package/node_modules/@compatra/core/dist/resource-match.js +84 -0
  23. package/node_modules/@compatra/core/dist/shopify-fetcher.d.ts +3 -0
  24. package/node_modules/@compatra/core/dist/shopify-fetcher.js +99 -0
  25. package/node_modules/@compatra/core/dist/spec-fetcher.d.ts +15 -0
  26. package/node_modules/@compatra/core/dist/spec-fetcher.js +129 -0
  27. package/node_modules/@compatra/core/dist/spec-sources.d.ts +13 -0
  28. package/node_modules/@compatra/core/dist/spec-sources.js +109 -0
  29. package/node_modules/@compatra/core/dist/vendor-registry.d.ts +12 -0
  30. package/node_modules/@compatra/core/dist/vendor-registry.js +36 -0
  31. package/node_modules/@compatra/core/package.json +17 -0
  32. 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,3 @@
1
+ import type { FetchedSpec } from "./spec-fetcher.js";
2
+ import type { GraphqlSpecSource } from "./spec-sources.js";
3
+ export declare function fetchShopifySchema(source: GraphqlSpecSource): Promise<FetchedSpec>;
@@ -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
+ }