Ctrl K
ComunidadGuías…

cv-sync-check.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node

/**
 * cv-sync-check.mjs — Validates that the career-ops setup is consistent.
 *
 * Checks:
 * 1. cv.md exists
 * 2. config/profile.yml exists and has required fields
 * 3. No hardcoded metrics in _shared.md or batch/batch-prompt.md
 * 4. article-digest.md freshness (if exists)
 */

import { readFileSync, existsSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

import { getCareerOpsRoot, getProfilePath, hasProfileOverride } from './path-resolver.mjs';
import { validateFlags } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { loadYaml } from './lib/yaml-document.mjs';

const CODE_ROOT = dirname(fileURLToPath(import.meta.url));
const DATA_ROOT = getCareerOpsRoot();

// ── CLI flags + help ────────────────────────────────────────────────
//
// docs/SCRIPTS.md lists `node cv-sync-check.mjs` as a runnable command, but the
// script read no arguments at all: a mistyped flag was ignored and the checks
// ran anyway, so `--hlep` looked like a successful run of whatever the caller
// meant (#3565). KNOWN_FLAGS is exactly --help/-h because that is every flag
// this file parses. Unrecognized flags exit 1 naming the flag; --help/-h print
// USAGE and exit 0.

const KNOWN_FLAGS = ['--help', '-h'];

const USAGE = `Usage:
  node cv-sync-check.mjs            # run the cv.md / profile.yml / prompt checks
  node cv-sync-check.mjs --help|-h  # print this usage block and exit`;

// Numbers that look like candidate metrics ("170+ hours", "300+ tests") belong
// in cv.md or article-digest.md. A time window ("less than 24 hours old",
// "within 48 hours") is a rule, not a claim about the candidate.
const METRIC_RE = /\b\d{2,4}\+?\s*(hours?|%|evals?|layers?|tests?|fields?|bases?)\b/gi;
const WINDOW_BEFORE_RE = /\b(?:less than|fewer than|older than|within|every)\s*\**\s*$/i;
const WINDOW_AFTER_RE = /^\s*\**\s*(?:old|ago)\b/i;

export function hardcodedMetrics(content) {
  const found = [];
  String(content).split('\n').forEach((line, i) => {
    if (line.includes('NEVER hardcode') || line.includes('NUNCA hardcode') || line.startsWith('#') || line.startsWith('<!--')) return;
    for (const m of line.matchAll(METRIC_RE)) {
      const before = line.slice(0, m.index);
      const after = line.slice(m.index + m[0].length);
      if (WINDOW_BEFORE_RE.test(before) || WINDOW_AFTER_RE.test(after)) continue;
      found.push({ line: i + 1, match: m[0] });
      return;
    }
  });
  return found;
}

function main() {
  validateFlags(process.argv.slice(2), KNOWN_FLAGS, USAGE);

  const warnings = [];
  const errors = [];

  // 1. Check cv.md exists
  const cvPath = join(DATA_ROOT, 'cv.md');
  if (!existsSync(cvPath)) {
    errors.push('cv.md not found in project root. Create it with your CV in markdown format.');
  } else {
    const cvContent = readFileSync(cvPath, 'utf-8');
    if (cvContent.trim().length < 100) {
      warnings.push('cv.md seems too short. Make sure it contains your full CV.');
    }
  }

  // 2. Check profile.yml exists
  const profilePath = getProfilePath();
  if (!existsSync(profilePath)) {
    errors.push(hasProfileOverride()
      ? `config/profile.yml not found: CAREER_OPS_PROFILE names ${profilePath}. Create that file, or unset the variable.`
      : 'config/profile.yml not found. Copy from config/profile.example.yml and fill in your details.');
  } else {
    const profileContent = readFileSync(profilePath, 'utf-8');
    // Match the VALUE of each required field under `candidate`, against the
    // parsed YAML only. The shipped template documents optional fields with
    // commented-out examples (`#   # signature_name: "Jane Smith"`), and a
    // whole-file substring search treated that comment as proof the profile
    // was unedited — then reported the first field it checked (`full_name`),
    // naming an innocent field on a correctly filled profile. A line-based
    // regex was not enough either: it was not scoped to `candidate` (an
    // unrelated top-level `location:` mapping satisfied the check), and its
    // `\s*` could consume the newline after an empty `full_name:` and capture
    // the next key, hiding the missing value. Parse the document with loadYaml
    // (lib/yaml-document.mjs, the shared entry point: it returns null for an
    // empty or comment-only file instead of throwing) so only a real
    // `candidate[field]` string counts.
    let candidate;
    try {
      candidate = loadYaml(profileContent)?.candidate ?? {};
    } catch (err) {
      candidate = null;
      warnings.push(`config/profile.yml could not be parsed as YAML: ${err.message.split('\n')[0]}`);
    }
    if (candidate !== null) {
      const requiredFields = ['full_name', 'email', 'location'];
      for (const field of requiredFields) {
        const raw = candidate[field];
        // Only a string is a usable value for these three fields. Coercing with
        // String() made `full_name: {first: Jane}` pass as "[object Object]" and
        // a bare `email: 12345` pass as "12345" — an invalid field the check was
        // meant to catch, accepted without a warning.
        const value = typeof raw === 'string' ? raw.trim() : '';
        if (!value || value.toLowerCase() === 'jane smith') {
          // A string with no content (`full_name: ""`) is still a text value, so
          // it is "missing or empty"; only a wrong type is "not a text value".
          const detail = value
            ? ` (still "${value}")`
            : raw == null || typeof raw === 'string'
              ? ' (missing or empty)'
              : ' (not a text value)';
          warnings.push(`config/profile.yml may still have example data. Check field: ${field}${detail}`);
          break;
        }
      }
    }
  }

  // 3. Check for hardcoded metrics in prompt files
  const filesToCheck = [
    { path: join(CODE_ROOT, 'modes', '_shared.md'), name: '_shared.md' },
    { path: join(CODE_ROOT, 'modes', '_writing.md'), name: '_writing.md' },
    { path: join(CODE_ROOT, 'batch', 'batch-prompt.md'), name: 'batch-prompt.md' },
  ];

  for (const { path, name } of filesToCheck) {
    if (!existsSync(path)) continue;
    for (const { line, match } of hardcodedMetrics(readFileSync(path, 'utf-8'))) {
      warnings.push(`${name}:${line} — Possible hardcoded metric: "${match}". Should this be read from cv.md/article-digest.md?`);
    }
  }

  // 4. Check article-digest.md freshness
  const digestPath = join(DATA_ROOT, 'article-digest.md');
  if (existsSync(digestPath)) {
    const stats = statSync(digestPath);
    const daysSinceModified = (Date.now() - stats.mtimeMs) / (1000 * 60 * 60 * 24);
    if (daysSinceModified > 30) {
      warnings.push(`article-digest.md is ${Math.round(daysSinceModified)} days old. Consider updating if your projects have new metrics.`);
    }
  }

  // Output results
  console.log('\n=== career-ops sync check ===\n');

  if (errors.length === 0 && warnings.length === 0) {
    console.log('All checks passed.');
  } else {
    if (errors.length > 0) {
      console.log(`ERRORS (${errors.length}):`);
      errors.forEach(e => console.log(`  ERROR: ${e}`));
    }
    if (warnings.length > 0) {
      console.log(`\nWARNINGS (${warnings.length}):`);
      warnings.forEach(w => console.log(`  WARN: ${w}`));
    }
  }

  console.log('');
  process.exit(errors.length > 0 ? 1 : 0);
}

if (isMainModule(import.meta.url)) main();