Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stripe-divergent-api-pins.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/cli/src/format-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 15 additions & 2 deletions packages/core/src/check/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/domain/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/fix/fix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
8 changes: 7 additions & 1 deletion packages/core/src/fix/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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') &&
Expand Down Expand Up @@ -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(
'<details><summary>Verification details</summary>',
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/fix/reverify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/fix/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,12 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise<F
const rules = [...new Set(sites.map((s) => 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);
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/fix/types.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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. */
Expand Down
64 changes: 64 additions & 0 deletions packages/core/src/packs/stripe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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> (installed) → <target> (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 = {
Expand Down Expand Up @@ -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) {
Expand Down
111 changes: 109 additions & 2 deletions packages/core/src/packs/stripe/relevance.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<PackContext['apiClients']>[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
Expand Down
Loading
Loading