storebound/cli/lib/dry.ts

61 lines
3.4 KiB
TypeScript

// 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;
}