diff --git a/.changeset/stripe-divergent-api-pins.md b/.changeset/stripe-divergent-api-pins.md new file mode 100644 index 0000000..79ee977 --- /dev/null +++ b/.changeset/stripe-divergent-api-pins.md @@ -0,0 +1,5 @@ +--- +"uptide": patch +--- + +Report Stripe clients pinned to different API versions at the top of `check` and of the PR body, as a decision for the owner: every `new Stripe(...)` in TypeScript and JavaScript sources, scripts included, with the version it speaks after the upgrade (an unpinned client speaks the SDK default). The PR's decisions list, for each client behind the target, the changelog entries it would cross for the resources it calls. No pin is rewritten. diff --git a/packages/cli/src/format-check.ts b/packages/cli/src/format-check.ts index 21d0225..68fcd3f 100644 --- a/packages/cli/src/format-check.ts +++ b/packages/cli/src/format-check.ts @@ -501,6 +501,8 @@ export function formatCheck(report: CheckReport, opts: FormatCheckOptions = {}): const rows = checkRows(report, colors); for (const p of report.packages) if (p.packWarning) lines.push(colors.yellow(`${p.name}: ${p.packWarning}`), ''); + for (const p of report.packages) + if (p.headline) lines.push(colors.yellow(`${p.name}: ${p.headline}`), ''); if (opts.details) { lines.push(formatCheckDetails(report, { color: opts.color, all: opts.all }).trimEnd(), ''); } else { diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index a59152c..f7d2654 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -515,11 +515,18 @@ function planPackages(packages: PackageReport[], opts: CheckOptions): void { for (const p of packages) { if (!['breaking', 'deprecated'].includes(p.status) && p.findings.length === 0) continue; const pack = packsOf(opts).find((candidate) => candidate.name === p.name); + const context = p.planContext && { + from: p.installed, + to: p.target, + includeDeprecated: false, + ...p.planContext, + }; + const headline = context && pack?.headline?.(context); + if (headline) p.headline = headline.line; const plan = planPackage(p, pack, read, p.planContext ?? {}); if (plan.length === 0) continue; p.plan = plan; - if (!p.planContext) continue; - const context = { from: p.installed, to: p.target, includeDeprecated: false, ...p.planContext }; + if (!context) continue; // A pack's own finding is worded once every workspace's evidence is in: the client created // in one package speaks for the fields another reads. if (pack?.describeFinding) @@ -730,6 +737,12 @@ export function mergeAcrossWorkspaces( combined.planContext = { ...contexts[0], evidence: contexts.flatMap((c) => c.evidence ?? []), + // A workspace walk covers its nested ones: one client, one entry. + apiClients: [ + ...new Map( + contexts.flatMap((c) => c.apiClients ?? []).map((c) => [`${c.file}:${c.line}`, c]), + ).values(), + ], }; } const compiled = list.filter((p) => p.compile !== undefined); diff --git a/packages/core/src/domain/report.ts b/packages/core/src/domain/report.ts index 399acda..e53f1ca 100644 --- a/packages/core/src/domain/report.ts +++ b/packages/core/src/domain/report.ts @@ -143,7 +143,10 @@ export interface PackageReport { file: string; line: number; }[]; + apiClients?: { file: string; line: number; version: string | null; resources: string[] }[]; }; + /** One owner decision the pack says first (`MigrationPack.headline`). */ + headline?: string; /** Workspace package this report belongs to, relative to the checked root (`.` for the root itself). */ workspace: string; /** A dependency, or a release group (`@aws-sdk/*`) upgraded together; `members` lists the group. */ diff --git a/packages/core/src/fix/fix.test.ts b/packages/core/src/fix/fix.test.ts index 4aa1569..9fe15a4 100644 --- a/packages/core/src/fix/fix.test.ts +++ b/packages/core/src/fix/fix.test.ts @@ -3,6 +3,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, describe, expect, it, vi } from 'vitest'; import type { ProgressEvent } from '../domain/progress.js'; +import { stripePack } from '../packs/stripe/index.js'; +import { apiClients } from '../packs/stripe/relevance.js'; import { zodPack } from '../packs/zod/index.js'; import { published, version } from '../version.js'; import { git } from './process.js'; @@ -1353,3 +1355,33 @@ it('carries pre-edit inventory decisions through fix, PR rendering and reverify' const refreshed = await reverify({ cwd: root, pack }, services); expect(refreshed.decisions).toEqual(result.decisions); }, 15000); + +it('says the headline only while the tree still shows it: re-verify drops it once the pins agree', async () => { + const { root, services } = fixture(); + const pin = (version: string) => + `import Stripe from 'stripe';\nexport const s = new Stripe('k', { apiVersion: '${version}' });\n`; + writeFileSync(join(root, 'app.js'), pin('2026-09-30.endive')); + writeFileSync(join(root, 'sync.js'), pin('2022-11-15')); + git(root, 'add', '.'); + git(root, 'commit', '-q', '-m', 'two clients'); + const apiVersions = { from: '2025-02-24.acacia', to: '2026-09-30.endive' }; + const pack = { + ...zodPack, + scanContext: (dir: string, workspaces: string[]) => ({ + apiClients: apiClients(dir, workspaces), + }), + headline: (context: import('../packs/types.js').PackContext) => + stripePack.headline?.({ ...context, apiVersions }), + }; + const result = await fix({ cwd: root, only: 'zod', pack }, services); + expect(result.headline?.line).toContain('2 clients on different API versions'); + expect(readFileSync(result.prBody, 'utf8')).toContain('> **Decision for you:** 2 clients'); + // The owner aligns the script in a later commit; the stored run still has the old line. + writeFileSync(join(root, 'sync.js'), pin('2026-09-30.endive')); + git(root, 'commit', '-q', '-am', 'align the sync script'); + const refreshed = await reverify({ cwd: root, pack }, services); + expect(refreshed.headline).toBeUndefined(); + const body = readFileSync(refreshed.prBody, 'utf8'); + expect(body).not.toContain('Decision for you'); + expect(body).not.toContain('different API versions'); +}, 20000); diff --git a/packages/core/src/fix/report.ts b/packages/core/src/fix/report.ts index 7c2f56d..193a2fc 100644 --- a/packages/core/src/fix/report.ts +++ b/packages/core/src/fix/report.ts @@ -694,6 +694,7 @@ export function renderMigration( const unhealthy = baselineHeadline(baselineHealthOf(report)); if (mode === 'full' && unhealthy && !report.verificationPending) lines.push(`> ⚠️ ${sentenceCase(unhealthy)}.`, ''); + if (report.headline) lines.push(`> **Decision for you:** ${report.headline.line}`, ''); if (mode === 'full' && report.mode === 'pin') { const manual = report.sites.filter((s) => s.outcome === 'manual').length; lines.push( @@ -705,6 +706,7 @@ export function renderMigration( inputs = bs.filter((b) => !unchecked(b) && !differs(b)).reduce((n, b) => n + b.inputs, 0); const ready = report.verification.passed && + !report.headline && !noPackBlockers(report).length && migrationRisk(report).level !== 'High' && !report.sites.some((s) => s.outcome === 'manual') && @@ -1043,7 +1045,11 @@ export function renderMigration( decisions.push( '- Coordinate the API-version rollout with dashboard webhook endpoints and other services.', ); - decisions.push(...agentDecisionLines(report), ...(report.decisions ?? [])); + decisions.push( + ...(report.headline?.decision ?? []), + ...agentDecisionLines(report), + ...(report.decisions ?? []), + ); if (decisions.length) lines.push('### Decisions for you', '', ...decisions, ''); lines.push( '
Verification details', diff --git a/packages/core/src/fix/reverify.ts b/packages/core/src/fix/reverify.ts index 33ab0b1..e2f3c08 100644 --- a/packages/core/src/fix/reverify.ts +++ b/packages/core/src/fix/reverify.ts @@ -147,14 +147,21 @@ export async function reverify( ); const unverified = typeResolutionFailure(root, report.verification.baseline); const rules = [...new Set(sites.map((s) => s.rule).filter((r): r is string => !!r))]; + // Said first only while it is true of this tree: a stored line may describe pins since aligned. + const apiVersions = + report.packContext?.apiVersions ?? + (report.apiChanges && { from: report.apiChanges.from, to: report.apiChanges.to }); + const headline = pack.headline?.({ ...context, ...(apiVersions ? { apiVersions } : {}) }); const decisions = [ ...new Set([ ...(report.decisions ?? []), ...(packDecisions(pack, context, rules, followed) ?? []), ]), ]; + const { headline: _stored, ...kept } = report; const result: FixReport = { - ...report, + ...kept, + ...(headline ? { headline } : {}), repo: root, ...(options.tool ?? uptideVersionInfo()), verifiedAt: new Date().toISOString(), diff --git a/packages/core/src/fix/run.ts b/packages/core/src/fix/run.ts index de6a7a6..1f7a4e5 100644 --- a/packages/core/src/fix/run.ts +++ b/packages/core/src/fix/run.ts @@ -824,6 +824,12 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise s.rule).filter((r): r is string => !!r))]; result.packContext = packContext; refreshReview(result, pack); + // A pack that says something first reads the branch as the edits left it, not the base. + const settled = pack.headline + ? { ...packContext, ...pack.scanContext?.(root, workspaces) } + : packContext; + const headline = pack.headline?.(settled); + if (headline) result.headline = headline; const decisions = packDecisions(pack, packContext, rules, followed); if (decisions?.length) result.decisions = decisions; const remote = originOf(root); diff --git a/packages/core/src/fix/types.ts b/packages/core/src/fix/types.ts index cbba232..59f358b 100644 --- a/packages/core/src/fix/types.ts +++ b/packages/core/src/fix/types.ts @@ -1,6 +1,6 @@ import type { Finding, ImportersNotAnalyzed, Tier } from '../domain/report.js'; import type { Provider } from '../llm/types.js'; -import type { ApiChangeFacts, PackContext } from '../packs/types.js'; +import type { ApiChangeFacts, Headline, PackContext } from '../packs/types.js'; import type { BaselineHealth } from './baseline.js'; import type { BehaviorResult } from './behavior.js'; import type { InstallReport } from './managers/upgrade.js'; @@ -316,6 +316,8 @@ export interface FixReport { apiChanges?: ApiChangeFacts; /** Pack-provided decisions from facts of this repository, rendered under "Decisions for you". */ decisions?: string[]; + /** One owner decision the pack says first, at the top of the PR body (`MigrationPack.headline`). */ + headline?: Headline; /** The user's checkout the run was made for; `repo` is the private clone it ran in. */ source?: string; /** The temporary clone the run used: removed when no longer needed, kept (with why) otherwise. */ diff --git a/packages/core/src/packs/stripe/index.ts b/packages/core/src/packs/stripe/index.ts index 10dfdef..c2baac3 100644 --- a/packages/core/src/packs/stripe/index.ts +++ b/packages/core/src/packs/stripe/index.ts @@ -10,6 +10,8 @@ import type { PackContext, SharedHelper } from '../types.js'; import { between, type StripeCatalog } from './changelog.js'; import catalog from './changelog.v1.json' with { type: 'json' }; import { + type ApiClient, + apiClients, calledResources, clientPins, evidencedEntries, @@ -176,6 +178,58 @@ export function stripeConcerns(findings: Finding[], context: PackContext) { }, }; } +/** + * Clients that ask for different API versions once the SDK is at the target: an unpinned one + * speaks the target's default. Undefined when they agree, or when the target is unknown. + */ +function divergentClients(context: PackContext): (ApiClient & { speaks: string })[] | undefined { + const to = context.apiVersions?.to; + if (!to) return undefined; + const clients = (context.apiClients ?? []).map((c) => ({ ...c, speaks: c.version ?? to })); + return new Set(clients.map((c) => c.speaks)).size > 1 ? clients : undefined; +} +const CHANGELOG_SHOWN = 10; +/** `lib/stripe.ts:5 SDK default: (installed) → (after upgrade)`, or its pin. */ +function clientVersion(c: ApiClient, context: PackContext, quote = ''): string { + const q = (text: string) => `${quote}${text}${quote}`; + const at = q(`${c.file}:${c.line}`); + const api = context.apiVersions; + return c.version !== null + ? `${at} pinned ${q(c.version)}` + : `${at} SDK default: ${q(api?.from ?? 'unknown')} (installed) → ${q(api?.to ?? 'unknown')} (after upgrade)`; +} +/** Which version each client pins is the owner's: the run never moves a pin the bump did not. */ +function divergenceDecision(context: PackContext): string[] { + const clients = divergentClients(context); + const to = context.apiVersions?.to; + if (!clients || !to) return []; + const lines = [ + `- Stripe clients pin different API versions: ${clients.map((c) => clientVersion(c, context, '`')).join('; ')}. Which version each client pins is your decision; this PR leaves a pin the upgrade did not require where it is.`, + ]; + for (const c of clients) { + if (c.speaks >= to) continue; + const entries = between(catalog as StripeCatalog, c.speaks, to); + const touched = resourceEntries(entries, [], c.resources); + const breaking = touched.filter((e) => e.breaking); + const link = `[Stripe's changelog](https://docs.stripe.com/changelog), ${c.speaks} → ${to}`; + if (!touched.length) { + lines.push( + ` - Aligning \`${c.file}\` on \`${to}\` crosses ${entries.length} API changes; none names a resource its calls were recognised for: review ${link}.`, + ); + continue; + } + lines.push( + ` - Aligning \`${c.file}\` on \`${to}\` crosses ${touched.length} API changes to what it calls (${c.resources.map((r) => r.replaceAll('_', ' ')).join(', ')}), ${breaking.length} breaking${breaking.length ? ':' : '.'}`, + ...breaking + .slice(0, CHANGELOG_SHOWN) + .map((e) => ` - ${e.version}: [${e.title}](${e.url}) — ${e.products}`), + ...(breaking.length > CHANGELOG_SHOWN || touched.length > breaking.length + ? [` - The rest: ${link}.`] + : []), + ); + } + return lines; +} const SDK_GUIDE = 'Migrate the reported Stripe SDK usage while preserving payment, subscription and webhook semantics. Use the target diagnostic and checked-in API changelog. Never remove signature verification, change monetary amounts/currency, or introduce a cast to hide incompatible data.'; const meta: PackMeta = { @@ -521,6 +575,16 @@ export const stripePack: Pack = { return { apiVersions: { from: sdkApiVersion(installedDir), to: sdkApiVersion(targetDir) }, evidence: stripeEvidence(root, workspace, usages).evidence, + apiClients: apiClients(root, [workspace]), + }; + }, + scanContext: (root, workspaces) => ({ apiClients: apiClients(root, workspaces) }), + headline(context) { + const clients = divergentClients(context); + if (!clients) return undefined; + return { + line: `${clients.length} clients on different API versions: ${clients.map((c) => clientVersion(c, context)).join('; ')}. Which version each one pins is your decision.`, + decision: divergenceDecision(context), }; }, planNote(rule, context) { diff --git a/packages/core/src/packs/stripe/relevance.ts b/packages/core/src/packs/stripe/relevance.ts index bc96e76..dee1e22 100644 --- a/packages/core/src/packs/stripe/relevance.ts +++ b/packages/core/src/packs/stripe/relevance.ts @@ -1,11 +1,11 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; -import { Node, Project, SyntaxKind } from 'ts-morph'; +import { Node, Project, SyntaxKind, ts } from 'ts-morph'; import { createTypescriptAdapter } from '../../adapters/typescript/index.js'; import type { Finding } from '../../domain/report.js'; import { type Usage, usagePaths } from '../../domain/usage.js'; import { importSpecifier } from '../import-specifier.js'; -import type { FollowUp, MigrationEvidence, SharedHelper } from '../types.js'; +import type { FollowUp, MigrationEvidence, PackContext, SharedHelper } from '../types.js'; import type { StripeEntry } from './changelog.js'; export interface StripeTouches { @@ -377,6 +377,113 @@ export function unpinnedClients( } return found; } +/** A `new Stripe(...)`, the version it pins (`null`: the SDK default) and what its file calls. */ +export type ApiClient = NonNullable[number]; +/** + * Every `new Stripe(...)` in the workspaces' TS and JS sources, scripts included, with the + * version it pins: a literal, a top-level `const` of the same file, or none (the SDK default). + * Options built elsewhere (a variable, a spread, an imported constant) leave the client out: + * its version is unknown here, and a guess would invent a divergence. + */ +export function apiClients(root: string, workspaces: string[]): ApiClient[] { + const files: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (SKIPPED.has(entry.name)) continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) walk(path); + else if (SOURCE.test(entry.name) && !entry.name.endsWith('.d.ts')) { + const text = readFileSync(path, 'utf8'); + // Only a file that names the package constructs its client (under an alias too). + if (/\bnew\s+[\w$.]+\s*\(/.test(text) && /['"]stripe['"]/.test(text)) files.push(path); + } + } + }; + for (const workspace of workspaces) { + // The root holds scripts of its own (`sync-stripe.js`): walked too, nested workspaces + // included, so a client is found once whichever workspaces the run names. + try { + walk(join(root, workspace)); + } catch { + // An unreadable directory has no client to report. + } + } + if (files.length === 0) return []; + // The repository's own node_modules on disk: the checker resolves `stripe` from each file, + // `require` in JS included, so a receiver is a Stripe client by its type, whatever its name. + const project = new Project({ + skipAddingFilesFromTsConfig: true, + compilerOptions: { + allowJs: true, + noEmit: true, + skipLibCheck: true, + esModuleInterop: true, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + }, + }); + const sources = files.map((f) => project.addSourceFileAtPath(f)); + const isClient = (node: Node): boolean => { + const symbol = node.getType().getSymbol(); + return ( + symbol?.getName() === 'Stripe' && + symbol + .getDeclarations() + .some((d) => /[\\/]node_modules[\\/]stripe[\\/]/.test(d.getSourceFile().getFilePath())) + ); + }; + const literal = (node: Node | undefined): string | undefined => + node && (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) + ? node.getLiteralValue() + : undefined; + const found: ApiClient[] = []; + for (const source of sources) { + const constant = (name: string) => + literal(source.getVariableDeclaration(name)?.getInitializer()); + const clients = source + .getDescendantsOfKind(SyntaxKind.NewExpression) + .filter((n) => n.getExpression().getText() === 'Stripe' || isClient(n)); + if (clients.length === 0) continue; + // Typed: every member read on a Stripe instance. Untyped (no `stripe` types reachable): + // ponytail: receivers named like a client; one kept under another name adds no resource, + // so its changelog list is the empty one, never a wrong one. + const paths = clients.some(isClient) + ? source + .getDescendantsOfKind(SyntaxKind.PropertyAccessExpression) + .filter((p) => isClient(p.getExpression())) + .map((p) => p.getName()) + : [...source.getFullText().matchAll(/\b[\w$]*[Ss]tripe[\w$]*((?:\.[a-z]\w*)+)\s*\(/g)].map( + (m) => m[1] as string, + ); + const resources = usedResources(paths, []); + for (const node of clients) { + const options = node.getArguments()[1]; + let version: string | null | undefined = null; + if (options !== undefined) { + if (!Node.isObjectLiteralExpression(options)) continue; + const pin = options.getProperty('apiVersion'); + if (pin === undefined) { + if (options.getProperties().some((p) => Node.isSpreadAssignment(p))) continue; + } else if (Node.isShorthandPropertyAssignment(pin)) version = constant('apiVersion'); + else if (Node.isPropertyAssignment(pin)) { + const value = pin.getInitializer(); + version = + literal(value) ?? (Node.isIdentifier(value) ? constant(value.getText()) : undefined); + } else continue; + if (version === undefined) continue; + } + found.push({ + file: relative(root, source.getFilePath()), + line: node.getStartLineNumber(), + version, + resources, + }); + } + } + return [...new Map(found.map((c) => [`${c.file}:${c.line}`, c])).values()].sort( + (a, b) => a.file.localeCompare(b.file) || a.line - b.line, + ); +} /** * `api_version: "2026-07-29.dahlia"` inside payload objects: webhook fixtures and recorded * events. They mirror what the endpoint is configured to send, so an SDK upgrade must not diff --git a/packages/core/src/packs/stripe/stripe.test.ts b/packages/core/src/packs/stripe/stripe.test.ts index c5c7086..30f93e3 100644 --- a/packages/core/src/packs/stripe/stripe.test.ts +++ b/packages/core/src/packs/stripe/stripe.test.ts @@ -16,6 +16,7 @@ import { } from './index.js'; import { aboveDeclaration, + apiClients, calledResources, clientPins, evidencedEntries, @@ -1091,3 +1092,93 @@ it('places subscriptionPeriod in the file of the site when no client module is i rmSync(dir, { recursive: true, force: true }); } }); + +it('reports clients on different API versions first, as a decision, with the changelog the lagging one would cross', () => { + // The layout of boxyhq/saas-starter-kit: the app's client on the SDK default, a JS sync + // script pinned years earlier. No stripe types installed: resources come from the names. + const dir = mkdtempSync(join(tmpdir(), 'stripe-divergent-')); + try { + mkdirSync(join(dir, 'lib')); + writeFileSync( + join(dir, 'lib/stripe.ts'), + "import Stripe from 'stripe';\nimport env from './env';\n\nexport const stripe = new Stripe(env.stripe.secretKey ?? '');\n", + ); + writeFileSync( + join(dir, 'sync-stripe.js'), + "const Stripe = require('stripe');\nasync function sync() {\n const stripe = getStripeInstance();\n await Promise.all([stripe.products.list({ active: true }), stripe.prices.list({ active: true })]);\n}\nfunction getStripeInstance() {\n return new Stripe(process.env.STRIPE_SECRET_KEY || '', {\n apiVersion: '2022-11-15',\n });\n}\n", + ); + // Options built elsewhere: the version is unknown, so the client is left out. + writeFileSync( + join(dir, 'lib/other.ts'), + "import Stripe from 'stripe';\nimport { options } from './options';\nexport const other = new Stripe('k', { ...options });\n", + ); + const clients = apiClients(dir, ['.']); + expect(clients).toEqual([ + { file: 'lib/stripe.ts', line: 4, version: null, resources: [] }, + { file: 'sync-stripe.js', line: 7, version: '2022-11-15', resources: ['price', 'product'] }, + ]); + const to = catalog.versions.filter((v) => !v.endsWith('.preview')).at(-1) as string; + const context = { + from: '17.7.0', + to: '23.0.0', + includeDeprecated: false, + apiVersions: { from: '2025-02-24.acacia', to }, + apiClients: clients, + }; + const headline = stripePack.headline?.(context); + // A default client says what it spoke before and what it speaks after the upgrade. + expect(headline?.line).toBe( + `2 clients on different API versions: lib/stripe.ts:4 SDK default: 2025-02-24.acacia (installed) → ${to} (after upgrade); sync-stripe.js:7 pinned 2022-11-15. Which version each one pins is your decision.`, + ); + const [decision, align, ...entries] = headline?.decision ?? []; + expect(decision).toMatch( + /^- Stripe clients pin different API versions: .*this PR leaves a pin/, + ); + expect(align).toMatch( + new RegExp( + `^ - Aligning \`sync-stripe.js\` on \`${to.replace('.', '\\.')}\` crosses \\d+ API changes to what it calls \\(price, product\\), \\d+ breaking`, + ), + ); + expect(entries.length).toBeGreaterThan(0); + expect(entries.at(-1)).toContain( + "[Stripe's changelog](https://docs.stripe.com/changelog), 2022-11-15", + ); + // The decision lives with the headline, so re-verifying recomputes both; never stored twice. + expect(stripePack.decisions?.(context, []).join('\n')).not.toContain('different API versions'); + // Clients that agree, or one client alone, say nothing. + expect(stripePack.headline?.({ ...context, apiClients: clients.slice(0, 1) })).toBeUndefined(); + expect( + stripePack.headline?.({ + ...context, + apiClients: [clients[0], { ...clients[1], version: to }] as typeof clients, + }), + ).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +it('finds what a client calls by its type, whatever the variable is named', () => { + const dir = mkdtempSync(join(tmpdir(), 'stripe-typed-')); + try { + mkdirSync(join(dir, 'node_modules/stripe'), { recursive: true }); + writeFileSync( + join(dir, 'node_modules/stripe/package.json'), + JSON.stringify({ name: 'stripe', version: '17.7.0', types: 'index.d.ts' }), + ); + writeFileSync( + join(dir, 'node_modules/stripe/index.d.ts'), + 'type Api = { list(params?: object): Promise };\ndeclare class Stripe {\n constructor(key: string, config?: { apiVersion?: string });\n prices: Api;\n subscriptions: Api;\n}\nexport = Stripe;\n', + ); + // `client` is no Stripe-like name; `stripeCache` is one, but not a Stripe client. + writeFileSync( + join(dir, 'billing.ts'), + "import Stripe from 'stripe';\nconst client = new Stripe('k', { apiVersion: '2022-11-15' });\nconst stripeCache = { subscriptions: { list: () => [] } };\nexport const load = () => [client.prices.list(), stripeCache.subscriptions.list()];\n", + ); + expect(apiClients(dir, ['.'])).toEqual([ + { file: 'billing.ts', line: 2, version: '2022-11-15', resources: ['price'] }, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/core/src/packs/types.ts b/packages/core/src/packs/types.ts index 2bb4886..74e4da0 100644 --- a/packages/core/src/packs/types.ts +++ b/packages/core/src/packs/types.ts @@ -27,6 +27,12 @@ export interface SharedHelper { */ imports?: Record; } +/** What a pack says first: one line, and the decision lines that go with it. */ +export interface Headline { + line: string; + /** Rendered first under "Decisions for you"; recomputed with the line, never kept stale. */ + decision: string[]; +} export interface PackContext { /** Pre-edit inventory, with repository-relative paths and file contents. */ inventory?: { file: string; text: string }[]; @@ -41,6 +47,8 @@ export interface PackContext { apiVersions?: { from: string; to: string }; /** `api_version` literals in payload objects (webhook fixtures), with where they are. */ payloadVersions?: { file: string; line: number; value: string }[]; + /** Each `new Stripe(...)` and the API version it pins (`null`: the SDK default). */ + apiClients?: { file: string; line: number; version: string | null; resources: string[] }[]; /** Places that depend on the dependency's default messages, which the target words differently. */ defaultMessages?: DefaultMessageSite[]; } @@ -235,7 +243,12 @@ export interface MigrationPack { installedDir: string; targetDir: string; usages: Usage[]; - }): Pick; + }): Pick; + /** + * One owner decision said before anything else, at the top of `check` and of the PR body + * (stripe: clients pinned to different API versions). Never an edit. + */ + headline?(context: PackContext): Headline | undefined; /** One line under a planned rule in `check` (stripe: how many API changes touch the code). */ planNote?(rule: string, context: PackContext): string | undefined; /**