Ctrl K
ComunidadGuías…

evaluation-cache.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/** Local report-backed public facts cache (#1025); identity belongs to #1030. */
import { readFileSync, readdirSync, lstatSync, realpathSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { parseArgs } from 'node:util';
import { JSON_SCHEMA } from 'js-yaml';
import { loadYaml } from './lib/yaml-document.mjs';
import { machineSummaryStart } from './lib/machine-summary-section.mjs';
import { getCareerOpsRoot } from './path-resolver.mjs';
import { acquireTrackerLock, trackerLockDirFor, renameSyncWithRetry } from './tracker-utils.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { validateFlags } from './lib/cli-flags.mjs';

export const JOB_FACTS_TTL_MS = 24 * 60 * 60 * 1000;
const LIVENESS_TTL_MS = 5 * 60 * 1000;
const PUBLIC_FIELDS = ['company', 'role', 'advertised_comp', 'reports_to'];
const CACHE_FIELDS = ['schema_version', 'listing_fingerprint', 'captured_at', 'invalidated_at'];
const EXPLICIT_CLOSURE_CODES = new Set(['http_gone', 'expired_url', 'expired_body', 'listing_page']);
const miss = (reason) => ({ status: 'miss', reason });
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);

// No permissive Date.parse fallback: date-only/local time and normalized invalid
// calendar dates must not accidentally authorize reuse.
function timestamp(value) {
  if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(value)) return NaN;
  const n = Date.parse(value);
  return Number.isFinite(n) && new Date(n).toISOString() === (value.length === 20 ? value.replace(/Z$/, '.000Z') : value) ? n : NaN;
}

/**
 * The report's only Machine Summary. Two candidate headings are a miss, not a
 * choice, and the one heading must be the report's own (one pasted inside the
 * archived JD is not).
 */
function machineSummary(markdown) {
  if (typeof markdown !== 'string' || markdown.length > 2_000_000) return null;
  const lines = markdown.split(/(?<=\n)/);
  let fence = null, offset = 0;
  const headings = [];
  for (const line of lines) {
    const mark = line.match(/^ {0,3}(`{3,}|~{3,})(.*)/);
    if (fence) {
      if (mark && mark[1][0] === fence[0] && mark[1].length >= fence.length && !mark[2].trim()) fence = null;
    } else if (mark) fence = mark[1];
    else if (/^##[ \t]+Machine Summary[ \t]*\r?\n?$/.test(line)) headings.push({ line: offset, end: offset + line.length });
    offset += line.length;
  }
  if (headings.length !== 1 || headings[0].line !== machineSummaryStart(markdown)) return null;
  const start = headings[0].end;
  const match = markdown.slice(start).match(/^(?:[ \t]*\r?\n)*(`{3,})(?:yaml|yml|json)[ \t]*\r?\n([\s\S]*?)\r?\n\1[ \t]*(?=\r?\n|$)/);
  if (!match) return null;
  const raw = match[2];
  const rawStart = start + match[0].indexOf(raw);
  try {
    const summary = loadYaml(raw, { schema: JSON_SCHEMA });
    return isObject(summary) ? { summary, raw, rawStart } : null;
  } catch { return null; }
}

/** Explicit projection only. Never serialize the report, header or private maps. */
function publicFacts(summary) {
  const facts = {};
  for (const field of PUBLIC_FIELDS) {
    const value = summary[field] ?? null;
    if (value !== null && (typeof value !== 'string' || !value.trim() || value.length > 2000)) return null;
    facts[field] = value;
  }
  return facts.company && facts.role ? facts : null;
}

export function extractJobFacts(markdown) {
  const parsed = machineSummary(markdown);
  return parsed ? publicFacts(parsed.summary) : null;
}

async function identityModule() {
  try { return await import('./listing-fingerprint.mjs'); }
  catch (error) {
    // This dependent draft can be installed before #1030. No substitute key,
    // URL heuristic or local copy: reuse stays disabled until it is installed.
    if (error.code === 'ERR_MODULE_NOT_FOUND' && error.url === new URL('./listing-fingerprint.mjs', import.meta.url).href) return null;
    throw error;
  }
}

function livenessValid(identity, fingerprint, liveness, now) {
  if (!isObject(liveness) || !['active', 'expired', 'uncertain'].includes(liveness.result)) return false;
  const checked = timestamp(liveness.checked_at);
  if (!Number.isFinite(checked) || checked > now || now - checked >= LIVENESS_TTL_MS) return false;
  try {
    // Canonical host/path omit query, so they cannot prove identity (e.g.
    // Greenhouse embed/job_app?token=...). Bind to the independently resolved
    // ATS record of the posting actually checked, using the same #1030 API.
    if (identity.listingKey(liveness.listing_fingerprint) !== identity.listingKey(fingerprint)) return false;
    const context = identity.computeListingFingerprint({ url: liveness.url });
    return !!fingerprint.canonical_host && !!fingerprint.canonical_path &&
      context.canonical_host === liveness.listing_fingerprint.canonical_host &&
      context.canonical_path === liveness.listing_fingerprint.canonical_path &&
      context.canonical_host === fingerprint.canonical_host && context.canonical_path === fingerprint.canonical_path;
  } catch { return false; }
}

async function contextFor({ fingerprint, liveness, now }) {
  if (!Number.isFinite(now)) throw new TypeError('now must be a finite timestamp');
  const identity = await identityModule();
  if (!identity) return { failure: miss('listing-identity-unavailable') };
  let key;
  try { key = identity.listingKey(fingerprint); }
  catch { return { failure: miss('invalid-identity') }; }
  if (!key) return { failure: miss('partial-identity') };
  if (!livenessValid(identity, fingerprint, liveness, now)) return { failure: miss('liveness-unverified') };
  return { identity, key };
}

function reportFiles(reportsDir) {
  try {
    // A report symlink could read or rewrite unrelated user data. Only direct
    // regular Markdown files in the selected reports directory are eligible.
    if (lstatSync(reportsDir).isSymbolicLink()) throw new Error('reports directory must not be a symlink');
    return readdirSync(reportsDir, { withFileTypes: true })
      .filter(entry => entry.isFile() && entry.name.endsWith('.md'))
      .map(entry => join(reportsDir, entry.name)).sort();
  } catch (error) {
    if (error.code === 'ENOENT') return [];
    throw error;
  }
}

function reportRecord(path, identity, key) {
  try {
    if (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()) return null;
    const markdown = readFileSync(path, 'utf8');
    const parsed = machineSummary(markdown);
    const meta = parsed?.summary.job_facts_cache;
    if (!isObject(meta) || meta.schema_version !== 1 || Object.keys(meta).some(k => !CACHE_FIELDS.includes(k))) return null;
    if (identity.listingKey(meta.listing_fingerprint) !== key) return null;
    const captured = timestamp(meta.captured_at);
    // An invalid invalidation marker must fail closed, never become 'not invalidated'.
    const invalidated = meta.invalidated_at == null ? null : timestamp(meta.invalidated_at);
    return { path, markdown, parsed, meta, captured, invalidated, facts: publicFacts(parsed.summary) };
  } catch (error) {
    if (error.code && error.code !== 'ENOENT') throw error;
    return null;
  }
}

/** Read-only lookup. Call invalidateJobFacts on a confirmed liveness miss. */
export async function lookupJobFacts({ reportsDir = join(getCareerOpsRoot(), 'reports'), fingerprint, liveness, now = Date.now() } = {}) {
  const { identity, key, failure } = await contextFor({ fingerprint, liveness, now });
  if (failure) return failure;
  if (liveness.result !== 'active') return miss(`liveness-${liveness.result}`);
  const records = reportFiles(reportsDir).map(path => reportRecord(path, identity, key)).filter(Boolean);
  if (records.some(r => r.invalidated !== null && (!Number.isFinite(r.invalidated) || r.invalidated > now))) return miss('invalid-invalidation');
  const invalidatedAt = Math.max(-Infinity, ...records.map(r => r.invalidated ?? -Infinity));
  if (timestamp(liveness.checked_at) <= invalidatedAt) return miss('liveness-before-invalidation');
  const fresh = records.filter(r => r.facts && Number.isFinite(r.captured) && r.captured <= now &&
    now - r.captured < JOB_FACTS_TTL_MS && r.captured > invalidatedAt)
    .sort((a, b) => b.captured - a.captured);
  if (!fresh.length) return miss('no-fresh-facts');
  const newest = fresh[0];
  return { status: 'hit', payload: {
    schema_version: 1, listing_key: key, captured_at: newest.meta.captured_at, job_facts: newest.facts,
  } };
}

/** Persist tombstones in all matching reports; no separate cache store. */
export async function invalidateJobFacts({ reportsDir = join(getCareerOpsRoot(), 'reports'), fingerprint, liveness, now = Date.now() } = {}) {
  const { identity, key, failure } = await contextFor({ fingerprint, liveness, now });
  if (failure) return failure;
  if (liveness.result !== 'expired' || !EXPLICIT_CLOSURE_CODES.has(liveness.code)) {
    throw new TypeError('invalidation requires explicit closure evidence');
  }
  const files = reportFiles(reportsDir);
  if (!files.length) return miss('no-cached-facts');
  const lock = await acquireTrackerLock(trackerLockDirFor(join(realpathSync(reportsDir), '.job-facts-cache')), { timeoutMs: 5000 });
  try {
    const records = reportFiles(reportsDir).map(path => reportRecord(path, identity, key)).filter(Boolean);
    if (!records.length) return miss('no-cached-facts');
    const invalidatedAt = new Date(Math.max(now, ...records
      .map(record => Number.isFinite(record.invalidated) ? record.invalidated : -Infinity))).toISOString();
    // Stamp every duplicate so deleting/archiving one report cannot remove
    // the only closure marker and revive another still-fresh copy.
    for (const record of records) {
      const meta = { schema_version: 1, listing_fingerprint: record.meta.listing_fingerprint,
        captured_at: record.meta.captured_at, invalidated_at: invalidatedAt };
      // Replace only this top-level cache block. The rest of the YAML and the
      // report stay byte-identical, including private comments and formatting.
      const block = /^job_facts_cache:[^\r\n]*(?:\r?\n(?:[ \t]+[^\r\n]*|[ \t]*))*/m;
      const raw = record.parsed.raw;
      const matching = raw.match(block);
      if (!matching) throw new Error('cache metadata must use an unquoted top-level job_facts_cache key');
      const replacement = `job_facts_cache: ${JSON.stringify(meta)}\n`;
      const updated = raw.replace(block, () => replacement);
      // Reject YAML aliases or unusual layout instead of risking a broader rewrite.
      const reparsed = loadYaml(updated, { schema: JSON_SCHEMA });
      const expected = { ...record.parsed.summary, job_facts_cache: meta };
      if (JSON.stringify(reparsed) !== JSON.stringify(expected)) throw new Error('cache metadata cannot be updated safely');
      const start = record.parsed.rawStart;
      const temp = `${record.path}.${randomUUID()}.tmp`;
      try {
        const mode = lstatSync(record.path).mode & 0o777;
        writeFileSync(temp, record.markdown.slice(0, start) + updated + record.markdown.slice(start + raw.length), { flag: 'wx', mode: 0o600 });
        // Reports contain private fit: an atomic rewrite must not widen access.
        chmodSync(temp, mode);
        renameSyncWithRetry(temp, record.path);
      } finally { rmSync(temp, { force: true }); }
    }
    return { status: 'invalidated', invalidated_at: invalidatedAt };
  } finally { lock.release(); }
}

const USAGE = `Usage: node evaluation-cache.mjs --identity FILE --liveness FILE [--reports DIR] [--invalidate]

Read local reports and print strictly allowlisted public job facts as JSON.
--identity FILE  #1030 listing-fingerprint record (JSON)
--liveness FILE  Current {url, listing_fingerprint, result, checked_at} (JSON)
--reports DIR    Defaults to reports/ under the resolved career-ops data root
--invalidate     Record confirmed expired liveness in existing report metadata
--help, -h       Show help

Only a strong identity, active check less than 5 minutes old, and facts less than
24 hours old authorize reuse. No network requests or candidate scoring. Lookup
is read-only; --invalidate updates only the report's job_facts_cache metadata.`;

async function main() {
  const args = process.argv.slice(2);
  validateFlags(args, ['--identity', '--liveness', '--reports', '--invalidate', '--help', '-h'], USAGE,
    { valueFlags: ['--identity', '--liveness', '--reports'], requireOperand: true });
  const { values } = parseArgs({ args, options: {
    identity: { type: 'string' }, liveness: { type: 'string' }, reports: { type: 'string' }, invalidate: { type: 'boolean' },
  } });
  if (!values.identity || !values.liveness) throw new TypeError('--identity and --liveness are required');
  const options = { fingerprint: JSON.parse(readFileSync(values.identity, 'utf8')),
    liveness: JSON.parse(readFileSync(values.liveness, 'utf8')), reportsDir: values.reports };
  console.log(JSON.stringify(await (values.invalidate ? invalidateJobFacts(options) : lookupJobFacts(options)), null, 2));
}

if (isMainModule(import.meta.url)) main().catch(() => {
  // Input/report parse errors can contain candidate text: do not echo them.
  console.error('evaluation-cache: invalid input or unavailable report files; see --help');
  process.exitCode = 1;
});