compatra 0.1.0 → 0.1.1
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/README.md +14 -1
- package/dist/index.js +8 -1
- package/dist/scan.js +8 -8
- package/node_modules/@compatra/core/dist/deprecation-match.d.ts +1 -1
- package/node_modules/@compatra/core/dist/deprecation-match.js +43 -1
- package/node_modules/@compatra/core/dist/extract-usages.d.ts +6 -1
- package/node_modules/@compatra/core/dist/extract-usages.js +154 -21
- package/node_modules/@compatra/core/dist/spec-fetcher.d.ts +6 -0
- package/node_modules/@compatra/core/dist/spec-fetcher.js +9 -3
- package/node_modules/@compatra/core/dist/vendor-registry.d.ts +14 -0
- package/node_modules/@compatra/core/dist/vendor-registry.js +14 -1
- package/node_modules/@compatra/core/package.json +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -11,7 +11,8 @@ npx compatra scan
|
|
|
11
11
|
|
|
12
12
|
## What it does
|
|
13
13
|
|
|
14
|
-
1. Reads `package.json` for supported vendor SDKs (Stripe, OpenAI,
|
|
14
|
+
1. Reads `package.json` for supported vendor SDKs (Stripe, OpenAI, Twilio, Shopify, and GitHub via
|
|
15
|
+
`octokit`, `@octokit/*`, `@actions/github` or `probot`).
|
|
15
16
|
2. Parses your source with ts-morph to find where those SDKs are **actually called**, not just imported.
|
|
16
17
|
3. Downloads each vendor's public API spec and collects the endpoints they mark deprecated.
|
|
17
18
|
4. Reports call sites that look like they use one.
|
|
@@ -143,6 +144,18 @@ proof**. It is deliberately strict: `stripe.customers.listCards` is reported aga
|
|
|
143
144
|
`/customers/{id}/cards`, while `stripe.customers.create` is not, even though both touch the
|
|
144
145
|
`customers` resource. That means real uses can be missed — silence is not a guarantee.
|
|
145
146
|
|
|
147
|
+
**GitHub calls** are followed through the ways code actually gets a client: `new Octokit()`,
|
|
148
|
+
`Octokit.plugin(...)`, `github.getOctokit(token)` (`@actions/github`) and probot's `context.octokit`.
|
|
149
|
+
A call is matched to a deprecated operation exactly, by its Octokit method name (Octokit generates
|
|
150
|
+
those from the spec's operation IDs; `teams.getLegacy` is `teams/get-legacy`) or, for
|
|
151
|
+
`octokit.request("GET /teams/{team_id}")`, by its route string. Checked against GitHub's own spec:
|
|
152
|
+
every deprecated operation is flagged and none of the ~2,400 non-deprecated ones are
|
|
153
|
+
(`core/scripts/check-github-matching.mjs` repeats that check), and against the real method names
|
|
154
|
+
of three Octokit versions. Two honest limits: current Octokit no longer generates methods for most
|
|
155
|
+
deprecated endpoints, so today these calls mostly show up as route strings or in older Octokit
|
|
156
|
+
versions; and a client that reaches a function only through its parameters is not followed unless it
|
|
157
|
+
is probot's `.octokit`.
|
|
158
|
+
|
|
146
159
|
Only Stripe and GitHub calls can currently be mapped to endpoints. Other vendors are detected and
|
|
147
160
|
listed, but not call-checked, and the output says so rather than implying an all-clear. If a
|
|
148
161
|
vendor's spec cannot be downloaded, the report says the check is incomplete.
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { parseArgs } from "node:util";
|
|
4
5
|
import { fixProject } from "./fix.js";
|
|
@@ -35,6 +36,12 @@ verify options
|
|
|
35
36
|
verify runs your test command in your checkout, once as it is and once per endpoint with that
|
|
36
37
|
endpoint answering 404. Needs Node 22 or newer.
|
|
37
38
|
`;
|
|
39
|
+
// The version lives in package.json only, so `-v` cannot drift from what was published. From
|
|
40
|
+
// dist/ (installed or built) and from src/ (tests) the manifest is one directory up.
|
|
41
|
+
function packageVersion() {
|
|
42
|
+
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
43
|
+
return manifest.version;
|
|
44
|
+
}
|
|
38
45
|
function parseEndpointFlag(value) {
|
|
39
46
|
const [vendor, ...rest] = value.trim().split(/\s+/);
|
|
40
47
|
if (!vendor || rest.length < 2) {
|
|
@@ -63,7 +70,7 @@ async function main(argv) {
|
|
|
63
70
|
if (values.help)
|
|
64
71
|
return void process.stdout.write(HELP);
|
|
65
72
|
if (values.version)
|
|
66
|
-
return void console.log(
|
|
73
|
+
return void console.log(packageVersion());
|
|
67
74
|
const isCommand = positionals[0] === "scan" || positionals[0] === "verify" || positionals[0] === "fix";
|
|
68
75
|
const command = isCommand ? positionals[0] : "scan";
|
|
69
76
|
const root = path.resolve((isCommand ? positionals[1] : positionals[0]) ?? ".");
|
package/dist/scan.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile, readdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
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";
|
|
4
|
+
import { RESOURCE_MATCHING_SUPPORTED, VENDOR_SPEC_SOURCES, clientHintsFor, detectVendorDependencies, fetchShopifySchema, fetchVendorSpec, matchDeprecatedEndpoints, moduleMatchesVendor, } from "@compatra/core";
|
|
5
5
|
const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", ".next", "coverage", ".turbo"]);
|
|
6
6
|
/** Source files under `root`, using the same extensions, exclusions and cap as the hosted scanner. */
|
|
7
7
|
export async function listSourceFiles(root) {
|
|
@@ -33,12 +33,12 @@ export async function listSourceFiles(root) {
|
|
|
33
33
|
found.sort();
|
|
34
34
|
return { files: found.slice(0, MAX_FILES), truncated: found.length > MAX_FILES };
|
|
35
35
|
}
|
|
36
|
-
async function
|
|
36
|
+
async function loadDeprecated(vendor) {
|
|
37
37
|
const source = VENDOR_SPEC_SOURCES.find((s) => s.vendor === vendor);
|
|
38
38
|
if (!source)
|
|
39
|
-
return [];
|
|
39
|
+
return { paths: [] };
|
|
40
40
|
const spec = source.format === "graphql" ? await fetchShopifySchema(source) : await fetchVendorSpec(source);
|
|
41
|
-
return spec.deprecatedPaths;
|
|
41
|
+
return { paths: spec.deprecatedPaths, operationIds: spec.deprecatedOperationIds };
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
44
|
* Read a project the way the hosted scanner reads a repository: find vendor SDKs in
|
|
@@ -59,7 +59,7 @@ export async function scanProject(root) {
|
|
|
59
59
|
continue;
|
|
60
60
|
}
|
|
61
61
|
for (const vendor of new Set(dependencies.map((d) => d.vendor))) {
|
|
62
|
-
for (const usage of extractVendorUsages(rel, source, (m) => moduleMatchesVendor(m, vendor))) {
|
|
62
|
+
for (const usage of extractVendorUsages(rel, source, (m) => moduleMatchesVendor(m, vendor), clientHintsFor(vendor))) {
|
|
63
63
|
callSites.push({ ...usage, vendor });
|
|
64
64
|
}
|
|
65
65
|
}
|
|
@@ -76,16 +76,16 @@ export async function scanProject(root) {
|
|
|
76
76
|
for (const vendor of vendorsToCheck) {
|
|
77
77
|
let deprecated;
|
|
78
78
|
try {
|
|
79
|
-
deprecated = await
|
|
79
|
+
deprecated = await loadDeprecated(vendor);
|
|
80
80
|
}
|
|
81
81
|
catch (error) {
|
|
82
82
|
// Offline or the vendor's spec host is down: report it rather than claiming "all clear".
|
|
83
83
|
specError = error instanceof Error ? error.message : String(error);
|
|
84
84
|
continue;
|
|
85
85
|
}
|
|
86
|
-
checked.push({ vendor, deprecatedEndpoints: deprecated.length });
|
|
86
|
+
checked.push({ vendor, deprecatedEndpoints: deprecated.paths.length });
|
|
87
87
|
for (const site of checkable.filter((c) => c.vendor === vendor)) {
|
|
88
|
-
for (const endpoint of matchDeprecatedEndpoints(vendor, site.snippet, deprecated)) {
|
|
88
|
+
for (const endpoint of matchDeprecatedEndpoints(vendor, site.snippet, deprecated.paths, deprecated.operationIds)) {
|
|
89
89
|
hits.push({ ...site, endpoint });
|
|
90
90
|
}
|
|
91
91
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The deprecated endpoints (from `deprecatedPaths`, "METHOD /path") this call plausibly uses. */
|
|
2
|
-
export declare function matchDeprecatedEndpoints(vendor: string, snippet: string, deprecatedPaths: string[]): string[];
|
|
2
|
+
export declare function matchDeprecatedEndpoints(vendor: string, snippet: string, deprecatedPaths: string[], operationIds?: Record<string, string>): string[];
|
|
@@ -15,6 +15,40 @@ function tokenize(text) {
|
|
|
15
15
|
.filter(Boolean)
|
|
16
16
|
.map((t) => singular(t.toLowerCase()));
|
|
17
17
|
}
|
|
18
|
+
// `octokit.request("GET /teams/{team_id}")` -> "GET /teams/{team_id}" (as the extractor writes it).
|
|
19
|
+
function routeFromSnippet(snippet) {
|
|
20
|
+
const match = /\(("[A-Z]+ \/[^"]*")\)$/.exec(snippet);
|
|
21
|
+
if (!match)
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(match[1]);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// "code-scanning" / "codeScanning" / "get-legacy" / "getLegacy" all -> the same form. Separators
|
|
31
|
+
// are dropped rather than converted because camelCase cannot tell `getAClassroom` from
|
|
32
|
+
// `get-a-classroom`: the single letter "A" merges with the next word.
|
|
33
|
+
const snake = (s) => s.toLowerCase().replace(/[-_]/g, "");
|
|
34
|
+
/**
|
|
35
|
+
* `octokit.rest.teams.getLegacy` <-> the operationId `teams/get-legacy`. The client variable and
|
|
36
|
+
* octokit's literal `.rest` are dropped, and the next two segments are the scope and the method.
|
|
37
|
+
* A chain shorter than that (a bare client, `octokit.paginate`) matches nothing.
|
|
38
|
+
*/
|
|
39
|
+
function matchGithubByOperationId(snippet, deprecatedPaths, operationIds) {
|
|
40
|
+
const [scope, method] = snippet.split(".").slice(1).filter((part) => part !== "rest");
|
|
41
|
+
if (!scope || !method)
|
|
42
|
+
return [];
|
|
43
|
+
const wanted = `${snake(scope)}/${snake(method)}`;
|
|
44
|
+
return deprecatedPaths.filter((endpoint) => {
|
|
45
|
+
const id = operationIds[endpoint];
|
|
46
|
+
if (!id)
|
|
47
|
+
return false;
|
|
48
|
+
const [idScope, ...idRest] = id.split("/");
|
|
49
|
+
return `${snake(idScope)}/${snake(idRest.join("/"))}` === wanted;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
18
52
|
const COLLECTION_VERBS = new Set(["list", "search"]);
|
|
19
53
|
const ITEM_VERBS = new Set(["retrieve", "get", "update", "del", "delete", "remove"]);
|
|
20
54
|
const isParam = (segment) => segment.startsWith("{") && segment.endsWith("}");
|
|
@@ -50,9 +84,17 @@ function verbsFor(method, endsWithParam) {
|
|
|
50
84
|
}
|
|
51
85
|
}
|
|
52
86
|
/** The deprecated endpoints (from `deprecatedPaths`, "METHOD /path") this call plausibly uses. */
|
|
53
|
-
export function matchDeprecatedEndpoints(vendor, snippet, deprecatedPaths) {
|
|
87
|
+
export function matchDeprecatedEndpoints(vendor, snippet, deprecatedPaths, operationIds) {
|
|
54
88
|
if (!RESOURCE_MATCHING_SUPPORTED.has(vendor))
|
|
55
89
|
return [];
|
|
90
|
+
// A route-string call names its endpoint outright, so compare it exactly, with or without operation IDs.
|
|
91
|
+
const route = vendor === "github" ? routeFromSnippet(snippet) : null;
|
|
92
|
+
if (route)
|
|
93
|
+
return deprecatedPaths.filter((endpoint) => endpoint === route);
|
|
94
|
+
// Octokit builds its method names from the spec's operation IDs, so with those in hand the
|
|
95
|
+
// match is exact rather than a guess from words in the path.
|
|
96
|
+
if (vendor === "github" && operationIds)
|
|
97
|
+
return matchGithubByOperationId(snippet, deprecatedPaths, operationIds);
|
|
56
98
|
const resource = extractResourceFromUsage(vendor, snippet);
|
|
57
99
|
if (!resource)
|
|
58
100
|
return [];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ClientHints } from "./vendor-registry.js";
|
|
1
2
|
export interface CodeUsage {
|
|
2
3
|
filePath: string;
|
|
3
4
|
line: number;
|
|
@@ -10,5 +11,9 @@ export declare const MAX_FILES = 200;
|
|
|
10
11
|
* Pure AST extraction: given one file's content, find every place a vendor's SDK is
|
|
11
12
|
* imported (ES `import` or CommonJS `require`) and then actually called or accessed,
|
|
12
13
|
* returning the line and the access-chain text (e.g. "stripe.charges.create").
|
|
14
|
+
*
|
|
15
|
+
* `hints` describes vendors whose client is not always made with `new Import()`: it names
|
|
16
|
+
* factory calls that return a client (`getOctokit(token)`) and properties that hold one
|
|
17
|
+
* (probot's `context.octokit`). Without hints only the import and `new` are followed.
|
|
13
18
|
*/
|
|
14
|
-
export declare function extractVendorUsages(filePath: string, sourceCode: string, matchesModule: (moduleSpecifier: string) => boolean): CodeUsage[];
|
|
19
|
+
export declare function extractVendorUsages(filePath: string, sourceCode: string, matchesModule: (moduleSpecifier: string) => boolean, hints?: ClientHints): CodeUsage[];
|
|
@@ -9,12 +9,41 @@ const DECLARATION_SITE_KINDS = new Set([
|
|
|
9
9
|
SyntaxKind.VariableDeclaration,
|
|
10
10
|
SyntaxKind.BindingElement,
|
|
11
11
|
]);
|
|
12
|
+
// `await x()`, `(x())`, `x() as T`, `x()!` all still evaluate to whatever x() returns.
|
|
13
|
+
const TRANSPARENT_WRAPPERS = new Set([
|
|
14
|
+
SyntaxKind.AwaitExpression,
|
|
15
|
+
SyntaxKind.ParenthesizedExpression,
|
|
16
|
+
SyntaxKind.AsExpression,
|
|
17
|
+
SyntaxKind.NonNullExpression,
|
|
18
|
+
SyntaxKind.TypeAssertionExpression,
|
|
19
|
+
SyntaxKind.SatisfiesExpression,
|
|
20
|
+
]);
|
|
21
|
+
function unwrap(node) {
|
|
22
|
+
let current = node;
|
|
23
|
+
while (TRANSPARENT_WRAPPERS.has(current.getKind())) {
|
|
24
|
+
const inner = current.getExpression();
|
|
25
|
+
current = inner;
|
|
26
|
+
}
|
|
27
|
+
return current;
|
|
28
|
+
}
|
|
29
|
+
/** The leftmost identifier of `a.b.c(...)`, i.e. `a`, or null when the chain starts with something else. */
|
|
30
|
+
function rootIdentifierText(node) {
|
|
31
|
+
let current = node;
|
|
32
|
+
while (current.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
33
|
+
current = current.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getExpression();
|
|
34
|
+
}
|
|
35
|
+
return current.getKind() === SyntaxKind.Identifier ? current.getText() : null;
|
|
36
|
+
}
|
|
12
37
|
/**
|
|
13
38
|
* Pure AST extraction: given one file's content, find every place a vendor's SDK is
|
|
14
39
|
* imported (ES `import` or CommonJS `require`) and then actually called or accessed,
|
|
15
40
|
* returning the line and the access-chain text (e.g. "stripe.charges.create").
|
|
41
|
+
*
|
|
42
|
+
* `hints` describes vendors whose client is not always made with `new Import()`: it names
|
|
43
|
+
* factory calls that return a client (`getOctokit(token)`) and properties that hold one
|
|
44
|
+
* (probot's `context.octokit`). Without hints only the import and `new` are followed.
|
|
16
45
|
*/
|
|
17
|
-
export function extractVendorUsages(filePath, sourceCode, matchesModule) {
|
|
46
|
+
export function extractVendorUsages(filePath, sourceCode, matchesModule, hints) {
|
|
18
47
|
const project = new Project({ useInMemoryFileSystem: true });
|
|
19
48
|
let sourceFile;
|
|
20
49
|
try {
|
|
@@ -58,36 +87,86 @@ export function extractVendorUsages(filePath, sourceCode, matchesModule) {
|
|
|
58
87
|
}
|
|
59
88
|
}
|
|
60
89
|
}
|
|
61
|
-
// Propagate
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
// Propagate: `const stripe = new Stripe(key)` binds a new local name ("stripe") that real
|
|
91
|
+
// code then calls methods on — not the imported class name itself. Without this, only the
|
|
92
|
+
// `new Stripe(...)` construction line would count. With hints, a factory call does the same
|
|
93
|
+
// (`const octokit = github.getOctokit(token)`, `const MyOctokit = Octokit.plugin(retry)`).
|
|
94
|
+
// Repeat until nothing new is found, so the order the declarations appear in does not matter
|
|
95
|
+
// (`new MyOctokit()` may come before the line that defines MyOctokit's own plugin chain).
|
|
96
|
+
const factories = new Set(hints?.factories ?? []);
|
|
97
|
+
const variableDeclarations = sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration);
|
|
98
|
+
let grew = true;
|
|
99
|
+
while (grew) {
|
|
100
|
+
grew = false;
|
|
101
|
+
for (const varDecl of variableDeclarations) {
|
|
102
|
+
const nameNode = varDecl.getNameNode();
|
|
103
|
+
if (nameNode.getKind() !== SyntaxKind.Identifier || localIdentifiers.has(nameNode.getText()))
|
|
104
|
+
continue;
|
|
105
|
+
const initializer = varDecl.getInitializer();
|
|
106
|
+
if (!initializer)
|
|
107
|
+
continue;
|
|
108
|
+
const value = unwrap(initializer);
|
|
109
|
+
let derivesFromVendor = false;
|
|
110
|
+
if (value.getKind() === SyntaxKind.NewExpression) {
|
|
111
|
+
derivesFromVendor = localIdentifiers.has(value.asKindOrThrow(SyntaxKind.NewExpression).getExpression().getText());
|
|
112
|
+
}
|
|
113
|
+
else if (factories.size > 0 && value.getKind() === SyntaxKind.CallExpression) {
|
|
114
|
+
const callee = value.asKindOrThrow(SyntaxKind.CallExpression).getExpression();
|
|
115
|
+
if (callee.getKind() === SyntaxKind.Identifier) {
|
|
116
|
+
// A bare `getOctokit(token)` only counts when getOctokit was imported from the vendor.
|
|
117
|
+
derivesFromVendor = factories.has(callee.getText()) && localIdentifiers.has(callee.getText());
|
|
118
|
+
}
|
|
119
|
+
else if (callee.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
120
|
+
const access = callee.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
|
|
121
|
+
const root = rootIdentifierText(access);
|
|
122
|
+
derivesFromVendor = factories.has(access.getName()) && root !== null && localIdentifiers.has(root);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (derivesFromVendor) {
|
|
126
|
+
localIdentifiers.add(nameNode.getText());
|
|
127
|
+
grew = true;
|
|
128
|
+
}
|
|
74
129
|
}
|
|
75
130
|
}
|
|
76
131
|
if (localIdentifiers.size === 0)
|
|
77
132
|
return [];
|
|
133
|
+
const accessors = new Set(hints?.accessors ?? []);
|
|
78
134
|
const usages = [];
|
|
79
135
|
const seen = new Set();
|
|
80
136
|
for (const identifier of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
81
|
-
|
|
82
|
-
continue;
|
|
137
|
+
const text = identifier.getText();
|
|
83
138
|
const parent = identifier.getParent();
|
|
84
|
-
|
|
139
|
+
// `context.octokit.issues.create(...)`: the client is a property, so the chain starts at
|
|
140
|
+
// the property itself (`octokit.issues.create`), the same shape a variable would give.
|
|
141
|
+
const isPropertyName = parent?.getKind() === SyntaxKind.PropertyAccessExpression &&
|
|
142
|
+
parent.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getNameNode() === identifier;
|
|
143
|
+
const isAccessor = accessors.has(text) && isPropertyName;
|
|
144
|
+
if (!isAccessor && !localIdentifiers.has(text))
|
|
145
|
+
continue;
|
|
146
|
+
if (!isAccessor && parent && DECLARATION_SITE_KINDS.has(parent.getKind()))
|
|
85
147
|
continue;
|
|
148
|
+
let chain = text;
|
|
86
149
|
let node = parent;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
150
|
+
if (isAccessor) {
|
|
151
|
+
// Skip the `context.octokit` access itself; only what follows it belongs to the chain.
|
|
152
|
+
node = parent.getParent();
|
|
153
|
+
let previous = parent;
|
|
154
|
+
while (node?.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
155
|
+
const access = node.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
|
|
156
|
+
if (access.getExpression() !== previous)
|
|
157
|
+
break;
|
|
158
|
+
chain += `.${access.getName()}`;
|
|
159
|
+
previous = node;
|
|
160
|
+
node = node.getParent();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
while (node?.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
165
|
+
// A chain formatted across lines (`octokit.rest.issues\n .listComments`) is still one
|
|
166
|
+
// chain: drop the line breaks and indentation so it matches like any other.
|
|
167
|
+
chain = node.getText().replace(/\s+/g, "");
|
|
168
|
+
node = node.getParent();
|
|
169
|
+
}
|
|
91
170
|
}
|
|
92
171
|
const line = identifier.getStartLineNumber();
|
|
93
172
|
const key = `${line}:${chain}`;
|
|
@@ -96,5 +175,59 @@ export function extractVendorUsages(filePath, sourceCode, matchesModule) {
|
|
|
96
175
|
seen.add(key);
|
|
97
176
|
usages.push({ filePath, line, snippet: chain });
|
|
98
177
|
}
|
|
178
|
+
// `octokit.request("GET /teams/{team_id}")`: the modern way to reach an endpoint the SDK no
|
|
179
|
+
// longer wraps in a named method. The route is right there in the call, so record it in the
|
|
180
|
+
// snippet and let the matcher compare it exactly.
|
|
181
|
+
const routeCalls = new Set(hints?.routeCalls ?? []);
|
|
182
|
+
if (routeCalls.size > 0) {
|
|
183
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
184
|
+
const callee = call.getExpression();
|
|
185
|
+
if (callee.getKind() !== SyntaxKind.PropertyAccessExpression)
|
|
186
|
+
continue;
|
|
187
|
+
const access = callee.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
|
|
188
|
+
if (!routeCalls.has(access.getName()))
|
|
189
|
+
continue;
|
|
190
|
+
// Only calls on a client we can tell is one: rooted at a traced name, or through `.octokit`.
|
|
191
|
+
const calleeText = access.getText();
|
|
192
|
+
const accessorAt = [...accessors].map((a) => calleeText.indexOf(`${a}.`)).filter((i) => i >= 0)[0];
|
|
193
|
+
const root = rootIdentifierText(access);
|
|
194
|
+
if (accessorAt === undefined && !(root !== null && localIdentifiers.has(root)))
|
|
195
|
+
continue;
|
|
196
|
+
const route = routeFromArgument(call.getArguments()[0]);
|
|
197
|
+
if (!route)
|
|
198
|
+
continue;
|
|
199
|
+
const chain = accessorAt !== undefined ? calleeText.slice(accessorAt) : calleeText;
|
|
200
|
+
const line = call.getStartLineNumber();
|
|
201
|
+
const snippet = `${chain}(${JSON.stringify(route)})`;
|
|
202
|
+
const key = `${line}:${snippet}`;
|
|
203
|
+
if (seen.has(key))
|
|
204
|
+
continue;
|
|
205
|
+
seen.add(key);
|
|
206
|
+
usages.push({ filePath, line, snippet });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
99
209
|
return usages;
|
|
100
210
|
}
|
|
211
|
+
const ROUTE = /^([A-Za-z]+)\s+(\/\S*)$/;
|
|
212
|
+
/** "GET /teams/{team_id}" from a string literal, or from `{ method: "GET", url: "/teams/{team_id}" }`. */
|
|
213
|
+
function routeFromArgument(arg) {
|
|
214
|
+
if (!arg)
|
|
215
|
+
return null;
|
|
216
|
+
const literal = (node) => node && (node.getKind() === SyntaxKind.StringLiteral || node.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral)
|
|
217
|
+
? node.getText().slice(1, -1)
|
|
218
|
+
: null;
|
|
219
|
+
const direct = literal(arg);
|
|
220
|
+
if (direct !== null) {
|
|
221
|
+
const match = ROUTE.exec(direct.trim());
|
|
222
|
+
return match ? `${match[1].toUpperCase()} ${match[2]}` : null;
|
|
223
|
+
}
|
|
224
|
+
if (arg.getKind() === SyntaxKind.ObjectLiteralExpression) {
|
|
225
|
+
const object = arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
226
|
+
const property = (name) => object.getProperty(name)?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
|
|
227
|
+
const method = literal(property("method"));
|
|
228
|
+
const url = literal(property("url"));
|
|
229
|
+
if (method && url && url.startsWith("/"))
|
|
230
|
+
return `${method.toUpperCase()} ${url}`;
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
@@ -8,6 +8,12 @@ export interface NormalizedSchema {
|
|
|
8
8
|
export interface FetchedSpec {
|
|
9
9
|
paths: string[];
|
|
10
10
|
deprecatedPaths: string[];
|
|
11
|
+
/**
|
|
12
|
+
* "METHOD /path" -> the spec's operationId, for the deprecated operations that have one. SDKs
|
|
13
|
+
* that generate their method names from operation IDs (Octokit) can be matched to an operation
|
|
14
|
+
* exactly with it. Optional: snapshots stored before this existed do not carry it.
|
|
15
|
+
*/
|
|
16
|
+
deprecatedOperationIds?: Record<string, string>;
|
|
11
17
|
schemas: Record<string, NormalizedSchema>;
|
|
12
18
|
requestSchemas: Record<string, NormalizedSchema>;
|
|
13
19
|
hash: string;
|
|
@@ -81,14 +81,18 @@ async function fetchAndParseOne(url, vendor, format) {
|
|
|
81
81
|
const spec = (format === "yaml" ? parseYaml(raw) : JSON.parse(raw));
|
|
82
82
|
const paths = [];
|
|
83
83
|
const deprecatedPaths = [];
|
|
84
|
+
const deprecatedOperationIds = {};
|
|
84
85
|
for (const [path, methods] of Object.entries(spec.paths ?? {})) {
|
|
85
86
|
for (const [method, operation] of Object.entries(methods)) {
|
|
86
87
|
if (!HTTP_METHODS.includes(method))
|
|
87
88
|
continue;
|
|
88
89
|
const key = `${method.toUpperCase()} ${path}`;
|
|
89
90
|
paths.push(key);
|
|
90
|
-
if (operation.deprecated)
|
|
91
|
+
if (operation.deprecated) {
|
|
91
92
|
deprecatedPaths.push(key);
|
|
93
|
+
if (operation.operationId)
|
|
94
|
+
deprecatedOperationIds[key] = operation.operationId;
|
|
95
|
+
}
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
// Only property presence + required-ness + deprecated-ness, not full type info —
|
|
@@ -104,7 +108,7 @@ async function fetchAndParseOne(url, vendor, format) {
|
|
|
104
108
|
schemas[name] = directPropsRequired(schema);
|
|
105
109
|
}
|
|
106
110
|
const requestSchemas = extractRequestSchemas(spec.paths ?? {}, spec.components?.schemas ?? {});
|
|
107
|
-
return { raw, paths, deprecatedPaths, schemas, requestSchemas };
|
|
111
|
+
return { raw, paths, deprecatedPaths, deprecatedOperationIds, schemas, requestSchemas };
|
|
108
112
|
}
|
|
109
113
|
export async function fetchVendorSpec(source) {
|
|
110
114
|
// Most vendors have exactly one spec file; Twilio has ~60 per-product files that
|
|
@@ -121,9 +125,11 @@ export async function fetchVendorSpec(source) {
|
|
|
121
125
|
// specially reconciled beyond that.
|
|
122
126
|
const schemas = {};
|
|
123
127
|
const requestSchemas = {};
|
|
128
|
+
const deprecatedOperationIds = {};
|
|
124
129
|
for (const file of files) {
|
|
125
130
|
Object.assign(schemas, file.schemas);
|
|
126
131
|
Object.assign(requestSchemas, file.requestSchemas);
|
|
132
|
+
Object.assign(deprecatedOperationIds, file.deprecatedOperationIds);
|
|
127
133
|
}
|
|
128
|
-
return { paths, deprecatedPaths, schemas, requestSchemas, hash };
|
|
134
|
+
return { paths, deprecatedPaths, deprecatedOperationIds, schemas, requestSchemas, hash };
|
|
129
135
|
}
|
|
@@ -3,6 +3,20 @@ export interface VendorMatch {
|
|
|
3
3
|
packageName: string;
|
|
4
4
|
versionRange: string;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* How a vendor's client is reached when a plain `const c = new Import()` trace cannot see it.
|
|
8
|
+
* Used by the AST extractor; kept beside the package patterns so one place says what "GitHub
|
|
9
|
+
* code" looks like.
|
|
10
|
+
*/
|
|
11
|
+
export interface ClientHints {
|
|
12
|
+
/** Property names that hold a ready-made client, e.g. probot's `context.octokit`. */
|
|
13
|
+
accessors: string[];
|
|
14
|
+
/** Function or method names that return a client or a client class, e.g. `getOctokit(token)`. */
|
|
15
|
+
factories: string[];
|
|
16
|
+
/** Client methods called with a "METHOD /path" route string, e.g. `octokit.request("GET /teams/{id}")`. */
|
|
17
|
+
routeCalls: string[];
|
|
18
|
+
}
|
|
19
|
+
export declare function clientHintsFor(vendor: string): ClientHints | undefined;
|
|
6
20
|
interface PackageJsonDependencies {
|
|
7
21
|
dependencies?: Record<string, string>;
|
|
8
22
|
devDependencies?: Record<string, string>;
|
|
@@ -6,8 +6,21 @@ const VENDOR_PATTERNS = [
|
|
|
6
6
|
vendor: "shopify",
|
|
7
7
|
test: (name) => name.startsWith("@shopify/") || name === "shopify-api-node",
|
|
8
8
|
},
|
|
9
|
-
{
|
|
9
|
+
{
|
|
10
|
+
vendor: "github",
|
|
11
|
+
// `octokit` is the umbrella package, `@actions/github` is what Actions authors use, and
|
|
12
|
+
// `probot` is what GitHub Apps are built on; all three hand out an Octokit client.
|
|
13
|
+
test: (name) => name.startsWith("@octokit/") || name === "octokit" || name === "@actions/github" || name === "probot",
|
|
14
|
+
},
|
|
10
15
|
];
|
|
16
|
+
const CLIENT_HINTS = {
|
|
17
|
+
// getOctokit: @actions/github. plugin/defaults: `Octokit.plugin(...)` and `Octokit.defaults(...)`
|
|
18
|
+
// return a subclass that is then constructed. octokit: probot's `context.octokit` / `app.octokit`.
|
|
19
|
+
github: { accessors: ["octokit"], factories: ["getOctokit", "plugin", "defaults"], routeCalls: ["request", "paginate", "iterator"] },
|
|
20
|
+
};
|
|
21
|
+
export function clientHintsFor(vendor) {
|
|
22
|
+
return CLIENT_HINTS[vendor];
|
|
23
|
+
}
|
|
11
24
|
// package.json dependency keys are never subpaths, but an import/require specifier can
|
|
12
25
|
// be (e.g. "stripe/esm", "@octokit/rest/dist/foo") — check both the full specifier and
|
|
13
26
|
// its first path segment so vendor patterns written for plain package names still match.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "compatra",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
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
6
|
"keywords": [
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"postpack": "node scripts/vendor-core.mjs remove"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@compatra/core": "0.1.
|
|
41
|
+
"@compatra/core": "0.1.1",
|
|
42
42
|
"ts-morph": "^28.0.0",
|
|
43
43
|
"yaml": "^2.9.1"
|
|
44
44
|
},
|