/** * `select` handler: changed files + impact map → spec list. * * The file-system-aware wrapper around the pure {@link resolveImpact} resolver. * This is where the FAIL-OPEN safety contract lives — every failure mode * (missing map, unreadable map, corrupt JSON, empty map) must degrade to * `mode: 'broad'` so the caller runs the full suite, never an empty one. * * Extracted from {@link runSelect} in `cli.ts` so the contract can be * exhaustively unit-tested without spawning a subprocess. */ import * as fs from 'node:fs'; import { changedRuntimeDepsFromManifests, classifyManifestChange, configForcesBroad, dropDevDepOnlyDeps, filterImpactfulChanges, forcesBroad, isBackendConfig, isTsconfig, stripDependencyFiles, tsconfigForcesBroad, } from './changes.js'; import type { WorkspaceImporters } from './dep-graph.js'; import { type ImpactMap, type InternedImpactMap, type ResolveResult, decodeImpactMap, } from './impact-map.js'; import { CoverageMapStrategy } from './select/coverage-map-strategy.js'; import { DependencyGraphStrategy } from './select/dep-graph-strategy.js'; import { selectImpactedTests } from './select/pipeline.js'; import type { SelectionStrategy } from './select/strategy.js'; /** before/after content of changed files, keyed by path — the caller reads these * from git. A file absent here can't be classified, so it stays broad. */ export type FileDiffs = Record; export interface SelectTestsInput { /** Changed files (file paths). */ changedFiles: string[]; /** Path to the impact map JSON. Missing/unreadable → fail-open broad. */ mapFile?: string; /** Path to a newline/comma separated list of every known spec. */ allSpecsFile?: string; /** Changed package.json diffs. When supplied, a devDependencies-only manifest * change drops the lockfile + manifests from selection — a devDep can't reach * the runtime bundle the E2E suite exercises. */ manifests?: FileDiffs; /** Changed tsconfig diffs, fed to {@link tsconfigForcesBroad}. */ tsconfigs?: FileDiffs; /** Changed `@n8n/config` diffs, fed to {@link configForcesBroad}. */ configs?: FileDiffs; /** Workspace package dir → runtime dependency names it declares (parsed from * pnpm-lock.yaml's `importers`). With `manifests`, a changed runtime dep is * walked to its declaring packages and scoped via the map instead * of forcing broad. */ lockfileImporters?: WorkspaceImporters; /** Package names reachable from the deployed packages' runtime deps (see * `runtimeClosure` in dep-graph). Classifies `pnpm.overrides` changes; * omitted → an override change stays broad. */ runtimeClosure?: ReadonlySet; } export interface SelectTestsResult extends ResolveResult { /** Set when the map could not be loaded; the result is broad as a safety. */ failOpen?: string; } function parseSpecList(raw: string): string[] { return raw .split(/[\n,]+/) .map((s) => s.trim()) .filter(Boolean); } function loadMap(mapFile: string | undefined): { map: ImpactMap; failOpen?: string } { if (!mapFile) return { map: {}, failOpen: 'no --map provided' }; if (!fs.existsSync(mapFile)) return { map: {}, failOpen: `map not found: ${mapFile}` }; try { const parsed: unknown = JSON.parse(fs.readFileSync(mapFile, 'utf8')); // Interned form ({specs, files}) is decoded; a plain ImpactMap is used as-is. const isInterned = typeof parsed === 'object' && parsed !== null && 'specs' in parsed && 'files' in parsed; const map = isInterned ? decodeImpactMap(parsed as InternedImpactMap) : (parsed as ImpactMap); // An empty map carries no coverage data — a build/data failure, not a set // of genuinely-uncovered changes. Flag it as fail-open so selection runs // broad rather than declaring every change uncovered and skipping E2E. if (Object.keys(map).length === 0) return { map, failOpen: 'empty map (no coverage data)' }; return { map }; } catch (error) { return { map: {}, failOpen: `unreadable map: ${String(error)}` }; } } /** * Resolve changed files → the specs that must run. Routes each change to one of * three outcomes: BROAD (infra failure, runtime-defining change, or an * unattributable dependency), SCOPED (mapped source / attributable dep), or * declared UNCOVERED (no covering spec → not run; unit + sanity are the net). */ export function selectTests(input: SelectTestsInput): SelectTestsResult { const allSpecs = input.allSpecsFile ? parseSpecList(fs.readFileSync(input.allSpecsFile, 'utf8')) : undefined; const { map, failOpen } = loadMap(input.mapFile); const broad = (files: string[]): SelectTestsResult => ({ specs: allSpecs ?? [], unmapped: files, mode: 'broad', ...(failOpen ? { failOpen } : {}), }); let impactful = filterImpactfulChanges(input.changedFiles); // Fail-open: an unusable/empty map is an infra failure, not a coverage gap. if (failOpen) return broad(impactful); // Container image / harness changes define the runtime for EVERY spec and // aren't in the map → run the suite (low-churn, so broad is cheap). const forcing = impactful.filter(forcesBroad); if (forcing.length > 0) return broad(forcing); // Content-aware classifiers: a change of this kind forces broad only when the // diff proves it can move behaviour. An unsupplied diff (local dev, no base // ref) can't prove anything, so it forces broad. const forcingByContent = ( matches: (f: string) => boolean, diffs: FileDiffs | undefined, classify: (before: string, after: string) => boolean, ): string[] => impactful.filter((f) => { if (!matches(f)) return false; const diff = diffs?.[f]; return !diff || classify(diff.before, diff.after); }); const forcingTsconfig = forcingByContent(isTsconfig, input.tsconfigs, tsconfigForcesBroad); if (forcingTsconfig.length > 0) return broad(forcingTsconfig); // A resolution-neutral tsconfig resolves to the same modules → drop it. impactful = impactful.filter((f) => !isTsconfig(f)); // A changed config default moves behaviour in specs that never execute the // file; an additive one can't, and falls through to the map. const forcingConfig = forcingByContent(isBackendConfig, input.configs, configForcesBroad); if (forcingConfig.length > 0) return broad(forcingConfig); // devDependency-only change can't reach the runtime bundle → dropped. Same for // a `pnpm.overrides` pin whose target is outside the runtime closure. if (input.manifests) { impactful = dropDevDepOnlyDeps(impactful, input.manifests, input.runtimeClosure); } // Runtime-dep change: walk it to the packages that declare it and drop // the dep files from the coverage path. const strategies: SelectionStrategy[] = []; if (input.lockfileImporters && input.manifests) { const runtimeDeps = changedRuntimeDepsFromManifests(input.manifests); if (runtimeDeps.length > 0) { // Stripping the lockfile is intentional: the dep-graph scopes each changed // declared dep to its importers, which covers that dep's transitive subtree // (the subtree is reached only THROUGH the dep). What it does NOT cover is // UNRELATED transitive churn co-occurring in the same lockfile (e.g. a // `pnpm dedupe` alongside the bump) — the deferred transitive-closure case, // backstopped by the nightly full E2E run. A lockfile-only change (no // manifest) never reaches here and stays broad. impactful = stripDependencyFiles(impactful); strategies.push( new DependencyGraphStrategy(map, input.lockfileImporters, runtimeDeps, { allSpecs }), ); } } // A dep change the walk couldn't scope stays broad (never declared uncovered): // a lockfile bump, or a runtime manifest still present. A package.json with no // manifest metadata (e.g. local dev with no base ref) can't be classified, so // it's treated conservatively as runtime → broad. A classified non-dep manifest // change (version / scripts) falls through and is declared like source. const lockfileRemains = impactful.includes('pnpm-lock.yaml'); const unscopedRuntimeManifest = impactful.some((f) => { if (!/(^|\/)package\.json$/.test(f)) return false; const manifest = input.manifests?.[f]; if (!manifest) return true; // An `override` still present here failed the closure check → unproven, // treat like runtime. const kind = classifyManifestChange(manifest.before, manifest.after); return kind === 'runtime' || kind === 'override'; }); if (lockfileRemains || unscopedRuntimeManifest) return broad(impactful); // Source files: no covering spec → declared uncovered (surfaced, not run); // running its package's specs verifies nothing. strategies.unshift(new CoverageMapStrategy(map, { allSpecs, onUncovered: 'declare' })); return selectImpactedTests( impactful.map((file) => ({ file })), strategies, ); }