Ctrl K
ComunidadGuías…

scripts/local-gate.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

export function releaseEvidence(output) {
  const text = output.replace(/\x1b\[[0-9;]*m/g, '').replace(/\r\n/g, '\n');
  const plans = [...text.matchAll(/^Running test shard \d+\/\d+: (\d+)\/\d+ files$/gm)];
  if (plans.length !== 1 || Number(plans[0][1]) === 0) throw new Error('missing or empty release file plan');
  const expected = Number(plans[0][1]);
  const files = [...text.matchAll(/^- (test\/[^\n]+\.test\.js)$/gm)].map(m => m[1]);
  if (files.length !== expected || new Set(files).size !== expected) throw new Error('release file plan mismatch');
  const rows = new Map();
  // A file whose whole suite is skipped at the describe level reports zero tests
  // with a `suites N` line as its only witness. The runner accepts that shape
  // (see parseFileSummary); the gate must accept exactly the same shape or the
  // gate can never pass on a suite that contains such a file.
  const suiteRows = new Map();
  for (const m of text.matchAll(/^((?:\[[^\]]+\]\s+)+)(?:#|ℹ) (tests|pass|fail|skipped|cancelled|todo|suites) (\d+)$/gm)) {
    const names = [...m[1].matchAll(/\[([^\]]+)\]/g)].map(x => x[1].replace(/\\/g, '/'));
    const file = names[0];
    if (!names.every(name => name === file) || !files.includes(file)) throw new Error('unexpected release file');
    if (m[2] === 'suites') { suiteRows.set(file, Number(m[3])); continue; }
    const row = rows.get(file) || {};
    if (Object.hasOwn(row, m[2])) throw new Error('duplicate release summary');
    row[m[2]] = Number(m[3]); rows.set(file, row);
  }
  if (rows.size !== expected) throw new Error(`release parsed ${rows.size}/${expected} files`);
  const totals = { tests: 0, pass: 0, fail: 0, skipped: 0, cancelled: 0, todo: 0 };
  // Per-file skip census. Two shapes execute nothing while still declaring
  // work: a file whose every test skipped (zero pass+fail, tests counted), and
  // a suite skipped whole at the describe level (zero tests, with `suites N`
  // as its only witness — the same shape the runner accepts). A load-time
  // throw is NOT one of them: measured on node v24.21.0 it fails the file
  // (`tests 1 / fail 1`, exit 1), which counts.fail already catches. Summing
  // per file lets the caller name the offenders instead of only reporting a
  // total it cannot act on.
  const inert = [];
  for (const [file, row] of rows) {
    const zeroWithSuite = row.tests === 0 && Number.isSafeInteger(suiteRows.get(file)) && suiteRows.get(file) > 0;
    if (!Object.keys(totals).every(k => Number.isSafeInteger(row[k]))
        || (row.tests === 0 && !zeroWithSuite)
        || row.tests !== row.pass + row.fail + row.skipped + row.cancelled + row.todo) {
      throw new Error('incomplete release summary');
    }
    if ((row.tests > 0 && row.pass + row.fail === 0) || zeroWithSuite) {
      inert.push({ file, tests: row.tests, suites: zeroWithSuite ? suiteRows.get(file) : 0 });
    }
    for (const key of Object.keys(totals)) totals[key] += row[key];
  }
  return { files: rows.size, ...totals, inert };
}

function execute(command, args, root, env) {
  return new Promise(done => {
    let output = '', error = null;
    const child = spawn(command, args, { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] });
    child.stdout.on('data', chunk => { output += chunk; process.stdout.write(chunk); });
    child.stderr.on('data', chunk => { output += chunk; process.stderr.write(chunk); });
    child.on('error', failure => { error = failure.message; });
    child.on('close', (code, signal) => done({ code: error || signal ? 2 : code ?? 2, output, error,
      untrustworthy: Boolean(error || signal || code === null) }));
  });
}

/**
 * Inert test files the operator has explicitly accepted for this host.
 *
 * Semicolon- or newline-separated repo-relative paths in GATE_INERT_SKIP_PATHS.
 * The environment variable is an OVERRIDE, never a default: an empty value
 * means "accept nothing", so the gate fails on every inert file. Pre-seeding a
 * default list would recreate the original defect with extra steps — a file
 * that becomes inert for a new reason would inherit acceptance it was never
 * reviewed for. Inert means it declared a suite or tests and executed none —
 * declare it or the gate is red; a file that declares nothing at all is no
 * evidence either. A load-time throw is not one of these: it fails the file
 * (`tests 1 / fail 1`, exit 1), which the fail count already catches.
 */
export function inertSkipAllowlist(env = process.env) {
  return new Set(String(env.GATE_INERT_SKIP_PATHS || '')
    .split(/[;\n]/)
    .map(p => p.trim().replace(/\\/g, '/'))
    .filter(Boolean));
}

/**
 * The gate's steps, in order.
 *
 * Two scopes, deliberately:
 *   - INCREMENTAL (`npm run gate`): the baseline check measures only the specs the
 *     diff can have moved. Fast enough to run on every branch, which is the only
 *     reason anyone runs it.
 *   - RELEASE (`npm run gate:release`, and the release workflow): `--all` measures
 *     EVERY spec against its pin. This is the mode that catches a pin moved by a
 *     change no spec's own test list names, and the tag checkout has no diff to
 *     scope by anyway.
 *
 * Exported so test/local-gate.test.js asserts the wiring without running a suite.
 */
export function gateSteps({ release = false } = {}) {
  return [
    ['spec-static-check', process.execPath, ['scripts/spec-static-check.mjs']],
    ['spec-baseline-check', process.execPath,
      ['scripts/spec-baseline-check.mjs', ...(release ? ['--all'] : [])]],
    ['secret-scan', process.execPath, ['scripts/secret-scan.mjs']],
    ['test:release', process.execPath, [process.env.npm_execpath, 'run', '--silent', 'test:release']],
    ['git diff --check', 'git', ['diff', '--check']],
    ['git diff --cached --check', 'git', ['diff', '--cached', '--check']],
  ];
}

/** Parse the CLI flags. An unknown flag is a hard error, never a silently ignored one. */
export function parseGateArgs(argv) {
  const options = { release: false };
  for (const arg of argv) {
    if (arg === '--release' || arg === '--all') { options.release = true; continue; }
    throw new Error(`unknown option: ${arg}`);
  }
  return options;
}

export async function runGate(root = process.cwd(), options = {}) {
  // npm exposes the actual JS CLI path on Windows too. Launch it through Node rather
  // than spawning npm.cmd or interpolating command strings through a shell.
  // gateSteps reads the same variable for the test:release step.
  if (!process.env.npm_execpath) {
    console.error('GATE: FAIL exit=2 — invoke this entry point with npm run gate');
    return 2;
  }
  const env = { ...process.env, NO_COLOR: '1' };
  delete env.FORCE_COLOR; delete env.NODE_TEST_CONTEXT;
  const steps = gateSteps(options);
  const results = [];
  for (const [name, command, args] of steps) {
    console.log(`\n=== ${name} ===`);
    const result = await execute(command, args, root, env);
    // Git uses exit 2 for whitespace errors. Only evidence-producing scripts
    // reserve that status for untrustworthy evidence; preserve every raw code.
    result.untrustworthy ||= result.code === 2 && ['spec-static-check', 'spec-baseline-check', 'test:release'].includes(name);
    let detail = result.error || '';
    if (name === 'test:release') {
      try {
        const counts = releaseEvidence(result.output);
        detail = `${counts.pass} pass / ${counts.fail} fail / ${counts.skipped} skip (${counts.files} files)`;
        if ((counts.fail || counts.cancelled) && result.code === 0) result.code = 1;
        // A file that declares tests and executes none is invisible in the
        // verdict. The shard summary folds it into a skip total, so on a host
        // where 4813 tests ran and 6 files did not, the gate still says PASS
        // and the only trace is "79 skip" in a detail line. Naming the files is
        // the point — a total cannot be acted on, a path can.
        //
        // Note what this is NOT guarding against. An earlier version of this
        // comment claimed a module that throws while loading shows up as
        // N skipped / exit 0. Measured on node 24.19.0 that is false: a
        // top-level throw in an imported module makes the FILE fail
        // (`fail 1`, `✖ test failed`), which counts.fail already catches. The
        // real exposure is the opposite case — deliberate, reason-carrying
        // skips that a reader can mistake for coverage.
        //
        // Those skips are legitimate: the real-Git fixtures skip on any host
        // without git at a trusted absolute POSIX path, by design
        // (test/git-fixture-env.js:22-24, asserted in
        // git-fixture-availability.test.js:38-40), and wire-byte-identity skips
        // without WIRE_BASE_TREE because it is a CI-only comparison. So the
        // requirement is acknowledgement, not permission: an operator states
        // which paths this host is not running, and the gate says so in the
        // verdict. GATE_INERT_SKIP_PATHS has no default, so a file that starts
        // skipping for a new reason is reported rather than inherited.
        //
        // The allowlist is read even when no file is inert: a declaration that
        // matches nothing is a stale claim about the host, and reporting it
        // must not depend on some other file happening to be inert.
        if (result.code === 0) {
          const accepted = inertSkipAllowlist(env);
          const inert = counts.inert.map(f => f.file.replace(/\\/g, '/'));
          const undeclared = counts.inert.filter(f => !accepted.has(f.file.replace(/\\/g, '/')));
          const unused = [...accepted].filter(p => !inert.includes(p));
          if (accepted.size) {
            detail += ` — ${counts.inert.length} inert file(s), ${accepted.size} declared`
              + (unused.length ? ` (unused: ${unused.join(', ')})` : '');
          }
          if (undeclared.length) {
            result.code = 1;
            detail += ` — ${undeclared.length} ran no tests and are not declared inert: `
              + undeclared.map(f => `${f.file} (${f.tests > 0 ? f.tests : `0 tests, suites ${f.suites}`})`).join(', ')
              + (accepted.size ? '' : ' (set GATE_INERT_SKIP_PATHS to accept deliberate platform gates)');
          }
        }
      } catch (error) {
        // A real failing child stays a failure. Missing evidence on exit zero is exit two.
        if (result.code === 0) { result.code = 2; result.untrustworthy = true; }
        detail = `evidence unavailable: ${error.message}`;
      }
    }
    results.push({ name, code: result.code, untrustworthy: result.untrustworthy, detail });
  }
  console.log('\n=== Local gate summary ===');
  for (const row of results) console.log(`${row.code === 0 ? 'PASS' : 'FAIL'} ${row.name} exit=${row.code}${row.detail ? ` — ${row.detail}` : ''}`);
  console.log('SKIP mutation EXECUTION — this gate checks spec shape, anchors and baseline'
    + ' values, but does not run the mutants themselves; the Linux full-gate recipe runs the sweep.');
  const code = results.some(row => row.untrustworthy) ? 2 : results.some(row => row.code !== 0) ? 1 : 0;
  const mode = options.release ? 'RELEASE' : 'INCREMENTAL';
  console.log(`${mode} GATE: ${code ? 'FAIL' : 'PASS'} exit=${code}`);
  return code;
}

if (resolve(process.argv[1] || '') === resolve(fileURLToPath(import.meta.url))) {
  try { process.exitCode = await runGate(process.cwd(), parseGateArgs(process.argv.slice(2))); }
  catch (error) { console.error(`GATE: FAIL exit=2 — ${error.message}`); process.exitCode = 2; }
}