check-consistency.mjs

Valida que cada ID de requisito citado en el prototipo exista en un PRD, y avisa de los requisitos sin ancla.

check-consistency.mjs/137 líneas

metodologia/templates/prototype/check-consistency.mjs
#!/usr/bin/env node
/* Argui — validación de consistencia prototipo ↔ PRD
 *
 * Verifica que cada ID de requisito referido en el prototipo (atributos data-prd, p. ej.
 * `RF-12 · RDA automática`) esté definido en algún PRD, y avisa de requisitos del PRD que
 * no tienen ningún ancla en el prototipo.
 *
 * Se valida por ID estable y no por número de sección: el ID viaja con el requisito cuando
 * el PRD inicial se parte en PRD de negocio y PRD técnico (F3.4).
 *
 * Uso:
 *   node check-consistency.mjs --proto prototipo.html --prd prd.md
 *   node check-consistency.mjs --proto "*.html" --prd prd-business.md prd-technical.md
 *
 * Sin dependencias externas (Node 18+). Sale con código != 0 si hay referencias rotas.
 */
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, dirname } from "node:path";

function parseArgs(argv) {
  const out = { proto: [], prd: [] };
  let bucket = null;
  for (const a of argv.slice(2)) {
    if (a === "--proto") bucket = "proto";
    else if (a === "--prd") bucket = "prd";
    else if (bucket) out[bucket].push(a);
  }
  return out;
}

// Expansión mínima de un glob tipo "dir/*.html" o "dir/**.html" (sin dependencias).
function expand(patterns) {
  const files = [];
  for (const p of patterns) {
    if (!/[*?]/.test(p)) { files.push(p); continue; }
    const star = p.indexOf("*");
    const base = p.slice(0, star).replace(/\/$/, "") || ".";
    const recursive = p.includes("**");
    const ext = (p.match(/\.([a-z0-9]+)$/i) || [, ""])[1].toLowerCase();
    walk(base, recursive, ext, files);
  }
  return [...new Set(files)];
}

function walk(dir, recursive, ext, acc) {
  let entries;
  try { entries = readdirSync(dir); } catch { return; }
  for (const name of entries) {
    const full = join(dir, name);
    let st;
    try { st = statSync(full); } catch { continue; }
    if (st.isDirectory()) { if (recursive) walk(full, recursive, ext, acc); }
    else if (!ext || name.toLowerCase().endsWith("." + ext)) acc.push(full);
  }
}

// IDs estables de requisito / métrica: RF-12, MET-3, RNF-7…
const ID_RE = /\b([A-Z]{2,5}-\d+)\b/g;

function refsFromProto(files) {
  const refs = new Map(); // ID -> [archivos]
  for (const f of files) {
    let html;
    try { html = readFileSync(f, "utf8"); } catch { continue; }
    for (const m of html.matchAll(/data-prd\s*=\s*"([^"]*)"/g)) {
      for (const s of m[1].matchAll(ID_RE)) {
        const ref = s[1];
        if (!refs.has(ref)) refs.set(ref, []);
        refs.get(ref).push(f);
      }
    }
  }
  return refs;
}

// Solo cuentan las DEFINICIONES del ID —fila de tabla, encabezado o ítem en negrita—,
// no las menciones en prosa: así una mención con el ID mal escrito sale como rota.
const DEF_RES = [
  /^\s*\|\s*([A-Z]{2,5}-\d+)\s*\|/,
  /^#{1,6}\s+([A-Z]{2,5}-\d+)\b/,
  /^\s*[-*]\s+\*\*([A-Z]{2,5}-\d+)\*\*/
];

function idsFromPrd(files) {
  const ids = new Set();
  for (const f of files) {
    let md;
    try { md = readFileSync(f, "utf8"); } catch { continue; }
    for (const line of md.split("\n")) {
      for (const re of DEF_RES) {
        const m = line.match(re);
        if (m) ids.add(m[1]);
      }
    }
  }
  return ids;
}

const args = parseArgs(process.argv);
if (!args.proto.length || !args.prd.length) {
  console.error("Uso: node check-consistency.mjs --proto <htmls> --prd <mds>");
  process.exit(2);
}

const protoFiles = expand(args.proto);
const prdFiles = expand(args.prd);
const refs = refsFromProto(protoFiles);
const ids = idsFromPrd(prdFiles);

console.log(`Prototipo: ${protoFiles.length} archivo(s), ${refs.size} ID(s) referenciado(s).`);
console.log(`PRD: ${prdFiles.length} archivo(s), ${ids.size} ID(s) definido(s).\n`);

const broken = [];
for (const [ref, where] of refs) {
  if (!ids.has(ref)) broken.push({ ref, where });
}

const orphans = [];
for (const id of ids) {
  if (!refs.has(id)) orphans.push(id);
}

if (broken.length) {
  console.log("❌ IDs referidos en el prototipo que NO están definidos en el PRD:");
  for (const b of broken) console.log(`   ${b.ref}  ←  ${[...new Set(b.where)].join(", ")}`);
  console.log("");
}

if (orphans.length) {
  console.log("⚠️  Requisitos del PRD sin ancla en el prototipo (revisar si aplica):");
  console.log("   " + orphans.sort().join(", ") + "\n");
}

if (!broken.length) console.log("✅ Todo ID referido en el prototipo está definido en el PRD.");

process.exit(broken.length ? 1 : 0);