Ctrl K
ComunidadGuías…

fix-report-links.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * fix-report-links.mjs — Repair tracker rows whose Report link points at a missing file
 *
 * verify-pipeline.mjs (Check 3) reports `Report not found: ...` for every row
 * whose Report cell links to a file that is not on disk, but it is read-only.
 * This script is the explicit, previewed, backed-up repair: it rewrites ONLY
 * that one cell to `—` (the tracker's existing "no report" value). Every other
 * cell, the row order, the cell padding and the file's line endings (LF/CRLF)
 * are left byte-for-byte as they were; nothing is re-sorted or re-formatted.
 *
 * "Broken" is decided by findDeadReportLink() in tracker-utils.mjs, the same
 * function verify-pipeline and merge-tracker use, so the tools always agree on
 * which rows are broken. It does not guess WHY a report is missing (#4748).
 *
 * A Report cell that is anything other than exactly one markdown link (two
 * links, link plus text) is never rewritten: it is listed under "skipped,
 * please check by hand" instead.
 *
 * Run: node fix-report-links.mjs [--dry-run]
 */

import { readFileSync, writeFileSync, existsSync } from 'fs';
import { getCareerOpsRoot, resolveTrackerPath } from './path-resolver.mjs';
import { openTrackerTransaction, findDeadReportLink, trackerLinkDirsFor } from './tracker-utils.mjs';
import { resolveColumns, parseTrackerRow, firstLinkTarget, soleMarkdownLink } from './tracker-parse.mjs';
import { validateFlags } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';

const NO_REPORT = '—';

/**
 * Scan tracker lines and rewrite dead single-link Report cells to `—`.
 * Pure: touches no files. Exported so tests can call it directly.
 *
 * @param {string[]} lines - Tracker content split on '\n' (a CR stays on its line).
 * @param {string|string[]} trackerDir - Directory (or directories, see trackerLinkDirsFor) containing the tracker file.
 * @param {string} dataRoot - Data root.
 * @param {{stat?: Function}} [options] - Test seam for filesystem inspection.
 * @returns {{lines: string[], changed: object[], skipped: object[], inspectionErrors: object[], hasReportColumn: boolean}}
 */
export function fixReportLinks(lines, trackerDir, dataRoot, options = {}) {
  const COLS = resolveColumns(lines);
  if (COLS.report == null) return { lines, changed: [], skipped: [], inspectionErrors: [], hasReportColumn: false };

  const out = lines.slice();
  const changed = [];
  const skipped = [];
  const inspectionErrors = [];
  for (let i = 0; i < lines.length; i++) {
    const row = parseTrackerRow(lines[i], COLS);
    if (!row) continue; // header, separator, non-row, or a row missing cells

    const firstLink = firstLinkTarget(row.report);
    // Any Report cell that contains a link but is not exactly one bare link is
    // ambiguous by contract. Surface it for manual review before inspecting the
    // first target: otherwise a live first link hides a dead later link.
    if (firstLink !== null && !soleMarkdownLink(row.report)) {
      skipped.push({
        num: row.num, company: row.company, role: row.role,
        link: firstLink, cell: row.report, line: i + 1,
      });
      continue;
    }

    let inspectionFailure = null;
    const link = findDeadReportLink(row.report, trackerDir, dataRoot, {
      stat: options.stat,
      onInspectionError: (failure) => { inspectionFailure = failure; },
    });
    if (inspectionFailure) {
      inspectionErrors.push({
        num: row.num, company: row.company, role: row.role, cell: row.report,
        line: i + 1, ...inspectionFailure,
      });
      continue;
    }
    if (link === null) continue; // no link (—, N/A, empty) or it resolves

    const info = { num: row.num, company: row.company, role: row.role, link, cell: row.report, line: i + 1 };

    // Replace just this cell in the raw (untrimmed) split, keeping its padding,
    // so no other byte of the line moves. parseTrackerRow trims the same split.
    const parts = lines[i].split('|');
    const raw = parts[COLS.report];
    const lead = raw.match(/^\s*/)[0];
    const trail = raw.match(/\s*$/)[0];
    parts[COLS.report] = `${lead}${NO_REPORT}${trail}`;
    out[i] = parts.join('|');
    changed.push(info);
  }
  return { lines: out, changed, skipped, inspectionErrors, hasReportColumn: true };
}

const KNOWN_FLAGS = ['--help', '-h', '--dry-run'];
const USAGE = `Usage: node fix-report-links.mjs [options]

  Rewrites the Report cell of tracker rows whose markdown link points at a
  missing file to "${NO_REPORT}". Nothing else in the file changes.

  Options:
  --dry-run        List the rows that would change without writing applications.md
  -h, --help       Show this help and exit
  `;

function describe(info) {
  return `#${info.num} ${info.company} — ${info.role}: ${info.link}`;
}

const IS_CLI = isMainModule(import.meta.url);

if (IS_CLI) {
const args = process.argv.slice(2);

// Unrecognized flags exit 1 naming the flag; --help/-h print USAGE and exit 0.
validateFlags(args, KNOWN_FLAGS, USAGE);

const DRY_RUN = args.includes('--dry-run');
const CAREER_OPS = getCareerOpsRoot();
const APPS_FILE = resolveTrackerPath(CAREER_OPS);

if (!existsSync(APPS_FILE)) {
  console.log('No applications.md found. Nothing to fix.');
  process.exit(0);
}

let trackerTransaction = null;
if (!DRY_RUN) {
  try {
    trackerTransaction = await openTrackerTransaction(APPS_FILE);
  } catch (err) {
    console.error(`Cannot acquire tracker lock: ${err.message}`);
    process.exit(1);
  }
  process.once('exit', () => {
    try { trackerTransaction.close(); } catch {}
  });
}
try {
const content = trackerTransaction ? trackerTransaction.read() : readFileSync(APPS_FILE, 'utf-8');
const result = fixReportLinks(content.split('\n'), trackerLinkDirsFor(CAREER_OPS), CAREER_OPS);

if (!result.hasReportColumn) {
  console.log('The tracker has no Report column. Nothing to fix.');
  process.exit(0);
}

for (const c of result.changed) {
  console.log(`${DRY_RUN ? 'Would rewrite' : 'Rewrote'} ${describe(c)}`);
}

if (result.skipped.length > 0) {
  console.log(`\n⚠️  ${result.skipped.length} row(s) skipped, please check by hand (Report cell is not a single link):`);
  for (const s of result.skipped) {
    console.log(`  #${s.num} (line ${s.line}) ${s.company} — ${s.role}: "${s.cell}"`);
  }
}

if (result.inspectionErrors.length > 0) {
  console.log(`\n⚠️  ${result.inspectionErrors.length} row(s) preserved because the report target could not be inspected:`);
  for (const item of result.inspectionErrors) {
    const details = item.errors.map(({ path, error }) => `${path} (${error.code ?? error.message})`).join('; ');
    console.log(`  #${item.num} (line ${item.line}) ${item.company} — ${item.role}: ${details}`);
  }
}

console.log(`\n📊 ${result.changed.length} dead report link(s) ${DRY_RUN ? 'found' : 'fixed'}`);

if (DRY_RUN) {
  console.log('(dry-run — no changes written)');
} else if (result.changed.length > 0) {
  // Backup first. Written from the bytes this run parsed (read inside the
  // lock), next to the transaction's canonical path: copying APPS_FILE again
  // would follow a CAREER_OPS_TRACKER symlink and put the .bak beside the
  // link, or capture an edit made by a writer that bypasses the lock.
  const backupPath = `${trackerTransaction.path}.bak`;
  writeFileSync(backupPath, content);
  trackerTransaction.replace(result.lines.join('\n'));
  console.log(`✅ Written to ${trackerTransaction.path} (backup: ${backupPath})`);
} else {
  console.log('✅ No changes needed');
}
} finally {
  trackerTransaction?.close();
}
} // end IS_CLI