61 lines
3.4 KiB
TypeScript
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;
|
|
}
|