/** * cli-flags.mjs — shared CLI argv helpers. * * Two related but distinct defects, both born from scripts hand-rolling their * own argv parsing and independently getting it wrong the same way: * * `args.indexOf('--flag')` returns -1 for `--flag=value`, so a lookup written * that way silently DISCARDS the value the caller supplied and the script runs * with its default instead — reporting a result for inputs nobody asked for, * with no warning. That is the defect #2401 described for weekly-digest * (`--from=…` digesting the wrong week) and #2402 fixed there and in * company-history.mjs. `flagValue`/`hasFlag` are the shared fix. * * An unrecognized or mistyped flag (`--dryrun` instead of `--dry-run`) * silently falls through to default/live behavior instead of failing fast — * fixed independently, in the identical shape, in scan-ats-full.mjs * (#1633/#1635), reply-watch.mjs (#2743/#2745) and dedup-tracker.mjs * (#2744/#2746). `validateFlags` is that shape in one place (#2775). */ /** * Value of a value-taking flag, accepting both `--flag value` and * `--flag=value`. * * @param {string[]} args - argv slice. * @param {string} flag - Flag name including leading dashes, e.g. '--file'. * @returns {string|undefined} The value, or undefined when the flag is absent. */ export function flagValue(args, flag) { if (!Array.isArray(args)) return undefined; // `--flag=value` first: indexOf() cannot see it, so checking it second would // let the space-separated lookup fall through and drop the value. const eq = args.find((a) => typeof a === 'string' && a.startsWith(`${flag}=`)); if (eq !== undefined) return eq.slice(flag.length + 1); const idx = args.indexOf(flag); if (idx === -1) return undefined; return args[idx + 1]; } /** * Whether the flag appears at all, in either form. * * `flagValue` alone cannot tell an ABSENT flag from one supplied without a * value: both give `undefined`. A caller that treats the second as absent * silently falls back to its default — for `test-all --only` that meant * running the whole suite instead of refusing a filter it could not honour. * Pair the two whenever a missing value must be a usage error. * * @param {string[]} args - argv slice. * @param {string} flag - Flag name including leading dashes. * @returns {boolean} */ export function hasFlag(args, flag) { if (!Array.isArray(args)) return false; return args.some((a) => typeof a === 'string' && (a === flag || a.startsWith(`${flag}=`))); } /** * Reject an unrecognized CLI flag before it can fall through to default/live * behavior, then handle --help/-h. * * Three scripts hand-rolled this exact shape independently — scan-ats-full.mjs * (#1633/#1635), reply-watch.mjs (#2743/#2745), dedup-tracker.mjs * (#2744/#2746) — because an unrecognized or mistyped flag (e.g. `--dryrun` * for `--dry-run`) was silently ignored and the script ran with its default, * live behavior instead of failing fast. #2775 collects the pattern here so * the next script does not have to rediscover it; scan.mjs's own instance * (#2270 — `--help` triggered a full live scan) is fixed by calling this * directly rather than adding a fourth hand-rolled copy. * * Order matters: the unrecognized-flag check runs BEFORE the --help check. * CodeRabbit caught the reverse ordering as a bug on both #2745 and #2746 — * `--help --bogus` must still error, not exit 0 having never looked at * `--bogus` at all. * * @param {string[]} args - argv slice. * @param {string[]} knownFlags - Every flag name this script accepts, * leading dashes included (e.g. `['--dry-run', '--help', '-h']`). * @param {string} usage - Usage text printed for `--help`/`-h`. * @param {{valueFlags?: string[], requireOperand?: boolean}} [options] - * `valueFlags` lists flags whose space-separated value is the NEXT argv token * (e.g. `--since 7`). Without this, a value that itself starts with `-` (e.g. * `--since -5`, a negative day count) would be misread as an unrecognized * flag rather than left for the caller's own value validation to reject with * a clearer message. `--flag=value` is self-contained and never needs to be * listed. * * `requireOperand` turns a MISSING operand into a usage error here, before * --help (#3087; the block comment at the check itself says why it is * opt-in). Opt-in is a choice between two guards, never a way to have none: * tests/validate-flags-value-operand.test.mjs sweeps every call site and * fails CI unless the caller either passes `requireOperand: true` or is * listed there with the richer validator it runs instead. * * `usageExitCode` is the exit status for an unrecognized flag or a missing * operand (default 1, so every existing caller is unchanged). A script whose * exit 1 already means "the work failed" passes 2, so a caller can tell a * mistyped command from a failed run. * @returns {void} Returns normally when every flag is recognized and --help * was not requested; otherwise prints and calls `process.exit()`. * * CHANGING THIS SIGNATURE: tests/validate-flags-value-operand.test.mjs reads * call sites as TEXT — it locates `validateFlags(`, slices the arguments, and * inspects the 4th one for the names `valueFlags` and `requireOperand`. * Reordering the parameters, collapsing them into a single options object, or * renaming either property makes that sweep stop understanding the tree, so it * is part of any such diff. Most of those changes fail loudly; a few go * SILENTLY green while checking nothing. That test's header lists which is * which, measured case by case — read it before you change this line. */ export function validateFlags(args, knownFlags, usage, { valueFlags = [], requireOperand = false, usageExitCode = 1 } = {}) { if (!Array.isArray(args)) return; const isFlagToken = (arg) => arg.startsWith('--') || knownFlags.includes(arg.split('=')[0]); // A value-taking flag's space-separated value (e.g. the `-5` in // `--since -5`) must not be mistaken for an unrecognized flag just because // it happens to start with `-`. Long flags and recognized short flags must // remain flags, including malformed boolean forms such as `-h=1`, so they // still reach unknown-flag or missing-operand validation below. const consumedValueIndices = new Set(); args.forEach((a, idx) => { if (valueFlags.includes(a) && args[idx + 1] !== undefined && !isFlagToken(args[idx + 1])) { consumedValueIndices.add(idx + 1); } }); const unknownFlags = args.filter((a, idx) => { if (typeof a !== 'string' || !a.startsWith('-') || consumedValueIndices.has(idx)) return false; const flag = a.split('=')[0]; return !knownFlags.includes(flag) || (a.includes('=') && !valueFlags.includes(flag)); }); if (unknownFlags.length > 0) { console.error(`Error: unrecognized flag(s): ${unknownFlags.join(', ')}. Valid flags: ${knownFlags.join(', ')}`); process.exit(usageExitCode); } // OPT-IN (`requireOperand`): a value-taking flag whose operand is MISSING is a // usage error, caught BEFORE --help — otherwise `--min-score --help` prints // usage and exits 0, the malformed flag is never reported, and the caller's // own value validation never runs (#2961). Same ordering argument as the // unrecognized-flag check above. // // Opt-in rather than automatic because several callers already validate this // themselves and say more than a generic message can. scan.mjs's requireValue() // distinguishes "no value" from a zero, a negative, a non-finite, an // out-of-range cutoff and a repeated flag; firing here first would replace all // five diagnostics with one line. Callers with nothing better to say opt in. // // Missing means: nothing follows, what follows is a long flag or a // recognized short flag (including `-h`), or the equals form was supplied with // an empty value (`--flag=`). Other single-dash values such as `-5` or // `-notes.txt` remain operands. The `--flag=value` form with a non-empty value // carries its own operand and passes normally. const missingOperand = requireOperand ? args.map((a, idx) => { if (typeof a !== 'string') return null; if (a.startsWith('--') && a.includes('=')) { const eqIdx = a.indexOf('='); const flag = a.slice(0, eqIdx); return (valueFlags.includes(flag) && a.slice(eqIdx + 1) === '') ? flag : null; } if (!valueFlags.includes(a)) return null; const next = args[idx + 1]; return (next === undefined || (typeof next === 'string' && isFlagToken(next))) ? a : null; }).filter(Boolean) : []; if (missingOperand.length > 0) { // Wording matches scan.mjs's own hand-rolled requireValue() (scan.mjs:2345), // which already reports exactly this condition, so a caller that later // switches to this path keeps the message its tests assert on. console.error(`Error: ${missingOperand[0]} requires a value`); process.exit(usageExitCode); } if (args.includes('--help') || args.includes('-h')) { console.log(usage); process.exit(0); } } /** * A flag's value as a safe non-negative integer, or `fallback`. * * detect-reposts.mjs derived this and wrote down why (#2702); it is exported * here so the next caller does not re-derive it, and so the reasoning lives in * one place rather than in a comment on one file. The rules, all load-bearing: * * - The WHOLE string must match. parseInt reads "7abc" as 7 and "-5" as -5, * and a negative threshold silently disables the guard it exists to set. * - Number()/Number.isInteger() is not a substitute: Number('') is 0, so an * empty value would read as a deliberate zero. * - The regex alone is not enough either. It accepts a 400-digit run of * nines, which Number() turns into Infinity — an infinite threshold, and * JSON.stringify writes Infinity as `null`, so the metadata reports a * value that is not the one in effect. An UNSAFE integer is the quiet * version: 9007199254740993 becomes ...992. isSafeInteger rejects both. * * Falling back rather than exiting is deliberate, and is the behaviour * detect-reposts.mjs documents: an unusable value is treated as if the flag * were absent. Presence is `requireOperand`'s job above; this is shape. * * @param {string|undefined} raw - The flag's value (from flagValue()). * @param {number} fallback - Used when absent or unusable. * @returns {number} */ export function safeIntFlag(raw, fallback) { if (raw === undefined) return fallback; const text = String(raw).trim(); if (!/^\d+$/.test(text)) return fallback; const parsed = Number(text); return Number.isSafeInteger(parsed) ? parsed : fallback; }