fix-report-links.mjs
Rama main · réplica de lectura del repositorio original.
#!/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