Ctrl K
ComunidadGuías…

evidence-freshness.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * evidence-freshness.mjs — is the GitHub evidence behind an evaluation stale?
 *
 * `oferta`, `auto-pipeline` and batch workers score a JD against cv.md,
 * article-digest.md and the profile. GitHub activity reaches those files only
 * when the user runs `expand`, whose optional step (github-evidence.mjs) writes
 * output/github-evidence-{YYYY-MM-DD}.{md,json}. Nothing said how old that scan
 * was, so PRs merged after it were silently missing from the fit and a score
 * could understate it with no warning.
 *
 * Opt-in: off unless config/profile.yml sets evidence_freshness.enabled: true,
 * so nobody gets a GitHub call they did not ask for. Interactive evaluations run
 * it once before Block A; batch-runner.sh runs it once per batch run and hands
 * the result to every worker. When on, it reports:
 *   - the newest output/github-evidence-*.json and how many days old it is
 *   - how many PRs the user authored and merged since then in repos they do not
 *     own (owning a repo is not authorship, so github-evidence.mjs never counts
 *     those either), from ONE metadata-only `gh search prs` call through the
 *     user's existing gh auth (no new token, no repo names or titles kept)
 *   - a stale verdict against thresholds in config/profile.yml
 *
 * Read-only and never blocking: it writes nothing, never runs expand or
 * github-evidence.mjs, and exits 0 with JSON on stdout whatever happens. When
 * candidate.github is unset, gh is missing or logged out, or the query fails,
 * the status says so and `stale` is false.
 *
 * Config (config/profile.yml, all optional):
 *   evidence_freshness:
 *     enabled: true            # off unless set to true
 *     min_new_merged_prs: 5    # stale at this many merged PRs since the last scan
 *     max_age_days: 30         # stale when the last scan is older than this
 * With no scan on record, the evidence is stale as soon as the user has one
 * merged PR in someone else's repo.
 *
 * Usage:
 *   node evidence-freshness.mjs   # JSON on stdout, always exit 0
 */

import { spawnSync } from 'child_process';
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
import { loadYaml } from './lib/yaml-document.mjs';
import { getCareerOpsRoot, getProfilePath, hasProfileOverride } from './path-resolver.mjs';
import { localToday } from './lib/local-today.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { validateFlags } from './lib/cli-flags.mjs';
import { normalizeGithubUser } from './github-evidence.mjs';

const KNOWN_FLAGS = ['--help', '-h'];
const USAGE = `Usage:
  node evidence-freshness.mjs   # JSON on stdout, always exit 0

Says whether the newest output/github-evidence-*.json is stale: its age, and
the PRs you authored and merged since then in repos you do not own (one
metadata-only gh search through your existing gh auth). Read-only.
Opt-in: set config/profile.yml -> evidence_freshness.enabled: true.`;

export const DEFAULTS = Object.freeze({ enabled: false, minNewMergedPrs: 5, maxAgeDays: 30 });

/** One search page: the count is exact below this and shown as "100+" at it. */
export const QUERY_LIMIT = 100;

const SCAN_FILE_RE = /^github-evidence-(\d{4})-(\d{2})-(\d{2})\.json$/;
const DAY_MS = 24 * 60 * 60 * 1000;

/**
 * `evidence_freshness` from config/profile.yml, with the defaults filled in.
 * The check is on only when `enabled` is exactly true. A threshold that is not a
 * whole number of at least 1 keeps its default and is reported, so a typo never
 * switches a rule off.
 *
 * @param {object|null} profile - Parsed config/profile.yml.
 * @returns {{thresholds: {enabled: boolean, minNewMergedPrs: number, maxAgeDays: number}, warnings: string[]}}
 */
export function readThresholds(profile) {
  const thresholds = { ...DEFAULTS };
  const warnings = [];
  const raw = profile?.evidence_freshness;
  if (raw == null) return { thresholds, warnings };
  if (typeof raw !== 'object' || Array.isArray(raw)) {
    warnings.push('evidence_freshness is not a map; using the defaults');
    return { thresholds, warnings };
  }
  if (raw.enabled === true) thresholds.enabled = true;
  else if (raw.enabled != null && raw.enabled !== false) warnings.push('evidence_freshness.enabled is not true or false; the check stays off');
  for (const [key, prop] of [['min_new_merged_prs', 'minNewMergedPrs'], ['max_age_days', 'maxAgeDays']]) {
    if (raw[key] == null) continue;
    if (Number.isInteger(raw[key]) && raw[key] >= 1) thresholds[prop] = raw[key];
    else warnings.push(`evidence_freshness.${key} must be a whole number of at least 1; using ${DEFAULTS[prop]}`);
  }
  return { thresholds, warnings };
}

/**
 * The newest github-evidence scan for this user, or null when there is none.
 *
 * Files are taken newest date first. One that does not parse as a JSON object,
 * or that records a different `user`, is skipped: it is not a scan of this
 * account. `since` is the lower bound for "merged after the scan": the file's
 * modification time when it falls on the file's own date (the time the scan was
 * written), otherwise the start of that local day, so PRs merged earlier on the
 * scan day are not counted as new right after a scan.
 *
 * @param {string} dataRoot
 * @param {string} user - Normalized GitHub username.
 * @returns {{date: string, file: string, since: string}|null}
 */
export function findLatestScan(dataRoot, user) {
  const dir = join(dataRoot, 'output');
  if (!existsSync(dir)) return null;
  const candidates = readdirSync(dir)
    .map((name) => ({ name, m: name.match(SCAN_FILE_RE) }))
    .filter(({ m }) => m && isCalendarDate(+m[1], +m[2], +m[3]))
    .sort((a, b) => b.name.localeCompare(a.name));
  for (const { name, m } of candidates) {
    const path = join(dir, name);
    let doc;
    try {
      doc = JSON.parse(readFileSync(path, 'utf8'));
    } catch {
      continue;
    }
    if (!doc || typeof doc !== 'object' || Array.isArray(doc)) continue;
    if (typeof doc.user === 'string' && doc.user.toLowerCase() !== user.toLowerCase()) continue;
    const date = `${m[1]}-${m[2]}-${m[3]}`;
    let since = isoSeconds(new Date(+m[1], +m[2] - 1, +m[3]));
    try {
      const mtime = statSync(path).mtime;
      if (localToday(mtime) === date) since = isoSeconds(mtime);
    } catch {
      // Unreadable metadata: keep the start of the scan's day.
    }
    return { date, file: `output/${name}`, since };
  }
  return null;
}

// GitHub search takes `YYYY-MM-DDTHH:MM:SSZ`; milliseconds are dropped (rounding down).
const isoSeconds = (d) => d.toISOString().replace(/\.\d{3}Z$/, 'Z');

function isCalendarDate(y, m, d) {
  const t = new Date(Date.UTC(y, m - 1, d));
  return t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d;
}

/** Whole days from `from` to `to` (both YYYY-MM-DD), never negative. */
export function daysBetween(from, to) {
  const utc = (s) => Date.UTC(+s.slice(0, 4), +s.slice(5, 7) - 1, +s.slice(8, 10));
  return Math.max(0, Math.round((utc(to) - utc(from)) / DAY_MS));
}

/**
 * The single gh call: PRs the user authored that were merged (after `since`,
 * when there is a scan), excluding repos the user owns. One search page, so one
 * request; only the repository field is asked for, and only the count is kept.
 */
export function searchArgs(user, since) {
  const args = ['search', 'prs', '--author', user, '--merged'];
  if (since) args.push('--merged-at', `>=${since}`);
  args.push('--limit', String(QUERY_LIMIT), '--json', 'repository', '--', `-user:${user}`);
  return args;
}

/**
 * The real gh runner. Injectable, so tests never spawn gh or reach the network.
 * Bounded, so a hung gh never holds up an evaluation.
 */
export function defaultGhRunner(args) {
  const res = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: 30_000 });
  return {
    status: res.status,
    signal: res.signal ?? null,
    stdout: res.stdout || '',
    stderr: res.stderr || '',
    notFound: res.error?.code === 'ENOENT',
  };
}

// gh exits 4 when it has no credentials; a bad or expired token comes back as a 401.
const AUTH_FAILURE = /auth login|not logged|bad credentials|401 unauthorized|HTTP 401|authentication/i;
const firstLine = (s) => String(s ?? '').trim().split('\n')[0];

/**
 * Run the search and classify the outcome.
 *
 * @returns {{ok: true, count: number, truncated: boolean}
 *   | {ok: false, status: 'gh-unavailable'|'gh-query-failed', detail: string}}
 */
export function countNewMergedPrs(user, since, runGh) {
  const res = runGh(searchArgs(user, since));
  if (res.notFound) {
    return { ok: false, status: 'gh-unavailable', detail: 'gh is not installed (not found on PATH)' };
  }
  if (res.status !== 0) {
    if (res.status === 4 || AUTH_FAILURE.test(res.stderr)) {
      return { ok: false, status: 'gh-unavailable', detail: 'gh is installed but not logged in to github.com' };
    }
    const detail = firstLine(res.stderr) || (res.signal ? `killed by ${res.signal}` : `exit ${res.status}`);
    return { ok: false, status: 'gh-query-failed', detail: `gh search prs failed: ${detail}` };
  }
  let rows;
  try {
    rows = JSON.parse(res.stdout);
  } catch {
    return { ok: false, status: 'gh-query-failed', detail: 'gh search prs returned output that is not JSON' };
  }
  if (!Array.isArray(rows)) return { ok: false, status: 'gh-query-failed', detail: 'gh search prs did not return a list' };
  // The query already excludes the user's repos; this re-checks it.
  const own = `${user.toLowerCase()}/`;
  const count = rows.filter((r) => !String(r?.repository?.nameWithOwner ?? '').toLowerCase().startsWith(own)).length;
  return { ok: true, count, truncated: rows.length >= QUERY_LIMIT };
}

const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
const ago = (days) => (days === 0 ? 'today' : `${plural(days, 'day')} ago`);

/**
 * Pure verdict.
 *
 * @param {object} input
 * @param {string|null} input.lastScan - YYYY-MM-DD, or null when no scan is on record.
 * @param {number|null} input.daysSince
 * @param {number} input.newMergedPrs
 * @param {boolean} input.truncated - The count reached QUERY_LIMIT.
 * @param {{minNewMergedPrs: number, maxAgeDays: number}} input.thresholds
 * @returns {{stale: boolean, reason: string, notice: string|null}}
 */
export function judge({ lastScan, daysSince, newMergedPrs, truncated, thresholds }) {
  const count = truncated ? `${QUERY_LIMIT}+` : String(newMergedPrs);
  const prs = `${count} merged PR${count === '1' ? '' : 's'}`;
  if (!lastScan) {
    if (newMergedPrs === 0) {
      return { stale: false, reason: 'no GitHub evidence scan on record, and no merged PRs in other people\'s repos for one to find', notice: null };
    }
    return {
      stale: true,
      reason: `no GitHub evidence scan on record, and ${prs} in other people's repos`,
      notice: `No GitHub evidence scan on record; ${prs} in other people's repos. Scores may understate fit.`,
    };
  }
  const byCount = truncated || newMergedPrs >= thresholds.minNewMergedPrs;
  const byAge = daysSince > thresholds.maxAgeDays;
  const parts = [
    `${prs} since the last scan (stale at ${thresholds.minNewMergedPrs})`,
    `last scan ${ago(daysSince)} (stale after ${plural(thresholds.maxAgeDays, 'day')})`,
  ];
  if (!byCount && !byAge) return { stale: false, reason: `within thresholds: ${parts.join('; ')}`, notice: null };
  return {
    stale: true,
    reason: [byCount && parts[0], byAge && parts[1]].filter(Boolean).join('; '),
    notice: `Last GitHub evidence scan: ${lastScan} (${ago(daysSince)}); ${prs} since. Scores may understate fit.`,
  };
}

// ── CLI ─────────────────────────────────────────────────────────────

/**
 * The CLI body, every outside effect injectable for tests.
 *
 * @returns {number} Always 0: this check informs an evaluation, it never stops one.
 */
export function runCli({
  dataRoot = getCareerOpsRoot(), runGh = defaultGhRunner,
  profilePath = hasProfileOverride() ? getProfilePath() : getProfilePath(dataRoot),
  now = new Date(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s),
} = {}) {
  // Every status prints the same keys in the same order; a field this run could
  // not fill is null (false for the two flags).
  const base = {
    status: null, stale: false, reason: null, notice: null, user: null,
    lastScan: null, scanFile: null, daysSince: null, newMergedPrs: null, newMergedPrsTruncated: false,
  };
  let thresholds = { ...DEFAULTS };
  const emit = (fields) => {
    const { minNewMergedPrs, maxAgeDays } = thresholds;
    stdout(`${JSON.stringify({ ...base, ...fields, thresholds: { minNewMergedPrs, maxAgeDays } }, null, 2)}\n`);
    return 0;
  };

  if (!existsSync(profilePath)) return emit({ status: 'no-github', reason: 'config/profile.yml not found' });
  let profile;
  try {
    profile = loadYaml(readFileSync(profilePath, 'utf8')) || {};
  } catch (err) {
    return emit({ status: 'no-github', reason: `config/profile.yml is not valid YAML: ${firstLine(err.message)}` });
  }
  const read = readThresholds(profile);
  thresholds = read.thresholds;
  for (const w of read.warnings) stderr(`evidence-freshness: ${w}\n`);
  if (!thresholds.enabled) return emit({ status: 'disabled', reason: 'the check is opt-in and evidence_freshness.enabled is not true in config/profile.yml' });

  const user = normalizeGithubUser(profile?.candidate?.github);
  if (!user) {
    return emit({ status: 'no-github', reason: 'config/profile.yml -> candidate.github is not set to a GitHub username or profile link' });
  }

  const today = localToday(now);
  const scan = findLatestScan(dataRoot, user);
  const scanFields = {
    user,
    lastScan: scan?.date ?? null,
    scanFile: scan?.file ?? null,
    daysSince: scan ? daysBetween(scan.date, today) : null,
  };
  const found = countNewMergedPrs(user, scan?.since ?? null, runGh);
  if (!found.ok) return emit({ ...scanFields, status: found.status, reason: `${found.detail}; freshness not checked` });
  const verdict = judge({
    lastScan: scanFields.lastScan, daysSince: scanFields.daysSince,
    newMergedPrs: found.count, truncated: found.truncated, thresholds,
  });
  return emit({
    status: 'ok', ...scanFields,
    newMergedPrs: found.count, newMergedPrsTruncated: found.truncated, ...verdict,
  });
}

if (isMainModule(import.meta.url)) {
  validateFlags(process.argv.slice(2), KNOWN_FLAGS, USAGE);
  process.exitCode = runCli();
}