compatra 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/dist/classify.js +19 -0
- package/dist/faults.js +49 -0
- package/dist/fix.js +30 -0
- package/dist/index.js +114 -0
- package/dist/inject.js +107 -0
- package/dist/report.js +145 -0
- package/dist/scan.js +94 -0
- package/dist/verify.js +110 -0
- package/node_modules/@compatra/core/dist/apply-migration.d.ts +29 -0
- package/node_modules/@compatra/core/dist/apply-migration.js +139 -0
- package/node_modules/@compatra/core/dist/deprecation-match.d.ts +2 -0
- package/node_modules/@compatra/core/dist/deprecation-match.js +86 -0
- package/node_modules/@compatra/core/dist/extract-usages.d.ts +14 -0
- package/node_modules/@compatra/core/dist/extract-usages.js +100 -0
- package/node_modules/@compatra/core/dist/index.d.ts +7 -0
- package/node_modules/@compatra/core/dist/index.js +11 -0
- package/node_modules/@compatra/core/dist/migrations.d.ts +22 -0
- package/node_modules/@compatra/core/dist/migrations.js +49 -0
- package/node_modules/@compatra/core/dist/resource-match.d.ts +24 -0
- package/node_modules/@compatra/core/dist/resource-match.js +84 -0
- package/node_modules/@compatra/core/dist/shopify-fetcher.d.ts +3 -0
- package/node_modules/@compatra/core/dist/shopify-fetcher.js +99 -0
- package/node_modules/@compatra/core/dist/spec-fetcher.d.ts +15 -0
- package/node_modules/@compatra/core/dist/spec-fetcher.js +129 -0
- package/node_modules/@compatra/core/dist/spec-sources.d.ts +13 -0
- package/node_modules/@compatra/core/dist/spec-sources.js +109 -0
- package/node_modules/@compatra/core/dist/vendor-registry.d.ts +12 -0
- package/node_modules/@compatra/core/dist/vendor-registry.js +36 -0
- package/node_modules/@compatra/core/package.json +17 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Compatra
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# compatra
|
|
2
|
+
|
|
3
|
+
Find third-party API calls in your code that use endpoints the vendor has **already deprecated** —
|
|
4
|
+
before the deprecation becomes a broken integration.
|
|
5
|
+
|
|
6
|
+
Runs locally. No account, no sign-up, and it never uploads your code.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx compatra scan
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## What it does
|
|
13
|
+
|
|
14
|
+
1. Reads `package.json` for supported vendor SDKs (Stripe, OpenAI, GitHub/Octokit, Twilio, Shopify).
|
|
15
|
+
2. Parses your source with ts-morph to find where those SDKs are **actually called**, not just imported.
|
|
16
|
+
3. Downloads each vendor's public API spec and collects the endpoints they mark deprecated.
|
|
17
|
+
4. Reports call sites that look like they use one.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Vendor SDKs in this project
|
|
21
|
+
Stripe stripe@^17.0.0
|
|
22
|
+
|
|
23
|
+
3 call sites across 1 file · 1 file scanned
|
|
24
|
+
|
|
25
|
+
Checked against endpoints these vendors already deprecated
|
|
26
|
+
Stripe 6 deprecated
|
|
27
|
+
|
|
28
|
+
1 call site may use a deprecated endpoint
|
|
29
|
+
|
|
30
|
+
src/billing.ts:3
|
|
31
|
+
stripe.customers.listCards
|
|
32
|
+
→ GET /v1/customers/{customer}/cards (deprecated by Stripe)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
compatra scan [path] scan a project (default: current directory)
|
|
39
|
+
compatra verify [path] run your tests with each deprecated endpoint simulated as gone
|
|
40
|
+
compatra fix [path] rewrite deprecated calls that have a provably equivalent replacement
|
|
41
|
+
|
|
42
|
+
--json machine-readable output
|
|
43
|
+
--fail-on-finding exit 1 when a deprecated endpoint is in use (for CI)
|
|
44
|
+
--no-color plain output
|
|
45
|
+
-h, --help show help
|
|
46
|
+
-v, --version show version
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Fix: rewrite the calls that have a safe replacement
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
compatra fix --dry-run # show what would change
|
|
53
|
+
compatra fix # apply it
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A vendor's spec says an endpoint is deprecated but never says what to use instead, so `fix` does
|
|
57
|
+
**not** guess. It rewrites a call only when there is a hand-checked recipe whose replacement is
|
|
58
|
+
provably equivalent, and leaves every other flagged call exactly as it was, with the reason.
|
|
59
|
+
|
|
60
|
+
Today that is Stripe's deprecated customer card reads:
|
|
61
|
+
|
|
62
|
+
| Before | After |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `stripe.customers.listCards(id)` | `stripe.customers.listSources(id, { object: "card" })` |
|
|
65
|
+
| `stripe.customers.retrieveCard(id, cardId)` | `stripe.customers.retrieveSource(id, cardId)` |
|
|
66
|
+
|
|
67
|
+
For each recipe the output shows why we believe it is equivalent (what Stripe's own spec says),
|
|
68
|
+
what can still differ, and links to the vendor docs. Checked against the real `stripe` package:
|
|
69
|
+
the request changes from `GET /v1/customers/{id}/cards` to `GET /v1/customers/{id}/sources` carrying
|
|
70
|
+
`object=card`, and the same test suite passes before and after.
|
|
71
|
+
|
|
72
|
+
**What it refuses, on purpose.** A call whose second argument is a variable or callback (it cannot
|
|
73
|
+
be told apart from a callback, and spreading a callback would corrupt the call), one that already
|
|
74
|
+
sets the filter, one with spread arguments, and any endpoint without a recipe. Formatting is
|
|
75
|
+
preserved: inline objects stay inline, multi-line ones get a line at the same indent, CRLF files
|
|
76
|
+
stay CRLF.
|
|
77
|
+
|
|
78
|
+
**Caveats we state rather than hide.** The runtime objects are the same, but in TypeScript the
|
|
79
|
+
return type widens from `Card` to a union, so you may need to narrow. Stripe's current
|
|
80
|
+
recommendation is the PaymentMethods API, which is a larger change than this tool makes.
|
|
81
|
+
|
|
82
|
+
## Verify: does your test suite notice?
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
compatra verify --test "npm test"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Finding a deprecated call says it *might* matter. `verify` shows whether it *would*: it runs **your own
|
|
89
|
+
test command** once as it is, then once per flagged endpoint with that endpoint answering
|
|
90
|
+
`404 Not Found`, and compares.
|
|
91
|
+
|
|
92
|
+
| Result | Meaning |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `BROKEN` | Your tests pass normally and fail when this endpoint goes away. A real break reaches your code. |
|
|
95
|
+
| `HANDLED` | A test sent a request to the endpoint and your tests still passed: the code copes, or nothing asserts on the result. |
|
|
96
|
+
| `NOT EXERCISED` | Tests are green, but none sent a request there. Proves nothing either way. Tests that mock the SDK land here. |
|
|
97
|
+
| `INCONCLUSIVE` | The suite is red or timed out on its own, or it failed without ever touching the endpoint. |
|
|
98
|
+
|
|
99
|
+
The point of the split is that "your tests passed" is only evidence when a test actually reached
|
|
100
|
+
the endpoint, so the report never counts an untouched endpoint as safe.
|
|
101
|
+
|
|
102
|
+
**How it works.** Compatra loads a small preload (`node --import`) into your test processes. It
|
|
103
|
+
redirects requests for the flagged endpoint to a tiny local server that answers 404 and counts the
|
|
104
|
+
hit; every other request goes to the original `http`, `https` or `fetch` function untouched. Your
|
|
105
|
+
tests run on your machine (or your CI runner) and nothing is uploaded.
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
compatra verify [path]
|
|
109
|
+
|
|
110
|
+
--test <command> your test command (default: "npm test")
|
|
111
|
+
--endpoint "<vendor> <METHOD> <path>"
|
|
112
|
+
simulate this endpoint instead of the ones the scan finds (repeatable)
|
|
113
|
+
--any-host match on any host, for tests that point an SDK at stripe-mock
|
|
114
|
+
--timeout <seconds> per test run (default: 600)
|
|
115
|
+
--fail-on-broken exit 1 when a simulated failure breaks your tests
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Exit codes: `1` for a `BROKEN` result with `--fail-on-broken`, `2` when your suite does not pass
|
|
119
|
+
as it is.
|
|
120
|
+
|
|
121
|
+
**Before and after a fix.** Run it on the old code, then on the migrated code with the same
|
|
122
|
+
`--endpoint`. `BROKEN` before and no longer `BROKEN` after is evidence the migration took: the
|
|
123
|
+
test that used to reach the old endpoint no longer does.
|
|
124
|
+
|
|
125
|
+
**Limits, stated plainly.**
|
|
126
|
+
|
|
127
|
+
- Needs Node 22 or newer. Only the JavaScript/TypeScript test process is instrumented.
|
|
128
|
+
- Covers requests made through `http`, `https` or `fetch`, which includes the vendors' own SDKs.
|
|
129
|
+
It cannot see a test that mocks the SDK itself (those show as `NOT EXERCISED`).
|
|
130
|
+
- Simulates one failure, an HTTP 404. It shows whether your suite would notice a removal; it does
|
|
131
|
+
not prove your code is correct, and it does not model changed response shapes.
|
|
132
|
+
- Each endpoint costs one full run of your test suite (the first five are simulated).
|
|
133
|
+
- Stripe, OpenAI, GitHub and Twilio only. Shopify (GraphQL) has no per-endpoint model yet.
|
|
134
|
+
- By default an endpoint is matched on the vendor's real host, so tests that point an SDK at a
|
|
135
|
+
local stand-in need `--any-host`.
|
|
136
|
+
- Tried with `node --test`, vitest and jest, against the real `stripe` package. Other runners that
|
|
137
|
+
run your tests in Node processes should work because the preload is inherited, but those are untried.
|
|
138
|
+
|
|
139
|
+
## What it does not claim
|
|
140
|
+
|
|
141
|
+
Matching is by SDK method name against the endpoint's path, so a result is **worth a look, not
|
|
142
|
+
proof**. It is deliberately strict: `stripe.customers.listCards` is reported against
|
|
143
|
+
`/customers/{id}/cards`, while `stripe.customers.create` is not, even though both touch the
|
|
144
|
+
`customers` resource. That means real uses can be missed — silence is not a guarantee.
|
|
145
|
+
|
|
146
|
+
Only Stripe and GitHub calls can currently be mapped to endpoints. Other vendors are detected and
|
|
147
|
+
listed, but not call-checked, and the output says so rather than implying an all-clear. If a
|
|
148
|
+
vendor's spec cannot be downloaded, the report says the check is incomplete.
|
|
149
|
+
|
|
150
|
+
Network is used only to fetch vendors' public API specifications.
|
|
151
|
+
|
|
152
|
+
## Limits
|
|
153
|
+
|
|
154
|
+
JavaScript and TypeScript projects with a `package.json` at the path you scan. The first 200 source
|
|
155
|
+
files, skipping `node_modules`, build output and dot-directories — the same rules the hosted
|
|
156
|
+
scanner uses, so local and hosted results agree.
|
|
157
|
+
|
|
158
|
+
## Continuous monitoring
|
|
159
|
+
|
|
160
|
+
This is a point-in-time check. For monitoring that watches vendors for *new* changes, maps them to
|
|
161
|
+
your code, alerts you and opens fix PRs, install the GitHub App:
|
|
162
|
+
<https://github.com/apps/compatra>
|
|
163
|
+
|
|
164
|
+
## Requirements
|
|
165
|
+
|
|
166
|
+
Node 22 or newer. `npx compatra scan` runs it without installing anything; `npm install --save-dev
|
|
167
|
+
compatra` adds it to a project. It has two runtime dependencies (`ts-morph` and `yaml`) and nothing
|
|
168
|
+
else.
|
|
169
|
+
|
|
170
|
+
To work on it from source (a monorepo checkout):
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
npm install && npm run build -w cli
|
|
174
|
+
node cli/dist/index.js scan /path/to/project
|
|
175
|
+
```
|
package/dist/classify.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// What a pair of test runs proves about one flagged endpoint. The point of the four-way split is
|
|
2
|
+
// that "your tests passed" is only evidence when a test actually reached the endpoint.
|
|
3
|
+
export const passed = (run) => !run.timedOut && run.exitCode === 0;
|
|
4
|
+
export function classify(baseline, injected, hits) {
|
|
5
|
+
if (!passed(baseline)) {
|
|
6
|
+
return { verdict: "inconclusive", reason: "your tests do not pass even without the simulated failure, so there is nothing to compare against" };
|
|
7
|
+
}
|
|
8
|
+
if (injected.timedOut) {
|
|
9
|
+
return { verdict: "inconclusive", reason: "the run with the simulated failure timed out" };
|
|
10
|
+
}
|
|
11
|
+
if (!passed(injected)) {
|
|
12
|
+
return hits > 0
|
|
13
|
+
? { verdict: "broken", reason: `your tests pass normally but fail when this endpoint stops working (${hits} request${hits === 1 ? "" : "s"} hit it)` }
|
|
14
|
+
: { verdict: "inconclusive", reason: "the run failed, but no request ever reached this endpoint, so the failure is probably unrelated (a flaky test?)" };
|
|
15
|
+
}
|
|
16
|
+
return hits > 0
|
|
17
|
+
? { verdict: "handled", reason: `${hits} request${hits === 1 ? "" : "s"} reached this endpoint and your tests still passed: the code copes with it failing, or no test asserts on the result` }
|
|
18
|
+
: { verdict: "unexercised", reason: "no test sent a request to this endpoint, so this proves nothing about it either way (tests that mock the SDK never do, and neither does code that has already moved off it)" };
|
|
19
|
+
}
|
package/dist/faults.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Turns a deprecated endpoint from a vendor spec into a fault we can inject at the HTTP layer,
|
|
2
|
+
// and decides whether an outgoing request is that endpoint. Pure: the interceptor that applies
|
|
3
|
+
// it lives in inject.ts, so this is what gets unit-tested.
|
|
4
|
+
// Where each vendor's REST API lives. Shopify is GraphQL, so it has no per-endpoint fault.
|
|
5
|
+
const VENDOR_API = {
|
|
6
|
+
stripe: { host: "api.stripe.com", pathPrefix: "" },
|
|
7
|
+
// OpenAI's spec lists paths relative to its /v1 base URL.
|
|
8
|
+
openai: { host: "api.openai.com", pathPrefix: "/v1" },
|
|
9
|
+
github: { host: "api.github.com", pathPrefix: "" },
|
|
10
|
+
// Twilio spreads its products over subdomains (api., verify., ...).
|
|
11
|
+
twilio: { host: "twilio.com", pathPrefix: "" },
|
|
12
|
+
};
|
|
13
|
+
export const FAULT_SUPPORTED = new Set(Object.keys(VENDOR_API));
|
|
14
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
15
|
+
/** "/v1/customers/{customer}/cards" -> a regex matching any concrete id in the placeholder slot. */
|
|
16
|
+
export function pathTemplateToPattern(template) {
|
|
17
|
+
const body = template
|
|
18
|
+
.split(/(\{[^}]+\})/)
|
|
19
|
+
.map((part) => (/^\{[^}]+\}$/.test(part) ? "[^/]+" : escapeRegExp(part)))
|
|
20
|
+
.join("");
|
|
21
|
+
return `^${body}/?$`;
|
|
22
|
+
}
|
|
23
|
+
/** Parses "GET /v1/customers/{customer}/cards" into its method and path, or null if it isn't that shape. */
|
|
24
|
+
export function parseEndpoint(endpoint) {
|
|
25
|
+
const match = /^([A-Za-z]+)\s+(\/\S*)$/.exec(endpoint.trim());
|
|
26
|
+
return match ? { method: match[1].toUpperCase(), path: match[2] } : null;
|
|
27
|
+
}
|
|
28
|
+
export function buildFault(vendor, endpoint, anyHost = false) {
|
|
29
|
+
const api = VENDOR_API[vendor];
|
|
30
|
+
const parsed = parseEndpoint(endpoint);
|
|
31
|
+
if (!api || !parsed)
|
|
32
|
+
return null;
|
|
33
|
+
return {
|
|
34
|
+
vendor,
|
|
35
|
+
endpoint,
|
|
36
|
+
method: parsed.method,
|
|
37
|
+
pathPattern: pathTemplateToPattern(api.pathPrefix + parsed.path),
|
|
38
|
+
hostSuffix: anyHost ? null : api.host,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function faultMatches(fault, method, hostname, pathname) {
|
|
42
|
+
if (fault.method !== method.toUpperCase())
|
|
43
|
+
return false;
|
|
44
|
+
if (fault.hostSuffix !== null && hostname !== fault.hostSuffix && !hostname.endsWith(`.${fault.hostSuffix}`))
|
|
45
|
+
return false;
|
|
46
|
+
// The pattern is built by pathTemplateToPattern from a spec path with every regex character escaped.
|
|
47
|
+
// eslint-disable-next-line security/detect-non-literal-regexp
|
|
48
|
+
return new RegExp(fault.pathPattern).test(pathname);
|
|
49
|
+
}
|
package/dist/fix.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { applyMigrations } from "@compatra/core/migrate";
|
|
4
|
+
import { scanProject } from "./scan.js";
|
|
5
|
+
/**
|
|
6
|
+
* Rewrites the flagged calls that have a provably equivalent replacement (see core's migration
|
|
7
|
+
* recipes) and leaves every other flagged call exactly as it was, reporting why.
|
|
8
|
+
*/
|
|
9
|
+
export async function fixProject(root, options = {}) {
|
|
10
|
+
const { dryRun = false } = options;
|
|
11
|
+
const hits = options.hits ?? (await scanProject(root)).hits;
|
|
12
|
+
const byFile = new Map();
|
|
13
|
+
for (const hit of hits)
|
|
14
|
+
byFile.set(hit.filePath, [...(byFile.get(hit.filePath) ?? []), hit]);
|
|
15
|
+
const rootWithSep = path.resolve(root) + path.sep;
|
|
16
|
+
const files = [];
|
|
17
|
+
for (const [filePath, fileHits] of byFile) {
|
|
18
|
+
const absolute = path.resolve(root, filePath);
|
|
19
|
+
// The paths come from our own scan, but never write outside the project regardless.
|
|
20
|
+
if (!absolute.startsWith(rootWithSep))
|
|
21
|
+
continue;
|
|
22
|
+
const source = await readFile(absolute, "utf8");
|
|
23
|
+
const result = applyMigrations(filePath, source, fileHits.map((h) => ({ line: h.line, vendor: h.vendor, endpoint: h.endpoint, snippet: h.snippet })));
|
|
24
|
+
const changed = result.output !== source;
|
|
25
|
+
if (changed && !dryRun)
|
|
26
|
+
await writeFile(absolute, result.output, "utf8");
|
|
27
|
+
files.push({ filePath, changed, applied: result.applied, skipped: result.skipped });
|
|
28
|
+
}
|
|
29
|
+
return { root, dryRun, flagged: hits.length, files };
|
|
30
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { fixProject } from "./fix.js";
|
|
5
|
+
import { formatFixReport, formatReport, formatVerifyReport, makePaint } from "./report.js";
|
|
6
|
+
import { scanProject } from "./scan.js";
|
|
7
|
+
import { verifyProject } from "./verify.js";
|
|
8
|
+
const HELP = `
|
|
9
|
+
compatra — find third-party API calls that use endpoints the vendor already deprecated
|
|
10
|
+
|
|
11
|
+
compatra scan [path] scan a project (default: current directory)
|
|
12
|
+
compatra verify [path] run your tests with each deprecated endpoint simulated as gone
|
|
13
|
+
compatra fix [path] rewrite deprecated calls that have a provably equivalent replacement
|
|
14
|
+
|
|
15
|
+
Options
|
|
16
|
+
--json machine-readable output
|
|
17
|
+
--fail-on-finding scan: exit 1 when a deprecated endpoint is in use (for CI)
|
|
18
|
+
--no-color plain output
|
|
19
|
+
-h, --help show this
|
|
20
|
+
-v, --version show version
|
|
21
|
+
|
|
22
|
+
fix options
|
|
23
|
+
--dry-run show what would change without writing anything
|
|
24
|
+
|
|
25
|
+
verify options
|
|
26
|
+
--test <command> your test command (default: "npm test")
|
|
27
|
+
--endpoint "<vendor> <METHOD> <path>"
|
|
28
|
+
simulate this endpoint instead of the deprecated ones the scan finds,
|
|
29
|
+
e.g. --endpoint "stripe GET /v1/customers/{customer}/cards" (repeatable)
|
|
30
|
+
--any-host match the endpoint on any host, for tests that point an SDK at
|
|
31
|
+
stripe-mock or a local proxy instead of the vendor's real host
|
|
32
|
+
--timeout <seconds> per test run (default: 600)
|
|
33
|
+
--fail-on-broken exit 1 when a simulated failure breaks your tests
|
|
34
|
+
|
|
35
|
+
verify runs your test command in your checkout, once as it is and once per endpoint with that
|
|
36
|
+
endpoint answering 404. Needs Node 22 or newer.
|
|
37
|
+
`;
|
|
38
|
+
function parseEndpointFlag(value) {
|
|
39
|
+
const [vendor, ...rest] = value.trim().split(/\s+/);
|
|
40
|
+
if (!vendor || rest.length < 2) {
|
|
41
|
+
throw new Error(`--endpoint expects "<vendor> <METHOD> <path>", got "${value}"`);
|
|
42
|
+
}
|
|
43
|
+
return { vendor: vendor.toLowerCase(), endpoint: rest.join(" ") };
|
|
44
|
+
}
|
|
45
|
+
async function main(argv) {
|
|
46
|
+
const { values, positionals } = parseArgs({
|
|
47
|
+
args: argv,
|
|
48
|
+
allowPositionals: true,
|
|
49
|
+
options: {
|
|
50
|
+
json: { type: "boolean" },
|
|
51
|
+
"fail-on-finding": { type: "boolean" },
|
|
52
|
+
"fail-on-broken": { type: "boolean" },
|
|
53
|
+
"no-color": { type: "boolean" },
|
|
54
|
+
"any-host": { type: "boolean" },
|
|
55
|
+
"dry-run": { type: "boolean" },
|
|
56
|
+
test: { type: "string" },
|
|
57
|
+
endpoint: { type: "string", multiple: true },
|
|
58
|
+
timeout: { type: "string" },
|
|
59
|
+
help: { type: "boolean", short: "h" },
|
|
60
|
+
version: { type: "boolean", short: "v" },
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
if (values.help)
|
|
64
|
+
return void process.stdout.write(HELP);
|
|
65
|
+
if (values.version)
|
|
66
|
+
return void console.log("0.1.0");
|
|
67
|
+
const isCommand = positionals[0] === "scan" || positionals[0] === "verify" || positionals[0] === "fix";
|
|
68
|
+
const command = isCommand ? positionals[0] : "scan";
|
|
69
|
+
const root = path.resolve((isCommand ? positionals[1] : positionals[0]) ?? ".");
|
|
70
|
+
const colour = !values["no-color"] && !process.env.NO_COLOR && process.stdout.isTTY;
|
|
71
|
+
const paint = makePaint(Boolean(colour));
|
|
72
|
+
try {
|
|
73
|
+
if (command === "fix") {
|
|
74
|
+
const result = await fixProject(root, { dryRun: values["dry-run"] });
|
|
75
|
+
process.stdout.write(values.json ? `${JSON.stringify(result, null, 2)}\n` : formatFixReport(result, paint));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (command === "verify") {
|
|
79
|
+
const timeoutSeconds = Number(values.timeout ?? 600);
|
|
80
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)
|
|
81
|
+
throw new Error(`--timeout expects a number of seconds, got "${values.timeout}"`);
|
|
82
|
+
const result = await verifyProject({
|
|
83
|
+
root,
|
|
84
|
+
testCommand: values.test ?? "npm test",
|
|
85
|
+
endpoints: values.endpoint?.map(parseEndpointFlag),
|
|
86
|
+
anyHost: values["any-host"],
|
|
87
|
+
timeoutMs: timeoutSeconds * 1000,
|
|
88
|
+
});
|
|
89
|
+
process.stdout.write(values.json ? `${JSON.stringify(result, null, 2)}\n` : formatVerifyReport(result, paint));
|
|
90
|
+
if (result.baseline && !result.baseline.passed)
|
|
91
|
+
process.exitCode = 2;
|
|
92
|
+
else if (values["fail-on-broken"] && result.verdicts.some((v) => v.verdict === "broken"))
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const result = await scanProject(root);
|
|
97
|
+
process.stdout.write(values.json ? `${JSON.stringify(result, null, 2)}\n` : formatReport(result, paint));
|
|
98
|
+
if (values["fail-on-finding"] && result.hits.length > 0)
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
103
|
+
const hint = message.includes("package.json")
|
|
104
|
+
? `No package.json found in ${root}. Run this from a Node.js project, or pass its path.`
|
|
105
|
+
: message;
|
|
106
|
+
process.stderr.write(`\n ${hint}\n\n`);
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
111
|
+
// parseArgs rejects unknown flags here, before any command runs.
|
|
112
|
+
process.stderr.write(`compatra: ${error instanceof Error ? error.message : String(error)}\n${HELP}`);
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
});
|
package/dist/inject.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Loaded into the user's test process with `node --import` (see verify.ts).
|
|
2
|
+
//
|
|
3
|
+
// Requests to a flagged endpoint are redirected to a small real HTTP server in this process that
|
|
4
|
+
// answers "endpoint removed" (404) and records the hit; that is how we tell "tests survived it"
|
|
5
|
+
// from "no test ever got there". Every other request calls the original http/https/fetch function
|
|
6
|
+
// untouched, so the only thing that can change a test's outcome is the simulated failure itself.
|
|
7
|
+
import { appendFileSync } from "node:fs";
|
|
8
|
+
import http from "node:http";
|
|
9
|
+
import https from "node:https";
|
|
10
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
11
|
+
import { faultMatches } from "./faults.js";
|
|
12
|
+
const faults = JSON.parse(process.env.COMPATRA_FAULTS ?? "[]");
|
|
13
|
+
const hitLog = process.env.COMPATRA_FAULT_LOG;
|
|
14
|
+
const server = http.createServer((req, res) => {
|
|
15
|
+
if (hitLog)
|
|
16
|
+
appendFileSync(hitLog, `${JSON.stringify({ method: req.method, path: req.url })}\n`);
|
|
17
|
+
req.resume();
|
|
18
|
+
req.on("end", () => {
|
|
19
|
+
res.writeHead(404, { "content-type": "application/json", "x-compatra-fault": "simulated-removal" });
|
|
20
|
+
// Carries the fields the vendors' own SDKs read an error from (Stripe/OpenAI `error`, GitHub/Twilio `message`).
|
|
21
|
+
const message = "Simulated by Compatra: this endpoint no longer exists.";
|
|
22
|
+
res.end(JSON.stringify({ message, error: { type: "invalid_request_error", message } }));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
// Top-level await: Node finishes this module before the test runner starts, so the port is known
|
|
26
|
+
// before any request can be made. unref so the server never keeps a process alive.
|
|
27
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
28
|
+
server.unref();
|
|
29
|
+
const faultPort = server.address().port;
|
|
30
|
+
const isFaulted = (method, hostname, pathname) => faults.some((f) => faultMatches(f, method, hostname, pathname));
|
|
31
|
+
/** http.request accepts (url, options?, cb?) or (options, cb?). Pull the parts a fault matches on. */
|
|
32
|
+
function describeRequest(args, defaultProtocol) {
|
|
33
|
+
const [first, second] = args;
|
|
34
|
+
const url = typeof first === "string" ? new URL(first) : first instanceof URL ? first : null;
|
|
35
|
+
const options = (url ? (typeof second === "object" && second !== null ? second : {}) : first);
|
|
36
|
+
const protocol = options.protocol ?? url?.protocol ?? defaultProtocol;
|
|
37
|
+
const hostname = String(options.hostname ?? options.host ?? url?.hostname ?? "localhost").replace(/:\d+$/, "");
|
|
38
|
+
const rawPath = options.path ?? (url ? `${url.pathname}${url.search}` : "/");
|
|
39
|
+
const pathname = rawPath.split("?")[0];
|
|
40
|
+
return { url, options, protocol, hostname, rawPath, pathname, method: (options.method ?? "GET").toUpperCase() };
|
|
41
|
+
}
|
|
42
|
+
// The fault server speaks plain http, so redirected requests always go through http.request,
|
|
43
|
+
// even when the caller used https (https.request rejects an http: target).
|
|
44
|
+
const httpRequest = http.request;
|
|
45
|
+
function redirect(args, defaultProtocol) {
|
|
46
|
+
const d = describeRequest(args, defaultProtocol);
|
|
47
|
+
if (d.options.socketPath || !isFaulted(d.method, d.hostname, d.pathname))
|
|
48
|
+
return null;
|
|
49
|
+
// Keep everything the caller set except where the request goes. agent is dropped because an
|
|
50
|
+
// https.Agent cannot serve the plain-http fault server.
|
|
51
|
+
const options = { ...d.options };
|
|
52
|
+
delete options.agent;
|
|
53
|
+
const target = {
|
|
54
|
+
...options,
|
|
55
|
+
protocol: "http:",
|
|
56
|
+
hostname: "127.0.0.1",
|
|
57
|
+
host: "127.0.0.1",
|
|
58
|
+
port: faultPort,
|
|
59
|
+
path: d.rawPath,
|
|
60
|
+
method: d.method,
|
|
61
|
+
};
|
|
62
|
+
// (url, cb), (url, options, cb) and (options, cb) all put the callback somewhere after the first argument.
|
|
63
|
+
const callback = args.slice(1).find((a) => typeof a === "function");
|
|
64
|
+
const req = callback ? httpRequest(target, callback) : httpRequest(target);
|
|
65
|
+
if (d.protocol === "https:") {
|
|
66
|
+
// Some SDKs (older stripe-node) hold the request body until the socket reports
|
|
67
|
+
// "secureConnect". A plain-http socket never does, so report it once connected.
|
|
68
|
+
req.once("socket", (socket) => {
|
|
69
|
+
if (socket.connecting)
|
|
70
|
+
socket.once("connect", () => socket.emit("secureConnect"));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return req;
|
|
74
|
+
}
|
|
75
|
+
function patch(mod, defaultProtocol) {
|
|
76
|
+
const request = mod.request;
|
|
77
|
+
const get = mod.get;
|
|
78
|
+
mod.request = ((...args) => redirect(args, defaultProtocol) ?? request(...args));
|
|
79
|
+
mod.get = ((...args) => {
|
|
80
|
+
const redirected = redirect(args, defaultProtocol);
|
|
81
|
+
if (!redirected)
|
|
82
|
+
return get(...args);
|
|
83
|
+
// get() is request() plus an automatic end(); a redirected request must end too.
|
|
84
|
+
redirected.end();
|
|
85
|
+
return redirected;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
patch(http, "http:");
|
|
89
|
+
patch(https, "https:");
|
|
90
|
+
const realFetch = globalThis.fetch;
|
|
91
|
+
globalThis.fetch = ((input, init) => {
|
|
92
|
+
const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
93
|
+
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
|
|
94
|
+
let url;
|
|
95
|
+
try {
|
|
96
|
+
url = new URL(raw);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return realFetch(input, init); // relative URL or something odd: not ours to judge
|
|
100
|
+
}
|
|
101
|
+
if (!isFaulted(method, url.hostname, url.pathname))
|
|
102
|
+
return realFetch(input, init);
|
|
103
|
+
const target = `http://127.0.0.1:${faultPort}${url.pathname}${url.search}`;
|
|
104
|
+
return realFetch(input instanceof Request ? new Request(target, input) : target, init);
|
|
105
|
+
});
|
|
106
|
+
// Let ESM named imports (import { request } from "node:http") see the patched functions too.
|
|
107
|
+
syncBuiltinESMExports();
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const VENDOR_LABELS = {
|
|
2
|
+
stripe: "Stripe",
|
|
3
|
+
openai: "OpenAI",
|
|
4
|
+
github: "GitHub",
|
|
5
|
+
twilio: "Twilio",
|
|
6
|
+
shopify: "Shopify",
|
|
7
|
+
};
|
|
8
|
+
const label = (v) => VENDOR_LABELS[v] ?? v;
|
|
9
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
10
|
+
// Colour only when writing to a terminal that wants it, so piped and CI output stays plain.
|
|
11
|
+
export function makePaint(enabled) {
|
|
12
|
+
const wrap = (code) => (s) => (enabled ? `\u001b[${code}m${s}\u001b[0m` : s);
|
|
13
|
+
return {
|
|
14
|
+
bold: wrap("1"),
|
|
15
|
+
dim: wrap("2"),
|
|
16
|
+
red: wrap("31"),
|
|
17
|
+
green: wrap("32"),
|
|
18
|
+
amber: wrap("33"),
|
|
19
|
+
cyan: wrap("36"),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function formatReport(result, paint) {
|
|
23
|
+
const { bold, dim, green, amber, cyan } = paint;
|
|
24
|
+
const out = ["", bold("Compatra") + dim(" · third-party API reliability"), dim(` ${result.root}`), ""];
|
|
25
|
+
if (result.dependencies.length === 0) {
|
|
26
|
+
out.push(" No supported vendor SDKs found in package.json.", dim(" Compatra currently recognises Stripe, OpenAI, GitHub (Octokit), Twilio and Shopify."), "");
|
|
27
|
+
return out.join("\n");
|
|
28
|
+
}
|
|
29
|
+
out.push(bold(" Vendor SDKs in this project"));
|
|
30
|
+
for (const dep of result.dependencies) {
|
|
31
|
+
out.push(` ${label(dep.vendor).padEnd(8)} ${dim(`${dep.packageName}@${dep.versionRange}`)}`);
|
|
32
|
+
}
|
|
33
|
+
const fileCount = new Set(result.callSites.map((c) => c.filePath)).size;
|
|
34
|
+
out.push("", ` ${plural(result.callSites.length, "call site")} across ${plural(fileCount, "file")}` +
|
|
35
|
+
dim(` · ${plural(result.filesScanned, "file")} scanned${result.truncated ? " (capped)" : ""}`));
|
|
36
|
+
if (result.checked.length > 0) {
|
|
37
|
+
const summary = result.checked
|
|
38
|
+
.map((c) => `${label(c.vendor)} ${dim(`${c.deprecatedEndpoints} deprecated`)}`)
|
|
39
|
+
.join(dim(" · "));
|
|
40
|
+
out.push("", bold(" Checked against endpoints these vendors already deprecated"), ` ${summary}`);
|
|
41
|
+
}
|
|
42
|
+
out.push("");
|
|
43
|
+
if (result.hits.length > 0) {
|
|
44
|
+
out.push(amber(` ${plural(result.hits.length, "call site")} may use a deprecated endpoint`), "");
|
|
45
|
+
for (const hit of result.hits) {
|
|
46
|
+
out.push(` ${cyan(`${hit.filePath}:${hit.line}`)}`);
|
|
47
|
+
out.push(` ${hit.snippet}`);
|
|
48
|
+
out.push(` ${dim("→")} ${amber(hit.endpoint)} ${dim(`(deprecated by ${label(hit.vendor)})`)}`);
|
|
49
|
+
out.push("");
|
|
50
|
+
}
|
|
51
|
+
out.push(dim(" Worth a look rather than proof: matching is by SDK method name."));
|
|
52
|
+
}
|
|
53
|
+
else if (result.checked.length > 0) {
|
|
54
|
+
out.push(green(" No call site uses an endpoint these vendors have deprecated."));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
out.push(dim(" Nothing could be call-checked in this project yet."));
|
|
58
|
+
}
|
|
59
|
+
if (result.unsupported.length > 0) {
|
|
60
|
+
out.push(dim(` Not call-checked yet: ${result.unsupported.map(label).join(", ")} — detected, but only Stripe and GitHub`), dim(" calls can be mapped to endpoints in this version."));
|
|
61
|
+
}
|
|
62
|
+
if (result.specError) {
|
|
63
|
+
out.push(amber(` Could not reach every vendor spec, so this is incomplete: ${result.specError}`));
|
|
64
|
+
}
|
|
65
|
+
out.push("", dim(" This is a point-in-time check. Continuous monitoring, impact alerts and fix PRs:"), dim(" https://github.com/apps/compatra"), "");
|
|
66
|
+
return out.join("\n");
|
|
67
|
+
}
|
|
68
|
+
const VERDICT_TITLE = {
|
|
69
|
+
broken: "BROKEN",
|
|
70
|
+
handled: "HANDLED",
|
|
71
|
+
unexercised: "NOT EXERCISED",
|
|
72
|
+
inconclusive: "INCONCLUSIVE",
|
|
73
|
+
};
|
|
74
|
+
export function formatVerifyReport(result, paint) {
|
|
75
|
+
const { bold, dim, red, green, amber } = paint;
|
|
76
|
+
const out = ["", bold("Compatra verify") + dim(" · does your test suite notice when an endpoint goes away?"), dim(` ${result.root}`), ""];
|
|
77
|
+
if (!result.baseline) {
|
|
78
|
+
out.push(" No deprecated endpoint to simulate, so your tests were not run.", "");
|
|
79
|
+
for (const s of result.skipped)
|
|
80
|
+
out.push(dim(` Skipped ${s.endpoint}: no HTTP model for ${label(s.vendor)} yet.`));
|
|
81
|
+
return out.join("\n");
|
|
82
|
+
}
|
|
83
|
+
out.push(` Test command: ${bold(result.testCommand)}`);
|
|
84
|
+
if (!result.baseline.passed) {
|
|
85
|
+
out.push("", red(result.baseline.timedOut ? " Your tests timed out before any failure was simulated." : " Your tests fail as they are, so nothing was simulated."), dim(" A failing run tells us nothing until the suite passes on its own. Last output:"), "", ...result.baseline.outputTail.trimEnd().split("\n").map((line) => dim(` ${line}`)), "");
|
|
86
|
+
return out.join("\n");
|
|
87
|
+
}
|
|
88
|
+
out.push(green(" Passes as it is."), "");
|
|
89
|
+
for (const v of result.verdicts) {
|
|
90
|
+
const paintVerdict = { broken: red, handled: green, unexercised: amber, inconclusive: dim }[v.verdict];
|
|
91
|
+
out.push(` ${paintVerdict(VERDICT_TITLE[v.verdict].padEnd(13))} ${bold(v.endpoint)} ${dim(`(${label(v.vendor)})`)}`);
|
|
92
|
+
out.push(dim(` ${v.reason}`), "");
|
|
93
|
+
}
|
|
94
|
+
if (result.truncated) {
|
|
95
|
+
out.push(dim(" More endpoints were flagged than were simulated; each one costs a full run of your tests."), "");
|
|
96
|
+
}
|
|
97
|
+
for (const s of result.skipped) {
|
|
98
|
+
out.push(dim(` Skipped ${s.endpoint}: no HTTP model for ${label(s.vendor)} yet.`));
|
|
99
|
+
}
|
|
100
|
+
out.push(dim(" The simulated failure is an HTTP 404 on that one endpoint, applied to requests your tests make."), dim(" It shows whether your suite would notice. It does not prove your code is correct."), "");
|
|
101
|
+
return out.join("\n");
|
|
102
|
+
}
|
|
103
|
+
export function formatFixReport(result, paint) {
|
|
104
|
+
const { bold, dim, green, amber, cyan } = paint;
|
|
105
|
+
const out = ["", bold("Compatra fix") + dim(" · rewrite deprecated calls that have a provably equivalent replacement"), dim(` ${result.root}`), ""];
|
|
106
|
+
const applied = result.files.flatMap((f) => f.applied.map((a) => ({ file: f.filePath, ...a })));
|
|
107
|
+
const skipped = result.files.flatMap((f) => f.skipped.map((s) => ({ file: f.filePath, ...s })));
|
|
108
|
+
if (result.flagged === 0) {
|
|
109
|
+
out.push(" No deprecated call sites to fix.", "");
|
|
110
|
+
return out.join("\n");
|
|
111
|
+
}
|
|
112
|
+
if (applied.length > 0) {
|
|
113
|
+
out.push(green(` ${result.dryRun ? "Would rewrite" : "Rewrote"} ${plural(applied.length, "call")} in ${plural(new Set(applied.map((a) => a.file)).size, "file")}`), "");
|
|
114
|
+
for (const a of applied) {
|
|
115
|
+
out.push(` ${cyan(`${a.file}:${a.line}`)} ${a.recipe.from} ${dim("→")} ${a.recipe.to}`);
|
|
116
|
+
}
|
|
117
|
+
out.push("");
|
|
118
|
+
for (const recipe of new Map(applied.map((a) => [a.recipe.id, a.recipe])).values()) {
|
|
119
|
+
out.push(bold(` ${recipe.from} → ${recipe.to}`));
|
|
120
|
+
out.push(dim(` Why: ${recipe.evidence}`));
|
|
121
|
+
out.push(amber(` Check: ${recipe.caveat}`));
|
|
122
|
+
for (const doc of recipe.docs)
|
|
123
|
+
out.push(dim(` ${doc.label}: ${doc.url}`));
|
|
124
|
+
out.push("");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
out.push(amber(" Nothing was rewritten."), "");
|
|
129
|
+
}
|
|
130
|
+
if (skipped.length > 0) {
|
|
131
|
+
out.push(bold(` Left for you (${skipped.length})`));
|
|
132
|
+
for (const s of skipped) {
|
|
133
|
+
out.push(` ${cyan(`${s.file}:${s.line}`)} ${s.snippet}`);
|
|
134
|
+
out.push(dim(` ${s.reason}`));
|
|
135
|
+
}
|
|
136
|
+
out.push("");
|
|
137
|
+
}
|
|
138
|
+
out.push(dim(result.dryRun ? " Nothing was written. Run without --dry-run to apply." : " Review the changes (git diff) and run your tests."), ...(result.dryRun || applied.length === 0
|
|
139
|
+
? []
|
|
140
|
+
: [
|
|
141
|
+
dim(" To see whether your tests ever reached the old endpoint, run compatra verify on the original"),
|
|
142
|
+
dim(" code and again now with the same --endpoint: BROKEN before and not after means the change took."),
|
|
143
|
+
]), "");
|
|
144
|
+
return out.join("\n");
|
|
145
|
+
}
|