Ctrl K
ComunidadGuías…

detect-reposts.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * detect-reposts.mjs — Repost Detector for career-ops
 *
 * Reads data/scan-history.tsv, groups rows by company, groups role titles by
 * title identity (see titleIdentityKey), and flags any company+role that
 * appears 2+ times with different URLs, on 2+ distinct scan dates, within a
 * 90-day window. Such clusters are almost certainly the same opening being
 * re-listed by the employer — useful for tracking stale pipelines and
 * ghost postings.
 *
 * Only rows with status `added` are considered. Rows with a non-`added`
 * status (`skipped_expired`, `skipped_invalid_url`, `skipped_blocked_host`)
 * describe dead postings, not reposts, and are skipped.
 *
 * TWO CONSTRAINTS SEPARATE A REPOST FROM A SIBLING REQUISITION. Both were
 * added after the detector reported `reposts-detected` for three companies
 * that had never reposted anything:
 *
 *   1. MINIMUM SPAN. `first_seen` is when the SCANNER first saw a URL, not
 *      when the employer posted it. A whole company is swept at once, so two
 *      URLs sharing a first_seen date were listed CONCURRENTLY — that is the
 *      signature of parallel openings, and the exact opposite of a repost,
 *      which requires the role to reappear later. A cluster whose members all
 *      come from one sweep therefore spans 0 days and cannot be evidence of
 *      reposting at all. See MIN_REPOST_SPAN_DAYS / --min-span.
 *
 *   2. TITLE IDENTITY, not title similarity. This used to group titles with
 *      roleFuzzyMatch, which merges any two titles clearing a 0.6 Jaccard
 *      ratio. Per-region and per-segment variants of one job land exactly on
 *      that boundary — "Commercial Solutions Engineer - Munich" vs "- Berlin"
 *      share 3 of 5 tokens — so a company that fans one opening out across
 *      cities read as a company reposting. Those are distinct requisitions
 *      hiring distinct headcount, and merging them is a fabricated signal.
 *      Membership now requires the same SET of title words, which still
 *      tolerates word-order and punctuation churn between two listings of one
 *      role but never merges titles that differ by a city, country, language,
 *      segment or seniority word.
 *
 *   3. SAME SOURCE. A repost is the same site listing the role again under a
 *      new URL. scan-history.tsv mixes many sources — the employer's ATS, job
 *      boards, aggregators, alert emails — and one live opening is routinely
 *      picked up by two of them on different days: the ATS scan finds it on
 *      Monday, a job-board copy arrives on Thursday. Grouped across sources,
 *      that reads as a repost even though the opening was listed once and
 *      never changed. Membership therefore also requires the same source host
 *      (see sourceHost); a cross-source sighting is a duplicate, not a repost.
 *
 * ONE CASE THE TITLE RULE CANNOT SETTLE: a multi-employer aggregator. Its board
 * carries many different employers' postings under its own name, so identical
 * titles there are genuinely different jobs and every rule above rests on an
 * assumption that does not hold — same company + same title = same opening.
 * Mark such an entry `aggregator: true` in portals.yml and this skips it
 * entirely (see loadAggregatorCompanies, #2703). It has to be config: measured
 * on a real history, an aggregator's clusters are indistinguishable by shape
 * from legitimate ones.
 *
 * RETENTION. scan-history.tsv is append-only by contract — its rows are the
 * scanner's "seen" ledger, which is why a column may not be reordered
 * (DATA_CONTRACT.md) — but nothing ever removed a row, so an installation that
 * scans for a couple of years re-parses its entire history on every run.
 * --retain-days N drops rows older than N days. Because that changes the
 * ledger, it is opt-in and previewable (--dry-run), and three kinds of row are
 * never dropped regardless of age, because their meaning does not decay:
 * `skipped_invalid_url`, `skipped_blocked_host`, and `cooldown:*` (which names
 * its own expiry). A row whose date cannot be parsed is kept too; a retention
 * pass may not guess. Counts go to stderr, so `--json` on stdout stays
 * parseable.
 *
 * Run: node detect-reposts.mjs             (JSON to stdout)
 *      node detect-reposts.mjs --summary   (human-readable table)
 *      node detect-reposts.mjs --window 60 (override 90-day window)
 *      node detect-reposts.mjs --min-span 7 (override 1-day minimum span)
 *      node detect-reposts.mjs --retain-days 180 (drop rows older than 180 days first)
 *      node detect-reposts.mjs --retain-days 180 --dry-run (report, write nothing)
 *      node detect-reposts.mjs --self-test
 *      node detect-reposts.mjs --help
 *
 * Issue #1205 — github.com/career-ops-hq/career-ops
 */

import { readFileSync, existsSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
import { join, dirname } from 'path';
import { tmpdir } from 'os';
import { fileURLToPath } from 'url';
// Namespace import, not default: js-yaml 5.x drops the default export, and
// #2656 migrated the rest of the repo for exactly that reason.
import { loadYaml } from './lib/yaml-document.mjs';

import { roleFuzzyMatch } from './role-matcher.mjs';
import { getCareerOpsRoot } from './path-resolver.mjs';
import { normalizeCompanyName } from './invite-match.mjs';
import { flagValue, validateFlags, safeIntFlag } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { parseDate } from './lib/parse-date.mjs';
import { localToday } from './lib/local-today.mjs';
import { parseScanHistoryLine, scanHistoryLineHasColumn } from './lib/scan-history-columns.mjs';
import { withPipelineLock } from './pipeline-lock.mjs';
import { writeFileAtomic } from './tracker-utils.mjs';

const CAREER_OPS = getCareerOpsRoot();
// Same resolution scan.mjs uses, lane override included (scan.mjs's
// SCAN_HISTORY_PATH). This matters more here than it used to: reading the wrong
// lane only misreported, but a retention pass that ignored the override would
// DELETE rows from the default lane while leaving the selected one untouched.
// docs/SCRIPTS.md gives a lane its own CAREER_OPS_SCAN_HISTORY for exactly this
// reason, since the file is the dedup source.
const SCAN_HISTORY_PATH = process.env.CAREER_OPS_SCAN_HISTORY || join(CAREER_OPS, 'data/scan-history.tsv');
// Same resolution scan.mjs uses, so a sandboxed run overrides both together.
const PORTALS_PATH = process.env.CAREER_OPS_PORTALS || join(CAREER_OPS, 'portals.yml');
const DEFAULT_WINDOW_DAYS = 90;

// Smallest first_seen span a cluster may have and still count as a repost.
// 1, not a larger figure: scans run on an irregular cadence (this history has
// gaps of 1 to 13 days), so a genuine repost caught on two consecutive scan
// days spans exactly 1 and must survive. The load-bearing part is that 0 is
// excluded — see constraint 1 in the header. --min-span raises it for anyone
// whose sweeps straddle midnight and land one company on two dates.
const MIN_REPOST_SPAN_DAYS = 1;

// --- CLI args ---

// ADDING A FLAG: add it to BOTH lists below, and to USAGE. validateFlags reads
// these, not the code that consumes the flag, so a new flag wired up everywhere
// else still exits 1 with "unrecognized flag(s)". Appending to a list is also
// the shape git merges most willingly: two branches can each add a flag here and
// merge without a conflict, leaving whichever landed second working in every
// file except this one. That is how --min-span arrived rejected (#2919).
const KNOWN_FLAGS = ['--window', '--min-span', '--summary', '--self-test', '--help', '-h', '--retain-days', '--dry-run'];
const VALUE_FLAGS = ['--window', '--min-span', '--retain-days'];

const USAGE = `Usage:
  node detect-reposts.mjs                       # full JSON repost clusters to stdout
  node detect-reposts.mjs --summary             # human-readable table
  node detect-reposts.mjs --window 60           # override the default 90-day window
  node detect-reposts.mjs --min-span 7          # override the default 1-day minimum span
  node detect-reposts.mjs --retain-days 180     # drop rows older than 180 days first
  node detect-reposts.mjs --retain-days 180 --dry-run  # report what would be dropped
  node detect-reposts.mjs --self-test           # run the in-memory test suite
  node detect-reposts.mjs --help                # print this usage block and exit`;

const args = process.argv.slice(2);
const summaryMode = args.includes('--summary');
const selfTestMode = args.includes('--self-test');
// Both numeric flags read through flagValue, so `--min-span=7` behaves exactly
// like `--min-span 7` — the defect lib/cli-flags.mjs exists to keep the next
// script from rediscovering. The rules and the reasoning now live in
// lib/cli-flags.mjs's safeIntFlag() so process-quality.mjs stops being the
// file that lacks them (#2982); the behaviour here is unchanged, including
// the deliberate fall back to the default rather than an exit.
const intFlag = (flag, fallback) => safeIntFlag(flagValue(args, flag), fallback);
const windowDays = intFlag('--window', DEFAULT_WINDOW_DAYS);
const minSpanDays = intFlag('--min-span', MIN_REPOST_SPAN_DAYS);

// --- Date helpers ---
function daysBetween(d1, d2) {
  return Math.round((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24));
}

// --- Parse scan-history.tsv ---
// Columns as declared in lib/scan-history-columns.mjs. The normalized_company
// column is preferred as the clustering key when present; rows written before
// it existed simply lack it and carry `normCompany: ''`, so a consumer
// normalizes the raw company on the fly.
export function parseScanHistory(content) {
  const lines = content.split('\n').filter(line => line.trim());
  if (lines.length === 0) return [];
  const rows = [];
  // Only skip the header when it actually looks like one — older
  // headerless scan-history.tsv files and the seed file in the repo
  // don't have a header row, and slice(1) would silently lose row 0.
  const hasHeader = /^\s*url\s*\t/i.test(lines[0]);
  for (const line of lines.slice(hasHeader ? 1 : 0)) {
    if (!scanHistoryLineHasColumn(line, 'company')) continue;
    const row = parseScanHistoryLine(line);
    const date = parseDate(row.first_seen);
    if (!row.url || !date) continue;
    rows.push({
      url: row.url.trim(),
      date,
      dateStr: row.first_seen.trim(),
      portal: row.portal.trim(),
      title: row.title.trim(),
      company: row.company.trim(),
      status: (row.status || 'added').trim(),
      location: row.location.trim(),
      // '' for older rows that predate the normalized_company column —
      // companyKey() falls back to normalizing the raw name so old and new
      // rows still cluster on the same key.
      normCompany: row.normalized_company.trim(),
    });
  }
  return rows;
}

// Canonical clustering key for a row. Prefers the stored normalized-company
// column (written by scan.mjs via normalizeCompanyName) so "Acme Inc." and
// "Acme" cluster without re-deriving anything; falls back to normalizing the
// raw company on the fly for pre-column rows and for row objects built directly
// (e.g. tests). A final fallback to the raw lowercased name preserves the
// pre-normalization behavior for names that fold to empty (e.g. all-CJK or
// all-Cyrillic company names), so those never over-cluster under one '' key.
export function companyKey(row) {
  const stored = typeof row.normCompany === 'string' ? row.normCompany.trim() : '';
  const raw = typeof row.company === 'string' ? row.company.trim() : '';
  return stored || normalizeCompanyName(raw) || raw.toLowerCase();
}

// Companies the user has marked `aggregator: true` in portals.yml, as a Set of
// keys in the SAME space companyKey() produces, so the two can be compared
// directly without re-deriving anything.
//
// Why this needs config rather than a heuristic: a multi-employer board posts
// many different employers' roles under its own name, so identical titles there
// are genuinely different jobs — the one case where title identity is not
// enough. That cannot be inferred from the rows. Measured on a real history:
// the aggregator's clusters and the legitimate ones are indistinguishable by
// shape (both median 2 sightings over 2 dates, 1 row per date); the best
// threshold available removed under half the aggregator clusters and needed a
// magic number to do it. See #2703.
//
// portals.yml is a USER-LAYER file and may be absent (a fresh install, a
// sandboxed test, CI). Absent, unreadable or malformed all degrade to "no
// aggregators", which is exactly the behaviour before this existed — the
// detector must never fail because an optional config is missing.
// Returns a Map of key -> the raw name as written in portals.yml, not a bare
// Set. The key is what the detector matches on; the raw name is what a reader
// has to see. company-history.mjs renders a card for a flagged company even
// when it has no tracker rows and no clusters, and without the original name
// that card would be titled with the normalized key — "joinupch" rather than
// "joinup.ch". Map.has() is identical to Set.has() on the matching path.
// BOTH collections, not just tracked_companies. A multi-employer board is far
// likelier to live under `job_boards` — that is what the section is for — so
// reading only one collection would leave a flag the user can see in their own
// config silently doing nothing.
export function loadAggregatorCompanies(portalsPath = PORTALS_PATH) {
  const found = new Map();
  try {
    if (!existsSync(portalsPath)) return found;
    const doc = loadYaml(readFileSync(portalsPath, 'utf-8'));
    for (const entries of [doc?.tracked_companies, doc?.job_boards]) {
      if (!Array.isArray(entries)) continue;
      for (const entry of entries) {
        if (!entry || entry.aggregator !== true) continue;
        const raw = typeof entry.name === 'string' ? entry.name.trim() : '';
        if (!raw) continue;
        const key = normalizeCompanyName(raw) || raw.toLowerCase();
        if (key && !found.has(key)) found.set(key, raw);
      }
    }
  } catch {
    // A malformed portals.yml is scan.mjs's problem to report, not this
    // script's problem to crash on.
    return found;
  }
  return found;
}

// Set or Map — both answer .has(key), and every caller only asks that. Keeps a
// hand-built Set working in tests while the loader returns a Map.
export function isKeyLookup(value) {
  return value instanceof Set || value instanceof Map;
}

// Canonical identity of a role TITLE, for deciding whether two listings are the
// same opening. The key is the title's set of words: accent-folded, lowercased,
// stripped of punctuation, deduplicated and sorted.
//
// Set equality is the whole point, and it is chosen against the two
// alternatives on either side of it:
//
//   - Raw string equality is too tight. One opening re-listed months later
//     routinely comes back with the words reordered or repunctuated
//     ("Forward Deployed Engineer - Sweden" -> "Forward Deployed Engineer,
//     Sweden"), and a detector that misses those misses real reposts.
//
//   - roleFuzzyMatch (what this used to use) is too loose. It merges titles
//     that merely overlap enough, which is precisely how a family of sibling
//     requisitions — same job, one per city/country/language/segment/level —
//     collapses into a phantom repost cluster.
//
// Set equality sits exactly between them: every word must be accounted for on
// both sides, so any city, country, language or seniority word present in one
// title and absent from the other splits the two apart, while their ORDER and
// punctuation are free to drift.
//
// No length or stopword filtering, deliberately. roleTokens drops words of 3
// characters or fewer, which would erase the only thing distinguishing "…
// Engineer - UK" from "… Engineer - US" and merge two countries' openings.
//
// Word splitting is script-preserving: it keeps any Unicode letter or number
// and treats everything else as a separator, following the same reasoning as
// #2429 for company names. Restricting to [a-z0-9] would fold every Japanese
// or Cyrillic title to the empty string, and every such title at one company
// would then share a key. A CJK title has no spaces to split on, so it stays a
// single word and matches only its exact self — no over-clustering either way.
//
// A title that folds to nothing (all punctuation) falls back to its lowercased
// raw text, the same guard companyKey uses.
export function titleIdentityKey(title) {
  const raw = String(title ?? '').trim();
  const words = raw
    .normalize('NFD')
    .replace(/\p{Mn}/gu, '')
    .toLowerCase()
    .replace(/[^\p{L}\p{N}]+/gu, ' ')
    .trim()
    .split(/\s+/)
    .filter(Boolean);
  if (words.length === 0) return raw.toLowerCase();
  return [...new Set(words)].sort().join(' ');
}

function loadScanHistory(path = SCAN_HISTORY_PATH) {
  if (!existsSync(path)) return [];
  return parseScanHistory(readFileSync(path, 'utf-8'));
}

// --- Retention ---
//
// Statuses whose verdict is about the URL rather than the posting, so it cannot
// become outdated: an invalid URL does not become valid because time passed,
// and a blocked host does not become reachable. `cooldown:*` carries its own
// expiry, so age is not the rule that governs it either. Everything else
// describes a sighting, and a sighting older than the user's retention window
// is what --retain-days exists to forget.
export const PRUNE_PROTECTED_SCAN_HISTORY_STATUSES = new Set([
  'skipped_invalid_url',
  'skipped_blocked_host',
]);

/**
 * Decide which scan-history rows a retention pass would drop. Pure: touches no
 * files, exported so the policy can be tested without a temp data root.
 *
 * The result carries two views of the same decision. `lines` is the retained
 * text, convenient for reading. `keepIndexes` is the positions in the `\n`-split
 * source, and it is the one a writer must use: reading the file as text decodes
 * it lossily where it is not valid UTF-8, so a retained line that carried such a
 * byte would be corrupted by writing `lines` back. A caller holding the original
 * bytes rebuilds from `keepIndexes` instead (see rebuildScanHistoryBytes).
 *
 * @param {string} content - Full scan-history.tsv contents.
 * @param {number} retainDays - Drop rows whose first_seen is strictly older than this.
 * @param {{today?: Date|string}} [options] - Comparison day. Defaults to the local calendar day;
 *   a string is read as a calendar day (UTC midnight), which is what lib/local-today.mjs documents.
 * @returns {{lines: string[], keepIndexes: number[], pruned: Array<{url: string, status: string, firstSeen: string, ageDays: number}>, totalRows: number, keptRows: number}}
 */
export function planScanHistoryPrune(content, retainDays, { today = localToday() } = {}) {
  // localToday() answers with a calendar day, and this repo's date arithmetic
  // parses one at UTC midnight rather than at the current instant — the line
  // lib/local-today.mjs draws. A Date is taken as-is so tests can pin an instant.
  const todayDate = today instanceof Date ? today : parseDate(today);
  if (!todayDate) throw new Error(`planScanHistoryPrune: unreadable "today" (${today})`);
  const source = String(content ?? '').split('\n');
  const keepIndexes = [];
  const pruned = [];
  let totalRows = 0;
  let firstContentLine = true;
  for (let index = 0; index < source.length; index += 1) {
    const line = source[index];
    if (!line.trim()) { keepIndexes.push(index); continue; }
    // Same header rule parseScanHistory() applies: only the first non-blank
    // line can be the header, and only when it starts with a `url` column.
    if (firstContentLine) {
      firstContentLine = false;
      if (/^\s*url\s*\t/i.test(line)) { keepIndexes.push(index); continue; }
    }
    totalRows += 1;
    let row;
    try {
      row = scanHistoryLineHasColumn(line, 'company') ? parseScanHistoryLine(line) : null;
    } catch {
      row = null;
    }
    // Unreadable rows are kept: a retention pass must not be the thing that
    // silently deletes a row it could not understand.
    if (!row || !String(row.url || '').trim()) { keepIndexes.push(index); continue; }
    const status = String(row.status || 'added').trim();
    if (PRUNE_PROTECTED_SCAN_HISTORY_STATUSES.has(status) || status.startsWith('cooldown:')) {
      keepIndexes.push(index);
      continue;
    }
    const date = parseDate(row.first_seen);
    if (!date) { keepIndexes.push(index); continue; }
    const ageDays = daysBetween(date, todayDate);
    // "Older than N days": a row exactly N days old is still inside the window,
    // so retention N keeps the same span a window of N would.
    if (ageDays > retainDays) {
      pruned.push({ url: String(row.url).trim(), status, firstSeen: String(row.first_seen || '').trim(), ageDays });
      continue;
    }
    keepIndexes.push(index);
  }
  return {
    lines: keepIndexes.map(index => source[index]),
    keepIndexes,
    pruned,
    totalRows,
    keptRows: totalRows - pruned.length,
  };
}

/**
 * Split a buffer on `\n` without decoding it, mirroring `String.prototype.split`
 * exactly: the separator is dropped and a trailing newline still yields a final
 * empty piece. U+000A is a single byte in UTF-8, and no multi-byte sequence
 * contains 0x0A, so the piece count matches the decoded split for any input,
 * valid UTF-8 or not.
 *
 * @param {Buffer} bytes
 * @returns {Buffer[]}
 */
function splitByteLines(bytes) {
  const slices = [];
  let start = 0;
  for (let index = 0; index < bytes.length; index += 1) {
    if (bytes[index] === 0x0a) {
      slices.push(bytes.subarray(start, index));
      start = index + 1;
    }
  }
  slices.push(bytes.subarray(start));
  return slices;
}

/**
 * Reassemble a scan history from the byte slices that survived a retention
 * pass, keeping every retained line exactly as it was on disk.
 *
 * This exists because the decision has to decode a line to read its date and
 * status, and `Buffer.toString('utf-8')` is lossy on invalid input: it writes
 * U+FFFD over the offending bytes. A line the pass decided to keep must come
 * back unchanged anyway, so the writer takes the positions and copies the
 * original slices instead of re-encoding the text it read.
 *
 * @param {Buffer} bytes - The file as read, before any decode.
 * @param {number[]} keepIndexes - Positions from planScanHistoryPrune().
 * @returns {Buffer}
 */
export function rebuildScanHistoryBytes(bytes, keepIndexes) {
  const slices = splitByteLines(bytes);
  const parts = [];
  for (const index of keepIndexes) {
    if (!Number.isInteger(index) || index < 0 || index >= slices.length) {
      throw new Error(`rebuildScanHistoryBytes: line ${index} is outside a ${slices.length}-line file`);
    }
    parts.push(slices[index]);
    // Every piece except the last was followed by a newline in the source, so
    // the separator is reproduced rather than invented: a file with no trailing
    // newline keeps not having one.
    if (index < slices.length - 1) parts.push(Buffer.from('\n'));
  }
  return Buffer.concat(parts);
}

/**
 * Apply a retention pass to the real scan history, under the lock every scanner
 * appends through. The whole file is replaced, so reading it outside the lock
 * would drop a scanner's fresh rows; reading it here, inside, is the point.
 *
 * The file is read as bytes and the backup is those bytes verbatim. Decoding is
 * only how the decision reads a row: it is lossy where the file is not valid
 * UTF-8, so the result is rebuilt from the original slices (see
 * rebuildScanHistoryBytes) rather than from the text that was decoded.
 *
 * Counts go to stderr, leaving stdout for the repost report.
 */
async function pruneScanHistory(retainDays, { dryRun = false, path = SCAN_HISTORY_PATH } = {}) {
  if (!existsSync(path)) {
    console.error('scan-history prune: no data/scan-history.tsv — nothing to prune.');
    return;
  }
  await withPipelineLock(path, () => {
    const bytes = readFileSync(path);
    const plan = planScanHistoryPrune(bytes.toString('utf-8'), retainDays);
    const summary = `scan-history prune: ${plan.totalRows} row(s) read, ${plan.pruned.length} older than ${retainDays} day(s), ${plan.keptRows} kept`;
    if (plan.pruned.length === 0) {
      console.error(`${summary} (nothing to prune)`);
      return;
    }
    if (dryRun) {
      console.error(`${summary} (dry-run — no changes written)`);
      return;
    }
    const backupPath = `${path}.bak`;
    writeFileSync(backupPath, bytes);
    writeFileAtomic(path, rebuildScanHistoryBytes(bytes, plan.keepIndexes));
    console.error(`${summary} (backup: ${backupPath})`);
  });
}

// --- Core detection ---
//
// Group rows by company (case-insensitive), then within each company group by
// title identity (titleIdentityKey). Keep a cluster only if (a) it contains 2+
// rows, (b) at least two rows have different URLs, (c) the cluster's first_seen
// dates all fall within `windowDays` of each other, and (d) those dates span at
// least `minSpanDays` — see constraint 1 in the header.
//
// `aggregators` is an optional Set of company keys (see loadAggregatorCompanies)
// whose boards carry many employers' postings. Those companies are skipped
// entirely: an identical title there is a different employer's job, so the one
// assumption every other rule rests on — same company + same title means same
// opening — does not hold. Omitted or empty, nothing is skipped.
//
// The parameter is passed in rather than read from disk here so this stays a
// pure function of its arguments, which is what lets the tests drive it.
//
// Exported so external tests can call detectReposts() directly on a row list.
export function detectReposts(rows, windowDays = DEFAULT_WINDOW_DAYS, minSpan = MIN_REPOST_SPAN_DAYS, aggregators = null) {
  if (!Array.isArray(rows)) return [];
  const valid = rows
    .filter(r =>
      r &&
      typeof r === 'object' &&
      r.status === 'added' &&
      typeof r.url === 'string' && r.url.trim() &&
      r.date instanceof Date &&
      !Number.isNaN(r.date.getTime()) &&
      typeof r.company === 'string' && r.company.trim() &&
      typeof r.title === 'string' && r.title.trim()
    )
    .map(r => ({
      ...r,
      url: r.url.trim(),
      company: r.company.trim(),
      title: r.title.trim(),
    }));
  if (valid.length < 2) return [];

  // Group by normalized company key (prefers the stored normalized-company
  // column, falls back to normalizing the raw name — see companyKey). This is
  // what makes "Acme Inc." and "Acme" a single repost cluster instead of two.
  const byCompany = new Map();
  for (const row of valid) {
    const key = companyKey(row);
    if (!byCompany.has(key)) byCompany.set(key, []);
    byCompany.get(key).push(row);
  }

  const skip = isKeyLookup(aggregators) ? aggregators : null;

  const clusters = [];
  for (const [key, groupRows] of byCompany) {
    if (groupRows.length < 2) continue;
    if (skip && skip.has(key)) continue;
    clusters.push(...detectRepostsInGroup(groupRows, windowDays, minSpan));
  }
  return clusters.sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : -1));
}

// Cluster rows in a single company group. Rows are first grouped by title
// identity, then each title group is sorted by date and a sliding window finds
// sub-clusters within the windowDays span. This two-phase approach prevents
// non-matching roles (e.g. a Product Manager between two Backend Engineer
// postings) from breaking a valid repost cluster.
function detectRepostsInGroup(rows, windowDays, minSpan = MIN_REPOST_SPAN_DAYS) {
  const titleGroups = groupRowsByTitle(rows);

  const results = [];
  for (const group of titleGroups) {
    if (group.length < 2) continue;
    const sorted = [...group].sort((a, b) => (a.date < b.date ? -1 : 1));
    let cluster = [];

    for (const row of sorted) {
      if (cluster.length === 0) {
        cluster = [row];
        continue;
      }
      const first = cluster[0];
      const span = daysBetween(first.date, row.date);
      if (span <= windowDays) {
        cluster.push(row);
      } else {
        // Span exceeds window. Seal the current cluster if it has 2+ rows,
        // then slide the window: drop the oldest row(s) until the new row
        // fits within windowDays of the new cluster start. This preserves
        // valid overlapping repost pairs that would otherwise be dropped
        // (e.g. Jan 1 + Mar 15 sealed, but Mar 15 + Jun 10 also valid).
        if (cluster.length >= 2) {
          const result = buildRepostCluster(cluster, windowDays, minSpan);
          if (result) results.push(result);
        }
        cluster = cluster.filter(c => daysBetween(c.date, row.date) <= windowDays);
        cluster.push(row);
      }
    }
    if (cluster.length >= 2) {
      const result = buildRepostCluster(cluster, windowDays, minSpan);
      if (result) results.push(result);
    }
  }
  return results;
}

// The site a posting URL belongs to, for constraint 3 of the header: hostname,
// lowercased, without `www.`. A two-letter regional prefix is folded only on
// sites known to serve one board under per-country subdomains
// (`de.linkedin.com` and `www.linkedin.com` are one site), so two unrelated
// tenants such as `ca.example.com` and `www.example.com` stay apart. Greenhouse
// serves the same board under several hosts (the set prepare-application.mjs
// recognises), so they fold to one. Unparseable URLs keep the raw string, so
// they never merge with anything but themselves.
const REGIONAL_SUBDOMAIN_SITES = new Set(['linkedin.com', 'indeed.com']);
const GREENHOUSE_HOSTS = new Set(['boards.greenhouse.io', 'job-boards.greenhouse.io', 'job-boards.eu.greenhouse.io', 'greenhouse.io']);

export function sourceHost(url) {
  let host;
  try { host = new URL(url).hostname.toLowerCase().replace(/\.$/, ''); } catch { return String(url); }
  if (GREENHOUSE_HOSTS.has(host)) return 'greenhouse.io';
  const labels = host.split('.');
  if (labels.length > 2 && labels[0] === 'www') labels.shift();
  else if (labels.length > 2 && /^[a-z]{2}$/.test(labels[0]) && REGIONAL_SUBDOMAIN_SITES.has(labels.slice(1).join('.'))) labels.shift();
  return labels.join('.');
}

// Group one company's rows into title groups: all rows sharing a
// titleIdentityKey and a sourceHost land in one group.
//
// This is a single O(N) bucketing pass. It replaced a nested loop that compared
// every pair of titles with roleFuzzyMatch, and then an inverted token index
// built to make that nested loop affordable (#2383) on the shape
// scan-history.tsv actually grows into — the file is append-only with one row
// per scanned posting, so a large employer accumulates thousands of DISTINCT
// titles and nothing collapses. Deciding membership by an equality key instead
// of by pairwise similarity removes the quadratic term outright, so the index
// that existed to prune it has nothing left to prune.
//
// The correctness reason for the change, rather than the speed one, is in
// constraint 2 of the header: pairwise similarity merged sibling requisitions.
//
// Ordering is load-bearing downstream and is preserved. The date sort in
// detectRepostsInGroup uses a comparator returning 1 (not 0) for equal dates,
// so same-date rows keep their input order only if the group arrives in input
// order; buildRepostCluster also reads clusterRows[0].company. Groups are
// therefore emitted in first-appearance order of their key, and rows within a
// group stay in input order.
function groupRowsByTitle(rows) {
  const groups = [];
  const groupOfKey = new Map();
  for (const row of rows) {
    const key = `${titleIdentityKey(row.title)}\u0000${sourceHost(row.url)}`;
    let idx = groupOfKey.get(key);
    if (idx === undefined) {
      idx = groups.length;
      groupOfKey.set(key, idx);
      groups.push([]);
    }
    groups[idx].push(row);
  }
  return groups;
}

// A title-identity cluster becomes a repost cluster only when (a) at least two
// distinct URLs are present (same URL means a dedup hit, not a repost), (b)
// every row's first_seen date falls within windowDays of every other row, and
// (c) the span between the earliest and latest first_seen is at least
// minSpan days. We enforce the window by requiring max-min span <= windowDays.
// Rows sharing the same URL are collapsed (only the earliest sighting is kept)
// so a URL seen on multiple scan dates doesn't inflate the repost count.
//
// (c) is what keeps concurrent openings out. Two URLs first seen on the same
// date were listed side by side in one sweep — that is a company running two
// requisitions at once, which is the opposite of re-listing one requisition
// later, and it is the single largest source of false positives in a real
// history (165 of 252 clusters when it was missing). See header constraint 1.
function buildRepostCluster(clusterRows, windowDays, minSpan = MIN_REPOST_SPAN_DAYS) {
  const byUrl = new Map();
  for (const row of clusterRows) {
    if (!byUrl.has(row.url) || row.date < byUrl.get(row.url).date) {
      byUrl.set(row.url, row);
    }
  }
  const deduped = [...byUrl.values()];

  if (deduped.length < 2) return null;

  const sorted = [...deduped].sort((a, b) => (a.date < b.date ? -1 : 1));
  const first = sorted[0];
  const last = sorted[sorted.length - 1];
  const span = daysBetween(first.date, last.date);
  if (span > windowDays) return null;
  if (span < minSpan) return null;

  const role = last.title;
  const appearances = sorted.map(r => ({ url: r.url, date: r.dateStr, title: r.title }));

  return {
    company: clusterRows[0].company,
    role,
    repostCount: appearances.length,
    firstSeen: first.dateStr,
    lastSeen: last.dateStr,
    daysSpan: span,
    appearances,
  };
}

// --- Summary mode ---
function printSummary(clusters, aggregatorCount = 0) {
  console.log(`\n${'='.repeat(78)}`);
  console.log('  Repost Detector — career-ops');
  console.log(`  window: ${windowDays} days | min span: ${minSpanDays} day(s) | aggregators skipped: ${aggregatorCount} | clusters: ${clusters.length}`);
  console.log(`${'='.repeat(78)}\n`);

  if (clusters.length === 0) {
    console.log('  No reposted roles detected.\n');
    return;
  }

  const header =
    '  ' +
    'Company'.padEnd(22) +
    'Role'.padEnd(34) +
    'Reposts'.padEnd(9) +
    'Span'.padEnd(12) +
    'First → Last';
  console.log(header);
  console.log('  ' + '-'.repeat(90));

  for (const c of clusters) {
    const company = (c.company || '').substring(0, 20).padEnd(22);
    const role = (c.role || '').substring(0, 32).padEnd(34);
    const reposts = String(c.repostCount).padEnd(9);
    const span = `${c.daysSpan}d`.padEnd(12);
    const range = `${c.firstSeen} → ${c.lastSeen}`;
    console.log('  ' + company + role + reposts + span + range);
  }
  console.log('');
}

// --- Self-test ---
function runSelfTest() {
  const baseRows = [
    // Genuine repost: same role, different URL, within 90 days.
    { url: 'https://acme.com/jobs/sre-1', date: parseDate('2024-01-10'), dateStr: '2024-01-10', title: 'Senior Site Reliability Engineer', company: 'Acme', status: 'added', portal: 'greenhouse', location: '' },
    { url: 'https://acme.com/jobs/sre-2', date: parseDate('2024-03-01'), dateStr: '2024-03-01', title: 'Senior Site Reliability Engineer', company: 'Acme', status: 'added', portal: 'greenhouse', location: '' },
    // Distinct role at the same company — must NOT be flagged.
    { url: 'https://acme.com/jobs/eng-mgr', date: parseDate('2024-02-15'), dateStr: '2024-02-15', title: 'Engineering Manager Platform', company: 'Acme', status: 'added', portal: 'greenhouse', location: '' },
    // Same role + same URL — dedup hit, NOT a repost.
    { url: 'https://acme.com/jobs/sre-1', date: parseDate('2024-03-20'), dateStr: '2024-03-20', title: 'Senior Site Reliability Engineer', company: 'Acme', status: 'added', portal: 'greenhouse', location: '' },
    // Same role + different URL but outside 90-day window — NOT flagged.
    { url: 'https://acme.com/jobs/sre-3', date: parseDate('2024-12-01'), dateStr: '2024-12-01', title: 'Senior Site Reliability Engineer', company: 'Acme', status: 'added', portal: 'greenhouse', location: '' },
    // Skipped (expired) row — must be ignored entirely.
    { url: 'https://acme.com/jobs/sre-4', date: parseDate('2024-02-01'), dateStr: '2024-02-01', title: 'Senior Site Reliability Engineer', company: 'Acme', status: 'skipped_expired', portal: 'greenhouse', location: '' },
  ];

  const clusters = detectReposts(baseRows, DEFAULT_WINDOW_DAYS);

  let pass = 0;
  let fail = 0;
  const check = (cond, label) => {
    if (cond) { pass += 1; } else { fail += 1; console.error(`  FAIL: ${label}`); }
  };

  // The genuine repost cluster (sre-1 on 2024-01-10, sre-2 on 2024-03-01).
  const repostClusters = clusters.filter(c =>
    c.company === 'Acme' &&
    /Site Reliability/.test(c.role) &&
    c.appearances.some(a => a.url === 'https://acme.com/jobs/sre-1') &&
    c.appearances.some(a => a.url === 'https://acme.com/jobs/sre-2')
  );
  check(repostClusters.length === 1, 'genuine repost (same role, different URL, within 90d) should be flagged');

  // The "same URL" row (sre-1 on 2024-03-20) must NOT inflate the cluster with
  // itself as a separate appearance — it collapses onto the sre-1 edge.
  if (repostClusters.length === 1) {
    const urls = repostClusters[0].appearances.map(a => a.url);
    check(new Set(urls).size === urls.length, 'appearances should not duplicate the same URL within one cluster');
    check(repostClusters[0].repostCount === 2, 'repostCount should be 2 for the genuine cluster (sre-1, sre-2)');
  }

  // The distinct Engineering Manager role must NOT appear in any cluster.
  const mgrClusters = clusters.filter(c => /Engineering Manager/.test(c.role));
  check(mgrClusters.length === 0, 'distinct role at the same company should NOT be flagged');

  // The outside-window row (sre-3 on 2024-12-01) must NOT be in the 90-day cluster.
  const sre3Clusters = clusters.filter(c => c.appearances.some(a => a.url === 'https://acme.com/jobs/sre-3'));
  check(sre3Clusters.length === 0, 'same role + different URL but outside 90-day window should NOT be flagged');

  // The skipped_expired row must never appear.
  const expiredClusters = clusters.filter(c => c.appearances.some(a => a.url === 'https://acme.com/jobs/sre-4'));
  check(expiredClusters.length === 0, 'rows with skipped_expired status must be ignored');

  // Empty input -> empty output, no crash.
  check(detectReposts([], DEFAULT_WINDOW_DAYS).length === 0, 'empty input should return no clusters');
  check(detectReposts(baseRows.filter(r => r.status !== 'added'), DEFAULT_WINDOW_DAYS).length === 0, 'only-skipped rows should return no clusters');

  // --- Regression fixtures for the three phantom-repost shapes found in a
  // real scan-history. Company names and URLs are synthetic; only the SHAPE is
  // reproduced, which is the part that matters. ---
  const scanRow = (url, dateStr, title, company) =>
    ({ url, date: parseDate(dateStr), dateStr, title, company, status: 'added', portal: 'ashby-full', location: '' });

  // Shape 1 — one sweep, sibling per-city variants of one role. Both
  // constraints reject it independently, so assert each one on its own too.
  const perCity = [
    scanRow('https://jobs.example.com/acme/a', '2026-01-10', 'Commercial Solutions Engineer - Munich', 'Acme'),
    scanRow('https://jobs.example.com/acme/b', '2026-01-10', 'Commercial Solutions Engineer - Berlin', 'Acme'),
    scanRow('https://jobs.example.com/acme/c', '2026-01-10', 'Commercial Solutions Engineer - EMEA', 'Acme'),
  ];
  check(detectReposts(perCity, DEFAULT_WINDOW_DAYS).length === 0, 'per-city variants seen in one sweep are not reposts');
  check(
    detectReposts(perCity, DEFAULT_WINDOW_DAYS, 0).length === 0,
    'per-city variants are rejected on title identity even with the span floor disabled',
  );

  // Shape 2 — the exact same title twice in one sweep: two concurrent
  // requisitions. Only the span floor can reject this one; title identity
  // holds, which is precisely why the floor is needed as well.
  const sameSweep = [
    scanRow('https://jobs.example.com/globex/r1', '2026-01-10', 'Regional Sales Engineer (Remote, CHE)', 'Globex'),
    scanRow('https://jobs.example.com/globex/r2', '2026-01-10', 'Regional Sales Engineer (Remote, CHE)', 'Globex'),
  ];
  check(detectReposts(sameSweep, DEFAULT_WINDOW_DAYS).length === 0, 'two concurrent requisitions in one sweep are not a repost');
  check(
    detectReposts(sameSweep, DEFAULT_WINDOW_DAYS, 0).length === 1,
    'the same-sweep fixture is rejected by the span floor specifically (it survives with the floor disabled)',
  );

  // Shape 3 — two countries' requisitions in one sweep, titles differing only
  // by a trailing country. This is exactly what roleFuzzyMatch used to merge.
  const perCountry = [
    scanRow('https://jobs.example.com/initech/1', '2026-01-10', 'Channel Account Manager', 'Initech'),
    scanRow('https://jobs.example.com/initech/2', '2026-01-10', 'Channel Account Manager - Spain', 'Initech'),
  ];
  check(detectReposts(perCountry, DEFAULT_WINDOW_DAYS).length === 0, 'two countries\' requisitions are not a repost');

  // Shape 4 — one opening picked up by two sources on different days: the
  // employer's ATS first, a job-board copy later. Same company, same title,
  // different URLs, a real span — and still one listing, not a repost.
  const crossSource = [
    scanRow('https://jobs.ashbyhq.com/hooli/1', '2026-01-10', 'Senior Product Manager', 'Hooli'),
    scanRow('https://www.jobboard.example/jobs/hooli-senior-pm-77', '2026-01-14', 'Senior Product Manager', 'Hooli'),
  ];
  check(detectReposts(crossSource, DEFAULT_WINDOW_DAYS).length === 0, 'one opening seen on two sources is not a repost');
  check(
    detectReposts([...crossSource, scanRow('https://jobs.ashbyhq.com/hooli/2', '2026-02-20', 'Senior Product Manager', 'Hooli')], DEFAULT_WINDOW_DAYS).length === 1,
    'the same source re-listing the role under a new URL is still a repost',
  );
  check(sourceHost('https://de.linkedin.com/jobs/view/1') === sourceHost('https://www.linkedin.com/jobs/view/2'), 'regional subdomains are one source');
  check(sourceHost('https://ca.example.com/jobs/1') !== sourceHost('https://www.example.com/jobs/1'), 'a two-letter subdomain of an unknown site stays its own source');
  check(sourceHost('https://boards.greenhouse.io/acme/jobs/1') === sourceHost('https://job-boards.greenhouse.io/acme/jobs/2'), 'Greenhouse host aliases are one source');
  check(sourceHost('https://job-boards.eu.greenhouse.io/acme/jobs/1') === 'greenhouse.io', 'the EU Greenhouse host folds too');
  check(
    detectReposts(perCountry, DEFAULT_WINDOW_DAYS, 0).length === 0,
    'a trailing country word splits the titles even with the span floor disabled',
  );

  // --- The positive control these constraints must not break: the same role,
  // different URL, seen on two different scan dates. ---
  const genuine = [
    scanRow('https://jobs.example.com/umbrella/4066969101', '2026-01-08', 'Senior Customer Success Manager', 'Umbrella'),
    scanRow('https://jobs.example.com/umbrella/4948414101', '2026-02-11', 'Senior Customer Success Manager', 'Umbrella'),
  ];
  const genuineClusters = detectReposts(genuine, DEFAULT_WINDOW_DAYS);
  check(genuineClusters.length === 1, 'genuine repost across two scan dates is still flagged');
  check(genuineClusters[0]?.daysSpan === 34, 'genuine repost keeps its real 34-day span');

  // --- Aggregator skip (#2703): the one case title identity cannot settle,
  // because an identical title on a multi-employer board is a different
  // employer's job. Same fixture, flagged vs not, so the skip is the only
  // variable. ---
  check(
    detectReposts(genuine, DEFAULT_WINDOW_DAYS, MIN_REPOST_SPAN_DAYS, new Set(['umbrella'])).length === 0,
    'a company marked aggregator produces no clusters',
  );
  check(
    detectReposts(genuine, DEFAULT_WINDOW_DAYS, MIN_REPOST_SPAN_DAYS, new Set(['someoneelse'])).length === 1,
    'flagging a DIFFERENT company leaves this one detected (the skip is keyed, not global)',
  );
  check(
    detectReposts(genuine, DEFAULT_WINDOW_DAYS, MIN_REPOST_SPAN_DAYS, new Set()).length === 1,
    'an empty aggregator set changes nothing',
  );
  check(
    detectReposts(genuine, DEFAULT_WINDOW_DAYS, MIN_REPOST_SPAN_DAYS, null).length === 1,
    'a null aggregator set changes nothing (pre-#2703 behaviour)',
  );
  // The key space must match companyKey's, or the Set silently never matches —
  // a skip that quietly does nothing is worse than no skip at all.
  check(
    detectReposts(
      [
        scanRow('https://jobs.example.com/acme/1', '2026-01-08', 'Data Engineer', 'Acme Inc.'),
        scanRow('https://jobs.example.com/acme/2', '2026-02-11', 'Data Engineer', 'ACME, INC'),
      ],
      DEFAULT_WINDOW_DAYS,
      MIN_REPOST_SPAN_DAYS,
      new Set([normalizeCompanyName('Acme Inc.')]),
    ).length === 0,
    'the aggregator key is normalized the same way companyKey normalizes rows',
  );

  // A repost re-listed with the words reordered/repunctuated is still one role.
  const reworded = [
    scanRow('https://jobs.example.com/hooli/a', '2026-01-28', 'Forward Deployed Engineer - Sweden', 'Hooli'),
    scanRow('https://jobs.example.com/hooli/b', '2026-02-11', 'Forward Deployed Engineer, Sweden', 'Hooli'),
  ];
  check(detectReposts(reworded, DEFAULT_WINDOW_DAYS).length === 1, 'punctuation-only retitling still counts as the same role');

  // A same-day sighting must not be able to drag a real repost pair's span down
  // to 0 and suppress it.
  const mixedSpan = [
    ...genuine,
    scanRow('https://jobs.example.com/umbrella/4948414102', '2026-02-11', 'Senior Customer Success Manager', 'Umbrella'),
  ];
  const mixedClusters = detectReposts(mixedSpan, DEFAULT_WINDOW_DAYS);
  check(mixedClusters.length === 1 && mixedClusters[0].repostCount === 3, 'a same-day third sighting joins the real cluster rather than suppressing it');

  // titleIdentityKey contract.
  check(
    titleIdentityKey('Forward Deployed Engineer - Sweden') === titleIdentityKey('Forward Deployed Engineer, Sweden'),
    'titleIdentityKey ignores punctuation',
  );
  check(
    titleIdentityKey('Senior Backend Engineer Payments') === titleIdentityKey('Backend Engineer Payments Senior'),
    'titleIdentityKey ignores word order',
  );
  check(
    titleIdentityKey('Solutions Engineer - UK') !== titleIdentityKey('Solutions Engineer - US'),
    'titleIdentityKey keeps short country words that roleTokens would have dropped',
  );
  check(
    titleIdentityKey('Operations Manager') !== titleIdentityKey('Senior Operations Manager'),
    'titleIdentityKey keeps seniority words distinct',
  );
  // --- loadAggregatorCompanies against REAL yaml written to disk, not a
  // hand-built Set: the parse and the key derivation are the parts that can
  // silently produce an empty Set, and an empty Set looks exactly like
  // "no aggregators configured". ---
  {
    // One private directory rather than predictable names in the shared temp
    // dir: `co-aggr-<pid>.yml` can be pre-created as a symlink by anyone else
    // on the machine, and writeFileSync follows it. mkdtempSync gives a path
    // nobody can guess ahead of time, and one rmSync cleans all of it up.
    const fixtures = mkdtempSync(join(tmpdir(), 'co-aggr-'));
    try {
      const tmp = join(fixtures, 'portals.yml');
      writeFileSync(tmp, [
        'tracked_companies:',
        '  - name: Plain Co',
        '    provider: lever',
        '  - name: Board One',
        '    provider: lever',
        '    aggregator: true',
        '  - name: Board Two Inc.',
        '    aggregator: true',
        '  - name: Explicitly Not',
        '    aggregator: false',
        '  - name: Stringy',
        '    aggregator: "true"',
        'job_boards:',
        '  - name: Board Under Boards',
        '    aggregator: true',
        '',
      ].join('\n'), 'utf-8');
      const keys = loadAggregatorCompanies(tmp);
      check(keys.has(normalizeCompanyName('Board One')), 'aggregator: true is picked up');
      check(keys.has(normalizeCompanyName('Board Two Inc.')), 'a suffixed name is normalized into the companyKey space');
      // A multi-employer board is likelier to be configured here than under
      // tracked_companies, and both entries portals.example.yml flags are.
      check(keys.has(normalizeCompanyName('Board Under Boards')), 'a flagged entry under job_boards counts too');
      check(!keys.has(normalizeCompanyName('Plain Co')), 'an entry without the flag is not an aggregator');
      check(!keys.has(normalizeCompanyName('Explicitly Not')), 'aggregator: false is not an aggregator');
      // YAML would give the string "true" here. Accepting it would make the
      // flag's meaning depend on quoting; rejecting it keeps `=== true` honest.
      check(!keys.has(normalizeCompanyName('Stringy')), 'a quoted "true" is not the boolean true');
      check(keys.size === 3, 'exactly the three flagged entries are returned');

      check(loadAggregatorCompanies(join(fixtures, 'does-not-exist.yml')).size === 0, 'a missing portals.yml yields no aggregators, no crash');

      const bad = join(fixtures, 'malformed.yml');
      writeFileSync(bad, 'tracked_companies: [unclosed\n', 'utf-8');
      check(loadAggregatorCompanies(bad).size === 0, 'a malformed portals.yml yields no aggregators, no crash');

      const unflagged = join(fixtures, 'unflagged.yml');
      writeFileSync(unflagged, 'job_boards:\n  - name: Somewhere\n', 'utf-8');
      check(loadAggregatorCompanies(unflagged).size === 0, 'a config whose entries carry no flag yields no aggregators');
    } finally {
      rmSync(fixtures, { recursive: true, force: true });
    }
  }

  check(titleIdentityKey('—') === '—', 'a title that folds to nothing falls back to its raw text rather than an empty key');
  check(
    titleIdentityKey('シニアエンジニア') !== titleIdentityKey('データアナリスト'),
    'non-Latin titles keep distinct keys instead of both folding to empty (#2429 reasoning)',
  );
  check(
    titleIdentityKey('Ingénieur Données') === titleIdentityKey('Ingenieur Donnees'),
    'titleIdentityKey folds accents, so a re-listing that drops them is still the same role',
  );

  console.log(`\n  detect-reposts self-test: ${pass} passed, ${fail} failed\n`);
  process.exit(fail > 0 ? 1 : 0);
}

// --- Run (CLI only; guarded so the module is safely importable for tests) ---
if (isMainModule(import.meta.url)) {
  // Replaces a bare --help check that never looked at the other flags, so a
  // mistyped --window was ignored and the scan silently used the 90-day
  // default instead of the window that was asked for (#2919). validateFlags
  // also runs the unrecognized-flag check BEFORE --help, so `--help --bogus`
  // errors rather than exiting 0 unread.
  //
  // Inside the main-module guard, not at import time: company-history.mjs
  // imports detectReposts/parseScanHistory from here, so a top-level check
  // would judge the IMPORTER's argv.
  // requireOperand: without it, `--window --summary` reads --summary as the
  // window value; flagValue() has no adjacency check, parseInt('--summary')
  // is NaN, and windowDays silently falls back to DEFAULT_WINDOW_DAYS at exit
  // 0 instead of reporting the malformed flag (#3087). Nothing more specific
  // to say than the shared message.
  validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true });

  if (selfTestMode) {
    runSelfTest();
  }

  // --retain-days is validated by hand rather than through safeIntFlag: that
  // helper falls back to a default on an unparseable value, which is right for
  // a display window and wrong for a pass that deletes rows. A typo must stop
  // the run, not silently pick a retention the user never asked for.
  const retainRaw = flagValue(args, '--retain-days');
  let retainDays = null;
  if (retainRaw !== undefined) {
    const parsed = Number(retainRaw);
    if (!Number.isInteger(parsed) || parsed < 1) {
      console.error(`Invalid --retain-days "${retainRaw}". Use a positive whole number of days, e.g. --retain-days 180.`);
      process.exit(1);
    }
    retainDays = parsed;
  }
  const dryRun = args.includes('--dry-run');
  if (dryRun && retainDays === null) {
    console.error('--dry-run needs --retain-days N; it previews a retention pass and nothing else.');
    process.exit(1);
  }
  // Before the read below, so the report describes the retained file rather
  // than clusters the pass just removed.
  if (retainDays !== null) await pruneScanHistory(retainDays, { dryRun });

  const rows = loadScanHistory();
  const aggregators = loadAggregatorCompanies();
  const clusters = detectReposts(rows, windowDays, minSpanDays, aggregators);

  if (summaryMode) {
    printSummary(clusters, aggregators.size);
  } else {
    console.log(JSON.stringify({
      metadata: {
        windowDays,
        minSpanDays,
        totalRows: rows.length,
        // Reported so a reader can tell "no aggregators configured" from
        // "aggregators configured and skipped" — otherwise a mis-keyed flag
        // that silently matches nothing looks identical to a working one.
        aggregatorCompanies: aggregators.size,
        clusters: clusters.length,
      },
      clusters,
    }, null, 2));
  }
}