shapi-cli 0.2.0 → 0.3.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/README.md +6 -4
- package/dist/index.js +200 -14
- package/package.json +6 -9
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<p align="center"><a href="https://shapi.dev/"><img src="https://raw.githubusercontent.com/SriVarshini09/YC/main/brand/png/shapi-lockup-horizontal-light-on-bg-1024.png" alt="SHAPI" width="320"></a></p>
|
|
2
|
+
|
|
1
3
|
# shapi-cli
|
|
2
4
|
|
|
3
5
|
**Find the code that breaks when a third-party API changes, and fix it test-first.**
|
|
@@ -22,7 +24,7 @@ Install it to get the `shapi` command:
|
|
|
22
24
|
|
|
23
25
|
```sh
|
|
24
26
|
npm install -g shapi-cli
|
|
25
|
-
shapi scan path/to/repo
|
|
27
|
+
shapi scan path/to/repo # or: shapi-cli scan path/to/repo
|
|
26
28
|
```
|
|
27
29
|
|
|
28
30
|
## What it checks
|
|
@@ -41,9 +43,9 @@ Node.js 22 or later.
|
|
|
41
43
|
|
|
42
44
|
## More
|
|
43
45
|
|
|
44
|
-
- Documentation: https://
|
|
45
|
-
- The GitHub App watches your repositories, posts a check on each push, and opens verified fix pull requests: https://
|
|
46
|
-
- Report a problem: https://github.com/SriVarshini09/
|
|
46
|
+
- Documentation: https://shapi.dev/
|
|
47
|
+
- The GitHub App watches your repositories, posts a check on each push, and opens verified fix pull requests: https://app.shapi.dev
|
|
48
|
+
- Report a problem: https://github.com/SriVarshini09/shapi-advisories/issues or sri@shapi.dev
|
|
47
49
|
|
|
48
50
|
## License
|
|
49
51
|
|
package/dist/index.js
CHANGED
|
@@ -469,6 +469,14 @@ function providerForHost(host) {
|
|
|
469
469
|
}
|
|
470
470
|
return best?.provider;
|
|
471
471
|
}
|
|
472
|
+
var PATH_SIGNATURES = [
|
|
473
|
+
{ provider: "shopify", path: /^\/admin\/api\/\d{4}-\d{2}(\/|$)/ },
|
|
474
|
+
{ provider: "hubspot", path: /^\/crm-pipelines\/v\d+(\/|$)/ }
|
|
475
|
+
];
|
|
476
|
+
function providerForPath(path13) {
|
|
477
|
+
const hit = PATH_SIGNATURES.find((s) => s.path.test(path13));
|
|
478
|
+
return hit ? getProvider(hit.provider) : void 0;
|
|
479
|
+
}
|
|
472
480
|
function providerForPackage(name) {
|
|
473
481
|
return loadProviders().find((p) => p.sdkPackages.includes(name));
|
|
474
482
|
}
|
|
@@ -689,10 +697,16 @@ function matchAdvisories(lock, advisories, options = {}) {
|
|
|
689
697
|
const horizon = options.horizonDays ?? 90;
|
|
690
698
|
const affected = [];
|
|
691
699
|
const exposed = [];
|
|
700
|
+
const possible = [];
|
|
692
701
|
const hitsPerAdvisory = /* @__PURE__ */ new Map();
|
|
693
702
|
for (const advisory of advisories) {
|
|
694
703
|
for (const callSite of lock.callSites) {
|
|
695
|
-
if (!endpointMatches(callSite, advisory))
|
|
704
|
+
if (!endpointMatches(callSite, advisory)) {
|
|
705
|
+
if (endpointMayMatch(callSite, advisory) && triggerReasons(callSite, advisory.trigger).length > 0) {
|
|
706
|
+
possible.push({ advisory, callSite, reasons: [`builds the path at runtime (${describe(callSite)}); affected if it reaches ${advisory.endpoint.path}`] });
|
|
707
|
+
}
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
696
710
|
hitsPerAdvisory.set(advisory.id, (hitsPerAdvisory.get(advisory.id) ?? 0) + 1);
|
|
697
711
|
const reasons = triggerReasons(callSite, advisory.trigger);
|
|
698
712
|
if (reasons.length > 0) affected.push({ advisory, callSite, reasons });
|
|
@@ -706,7 +720,7 @@ function matchAdvisories(lock, advisories, options = {}) {
|
|
|
706
720
|
daysUntil: Math.round((Date.parse(advisory.effectiveDate) - today) / DAY_MS),
|
|
707
721
|
callSites: hitsPerAdvisory.get(advisory.id) ?? 0
|
|
708
722
|
})).filter((u) => present.has(u.advisory.provider) && u.daysUntil >= 0 && u.daysUntil <= horizon).sort((a, b) => a.daysUntil - b.daysUntil || cmp2(a.advisory.id, b.advisory.id));
|
|
709
|
-
return { affected: affected.sort(byFinding), exposed: exposed.sort(byFinding), upcoming };
|
|
723
|
+
return { affected: affected.sort(byFinding), exposed: exposed.sort(byFinding), possible: possible.sort(byFinding), upcoming };
|
|
710
724
|
}
|
|
711
725
|
function endpointMatches(site, advisory) {
|
|
712
726
|
const pattern = advisory.endpoint;
|
|
@@ -715,6 +729,30 @@ function endpointMatches(site, advisory) {
|
|
|
715
729
|
if (pattern.hosts && !(site.host && pattern.hosts.includes(site.host))) return false;
|
|
716
730
|
return pathMatches(site.endpoint, pattern);
|
|
717
731
|
}
|
|
732
|
+
function endpointMayMatch(site, advisory) {
|
|
733
|
+
const pattern = advisory.endpoint;
|
|
734
|
+
if (site.provider !== advisory.provider || site.endpoint === null) return false;
|
|
735
|
+
if (pattern.method !== "*" && site.method !== null && site.method !== pattern.method) return false;
|
|
736
|
+
if (pattern.hosts && site.host && !pattern.hosts.includes(site.host)) return false;
|
|
737
|
+
if (!/\{[^}]*\}/.test(site.endpoint)) return false;
|
|
738
|
+
const known = segments(site.endpoint);
|
|
739
|
+
const runtimeAt = known.findIndex(isRuntime);
|
|
740
|
+
const literals = segments(pattern.path).filter((p) => !/[*{]/.test(p));
|
|
741
|
+
if (!literals.some((l) => known.slice(0, runtimeAt < 0 ? known.length : runtimeAt).includes(l))) return false;
|
|
742
|
+
return segmentsMayMatch(known, segments(pattern.path));
|
|
743
|
+
}
|
|
744
|
+
var segments = (p) => p.split("/").filter(Boolean);
|
|
745
|
+
var isRuntime = (seg) => /^\{[^}]*\}$/.test(seg);
|
|
746
|
+
function segmentsMayMatch(site, pattern) {
|
|
747
|
+
if (pattern.length === 0) return site.length === 0;
|
|
748
|
+
const [p, ...prest] = pattern;
|
|
749
|
+
if (p === "**") return segmentsMayMatch(site, prest) || site.length > 0 && segmentsMayMatch(site.slice(1), pattern);
|
|
750
|
+
if (site.length === 0) return false;
|
|
751
|
+
const [s, ...srest] = site;
|
|
752
|
+
if (isRuntime(s)) return srest.length === 0 || segmentsMayMatch(srest, prest);
|
|
753
|
+
if (!pathMatches(`/${s}`, { path: `/${p}` })) return false;
|
|
754
|
+
return segmentsMayMatch(srest, prest);
|
|
755
|
+
}
|
|
718
756
|
function pathMatches(endpoint, pattern) {
|
|
719
757
|
const sample = endpoint.replace(/\{[^}]*\}/g, "\0");
|
|
720
758
|
let re = "";
|
|
@@ -857,7 +895,11 @@ function collectConsts(ctx) {
|
|
|
857
895
|
for (const decl of ctx.sf.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
858
896
|
const list = decl.getVariableStatement()?.getDeclarationKind();
|
|
859
897
|
const init = decl.getInitializer();
|
|
860
|
-
if (list === "const" && init && Node.isIdentifier(decl.getNameNode()))
|
|
898
|
+
if (list === "const" && init && Node.isIdentifier(decl.getNameNode())) {
|
|
899
|
+
ctx.consts.set(decl.getName(), init);
|
|
900
|
+
ctx.declared ??= /* @__PURE__ */ new Map();
|
|
901
|
+
ctx.declared.set(decl.getName(), [...ctx.declared.get(decl.getName()) ?? [], init]);
|
|
902
|
+
}
|
|
861
903
|
}
|
|
862
904
|
}
|
|
863
905
|
function collectClients(ctx) {
|
|
@@ -990,7 +1032,7 @@ function httpSite(ctx, call, lib, url, method, options, params) {
|
|
|
990
1032
|
const surface = emptySurface();
|
|
991
1033
|
surface.headers = headersOf(ctx, options);
|
|
992
1034
|
surface.query = [...query, ...objectEntries(ctx, params)];
|
|
993
|
-
const provider = host
|
|
1035
|
+
const provider = providerOf(host, path13);
|
|
994
1036
|
return {
|
|
995
1037
|
call,
|
|
996
1038
|
options,
|
|
@@ -1172,9 +1214,16 @@ function evalString(ctx, node, depth = 0) {
|
|
|
1172
1214
|
}
|
|
1173
1215
|
}
|
|
1174
1216
|
if (Node.isIdentifier(expr)) {
|
|
1175
|
-
const init = ctx.consts.get(expr.getText());
|
|
1217
|
+
const init = nearestConst(ctx, expr) ?? ctx.consts.get(expr.getText());
|
|
1176
1218
|
return init && init !== node ? evalString(ctx, init, depth + 1) : void 0;
|
|
1177
1219
|
}
|
|
1220
|
+
if (Node.isCallExpression(expr)) {
|
|
1221
|
+
const callee = expr.getExpression();
|
|
1222
|
+
const args = expr.getArguments();
|
|
1223
|
+
if (Node.isPropertyAccessExpression(callee) && callee.getName() === "toString" && args.length === 0) return evalString(ctx, callee.getExpression(), depth + 1);
|
|
1224
|
+
if (Node.isIdentifier(callee) && callee.getText() === "String" && args.length === 1) return evalString(ctx, args[0], depth + 1);
|
|
1225
|
+
}
|
|
1226
|
+
if (Node.isPropertyAccessExpression(expr) && expr.getName() === "href") return evalString(ctx, expr.getExpression(), depth + 1);
|
|
1178
1227
|
if (Node.isNewExpression(expr) && expr.getExpression().getText() === "URL") {
|
|
1179
1228
|
const [p, base] = expr.getArguments();
|
|
1180
1229
|
const pathPart = evalString(ctx, p, depth + 1);
|
|
@@ -1183,6 +1232,18 @@ function evalString(ctx, node, depth = 0) {
|
|
|
1183
1232
|
}
|
|
1184
1233
|
return void 0;
|
|
1185
1234
|
}
|
|
1235
|
+
function nearestConst(ctx, id) {
|
|
1236
|
+
const inits = ctx.declared?.get(id.getText());
|
|
1237
|
+
if (!inits || inits.length < 2) return void 0;
|
|
1238
|
+
let best;
|
|
1239
|
+
for (const init of inits) {
|
|
1240
|
+
const scope = init.getFirstAncestor((a) => Node.isBlock(a) || Node.isSourceFile(a));
|
|
1241
|
+
if (!scope || id.getPos() < scope.getPos() || id.getEnd() > scope.getEnd()) continue;
|
|
1242
|
+
const size = scope.getEnd() - scope.getPos();
|
|
1243
|
+
if (!best || size < best.size) best = { init, size };
|
|
1244
|
+
}
|
|
1245
|
+
return best?.init;
|
|
1246
|
+
}
|
|
1186
1247
|
function placeholder(node) {
|
|
1187
1248
|
const expr = unwrap(node);
|
|
1188
1249
|
if (Node.isIdentifier(expr)) return `{${expr.getText()}}`;
|
|
@@ -1190,6 +1251,53 @@ function placeholder(node) {
|
|
|
1190
1251
|
if (Node.isCallExpression(expr) && expr.getArguments().length === 1) return placeholder(expr.getArguments()[0]);
|
|
1191
1252
|
return "{}";
|
|
1192
1253
|
}
|
|
1254
|
+
function providerOf(host, path13) {
|
|
1255
|
+
if (host) return providerForHost(host)?.id ?? null;
|
|
1256
|
+
return path13 ? providerForPath(path13)?.id ?? null : null;
|
|
1257
|
+
}
|
|
1258
|
+
function urlParts(value2) {
|
|
1259
|
+
const v = value2.trim().replace(/\{\{\s*([^{}]*?)\s*\}\}/g, "{$1}");
|
|
1260
|
+
if (/^(?:https?:)?\/\//i.test(v)) return parseUrl(v);
|
|
1261
|
+
const asPath = "/" + v.replace(/^\{[^}]*\}(?=\/)/, "").replace(/^\/+/, "");
|
|
1262
|
+
if (providerForPath(asPath.split(/[?#]/)[0])) return parseUrl(asPath);
|
|
1263
|
+
return void 0;
|
|
1264
|
+
}
|
|
1265
|
+
function looseUrlSites(sf, file, found) {
|
|
1266
|
+
const ctx = { sf, imports: /* @__PURE__ */ new Map(), consts: /* @__PURE__ */ new Map(), http: /* @__PURE__ */ new Map(), sdk: /* @__PURE__ */ new Map() };
|
|
1267
|
+
collectConsts(ctx);
|
|
1268
|
+
const covered = new Set(found.map((f) => `${f.site.provider} ${f.site.endpoint}`));
|
|
1269
|
+
const inFoundCall = (node) => found.some((f) => node.getPos() >= f.call.getPos() && node.getEnd() <= f.call.getEnd());
|
|
1270
|
+
const candidates = [];
|
|
1271
|
+
for (const node of sf.getDescendants()) {
|
|
1272
|
+
if (!Node.isStringLiteral(node) && !Node.isNoSubstitutionTemplateLiteral(node) && !Node.isTemplateExpression(node) && !isConcat(node)) continue;
|
|
1273
|
+
const parent = node.getParent();
|
|
1274
|
+
if (parent && (Node.isTemplateSpan(parent) || isConcat(parent) || Node.isImportDeclaration(parent) || Node.isExportDeclaration(parent))) continue;
|
|
1275
|
+
if (parent && Node.isPropertyAssignment(parent) && parent.getNameNode() === node) continue;
|
|
1276
|
+
if (inFoundCall(node)) continue;
|
|
1277
|
+
const value2 = evalString(ctx, node);
|
|
1278
|
+
const parts = value2 === void 0 ? void 0 : urlParts(value2);
|
|
1279
|
+
if (!parts?.path || parts.path === "/") continue;
|
|
1280
|
+
const provider = providerOf(parts.host, parts.path);
|
|
1281
|
+
if (provider) candidates.push({ node, host: parts.host, path: parts.path, query: parts.query, provider });
|
|
1282
|
+
}
|
|
1283
|
+
const sites = [];
|
|
1284
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1285
|
+
for (const c of candidates) {
|
|
1286
|
+
const key = `${c.provider} ${c.path}`;
|
|
1287
|
+
if (covered.has(key) || seen.has(key)) continue;
|
|
1288
|
+
if (candidates.some((o) => o.provider === c.provider && o.path !== c.path && o.path.startsWith(`${c.path}/`))) continue;
|
|
1289
|
+
if (found.some((f) => f.site.provider === c.provider && f.site.endpoint?.startsWith(`${c.path}/`))) continue;
|
|
1290
|
+
seen.add(key);
|
|
1291
|
+
const { line, column } = sf.getLineAndColumnAtPos(c.node.getStart());
|
|
1292
|
+
const surface = emptySurface();
|
|
1293
|
+
surface.query = c.query;
|
|
1294
|
+
sites.push({ file, line, column, provider: c.provider, kind: "http", client: "url", method: null, host: c.host, endpoint: c.path, resolved: true, surface });
|
|
1295
|
+
}
|
|
1296
|
+
return sites;
|
|
1297
|
+
}
|
|
1298
|
+
function isConcat(node) {
|
|
1299
|
+
return Node.isBinaryExpression(node) && node.getOperatorToken().getKind() === SyntaxKind.PlusToken;
|
|
1300
|
+
}
|
|
1193
1301
|
function joinUrl(base, url) {
|
|
1194
1302
|
if (url === void 0) return void 0;
|
|
1195
1303
|
if (!base || /^[a-z]+:\/\//i.test(url)) return url;
|
|
@@ -1583,6 +1691,7 @@ function extractPython(source, file) {
|
|
|
1583
1691
|
collectConsts2(root, ctx);
|
|
1584
1692
|
collectClients2(root, ctx);
|
|
1585
1693
|
const sites = [];
|
|
1694
|
+
const calls = [];
|
|
1586
1695
|
for (const call of root.descendantsOfType("call")) {
|
|
1587
1696
|
if (!call) continue;
|
|
1588
1697
|
const site = httpCall(ctx, call) ?? sdkCall(ctx, call);
|
|
@@ -1592,8 +1701,9 @@ function extractPython(source, file) {
|
|
|
1592
1701
|
site.column = call.startPosition.column + 1;
|
|
1593
1702
|
addResponseSurface2(call, site);
|
|
1594
1703
|
sites.push(site);
|
|
1704
|
+
calls.push(call);
|
|
1595
1705
|
}
|
|
1596
|
-
return sites;
|
|
1706
|
+
return [...sites, ...looseUrls(ctx, root, file, sites, calls)];
|
|
1597
1707
|
} finally {
|
|
1598
1708
|
tree.delete();
|
|
1599
1709
|
}
|
|
@@ -1698,7 +1808,7 @@ function httpCall(ctx, call) {
|
|
|
1698
1808
|
const surface = emptySurface2();
|
|
1699
1809
|
surface.headers = [...binding.kind === "http-client" ? binding.headers : [], ...dictEntries(ctx, keyword(args, "headers"))].map((h) => ({ ...h, name: h.name.toLowerCase() }));
|
|
1700
1810
|
surface.query = [...query, ...dictEntries(ctx, keyword(args, "params"))];
|
|
1701
|
-
const provider = host
|
|
1811
|
+
const provider = providerOf(host, path13);
|
|
1702
1812
|
return { file: "", line: 0, column: 0, provider, kind: "http", client: binding.lib, method, host, endpoint: path13, resolved: Boolean(provider && path13 && method), surface };
|
|
1703
1813
|
}
|
|
1704
1814
|
function sdkCall(ctx, call) {
|
|
@@ -1983,7 +2093,7 @@ function evalString2(ctx, node, depth = 0) {
|
|
|
1983
2093
|
return (l ?? placeholder2(n.childForFieldName("left") ?? void 0)) + (r ?? placeholder2(n.childForFieldName("right") ?? void 0));
|
|
1984
2094
|
}
|
|
1985
2095
|
if (n.type === "identifier") {
|
|
1986
|
-
const init = ctx.consts.get(n.text);
|
|
2096
|
+
const init = localAssignment(n) ?? ctx.consts.get(n.text);
|
|
1987
2097
|
return init && init.id !== node.id ? evalString2(ctx, init, depth + 1) : void 0;
|
|
1988
2098
|
}
|
|
1989
2099
|
if (n.type === "call") {
|
|
@@ -1993,6 +2103,52 @@ function evalString2(ctx, node, depth = 0) {
|
|
|
1993
2103
|
}
|
|
1994
2104
|
return void 0;
|
|
1995
2105
|
}
|
|
2106
|
+
function localAssignment(use3) {
|
|
2107
|
+
let scope = use3.parent;
|
|
2108
|
+
while (scope && scope.type !== "function_definition" && scope.type !== "module") scope = scope.parent;
|
|
2109
|
+
if (!scope || scope.type === "module") return void 0;
|
|
2110
|
+
let last;
|
|
2111
|
+
for (const assign of scope.descendantsOfType("assignment")) {
|
|
2112
|
+
if (!assign || assign.endIndex > use3.startIndex) continue;
|
|
2113
|
+
const left = assign.childForFieldName("left");
|
|
2114
|
+
if (left?.type !== "identifier" || left.text !== use3.text) continue;
|
|
2115
|
+
let owner = assign.parent;
|
|
2116
|
+
while (owner && owner.type !== "function_definition") owner = owner.parent;
|
|
2117
|
+
if (owner?.id !== scope.id) continue;
|
|
2118
|
+
last = assign.childForFieldName("right") ?? void 0;
|
|
2119
|
+
}
|
|
2120
|
+
return last;
|
|
2121
|
+
}
|
|
2122
|
+
function looseUrls(ctx, root, file, sites, calls) {
|
|
2123
|
+
const covered = new Set(sites.map((s) => `${s.provider} ${s.endpoint}`));
|
|
2124
|
+
const inCall = (n) => calls.some((c) => n.startIndex >= c.startIndex && n.endIndex <= c.endIndex);
|
|
2125
|
+
const candidates = [];
|
|
2126
|
+
for (const node of root.descendantsOfType(["string", "concatenated_string", "binary_operator"])) {
|
|
2127
|
+
if (!node) continue;
|
|
2128
|
+
if (node.type === "binary_operator" && node.childForFieldName("operator")?.text !== "+") continue;
|
|
2129
|
+
const p = node.parent;
|
|
2130
|
+
if (p && (p.type === "concatenated_string" || p.type === "interpolation" || p.type === "expression_statement" || p.type === "binary_operator" && p.childForFieldName("operator")?.text === "+")) continue;
|
|
2131
|
+
if (inCall(node)) continue;
|
|
2132
|
+
const value2 = evalString2(ctx, node);
|
|
2133
|
+
const parts = value2 === void 0 ? void 0 : urlParts(value2);
|
|
2134
|
+
if (!parts?.path || parts.path === "/") continue;
|
|
2135
|
+
const provider = providerOf(parts.host, parts.path);
|
|
2136
|
+
if (provider) candidates.push({ node, host: parts.host, path: parts.path, query: parts.query, provider });
|
|
2137
|
+
}
|
|
2138
|
+
const out = [];
|
|
2139
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2140
|
+
for (const c of candidates) {
|
|
2141
|
+
const key = `${c.provider} ${c.path}`;
|
|
2142
|
+
if (covered.has(key) || seen.has(key)) continue;
|
|
2143
|
+
if (candidates.some((o) => o.provider === c.provider && o.path !== c.path && o.path.startsWith(`${c.path}/`))) continue;
|
|
2144
|
+
if (sites.some((s) => s.provider === c.provider && s.endpoint?.startsWith(`${c.path}/`))) continue;
|
|
2145
|
+
seen.add(key);
|
|
2146
|
+
const surface = emptySurface2();
|
|
2147
|
+
surface.query = c.query;
|
|
2148
|
+
out.push({ file, line: c.node.startPosition.row + 1, column: c.node.startPosition.column + 1, provider: c.provider, kind: "http", client: "url", method: null, host: c.host, endpoint: c.path, resolved: true, surface });
|
|
2149
|
+
}
|
|
2150
|
+
return out;
|
|
2151
|
+
}
|
|
1996
2152
|
function placeholder2(n) {
|
|
1997
2153
|
if (!n) return "{}";
|
|
1998
2154
|
const e = unwrap2(n);
|
|
@@ -2056,16 +2212,19 @@ function extractCallSites(rootDir) {
|
|
|
2056
2212
|
compilerOptions: { allowJs: true }
|
|
2057
2213
|
});
|
|
2058
2214
|
const found = [];
|
|
2215
|
+
const loose = [];
|
|
2059
2216
|
for (const abs of sources) {
|
|
2060
2217
|
const rel = toPosix(path4.relative(root, abs));
|
|
2061
2218
|
const sf = project.createSourceFile(`/${rel}`, readFileSync3(abs, "utf8"), {
|
|
2062
2219
|
overwrite: true,
|
|
2063
2220
|
scriptKind: SCRIPT_KINDS[path4.extname(abs)] ?? ScriptKind.TS
|
|
2064
2221
|
});
|
|
2065
|
-
|
|
2222
|
+
const inFile = extractFromFile(sf, rel);
|
|
2223
|
+
for (const f of inFile) {
|
|
2066
2224
|
addResponseSurface(f);
|
|
2067
2225
|
found.push(f);
|
|
2068
2226
|
}
|
|
2227
|
+
loose.push(...looseUrlSites(sf, rel, inFile));
|
|
2069
2228
|
}
|
|
2070
2229
|
found.sort((a, b) => a.site.file.localeCompare(b.site.file) || a.site.line - b.site.line || a.site.column - b.site.column);
|
|
2071
2230
|
const skipped = [];
|
|
@@ -2082,7 +2241,7 @@ function extractCallSites(rootDir) {
|
|
|
2082
2241
|
skipped.push({ file: rel, reason: err instanceof Error ? err.message : String(err) });
|
|
2083
2242
|
}
|
|
2084
2243
|
}
|
|
2085
|
-
const callSites = [...found.map((f) => f.site), ...pythonSites].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column);
|
|
2244
|
+
const callSites = [...found.map((f) => f.site), ...loose, ...pythonSites].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column);
|
|
2086
2245
|
return {
|
|
2087
2246
|
root,
|
|
2088
2247
|
filesScanned: sources.length + python.length - skipped.length,
|
|
@@ -3860,7 +4019,7 @@ function renderPrBody({ advisory, repair, shapeSource }) {
|
|
|
3860
4019
|
`Migration note: ${advisory.migrationNote}`,
|
|
3861
4020
|
"",
|
|
3862
4021
|
"---",
|
|
3863
|
-
"Opened by [SHAPI](https://
|
|
4022
|
+
"Opened by [SHAPI](https://shapi.dev). A change is kept only when its contract test failed before it and passes after it."
|
|
3864
4023
|
];
|
|
3865
4024
|
return lines.join("\n") + "\n";
|
|
3866
4025
|
}
|
|
@@ -3898,7 +4057,7 @@ function bundledDir(published, monorepo) {
|
|
|
3898
4057
|
}
|
|
3899
4058
|
|
|
3900
4059
|
// packages/cli/src/scan.ts
|
|
3901
|
-
var VERSION = "0.
|
|
4060
|
+
var VERSION = "0.3.0";
|
|
3902
4061
|
var DEFAULT_ADVISORIES = bundledDir("advisories", "advisories");
|
|
3903
4062
|
async function preparePython() {
|
|
3904
4063
|
await loadPythonParser().catch(() => void 0);
|
|
@@ -3925,8 +4084,10 @@ function scan(target, opts) {
|
|
|
3925
4084
|
const advisories = loadAdvisories(opts.advisories ?? DEFAULT_ADVISORIES);
|
|
3926
4085
|
const match = matchAdvisories(lock, advisories, { now });
|
|
3927
4086
|
const failing = failOn === "none" ? [] : match.affected.filter((f) => SEVERITY_RANK[f.advisory.severity] <= SEVERITY_RANK[failOn]);
|
|
4087
|
+
const where = opts.repo ? `--repo ${opts.repo}` : label2;
|
|
3928
4088
|
return {
|
|
3929
4089
|
label: label2,
|
|
4090
|
+
fixTarget: /\s/.test(where) ? JSON.stringify(where) : where,
|
|
3930
4091
|
filesScanned: extracted.filesScanned,
|
|
3931
4092
|
skipped: extracted.skipped,
|
|
3932
4093
|
lock,
|
|
@@ -3960,6 +4121,7 @@ function toJsonReport(o) {
|
|
|
3960
4121
|
filesScanned: o.filesScanned,
|
|
3961
4122
|
affected: o.match.affected.map(finding),
|
|
3962
4123
|
exposed: o.match.exposed.map(finding),
|
|
4124
|
+
possible: o.match.possible.map(finding),
|
|
3963
4125
|
upcoming: o.match.upcoming.map((u) => ({
|
|
3964
4126
|
advisory: u.advisory.id,
|
|
3965
4127
|
provider: u.advisory.provider,
|
|
@@ -4093,13 +4255,19 @@ function originRepo(dir) {
|
|
|
4093
4255
|
|
|
4094
4256
|
// packages/cli/src/report.ts
|
|
4095
4257
|
import { styleText } from "util";
|
|
4258
|
+
var APP_URL = "https://app.shapi.dev";
|
|
4259
|
+
function title(color) {
|
|
4260
|
+
const paint = (style, s) => color ? styleText(style, s) : s;
|
|
4261
|
+
return `${paint("bold", "SHAPI")} ${paint("dim", "third-party API changes, caught and fixed")}`;
|
|
4262
|
+
}
|
|
4096
4263
|
function renderText(o, color) {
|
|
4097
4264
|
const paint = (style, s) => color ? styleText(style, s) : s;
|
|
4098
4265
|
const out = [];
|
|
4099
4266
|
const { lock, match } = o;
|
|
4100
4267
|
const providers = lock.providers;
|
|
4101
4268
|
const unresolved = lock.callSites.filter((c) => !c.resolved).length;
|
|
4102
|
-
out.push(
|
|
4269
|
+
out.push(title(color));
|
|
4270
|
+
out.push(`${paint("bold", "scan")} ${o.label}`);
|
|
4103
4271
|
out.push(
|
|
4104
4272
|
paint(
|
|
4105
4273
|
"dim",
|
|
@@ -4120,6 +4288,11 @@ function renderText(o, color) {
|
|
|
4120
4288
|
if (match.affected.length === 0) out.push(" none");
|
|
4121
4289
|
for (const f of match.affected) out.push(...renderFinding(f, paint));
|
|
4122
4290
|
out.push("");
|
|
4291
|
+
if (match.possible.length > 0) {
|
|
4292
|
+
out.push(paint("bold", `Possibly affected, check these (${match.possible.length})`));
|
|
4293
|
+
for (const f of match.possible) out.push(...renderFinding(f, paint));
|
|
4294
|
+
out.push("");
|
|
4295
|
+
}
|
|
4123
4296
|
if (match.exposed.length > 0) {
|
|
4124
4297
|
out.push(paint("bold", `Exposed, not affected (${match.exposed.length})`));
|
|
4125
4298
|
for (const f of match.exposed) {
|
|
@@ -4137,6 +4310,10 @@ function renderText(o, color) {
|
|
|
4137
4310
|
out.push("");
|
|
4138
4311
|
out.push(paint("dim", `${plural(unresolved, "call site")} could not be fully resolved (see --json or --out api.lock).`));
|
|
4139
4312
|
}
|
|
4313
|
+
out.push("");
|
|
4314
|
+
out.push(paint("bold", "Next"));
|
|
4315
|
+
if (match.affected.length > 0) out.push(` npx shapi-cli fix ${o.fixTarget} ${paint("dim", "fix the affected call sites test-first (nothing changes until you add --write or --pr)")}`);
|
|
4316
|
+
out.push(` ${APP_URL} ${paint("dim", "install the GitHub App: every push scanned, verified fix PRs, and a dashboard")}`);
|
|
4140
4317
|
return out.join("\n") + "\n";
|
|
4141
4318
|
}
|
|
4142
4319
|
var SEVERITY_STYLE = { critical: "red", high: "yellow", medium: "cyan", low: "gray" };
|
|
@@ -4167,7 +4344,11 @@ var processIo = {
|
|
|
4167
4344
|
async function run(argv, io = processIo, fixDeps = {}) {
|
|
4168
4345
|
let exitCode = 0;
|
|
4169
4346
|
await preparePython();
|
|
4170
|
-
const program = new Command2().name("shapi").description("Map the third-party APIs a codebase depends on and flag the breaking changes that hit it.").version(VERSION).
|
|
4347
|
+
const program = new Command2().name("shapi").description("Map the third-party APIs a codebase depends on and flag the breaking changes that hit it.").version(VERSION).addHelpText("beforeAll", `${title(Boolean(io.isTTY))}
|
|
4348
|
+
`).addHelpText("afterAll", `
|
|
4349
|
+
Run it without installing: npx shapi-cli scan .
|
|
4350
|
+
Fix PRs and a dashboard on every push: install the GitHub App at ${APP_URL}
|
|
4351
|
+
`).exitOverride().configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
|
|
4171
4352
|
program.command("scan").description("Scan a repository and report affected call sites").argument("[path]", "directory to scan", ".").option("--json", "print a JSON report (includes the api.lock)").option("--out <file>", "write the api.lock to this file").option("--repo <url>", "clone a git repository (shallow) into a temp dir and scan it").option("--advisories <dir>", "advisory directory (default: the bundled advisories/)").option("--fail-on <severity>", "exit 1 when an affected call site is at least this severe (critical|high|medium|low|none)", "critical").option("--no-color", "disable color").addOption(new Option("--now <date>", "evaluate deadlines as of this date").hideHelp()).action((target, opts) => {
|
|
4172
4353
|
const outcome = scan(opts.repo ? void 0 : target, opts);
|
|
4173
4354
|
if (opts.json) io.stdout(JSON.stringify(toJsonReport(outcome), null, 2) + "\n");
|
|
@@ -4175,8 +4356,13 @@ async function run(argv, io = processIo, fixDeps = {}) {
|
|
|
4175
4356
|
exitCode = outcome.exitCode;
|
|
4176
4357
|
});
|
|
4177
4358
|
program.command("fix").description("Repair affected call sites test-first: generate a contract test, apply the recipe (or Claude), keep the change only if the test passes").argument("[path]", "repository to fix", ".").option("--repo <url>", "clone a GitHub repository (shallow) and fix it; with --pr the PR goes to that repository").option("--advisory <id>", "only this advisory").option("--advisories <dir>", "advisory directory (default: the bundled advisories/)").option("--db <file>", "shape store with canary observations (checked before recordings)", ".shapi/shapes.db").option("--recordings <dir>", "recorded response sequences (default: the bundled fixtures/recordings/)").option("--llm", "ask Claude when no recipe fixes the call site (needs ANTHROPIC_API_KEY)").option("--write", "write the fix and the contract test into the local repository").option("--pr", "open a pull request with the fix and the contract test").addOption(new Option("--now <date>", "evaluate deadlines as of this date").hideHelp()).action(async (target, opts) => {
|
|
4359
|
+
io.stdout(`${title(Boolean(io.isTTY))}
|
|
4360
|
+
`);
|
|
4178
4361
|
const outcomes = await fix(opts.repo ? void 0 : target, opts, { log: (l) => io.stdout(`${l}
|
|
4179
4362
|
`), ...fixDeps });
|
|
4363
|
+
if (outcomes.some((o) => o.result?.status === "fixed") && !opts.write && !opts.pr) io.stdout("\nNothing was changed. Add --write to keep these fixes, or --pr to open pull requests.\n");
|
|
4364
|
+
io.stdout(`Fix PRs and a dashboard on every push: install the GitHub App at ${APP_URL}
|
|
4365
|
+
`);
|
|
4180
4366
|
exitCode = outcomes.every((o) => o.result?.status === "fixed" || o.result?.status === "already-handled") ? 0 : 1;
|
|
4181
4367
|
});
|
|
4182
4368
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shapi-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Find the code that breaks when a third-party API changes, and fix it test-first.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"api",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
],
|
|
15
15
|
"type": "module",
|
|
16
16
|
"bin": {
|
|
17
|
-
"shapi": "bin/shapi.js"
|
|
17
|
+
"shapi": "bin/shapi.js",
|
|
18
|
+
"shapi-cli": "bin/shapi.js"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"bin",
|
|
@@ -28,14 +29,10 @@
|
|
|
28
29
|
"node": ">=22"
|
|
29
30
|
},
|
|
30
31
|
"license": "Apache-2.0",
|
|
31
|
-
"homepage": "https://
|
|
32
|
-
"repository": {
|
|
33
|
-
"type": "git",
|
|
34
|
-
"url": "git+https://github.com/SriVarshini09/YC.git",
|
|
35
|
-
"directory": "packages/cli"
|
|
36
|
-
},
|
|
32
|
+
"homepage": "https://shapi.dev",
|
|
37
33
|
"bugs": {
|
|
38
|
-
"url": "https://github.com/SriVarshini09/
|
|
34
|
+
"url": "https://github.com/SriVarshini09/shapi-advisories/issues",
|
|
35
|
+
"email": "sri@shapi.dev"
|
|
39
36
|
},
|
|
40
37
|
"dependencies": {
|
|
41
38
|
"@anthropic-ai/sdk": "^1.1.0",
|