Ctrl K
ComunidadGuías…

scripts/audit-equivalent-mutants.ts

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env -S deno run --allow-all

import { relative, resolve } from "@std/path";
import { splitFlagValues } from "#scripts/flag-values.ts";
import {
  auditEquivalentMutants,
  type ReproveContext,
  type ReproveOutcome,
  type ResolvedEntry,
} from "#scripts/mutation/equivalent-audit.ts";
import { reproveEntries } from "#scripts/mutation/equivalent-reproof.ts";
import {
  createFilePlan,
  evaluateMutantTests,
  evaluateOriginalTests,
} from "#scripts/mutation/evaluate.ts";
import { createStaticGates, testEnv } from "#scripts/mutation/execution.ts";
import {
  listRegistryFiles,
  registryFilePath,
} from "#scripts/mutation/ignore.ts";
import { runInSnapshot } from "#scripts/mutation/isolation.ts";
import { defaultBatchJobs } from "#scripts/mutation/runner.ts";
import {
  isSnapshotChild,
  runSnapshotChild,
} from "#scripts/mutation/snapshot-child.ts";
import { collectStateBuilderFiles } from "#scripts/mutation/state-graph.ts";
import { directTestMapFor } from "#scripts/mutation/test-map.ts";
import { projectRoot } from "#scripts/project-root.ts";
import {
  offTerminationSignals,
  onTerminationSignals,
} from "#scripts/termination-signals.ts";
import { collectTestFiles } from "#scripts/test-groups.ts";
import { withTestHarness } from "#scripts/test-harness.ts";

const usage = `Usage: deno task mutation:audit-equivalents [--tests] [--write]

Runs lint and type-check against every recorded equivalent mutant without
running tests. Pass --tests to also run each confirmed entry's mapped direct
tests and drop the entries a test kills (a killed mutant is not equivalent).
Pass --write to remove entries that the static checks or the tests now kill.
The audit never re-stamps an entry: re-deriving a proof is a person's read of
the reason against the current file.`;

/** The audit's options. The registry list is internal plumbing: the parent
 * enumerates the shards once and hands the same list to its snapshot child,
 * so the audited files and the copied-back files can never differ. */
interface AuditOptions {
  registry: string[];
  tests: boolean;
  write: boolean;
}

const parseOptions = (args: string[]): AuditOptions => {
  const { rest, values } = splitFlagValues(args, "--registry");
  const registry = values.map((path) => {
    if (!path || path.startsWith("--")) {
      throw new Error(`--registry needs a path\n\n${usage}`);
    }
    return path;
  });
  if (rest.length === 1 && ["-h", "--help"].includes(rest[0]!)) {
    console.log(usage);
    Deno.exit(0);
  }
  if (
    rest.length > 2 ||
    rest.some((arg) => arg !== "--write" && arg !== "--tests")
  ) {
    throw new Error(`Unknown arguments: ${rest.join(" ")}\n\n${usage}`);
  }
  return {
    registry,
    tests: rest.includes("--tests"),
    write: rest.includes("--write"),
  };
};

/** Audit inside this checkout, which the snapshot child owns outright. */
const runAudit = async (options: AuditOptions): Promise<number> => {
  const controller = new AbortController();
  const onSignal = (): void => controller.abort();
  onTerminationSignals(onSignal);
  const result = await auditEquivalentMutants(
    {
      ignoreFiles: options.registry.map((path) => resolve(projectRoot, path)),
      root: projectRoot,
      signal: controller.signal,
      write: options.write,
    },
    {
      createGates: createStaticGates,
      ...(options.tests ? { reprove: reproveWithTests } : {}),
    },
  )
    .catch((error) => {
      // An interrupt must return through the snapshot child's cleanup, not
      // exit straight past it — an exit here would leave the run's claim
      // reading as live until it aged out on its own.
      if (controller.signal.aborted) return null;
      throw error;
    })
    .finally(() => offTerminationSignals(onSignal));
  if (result === null) return 130;
  console.log(`Checked ${result.checked} equivalent mutants.`);
  console.log(`Killed by lint: ${result.killedByLint.length}`);
  console.log(`Killed by type-check: ${result.killedByTypeCheck.length}`);
  if (options.tests) {
    console.log(`Killed by a test: ${result.killedByTests.length}`);
    console.log(
      `No direct tests to distinguish with: ${result.untested.length}`,
    );
  }
  console.log(`Needs re-derivation: ${result.unconfirmed.length}`);
  console.log(`Still reaches tests: ${result.retained}`);
  if (!options.tests) console.log("No tests were run.");
  for (const line of [
    ...result.killedByLint,
    ...result.killedByTypeCheck,
    ...result.killedByTests,
    ...result.untested,
    ...result.unconfirmed,
  ]) {
    console.log(`  ${line}`);
  }
  const killed =
    result.killedByLint.length +
    result.killedByTypeCheck.length +
    result.killedByTests.length;
  const failed =
    result.unconfirmed.length > 0 || (!options.write && killed > 0);
  return failed ? 1 : 0;
};

const reproveWithTests = async (
  entries: ResolvedEntry[],
  context: ReproveContext,
): Promise<ReproveOutcome> =>
  withTestHarness(async ({ staticAssets }) => {
    let stateBuilderFiles: Set<string> | null = null;
    const allTestFiles = await collectTestFiles(projectRoot);
    const directTestFiles = directTestMapFor(
      entries.map((entry) => entry.file),
      allTestFiles,
    );
    return reproveEntries(
      entries,
      {
        batchJobs: defaultBatchJobs(),
        createPlan: async (file, testFiles) => {
          // The harness exports the prebuilt state's directory only once it is
          // up, so the state-feeding files are named on the first plan.
          stateBuilderFiles ??= await collectStateBuilderFiles();
          return await createFilePlan(
            staticAssets,
            stateBuilderFiles,
            true,
            file,
            testFiles,
          );
        },
        directTestFiles: (files) =>
          Promise.resolve(
            new Map(
              files.map((file) => [file, directTestFiles.get(file) ?? []]),
            ),
          ),
        env: testEnv(),
        evaluate: (plan, mutant, run, signal) =>
          // Integration tests never run here: only a direct-test kill can
          // disprove an entry, and survivors stand either way.
          evaluateMutantTests(plan, mutant, run, [], signal, []),
        evaluateBaseline: (plan, run, signal) =>
          evaluateOriginalTests(plan, run, signal),
      },
      context.signal,
    );
  });

/** Project-relative registry paths, listed before snapshotting so a --write
 * audit can carry each pruned file back to the live checkout. */
const registryCopyBackPaths = async (): Promise<string[]> =>
  (await listRegistryFiles()).map((file) =>
    relative(projectRoot, registryFilePath(file)),
  );

if (import.meta.main) {
  // Parsed here too, so a bad flag or --help answers without copying anything.
  const options = parseOptions(Deno.args);
  if (isSnapshotChild()) {
    Deno.exit(await runSnapshotChild(() => runAudit(options)));
  }
  // The manifest is the parent's to build: a hand-passed file would be
  // audited in the snapshot yet never copied back, so refuse it outright.
  if (options.registry.length > 0) {
    throw new Error(`--registry is internal to the audit\n\n${usage}`);
  }
  // One shard list serves the audit and the copy-back alike, so a shard added
  // while the snapshot is being made can never be pruned and then lost.
  const registry = await registryCopyBackPaths();
  Deno.exit(
    await runInSnapshot({
      args: [...Deno.args, ...registry.flatMap((path) => ["--registry", path])],
      // Only a --write audit rewrites the registry, so only it keeps files.
      copyBack: options.write ? registry : [],
      entryScript: "scripts/audit-equivalent-mutants.ts",
    }),
  );
}