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.
Files changed (3) hide show
  1. package/README.md +6 -4
  2. package/dist/index.js +200 -14
  3. 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://srivarshini09.github.io/YC/
45
- - The GitHub App watches your repositories, posts a check on each push, and opens verified fix pull requests: https://github.com/SriVarshini09/YC
46
- - Report a problem: https://github.com/SriVarshini09/YC/issues
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)) continue;
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())) ctx.consts.set(decl.getName(), init);
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 ? providerForHost(host)?.id ?? null : null;
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 ? providerForHost(host)?.id ?? null : null;
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
- for (const f of extractFromFile(sf, rel)) {
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://github.com/SriVarshini09/YC). A change is kept only when its contract test failed before it and passes after it."
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.2.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(`${paint("bold", "SHAPI scan")} ${o.label}`);
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).exitOverride().configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
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.2.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://github.com/SriVarshini09/YC#readme",
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/YC/issues"
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",