---
title: Refuse an unknown flag on the 20 league scripts that write by default
type: task
status: completed
description: >-
  20 scripts under scripts/ write to production by default, offer a dry-run flag, and let yargs
  silently drop an unknown one -- the shape that made a misspelled --dry recompute production twice
  on 2026-09-16.
base_uri: user:task/league/refuse-unknown-flags-on-write-by-default-scripts.md
created_at: '2026-09-16T15:51:50.947Z'
entity_id: ef3eccae-0832-47c8-b7f9-202f5e87eb23
finished_at: '2026-09-16T16:14:38.379Z'
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-16T16:14:38.379Z'
visibility_analyzed_at: '2026-11-05T23:49:59.678Z'
---

## Why this exists

`scripts/calculate-historical-hit-rates.mjs` writes to production by default and takes `--dry_run` to suppress the write. Yargs without `.strict()` accepts an unknown flag silently and drops it, so `--dry` parsed clean, left `dry_run` false, and ran the write path. That happened twice on 2026-09-16. One of those runs was killed by a bare 1800-second `timeout` at batch 74 of 164 and left the 2026 hit-rate population half-written, which cost a second 70-minute recompute to converge.

That script is fixed (league `f7ae49d4a`). The trap is not: 20 other scripts write by default, offer a dry-run flag, and accept unknown flags silently.

## The population, measured 2026-09-16

227 scripts under `scripts/` build a yargs CLI. 13 call `.strict()`. The 214 without it are not all worth changing — a read-only audit script that ignores a typo is a nuisance, not an incident. The slice that matters is the one where a dropped flag turns a no-op into a production write:

| Script |
| --- |
| `generate-data-view-llm-tags.mjs` |
| `import-nflverse-injuries.mjs` |
| `release-player.mjs` |
| `populate-super-priority-table.mjs` |
| `process-market-results.mjs` |
| `import-nfl-coaches.mjs` |
| `generate-nfl-player-gamelog-percentiles.mjs` |
| `archive-nfl-gamebooks.mjs` |
| `import-nfl-gamebook-starters.mjs` |
| `import-players.mjs` |
| `update-market-settlement-status.mjs` |
| `generate-format-data.mjs` |
| `generate-graded-seasonlog-season-type-rows.mjs` |
| `generate-historical-injury-index.mjs` |
| `backfill-penalty-type.mjs` |
| `backfill-players-from-nflverse-weekly-rosters.mjs` |
| `import-players-nflverse.mjs` |
| `import-nflverse-weekly-rosters.mjs` |
| `canonicalize-chained-shorten-urls.mjs` |
| `calculate-team-historical-hit-rates.mjs` |

Selected by the predicate "declares a `dry_run`/`dry-run`/`dryrun` option AND does not call `.strict()`", which is the shape that already caused an incident.

## What is already established

**No cron caller breaks.** Every flag the crontabs pass to these 20 is a declared option. Extracted from `server/crontab-main/`, `server/crontab-worker-1/` and `private/crontab/`, cross-checked against each script's `.option()` declarations. The extraction is controlled rather than a bare zero: it found `--current_week_only` on `calculate-team-historical-hit-rates.mjs`, `--allow_name_tier` on `import-players.mjs` and `--force_download` on three importers, so it matches when there is something to match.

**The declaration scan is complete.** None of the 20 uses `.options({...})` or `.command()`, so scanning `.option('name'` misses nothing. Two use `.alias()`, which strict honors.

**The crontabs are DIRECTORIES, not files.** `server/crontab-main`, `server/crontab-worker-1` and `private/crontab` each hold `.cron` files. Reading them as files returns empty and reads as "nothing invokes this".

## Assumptions

- Cron is the only unattended caller. Human and agent invocations from runbooks are in scope to break, and breaking loudly is the point.
- `.strict()` is the right tool rather than per-flag validation, matching the 13 scripts that already use it.

## Plan

- [x] **Add `.strict()` to each of the 20, one commit for the set** — league `8ff12d937`, 90 insertions and 10 deletions across the 20. Prettier clean with no reflow and eslint silent, both run on the 20 as a set; the 10 deletions are the files where a terminal `}).argv` split into two lines.
  - The chain shapes are heterogeneous and a single sed will not do it: some terminate on a bare `.argv`, some on `.parse()`, some on `.help().parse()`, and `import-nflverse-injuries.mjs` has `.option(...).argv` on ONE line. Edit each by hand.
  - Watch prettier: an inline comment inside the chain makes it wrap the whole expression in parentheses and turns a 2-line change into a 50-line reflow. Put any comment ABOVE the `const initialize_cli`, as `f7ae49d4a` does.
  - Verify: `npx prettier --check <files>` and `npx eslint <files>`.

- [x] **Control-test every one of the 20, as a pair** — all 20 pass both arms.
  - Negative: an unknown flag must exit non-zero with `Unknown argument`. Pair it with the script's own dry-run flag so a strict failure still cannot write.
  - Positive: the script's real cron flags plus `--help` must exit 0. Yargs handles `--help` without running `main()`, so this proves the declared flags still parse and writes nothing.
  - A green here is only evidence if you have seen the negative go red on the same script.
  - **`NODE_ENV` must be set or every script dies identically at module load**, on `config/config-undefined.json`, before yargs ever parses. The first pass read as 20 uniform failures of the sweep and was an unset variable. `NODE_ENV=development` is the right setting: it loads the placeholder config and reaches no production database, and `--help` exits before `main()` regardless.
  - **`release-player.mjs` needs its `demandOption` arguments supplied first.** Yargs reports `Missing required arguments: league-id, team-id` and exits 1 before strict says anything, so the bare negative exits non-zero for the wrong reason and never prints `Unknown argument`. With `--league-id 1 --team-id 5 --player-id ...` it prints `Unknown arguments: definitely-not-a-flag, definitelyNotAFlag` and exits 1.
  - Discriminating control: the same negative run against `analyze-championship-lineups.mjs`, which has no `.strict()`, prints no `Unknown argument` at all. It also exits 1, for an unrelated reason — which is why the assertion is on the MESSAGE and not on the exit code.

## Verify

```
cd repository/active/league
# NODE_ENV is required -- unset, every script dies on config-undefined.json
# before yargs parses, and 20 identical failures look like a broken sweep.
# development loads the placeholder config and reaches no production database.
for f in <the 20, each with its own dry-run flag spelling>; do
  NODE_ENV=development node "scripts/$f" --definitely-not-a-flag "$dry" 2>&1 \
    | grep -q 'Unknown argument' && echo "ok $f" || echo "FAIL $f"
done
```

Pass `release-player.mjs` its `demandOption` arguments too, or it exits on those first and never
reaches strict. Before trusting the greens, run the same negative against a script with no
`.strict()` and confirm it prints no `Unknown argument` — the exit code alone does not
discriminate, because a non-strict script exits 1 for its own unrelated reasons.

Done when all 20 refuse an unknown flag and all 20 still accept the flags their crontab passes.

**Done, 2026-09-16.** All 20 carry `.strict()` in league `8ff12d937` and both control arms pass on
every one of them.

## Out of scope

The other 194 yargs scripts without `.strict()`. They are a style question rather than an incident class, and widening this to all of them buries the 20 that can write.
