Ctrl K
ComunidadGuías…

lib/syntax-check.mjs

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

Archivo original ↗Preguntar a KiBot
/**
 * syntax-check.mjs — "does every one of these .mjs files parse?", answered in
 * one process instead of one process per file (#5078).
 *
 * Two things syntax-check this repository: `scripts/check-syntax.mjs` (run as
 * `npm run lint`) and section 1 of `test-all.mjs`. Both ran `node --check` once
 * per file. `test-all.mjs` put them through a pool of 8 (#2387) and still spent
 * 22 s on ~1,190 files, because the cost was never the parsing: it was starting
 * node 1,190 times. Parsing all of them in one process takes about 0.3 s.
 *
 * HOW. `node --check` on an ES module does one thing with the source:
 * `new ModuleWrap(filename, undefined, source, 0, 0)` (node's
 * lib/internal/main/check_syntax.js). `new vm.SourceTextModule(source)` makes
 * the same call (lib/internal/vm/module.js), so it is the same parser entered
 * the same way, without the process. Neither one links, resolves an import or
 * runs a line of the file.
 *
 * `vm.SourceTextModule` only exists under `--experimental-vm-modules`, so the
 * pass is a child started with that flag: lib/syntax-check-child.mjs.
 *
 * WHAT IT IS TRUSTED FOR. Only for "this file parses". A file the fast pass
 * does not vouch for goes through `node --check` exactly as before, so:
 *
 *   - a syntax error prints the diagnostic it always printed;
 *   - a file the fast pass rejects wrongly costs one spawn, not a wrong verdict;
 *   - a fast pass that cannot run at all (a node without the flag, a child that
 *     died, output that is not the expected JSON) vouches for nothing, and every
 *     file is checked the old way. Slower, never less strict.
 *
 * That leaves one way to be wrong: accepting a file `node --check` would
 * reject. The shared constructor call above is the argument that it cannot, and
 * `tests/syntax-check.test.mjs` is the check: it runs both over a corpus of
 * broken and unusual modules and requires the same verdict on every one.
 *
 * ONLY FOR `.mjs`. `node --check` picks the goal (module or CommonJS) from the
 * extension and the nearest package.json; this parses everything as a module.
 * `collectMjsFiles()` returns nothing else, and `checkSyntax` refuses a list
 * that does.
 */

import { execFile } from 'child_process';
import { fileURLToPath } from 'url';
import { promisify } from 'util';

const execFileAsync = promisify(execFile);
const FAST_PASS_CHILD = fileURLToPath(new URL('./syntax-check-child.mjs', import.meta.url));

// How many `node --check` children run at once. Only reached for the files the
// fast pass did not vouch for: none on a healthy tree, all of them when the
// fast pass is unavailable, which is the case this pool was sized for (#2387).
const CHECK_POOL_SIZE = 8;
// Exported so a caller describing a timed-out check names the real limit.
export const NODE_CHECK_TIMEOUT_MS = 30000;
// The fast pass parses the whole repository in well under a second. A child
// that has not answered in this long is not going to; the per-file path is
// still there, so this only bounds how long a hung child can delay it.
const FAST_PASS_TIMEOUT_MS = 60000;

/**
 * Parse every file in one child process and report which ones parsed.
 *
 * Never throws and never reports a file as broken: the answer is the set of
 * indexes the child vouched for, and the caller decides what to do with the
 * rest. `unavailable` is null when the child ran and answered, otherwise one
 * line saying why it did not.
 *
 * @param {string[]} files - Paths, absolute or relative to `cwd`.
 * @param {object} [opts]
 * @param {string} [opts.cwd] - Directory relative paths resolve against.
 * @param {string} [opts.node] - Node binary to start.
 * @param {string[]} [opts.nodeArgs] - Flags for the child. Overridden by tests
 *   to stand in for a node that does not have the flag.
 * @returns {Promise<{parsed: Set<number>, unavailable: string|null}>}
 */
export async function parseInOneProcess(files, {
  cwd = process.cwd(),
  node = process.execPath,
  nodeArgs = ['--experimental-vm-modules', '--no-warnings'],
} = {}) {
  const none = (why) => ({ parsed: new Set(), unavailable: why });
  let stdout;
  try {
    const running = execFileAsync(node, [...nodeArgs, FAST_PASS_CHILD], {
      cwd,
      timeout: FAST_PASS_TIMEOUT_MS,
      maxBuffer: 64 * 1024 * 1024,
    });
    // A child that exits before reading (a rejected flag) closes the pipe under
    // this write. That failure is already reported through the exit code.
    running.child.stdin?.on('error', () => {});
    running.child.stdin?.end(JSON.stringify(files));
    ({ stdout } = await running);
  } catch (err) {
    const stderr = String(err?.stderr ?? '').trim().split('\n').filter(Boolean).pop();
    return none(err?.killed ? `no answer after ${FAST_PASS_TIMEOUT_MS}ms` : stderr || String(err?.message ?? err));
  }
  let indexes;
  try {
    indexes = JSON.parse(stdout).parsed;
  } catch {
    return none('its output was not JSON');
  }
  // All or nothing. An answer that names an index that does not exist did not
  // come from the child's loop over this list, and none of it is worth believing.
  if (!Array.isArray(indexes) || !indexes.every((i) => Number.isInteger(i) && i >= 0 && i < files.length)) {
    return none('its output was not a list of file indexes');
  }
  return { parsed: new Set(indexes), unavailable: null };
}

/**
 * Run `node --check` on one file.
 *
 * @param {string} file - Path, absolute or relative to `cwd`.
 * @param {object} [opts]
 * @param {string} [opts.cwd] - Directory a relative path resolves against.
 * @param {string} [opts.node] - Node binary to start.
 * @returns {Promise<Error|null>} Null when the file parses; otherwise the
 *   `execFile` error, with the child's `stderr`, `code`, `signal` and `killed`.
 */
export async function nodeCheck(file, { cwd = process.cwd(), node = process.execPath } = {}) {
  try {
    await execFileAsync(node, ['--check', file], { cwd, timeout: NODE_CHECK_TIMEOUT_MS });
    return null;
  } catch (err) {
    return err;
  }
}

/**
 * Syntax-check a list of `.mjs` files.
 *
 * One fast pass over all of them, then `node --check` on each file the fast
 * pass did not vouch for. Every error returned is `node --check`'s own.
 *
 * @param {string[]} files - `.mjs` paths, absolute or relative to `cwd`.
 * @param {object} [opts]
 * @param {string} [opts.cwd] - Directory relative paths resolve against.
 * @param {string} [opts.node] - Node binary to start.
 * @param {string[]} [opts.nodeArgs] - Flags for the fast pass (see `parseInOneProcess`).
 * @returns {Promise<{errors: (Error|null)[], fastPassUnavailable: string|null}>}
 *   `errors[i]` is null when `files[i]` parses, in the order given.
 *   `fastPassUnavailable` is null when the fast pass ran, otherwise why not.
 */
export async function checkSyntax(files, opts = {}) {
  const notMjs = files.find((f) => !f.endsWith('.mjs'));
  if (notMjs !== undefined) {
    throw new Error(`checkSyntax parses every file as an ES module and was given ${notMjs}`);
  }
  const { parsed, unavailable } = await parseInOneProcess(files, opts);
  const errors = new Array(files.length).fill(null);
  const pending = files.map((_, i) => i).filter((i) => !parsed.has(i));
  // Collected by index, so the result does not depend on which child finishes
  // first.
  let next = 0;
  const worker = async () => {
    while (next < pending.length) {
      const i = pending[next++];
      errors[i] = await nodeCheck(files[i], opts);
    }
  };
  await Promise.all(Array.from({ length: Math.min(CHECK_POOL_SIZE, pending.length) }, worker));
  return { errors, fastPassUnavailable: unavailable };
}