// Dry-run plans every write command shows the same way, so the owner sees WHAT would change, not only how many // things ("listing: 20 locales" is not a plan). Pure. `tests/dry-plan.test.ts` scans cli/** for --dry branches that // print nothing but a count (docs/pitfalls.md, "Dry runs that print no plan"). import { textTable } from './table'; export interface PlanRow { /** What is changed: a product id, a locale, a file, a build. */ item: string; /** create, update, upload, replace, delete, keep ... */ action: string; /** The concrete change: fields, values, old -> new. */ detail?: string; } /** * Standard dry-run plan: a title line and one aligned row per change. Rows whose action is "keep" are counted in the * title but left out of the table, so a long "nothing to do" list does not hide the real changes. */ export function dryPlan(title: string, rows: PlanRow[]): string { const change = rows.filter(r => r.action !== 'keep'); const kept = rows.length - change.length; const head = `dry run: ${title}: ${change.length ? `${change.length} change(s)` : 'nothing to change'}${kept ? `, ${kept} unchanged` : ''}; nothing written`; if (!change.length) return head; return `${head}\n${textTable(['item', 'action', 'detail'], change.map(r => [r.item, r.action, r.detail ?? '']))}`; } /** A source line that leaves a --dry branch (`if (dry) return` / `if (a.has('dry')) { ...; return; }`) at the top level of a function. */ const DRY = String.raw`(?:dry|(?:ctx\.)?a(?:rgs)?\.has\('dry'\))`; const EXIT = new RegExp(String.raw`^ (?:\} )?if \(${DRY}\) (?:\{ )?(?:.*\b)?return\b`); /** Output that is a plan by itself: the shared table, the request lister, JSON, or printing the content that would be written. */ const RICH = /dryPlan\(|execute\(|printJson\(|describeReq\(|console\.log\((?!['`])|process\.stdout\.write\(/; const OUT = /\b(?:log|done|say|warn|console\.log)\(\s*(?:[A-Za-z_'".]+,\s*)?(['`])(.*)\1/; /** Interpolations that only count or name sections: `${x.length}`, `${n}`, `${Object.keys(listing).join(', ')}`, `${dry ? ...}`. */ const countOnly = (e: string): boolean => /(?:\.length|\.size|\bn|count|total)\s*$/.test(e.trim()) || /Object\.keys\(/.test(e) || /\bdry\s*\?|^\s*(?:TAG|tag)\s*$/.test(e); /** True when the line prints something that names the concrete change (not only a count). */ export function isPlanLine(line: string): boolean { if (RICH.test(line)) return true; const m = OUT.exec(line); if (!m) return false; const parts = [...m[2]!.matchAll(/\$\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g)].map(x => x[1]!); if (parts.some(p => !countOnly(p))) return true; // a literal that spells out the one request ("would POST /v1/analyticsReportRequests {accessType=ONGOING}") return !parts.length && /\bwould\b/.test(m[2]!) && !/nothing|\d+ \w+\(s\)/.test(m[2]!); } /** * --dry exits in one source file that print nothing but a count before them (scanning back to the start of the * enclosing top-level function, at most 40 lines). Returns 1-based line numbers. Pure; heuristic by design. */ export function countOnlyDryExits(source: string): number[] { const lines = source.split('\n'); const out: number[] = []; lines.forEach((l, i) => { if (!EXIT.test(l)) return; let from = i; while (from > 0 && i - from < 40 && !/^(?:export )?(?:async )?function |^\}/.test(lines[from - 1]!)) from--; if (!lines.slice(from, i + 1).some(isPlanLine)) out.push(i + 1); }); return out; }