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
package/dist/scan.js ADDED
@@ -0,0 +1,94 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { EXCLUDED_PREFIXES, MAX_FILES, SOURCE_EXTENSIONS, extractVendorUsages, } from "@compatra/core/ast";
4
+ import { RESOURCE_MATCHING_SUPPORTED, VENDOR_SPEC_SOURCES, detectVendorDependencies, fetchShopifySchema, fetchVendorSpec, matchDeprecatedEndpoints, moduleMatchesVendor, } from "@compatra/core";
5
+ const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", ".next", "coverage", ".turbo"]);
6
+ /** Source files under `root`, using the same extensions, exclusions and cap as the hosted scanner. */
7
+ export async function listSourceFiles(root) {
8
+ const found = [];
9
+ async function walk(dir) {
10
+ let entries;
11
+ try {
12
+ entries = await readdir(dir, { withFileTypes: true });
13
+ }
14
+ catch {
15
+ return; // unreadable directory: skip rather than fail the whole scan
16
+ }
17
+ for (const entry of entries) {
18
+ const full = path.join(dir, entry.name);
19
+ const rel = path.relative(root, full).split(path.sep).join("/");
20
+ if (entry.isDirectory()) {
21
+ if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith("."))
22
+ continue;
23
+ if (EXCLUDED_PREFIXES.some((p) => `${rel}/`.startsWith(p)))
24
+ continue;
25
+ await walk(full);
26
+ }
27
+ else if (SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
28
+ found.push(rel);
29
+ }
30
+ }
31
+ }
32
+ await walk(root);
33
+ found.sort();
34
+ return { files: found.slice(0, MAX_FILES), truncated: found.length > MAX_FILES };
35
+ }
36
+ async function loadDeprecatedPaths(vendor) {
37
+ const source = VENDOR_SPEC_SOURCES.find((s) => s.vendor === vendor);
38
+ if (!source)
39
+ return [];
40
+ const spec = source.format === "graphql" ? await fetchShopifySchema(source) : await fetchVendorSpec(source);
41
+ return spec.deprecatedPaths;
42
+ }
43
+ /**
44
+ * Read a project the way the hosted scanner reads a repository: find vendor SDKs in
45
+ * package.json, locate their real call sites, then compare those against endpoints the vendor
46
+ * has already marked deprecated. Network is only used for the vendors' public specs.
47
+ */
48
+ export async function scanProject(root) {
49
+ const pkgRaw = await readFile(path.join(root, "package.json"), "utf8");
50
+ const dependencies = detectVendorDependencies(JSON.parse(pkgRaw));
51
+ const { files, truncated } = await listSourceFiles(root);
52
+ const callSites = [];
53
+ for (const rel of files) {
54
+ let source;
55
+ try {
56
+ source = await readFile(path.join(root, rel), "utf8");
57
+ }
58
+ catch {
59
+ continue;
60
+ }
61
+ for (const vendor of new Set(dependencies.map((d) => d.vendor))) {
62
+ for (const usage of extractVendorUsages(rel, source, (m) => moduleMatchesVendor(m, vendor))) {
63
+ callSites.push({ ...usage, vendor });
64
+ }
65
+ }
66
+ }
67
+ // Only real call chains can be matched to an endpoint; a bare constructor cannot.
68
+ const checkable = callSites.filter((c) => RESOURCE_MATCHING_SUPPORTED.has(c.vendor) && c.snippet.includes("."));
69
+ const vendorsToCheck = [...new Set(checkable.map((c) => c.vendor))].sort();
70
+ const unsupported = [...new Set(dependencies.map((d) => d.vendor))]
71
+ .filter((v) => !RESOURCE_MATCHING_SUPPORTED.has(v))
72
+ .sort();
73
+ const checked = [];
74
+ const hits = [];
75
+ let specError = null;
76
+ for (const vendor of vendorsToCheck) {
77
+ let deprecated;
78
+ try {
79
+ deprecated = await loadDeprecatedPaths(vendor);
80
+ }
81
+ catch (error) {
82
+ // Offline or the vendor's spec host is down: report it rather than claiming "all clear".
83
+ specError = error instanceof Error ? error.message : String(error);
84
+ continue;
85
+ }
86
+ checked.push({ vendor, deprecatedEndpoints: deprecated.length });
87
+ for (const site of checkable.filter((c) => c.vendor === vendor)) {
88
+ for (const endpoint of matchDeprecatedEndpoints(vendor, site.snippet, deprecated)) {
89
+ hits.push({ ...site, endpoint });
90
+ }
91
+ }
92
+ }
93
+ return { root, dependencies, callSites, filesScanned: files.length, truncated, checked, unsupported, hits, specError };
94
+ }
package/dist/verify.js ADDED
@@ -0,0 +1,110 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { classify, passed } from "./classify.js";
7
+ import { buildFault, FAULT_SUPPORTED } from "./faults.js";
8
+ import { scanProject } from "./scan.js";
9
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
10
+ const DEFAULT_MAX_ENDPOINTS = 5;
11
+ const OUTPUT_TAIL_BYTES = 2000;
12
+ // ../dist works whether we run from src/ (tests) or dist/ (installed), as both sit beside dist/.
13
+ const injectPreload = () => pathToFileURL(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dist", "inject.js")).href;
14
+ /** Stops a test run and everything it started; killing only the shell would leave the tests running. */
15
+ function stopTree(pid, detached) {
16
+ if (!pid)
17
+ return;
18
+ try {
19
+ if (process.platform === "win32")
20
+ spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
21
+ else if (detached)
22
+ process.kill(-pid, "SIGKILL");
23
+ else
24
+ process.kill(pid, "SIGKILL");
25
+ }
26
+ catch {
27
+ // already exited
28
+ }
29
+ }
30
+ /** Runs the user's test command. It is their command in their checkout, on their machine or runner. */
31
+ function runTests(command, cwd, env, timeoutMs) {
32
+ return new Promise((resolve) => {
33
+ // detached puts the shell in its own process group so a timeout can stop the whole test tree.
34
+ const detached = process.platform !== "win32";
35
+ const child = spawn(command, { cwd, env, shell: true, detached });
36
+ let output = "";
37
+ let timedOut = false;
38
+ const keep = (chunk) => {
39
+ output = (output + chunk.toString()).slice(-OUTPUT_TAIL_BYTES);
40
+ };
41
+ child.stdout.on("data", keep);
42
+ child.stderr.on("data", keep);
43
+ const timer = setTimeout(() => {
44
+ timedOut = true;
45
+ stopTree(child.pid, detached);
46
+ }, timeoutMs);
47
+ child.on("close", (exitCode) => {
48
+ clearTimeout(timer);
49
+ resolve({ exitCode, timedOut, output });
50
+ });
51
+ child.on("error", (error) => {
52
+ clearTimeout(timer);
53
+ resolve({ exitCode: null, timedOut: false, output: error.message });
54
+ });
55
+ });
56
+ }
57
+ async function countHits(logFile) {
58
+ try {
59
+ return (await readFile(logFile, "utf8")).split("\n").filter(Boolean).length;
60
+ }
61
+ catch {
62
+ return 0; // no file means nothing was ever hit
63
+ }
64
+ }
65
+ /** The distinct endpoints a scan flagged, one per vendor+endpoint, ready to hand to verifyProject. */
66
+ export function uniqueEndpoints(hits) {
67
+ return [...new Map(hits.map((h) => [`${h.vendor} ${h.endpoint}`, { vendor: h.vendor, endpoint: h.endpoint }])).values()];
68
+ }
69
+ export async function verifyProject(options) {
70
+ const { root, testCommand, anyHost = false, timeoutMs = DEFAULT_TIMEOUT_MS, maxEndpoints = DEFAULT_MAX_ENDPOINTS } = options;
71
+ const wanted = options.endpoints ?? uniqueEndpoints((await scanProject(root)).hits);
72
+ const skipped = [];
73
+ const faults = [];
74
+ for (const input of wanted) {
75
+ const fault = FAULT_SUPPORTED.has(input.vendor) ? buildFault(input.vendor, input.endpoint, anyHost) : null;
76
+ if (fault)
77
+ faults.push(fault);
78
+ else
79
+ skipped.push(input);
80
+ }
81
+ const truncated = faults.length > maxEndpoints;
82
+ const toRun = faults.slice(0, maxEndpoints);
83
+ if (toRun.length === 0)
84
+ return { root, testCommand, baseline: null, verdicts: [], skipped, truncated };
85
+ const base = await runTests(testCommand, root, process.env, timeoutMs);
86
+ const baseline = { passed: passed(base), timedOut: base.timedOut, exitCode: base.exitCode, outputTail: base.output };
87
+ const result = { root, testCommand, baseline, verdicts: [], skipped, truncated };
88
+ // Without a green baseline a failing run tells us nothing, so don't spend the time.
89
+ if (!baseline.passed)
90
+ return result;
91
+ const scratch = await mkdtemp(path.join(tmpdir(), "compatra-verify-"));
92
+ try {
93
+ for (const [index, fault] of toRun.entries()) {
94
+ const hitLog = path.join(scratch, `hits-${index}.log`);
95
+ const existing = process.env.NODE_OPTIONS ? `${process.env.NODE_OPTIONS} ` : "";
96
+ const injected = await runTests(testCommand, root, {
97
+ ...process.env,
98
+ NODE_OPTIONS: `${existing}--import=${injectPreload()}`,
99
+ COMPATRA_FAULTS: JSON.stringify([fault]),
100
+ COMPATRA_FAULT_LOG: hitLog,
101
+ }, timeoutMs);
102
+ const hits = await countHits(hitLog);
103
+ result.verdicts.push({ vendor: fault.vendor, endpoint: fault.endpoint, hits, ...classify(base, injected, hits) });
104
+ }
105
+ }
106
+ finally {
107
+ await rm(scratch, { recursive: true, force: true });
108
+ }
109
+ return result;
110
+ }
@@ -0,0 +1,29 @@
1
+ import { type MigrationRecipe } from "./migrations.js";
2
+ export interface MigrationSite {
3
+ /** 1-based line the scan reported for the call. */
4
+ line: number;
5
+ vendor: string;
6
+ endpoint: string;
7
+ /** The call's access chain, e.g. "stripe.customers.listCards". */
8
+ snippet: string;
9
+ }
10
+ export interface AppliedMigration {
11
+ line: number;
12
+ recipe: MigrationRecipe;
13
+ }
14
+ export interface SkippedMigration {
15
+ line: number;
16
+ snippet: string;
17
+ reason: string;
18
+ }
19
+ export interface MigrationResult {
20
+ output: string;
21
+ applied: AppliedMigration[];
22
+ skipped: SkippedMigration[];
23
+ }
24
+ /**
25
+ * Rewrites the flagged calls that have a recipe. Sites without one, or whose call has a shape we
26
+ * will not touch (spread arguments, a filter already set, a callback), are reported in `skipped`
27
+ * with the reason and left exactly as they were.
28
+ */
29
+ export declare function applyMigrations(filePath: string, source: string, sites: MigrationSite[]): MigrationResult;
@@ -0,0 +1,139 @@
1
+ // Applies migration recipes to a source file. Kept apart from migrations.ts because it needs
2
+ // ts-morph (see the `@compatra/core/migrate` subpath), which is slow to load.
3
+ import { NewLineKind, Node, Project, SyntaxKind, } from "ts-morph";
4
+ import { findRecipe } from "./migrations.js";
5
+ const leaf = (path) => path.slice(path.lastIndexOf(".") + 1);
6
+ const prefix = (path) => path.slice(0, path.lastIndexOf("."));
7
+ /** Calls whose callee ends with `recipe.from` and that start on `line`, in source order. */
8
+ function callsOnLine(file, line, recipe) {
9
+ return file.getDescendantsOfKind(SyntaxKind.CallExpression).filter((call) => {
10
+ const callee = call.getExpression();
11
+ if (!Node.isPropertyAccessExpression(callee))
12
+ return false;
13
+ if (callee.getName() !== leaf(recipe.from))
14
+ return false;
15
+ if (!`${callee.getExpression().getText()}.${callee.getName()}`.endsWith(`.${recipe.from}`))
16
+ return false;
17
+ return call.getStartLineNumber() === line || callee.getNameNode().getStartLineNumber() === line;
18
+ });
19
+ }
20
+ /**
21
+ * The object literal's text with one more property, written in the object's own style: an inline
22
+ * `{ a: 1 }` stays inline, a multi-line object gets a new line at the same indent, and a trailing
23
+ * comma is kept if the object had one.
24
+ */
25
+ function withProperty(literal, property) {
26
+ const text = literal.getText();
27
+ const last = literal.getProperties().at(-1);
28
+ if (!last)
29
+ return `{ ${property} }`;
30
+ const end = last.getEnd() - literal.getStart();
31
+ const comma = /^\s*,/.exec(text.slice(end));
32
+ const insertAt = comma ? end + comma[0].length : end;
33
+ const before = text.slice(0, insertAt);
34
+ const after = text.slice(insertAt);
35
+ if (!text.includes("\n")) {
36
+ return comma ? `${before} ${property},${after}` : `${before}, ${property}${after}`;
37
+ }
38
+ // Match the file's own line endings so a CRLF file does not end up with mixed ones.
39
+ const newline = text.includes("\r\n") ? "\r\n" : "\n";
40
+ const indent = /^\s*/.exec(literal.getSourceFile().getFullText().slice(last.getStartLinePos(), last.getStart()))?.[0] ?? "";
41
+ return comma ? `${before}${newline}${indent}${property},${after}` : `${before},${newline}${indent}${property}${after}`;
42
+ }
43
+ /** Why the params argument cannot take `key` safely, or null when it can. Inspects only; changes nothing. */
44
+ function paramRefusal(call, key) {
45
+ const params = call.getArguments()[1];
46
+ if (!params)
47
+ return null;
48
+ if (!Node.isObjectLiteralExpression(params)) {
49
+ // A variable or expression could be a params object or, in older SDKs, a callback: spreading a
50
+ // callback would silently corrupt the call, and we cannot tell the two apart without types.
51
+ return "the second argument is not an object literal, so it cannot be told apart from a callback";
52
+ }
53
+ if (params.getProperty(key))
54
+ return `the call already passes \`${key}\``;
55
+ if (params.getProperties().some((p) => Node.isSpreadAssignment(p))) {
56
+ return `the params object contains a spread, so \`${key}\` might already be set`;
57
+ }
58
+ return null;
59
+ }
60
+ /**
61
+ * Adds `key: value` as the params argument. This replaces a verbatim text range rather than a
62
+ * node: ts-morph's node-level insertion re-indents or reformats the whole literal, and a migration
63
+ * must not churn formatting it has no business touching. It forgets the tree, so callers re-query.
64
+ */
65
+ function addParam(call, key, value) {
66
+ const property = `${key}: ${JSON.stringify(value)}`;
67
+ const params = call.getArguments()[1];
68
+ const file = call.getSourceFile();
69
+ if (!params) {
70
+ const args = call.getArguments();
71
+ const at = args[args.length - 1].getEnd();
72
+ file.replaceText([at, at], `, { ${property} }`);
73
+ return;
74
+ }
75
+ file.replaceText([params.getStart(), params.getEnd()], withProperty(params, property));
76
+ }
77
+ /**
78
+ * Rewrites the flagged calls that have a recipe. Sites without one, or whose call has a shape we
79
+ * will not touch (spread arguments, a filter already set, a callback), are reported in `skipped`
80
+ * with the reason and left exactly as they were.
81
+ */
82
+ export function applyMigrations(filePath, source, sites) {
83
+ // ts-morph rewrites the newlines of any text it inserts to this style, so match the file's own.
84
+ const newLineKind = source.includes("\r\n") ? NewLineKind.CarriageReturnLineFeed : NewLineKind.LineFeed;
85
+ const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true, manipulationSettings: { newLineKind } });
86
+ const file = project.createSourceFile(filePath, source, { overwrite: true });
87
+ const applied = [];
88
+ const skipped = [];
89
+ // Highest line first, so an edit never shifts the line numbers of sites still to do.
90
+ for (const site of [...sites].sort((a, b) => b.line - a.line)) {
91
+ const skip = (reason) => skipped.push({ line: site.line, snippet: site.snippet, reason });
92
+ const recipe = findRecipe(site.vendor, site.endpoint, site.snippet);
93
+ if (!recipe) {
94
+ skip("no provably equivalent replacement is known for this endpoint");
95
+ continue;
96
+ }
97
+ if (prefix(recipe.from) !== prefix(recipe.to)) {
98
+ skip("recipe changes more than the method name, which this engine does not do");
99
+ continue;
100
+ }
101
+ if (callsOnLine(file, site.line, recipe).length === 0) {
102
+ skip("the call was not found on that line (the file may have changed since the scan)");
103
+ continue;
104
+ }
105
+ // Last call on the line first. Edits forget the tree and a rewritten call stops matching, so
106
+ // re-find the calls each time and take the last one that starts before the one just handled.
107
+ // (Earlier positions are unaffected by an edit further along, so this reaches every call.)
108
+ let handledStart = Infinity;
109
+ for (;;) {
110
+ const call = callsOnLine(file, site.line, recipe)
111
+ .filter((c) => c.getStart() < handledStart)
112
+ .at(-1);
113
+ if (!call)
114
+ break;
115
+ handledStart = call.getStart();
116
+ const args = call.getArguments();
117
+ if (args.length === 0) {
118
+ skip("the call has no arguments, so its shape is not what the recipe expects");
119
+ continue;
120
+ }
121
+ if (args.some((a) => Node.isSpreadElement(a))) {
122
+ skip("the call spreads its arguments, so the argument positions are unknown");
123
+ continue;
124
+ }
125
+ const [param] = Object.entries(recipe.addParams ?? {});
126
+ const refusal = param ? paramRefusal(call, param[0]) : null;
127
+ if (refusal) {
128
+ skip(refusal);
129
+ continue;
130
+ }
131
+ // Rename first (it keeps the tree valid), then the verbatim param edit, which does not.
132
+ call.getExpression().getNameNode().replaceWithText(leaf(recipe.to));
133
+ if (param)
134
+ addParam(call, param[0], param[1]);
135
+ applied.push({ line: site.line, recipe });
136
+ }
137
+ }
138
+ return { output: file.getFullText(), applied, skipped };
139
+ }
@@ -0,0 +1,2 @@
1
+ /** The deprecated endpoints (from `deprecatedPaths`, "METHOD /path") this call plausibly uses. */
2
+ export declare function matchDeprecatedEndpoints(vendor: string, snippet: string, deprecatedPaths: string[]): string[];
@@ -0,0 +1,86 @@
1
+ import { RESOURCE_MATCHING_SUPPORTED, extractResourceFromPath, extractResourceFromUsage } from "./resource-match.js";
2
+ // Precision-first: does this SDK call plausibly use one of the vendor's CURRENTLY
3
+ // deprecated endpoints? Resource-level matching alone is far too coarse here —
4
+ // `stripe.customers.create` shares the `customers` resource with the deprecated
5
+ // `customers/{id}/cards`, but doesn't use it — so a deprecated endpoint only counts when the
6
+ // call's own method name carries the endpoint's distinguishing segments
7
+ // (`customers.listCards` -> `.../cards`). Recall is deliberately lower than precision: a
8
+ // missed match is silence, a wrong one is a false alarm on a customer's first day.
9
+ const singular = (t) => (t.length > 3 && t.endsWith("s") ? t.slice(0, -1) : t);
10
+ // "listBankAccounts" / "bank_accounts" / "bank-accounts" -> ["list","bank","accounts"]
11
+ function tokenize(text) {
12
+ return text
13
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
14
+ .split(/[^A-Za-z0-9]+/)
15
+ .filter(Boolean)
16
+ .map((t) => singular(t.toLowerCase()));
17
+ }
18
+ const COLLECTION_VERBS = new Set(["list", "search"]);
19
+ const ITEM_VERBS = new Set(["retrieve", "get", "update", "del", "delete", "remove"]);
20
+ const isParam = (segment) => segment.startsWith("{") && segment.endsWith("}");
21
+ // Segments that tell THIS endpoint apart from its resource's other endpoints: everything
22
+ // after the resource, minus path parameters.
23
+ function distinguishingTokens(vendor, path) {
24
+ const rawPath = path.includes(" ") ? path.split(" ")[1] : path;
25
+ const segments = rawPath.split("/").filter(Boolean);
26
+ const resource = extractResourceFromPath(vendor, path);
27
+ const resourceIndex = segments.findIndex((s) => !isParam(s) && tokenize(s).join("_") === tokenize(resource ?? "").join("_"));
28
+ if (resourceIndex < 0)
29
+ return [];
30
+ return segments
31
+ .slice(resourceIndex + 1)
32
+ .filter((s) => !isParam(s))
33
+ .flatMap(tokenize);
34
+ }
35
+ // SDK verbs that correspond to an HTTP method on a collection/item, used only when the
36
+ // path has no distinguishing segment of its own.
37
+ function verbsFor(method, endsWithParam) {
38
+ switch (method.toUpperCase()) {
39
+ case "GET":
40
+ return endsWithParam ? ["retrieve", "get"] : ["list", "search"];
41
+ case "POST":
42
+ return endsWithParam ? ["update"] : ["create"];
43
+ case "DELETE":
44
+ return ["del", "delete", "remove", "cancel"];
45
+ case "PUT":
46
+ case "PATCH":
47
+ return ["update"];
48
+ default:
49
+ return [];
50
+ }
51
+ }
52
+ /** The deprecated endpoints (from `deprecatedPaths`, "METHOD /path") this call plausibly uses. */
53
+ export function matchDeprecatedEndpoints(vendor, snippet, deprecatedPaths) {
54
+ if (!RESOURCE_MATCHING_SUPPORTED.has(vendor))
55
+ return [];
56
+ const resource = extractResourceFromUsage(vendor, snippet);
57
+ if (!resource)
58
+ return [];
59
+ // The chain minus the client variable (first token) and octokit's literal `.rest`.
60
+ const chain = snippet.split(".").filter((p) => p !== "rest").slice(1).join(".");
61
+ const usageTokens = new Set(tokenize(chain));
62
+ if (usageTokens.size < 2)
63
+ return []; // a bare constructor / namespace, not a call
64
+ return deprecatedPaths.filter((deprecated) => {
65
+ if (extractResourceFromPath(vendor, deprecated) !== resource)
66
+ return false;
67
+ const [method, path = ""] = deprecated.split(" ");
68
+ const segments = path.split("/").filter(Boolean);
69
+ const endsWithParam = isParam(segments[segments.length - 1] ?? "");
70
+ const distinguishing = distinguishingTokens(vendor, deprecated);
71
+ if (distinguishing.length > 0) {
72
+ if (!distinguishing.every((t) => usageTokens.has(t)))
73
+ return false;
74
+ // Same sub-resource, but list-vs-single must agree: `listCards` is the collection
75
+ // endpoint, `retrieveCard` the single-item one.
76
+ const wantsCollection = [...COLLECTION_VERBS].some((v) => usageTokens.has(v));
77
+ const wantsItem = [...ITEM_VERBS].some((v) => usageTokens.has(v));
78
+ if (endsWithParam && wantsCollection && !wantsItem)
79
+ return false;
80
+ if (!endsWithParam && wantsItem && !wantsCollection)
81
+ return false;
82
+ return true;
83
+ }
84
+ return verbsFor(method, endsWithParam).some((v) => usageTokens.has(v));
85
+ });
86
+ }
@@ -0,0 +1,14 @@
1
+ export interface CodeUsage {
2
+ filePath: string;
3
+ line: number;
4
+ snippet: string;
5
+ }
6
+ export declare const SOURCE_EXTENSIONS: string[];
7
+ export declare const EXCLUDED_PREFIXES: string[];
8
+ export declare const MAX_FILES = 200;
9
+ /**
10
+ * Pure AST extraction: given one file's content, find every place a vendor's SDK is
11
+ * imported (ES `import` or CommonJS `require`) and then actually called or accessed,
12
+ * returning the line and the access-chain text (e.g. "stripe.charges.create").
13
+ */
14
+ export declare function extractVendorUsages(filePath: string, sourceCode: string, matchesModule: (moduleSpecifier: string) => boolean): CodeUsage[];
@@ -0,0 +1,100 @@
1
+ import { Project, SyntaxKind } from "ts-morph";
2
+ export const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
3
+ export const EXCLUDED_PREFIXES = ["node_modules/", "dist/", "build/", ".next/", "out/"];
4
+ export const MAX_FILES = 200;
5
+ const DECLARATION_SITE_KINDS = new Set([
6
+ SyntaxKind.ImportSpecifier,
7
+ SyntaxKind.ImportClause,
8
+ SyntaxKind.NamespaceImport,
9
+ SyntaxKind.VariableDeclaration,
10
+ SyntaxKind.BindingElement,
11
+ ]);
12
+ /**
13
+ * Pure AST extraction: given one file's content, find every place a vendor's SDK is
14
+ * imported (ES `import` or CommonJS `require`) and then actually called or accessed,
15
+ * returning the line and the access-chain text (e.g. "stripe.charges.create").
16
+ */
17
+ export function extractVendorUsages(filePath, sourceCode, matchesModule) {
18
+ const project = new Project({ useInMemoryFileSystem: true });
19
+ let sourceFile;
20
+ try {
21
+ sourceFile = project.createSourceFile(filePath, sourceCode);
22
+ }
23
+ catch {
24
+ return []; // unparseable file — skip, not fatal to the overall scan
25
+ }
26
+ const localIdentifiers = new Set();
27
+ for (const importDecl of sourceFile.getImportDeclarations()) {
28
+ if (!matchesModule(importDecl.getModuleSpecifierValue()))
29
+ continue;
30
+ const defaultImport = importDecl.getDefaultImport();
31
+ if (defaultImport)
32
+ localIdentifiers.add(defaultImport.getText());
33
+ const namespaceImport = importDecl.getNamespaceImport();
34
+ if (namespaceImport)
35
+ localIdentifiers.add(namespaceImport.getText());
36
+ for (const named of importDecl.getNamedImports()) {
37
+ localIdentifiers.add(named.getAliasNode()?.getText() ?? named.getName());
38
+ }
39
+ }
40
+ for (const callExpr of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
41
+ if (callExpr.getExpression().getText() !== "require")
42
+ continue;
43
+ const arg = callExpr.getArguments()[0];
44
+ if (!arg || arg.getKind() !== SyntaxKind.StringLiteral)
45
+ continue;
46
+ if (!matchesModule(arg.getText().slice(1, -1)))
47
+ continue;
48
+ const varDecl = callExpr.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
49
+ if (!varDecl)
50
+ continue;
51
+ const nameNode = varDecl.getNameNode();
52
+ if (nameNode.getKind() === SyntaxKind.Identifier) {
53
+ localIdentifiers.add(nameNode.getText());
54
+ }
55
+ else if (nameNode.getKind() === SyntaxKind.ObjectBindingPattern) {
56
+ for (const element of nameNode.asKindOrThrow(SyntaxKind.ObjectBindingPattern).getElements()) {
57
+ localIdentifiers.add(element.getName());
58
+ }
59
+ }
60
+ }
61
+ // Propagate one level: `const stripe = new Stripe(key)` binds a new local name
62
+ // ("stripe") that real code then calls methods on — not the imported class name
63
+ // itself. Without this, only the `new Stripe(...)` construction line would count.
64
+ for (const varDecl of sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
65
+ const initializer = varDecl.getInitializer();
66
+ if (!initializer || initializer.getKind() !== SyntaxKind.NewExpression)
67
+ continue;
68
+ const newExpr = initializer.asKindOrThrow(SyntaxKind.NewExpression);
69
+ if (!localIdentifiers.has(newExpr.getExpression().getText()))
70
+ continue;
71
+ const nameNode = varDecl.getNameNode();
72
+ if (nameNode.getKind() === SyntaxKind.Identifier) {
73
+ localIdentifiers.add(nameNode.getText());
74
+ }
75
+ }
76
+ if (localIdentifiers.size === 0)
77
+ return [];
78
+ const usages = [];
79
+ const seen = new Set();
80
+ for (const identifier of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
81
+ if (!localIdentifiers.has(identifier.getText()))
82
+ continue;
83
+ const parent = identifier.getParent();
84
+ if (parent && DECLARATION_SITE_KINDS.has(parent.getKind()))
85
+ continue;
86
+ let node = parent;
87
+ let chain = identifier.getText();
88
+ while (node?.getKind() === SyntaxKind.PropertyAccessExpression) {
89
+ chain = node.getText();
90
+ node = node.getParent();
91
+ }
92
+ const line = identifier.getStartLineNumber();
93
+ const key = `${line}:${chain}`;
94
+ if (seen.has(key))
95
+ continue;
96
+ seen.add(key);
97
+ usages.push({ filePath, line, snippet: chain });
98
+ }
99
+ return usages;
100
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./vendor-registry.js";
2
+ export * from "./spec-sources.js";
3
+ export * from "./spec-fetcher.js";
4
+ export * from "./shopify-fetcher.js";
5
+ export * from "./resource-match.js";
6
+ export * from "./deprecation-match.js";
7
+ export * from "./migrations.js";
@@ -0,0 +1,11 @@
1
+ // The analysis engine, with no database, no GitHub client and no server dependencies, so the
2
+ // same code runs in the hosted app, the CLI and the GitHub Action and cannot drift between them.
3
+ // The ts-morph-backed AST extractor is deliberately NOT re-exported here: it costs seconds to
4
+ // load, so it lives behind "@compatra/core/ast" and only the code scanner and CLI pay for it.
5
+ export * from "./vendor-registry.js";
6
+ export * from "./spec-sources.js";
7
+ export * from "./spec-fetcher.js";
8
+ export * from "./shopify-fetcher.js";
9
+ export * from "./resource-match.js";
10
+ export * from "./deprecation-match.js";
11
+ export * from "./migrations.js";
@@ -0,0 +1,22 @@
1
+ export interface MigrationRecipe {
2
+ id: string;
3
+ vendor: string;
4
+ /** The deprecated endpoint exactly as the scan names it, e.g. "GET /v1/customers/{customer}/cards". */
5
+ endpoint: string;
6
+ /** SDK method path relative to the client, e.g. "customers.listCards". Same prefix as `to`. */
7
+ from: string;
8
+ to: string;
9
+ /** Literal params the replacement needs that the old call implied (added to the params argument). */
10
+ addParams?: Record<string, string>;
11
+ /** Why we believe this is equivalent, in terms the vendor's own spec backs up. */
12
+ evidence: string;
13
+ /** What can still differ after the rewrite. Shown to the developer, never hidden. */
14
+ caveat: string;
15
+ docs: {
16
+ label: string;
17
+ url: string;
18
+ }[];
19
+ }
20
+ export declare const MIGRATION_RECIPES: MigrationRecipe[];
21
+ /** The recipe for a flagged call, or null when there is no provably equivalent replacement. */
22
+ export declare function findRecipe(vendor: string, endpoint: string, callText: string): MigrationRecipe | null;