Skip to content

Instantly share code, notes, and snippets.

@xiongemi
Last active July 16, 2026 17:15
Show Gist options
  • Select an option

  • Save xiongemi/262812339bc3cf4381eeb705a2fc6ee0 to your computer and use it in GitHub Desktop.

Select an option

Save xiongemi/262812339bc3cf4381eeb705a2fc6ee0 to your computer and use it in GitHub Desktop.
Test Impact Analysis for Monorepos
#!/usr/bin/env node
// @ts-check
/**
* Test Impact Analysis (TIA) for an Nx monorepo.
*
* Why this exists
* ---------------
* `jest --changedSince` (and the equivalent flags on vitest / rstest / playwright)
* only understands ONE project's module graph. In a monorepo, a change in
* `libs/shared/icons` has to trigger tests in every project that imports it, but
* each runner resolves `@interstellar/*` path aliases through its own config and
* never crosses the project boundary. So `--changedSince` silently *under-selects*
* (misses cross-project impact) — which is worse than useless for CI.
*
* This script builds ONE workspace-wide, file-level dependency graph — resolving
* TypeScript path aliases, relative imports, and CSS side-effect imports — then
* walks it in reverse from the git-changed files to find exactly the test files
* (and the projects that own them) that a change can affect.
*
* Usage
* -----
* node tools/tia/tia.mjs [--base <ref>] [--runner jest|vitest|rstest|playwright]
* [--run] [--json] [--verbose]
*
* --base <ref> Git ref to diff against (default: origin/main). Uses merge-base.
* --runner <r> Which runner to print/execute commands for (default: jest).
* --run Actually execute the selected tests (default: just print).
* --json Emit machine-readable JSON only.
* --verbose Print graph diagnostics + per-step timings to stderr.
* Also enabled by NX_VERBOSE_LOGGING or NX_PERF_LOGGING.
*
* Only dependency beyond Node built-ins is @nx/devkit (already a workspace dep),
* used to read the project list from Nx's cached project graph.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { performance, PerformanceObserver } from 'node:perf_hooks';
import { fileURLToPath } from 'node:url';
import { createProjectGraphAsync, readCachedProjectGraph } from '@nx/devkit';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const WORKSPACE_ROOT = path.resolve(__dirname, '..', '..');
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const SOURCE_DIRS = ['apps', 'libs'];
const SOURCE_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
const ASSET_EXTS = ['.css', '.scss', '.sass', '.less']; // side-effect imports we track as leaves
const RESOLVE_EXTS = ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.mjs', '.cjs'];
const IGNORE_DIRS = new Set(['node_modules', 'dist', '.next', 'coverage', '.nx', 'out-tsc']);
const TEST_RE = /\.(spec|test)\.[jt]sx?$/;
// A change to any of these invalidates the whole workspace — no graph can bound
// their blast radius, so TIA must fall back to "run everything".
const GLOBAL_FILES = new Set([
'nx.json',
'tsconfig.base.json',
'tsconfig.json',
'jest.config.js',
'jest.preset.js',
'babel.config.json',
'package.json',
'yarn.lock',
'eslint.config.mjs',
]);
// ---------------------------------------------------------------------------
// Args
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const args = { base: 'origin/main', runner: 'jest', run: false, json: false, verbose: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--base') args.base = argv[++i];
else if (a === '--runner') args.runner = argv[++i];
else if (a === '--run') args.run = true;
else if (a === '--json') args.json = true;
else if (a === '--verbose') args.verbose = true;
else if (a === '--explain') args.explain = argv[++i];
else if (a === '--help' || a === '-h') args.help = true;
}
return args;
}
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(fs.readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n').slice(2, 33).join('\n').replace(/^ \*?/gm, ''));
process.exit(0);
}
// --verbose, NX_VERBOSE_LOGGING=true, or NX_PERF_LOGGING=true all turn on
// diagnostics + per-step timings. (Strict `=== 'true'` matches Nx's own convention,
// so NX_PERF_LOGGING=false doesn't accidentally enable it.)
args.verbose =
args.verbose ||
process.env.NX_VERBOSE_LOGGING === 'true' ||
process.env.NX_PERF_LOGGING === 'true';
const log = (...m) => args.verbose && console.error('[tia]', ...m);
const t0 = Date.now();
// TIA steps go through node:perf_hooks (`performance.measure`) the same way Nx's
// perf-logging util does. We log our own `tia:*` steps synchronously below (ordered,
// and never truncated by process.exit under --run); this observer then surfaces Nx's
// in-process measures (project-graph load, daemon round-trips) in the same stream.
// Printed to stderr so `--json` stdout stays clean.
if (args.verbose) {
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.name.startsWith('tia:')) continue; // ours are logged synchronously in step()
console.error(`[tia] ${e.name} … ${e.duration.toFixed(1)}ms`);
}
}).observe({ entryTypes: ['measure'] });
}
/** Run `fn` (sync or async) inside a perf_hooks measure, logging its duration. */
async function step(label, fn) {
const name = `tia:${label}`;
const start = performance.now();
performance.mark(`${name}:start`);
try {
return await fn();
} finally {
performance.measure(name, `${name}:start`);
log(`${label} … ${(performance.now() - start).toFixed(1)}ms`);
}
}
// ---------------------------------------------------------------------------
// JSONC (tsconfig) reader
// ---------------------------------------------------------------------------
/** Strip // and /* *\/ comments and trailing commas, respecting string literals. */
function stripJsonc(src) {
let out = '';
let inStr = false;
let quote = '';
for (let i = 0; i < src.length; i++) {
const c = src[i];
const next = src[i + 1];
if (inStr) {
out += c;
if (c === '\\') { out += src[++i] ?? ''; continue; }
if (c === quote) inStr = false;
continue;
}
if (c === '"' || c === "'") { inStr = true; quote = c; out += c; continue; }
if (c === '/' && next === '/') { while (i < src.length && src[i] !== '\n') i++; out += '\n'; continue; }
if (c === '/' && next === '*') { i += 2; while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++; i++; continue; }
out += c;
}
// remove trailing commas
return out.replace(/,(\s*[}\]])/g, '$1');
}
function readJsonc(file) {
return JSON.parse(stripJsonc(fs.readFileSync(file, 'utf8')));
}
// ---------------------------------------------------------------------------
// Alias maps — resolved from the CLOSEST tsconfig to each file, not just the
// root. A project's tsconfig can add or override `compilerOptions.paths`; using
// only tsconfig.base.json silently drops those edges and under-selects tests
// (a real false-negative source — see the "shadow data" section of the post).
// ---------------------------------------------------------------------------
/** @typedef {{exact: Map<string,string>, wildcard: Array<{prefix:string, target:string}>}} AliasMap */
const BASE_TSCONFIG = path.join(WORKSPACE_ROOT, 'tsconfig.base.json');
/** @type {AliasMap} fallback for files with no nearer tsconfig */
let baseAliases;
/** @type {Map<string, AliasMap>} tsconfig abs path -> its effective alias map */
const aliasMapCache = new Map();
/** @type {Map<string, string|null>} dir abs path -> nearest tsconfig.json (memoized walk) */
const nearestTsconfigCache = new Map();
function loadAliases() {
baseAliases = aliasMapFor(BASE_TSCONFIG);
log(`loaded ${baseAliases.exact.size} exact + ${baseAliases.wildcard.length} wildcard base aliases`);
}
/** Resolve a tsconfig `extends` target (relative path or bare package) to a file. */
function resolveExtends(spec, fromDir) {
if (spec.startsWith('.') || spec.startsWith('/')) {
let p = path.resolve(fromDir, spec);
if (!p.endsWith('.json')) p += '.json';
return fs.existsSync(p) ? p : null;
}
const pkg = path.join(WORKSPACE_ROOT, 'node_modules', spec);
if (fs.existsSync(pkg)) return fs.statSync(pkg).isDirectory() ? path.join(pkg, 'tsconfig.json') : pkg;
if (fs.existsSync(pkg + '.json')) return pkg + '.json';
return null;
}
/**
* Walk the `extends` chain (base-first, child overrides) and record which file
* declared `baseUrl` / `paths` — TS resolves `paths` relative to `baseUrl`, or,
* when there is no `baseUrl`, relative to the tsconfig that declares `paths`.
* @returns {{baseUrl?:{value:string,dir:string}, paths?:{value:object,dir:string}}}
*/
function readCompilerChain(tsconfigAbs, seen = new Set()) {
if (seen.has(tsconfigAbs)) return {};
seen.add(tsconfigAbs);
let json;
try { json = readJsonc(tsconfigAbs); } catch { return {}; }
let result = {};
const ext = json.extends;
for (const e of Array.isArray(ext) ? ext : ext ? [ext] : []) {
const resolved = resolveExtends(e, path.dirname(tsconfigAbs));
if (resolved) result = { ...result, ...readCompilerChain(resolved, seen) };
}
const co = json.compilerOptions ?? {};
const dir = path.dirname(tsconfigAbs);
if (co.baseUrl !== undefined) result.baseUrl = { value: co.baseUrl, dir };
if (co.paths !== undefined) result.paths = { value: co.paths, dir }; // child `paths` replaces parent's
return result;
}
/** Build the {exact, wildcard} alias map for a tsconfig (following `extends`). */
function aliasMapFor(tsconfigAbs) {
let m = aliasMapCache.get(tsconfigAbs);
if (m) return m;
const chain = readCompilerChain(tsconfigAbs);
const baseDir = chain.baseUrl
? path.resolve(chain.baseUrl.dir, chain.baseUrl.value)
: chain.paths?.dir;
m = { exact: new Map(), wildcard: [] };
if (chain.paths && baseDir) {
for (const [key, targets] of Object.entries(chain.paths.value)) {
const target = Array.isArray(targets) ? targets[0] : targets;
if (!target) continue;
const absTarget = path.resolve(baseDir, target);
if (key.endsWith('/*')) m.wildcard.push({ prefix: key.slice(0, -1), target: absTarget.replace(/\*$/, '') });
else m.exact.set(key, absTarget);
}
}
aliasMapCache.set(tsconfigAbs, m);
return m;
}
/** Nearest `tsconfig.json` walking up from `startDir` (memoized per directory). */
function findNearestTsconfig(startDir) {
const chain = [];
let dir = startDir;
while (true) {
const cached = nearestTsconfigCache.get(dir);
if (cached !== undefined) { for (const d of chain) nearestTsconfigCache.set(d, cached); return cached; }
chain.push(dir);
const cand = path.join(dir, 'tsconfig.json');
if (fs.existsSync(cand)) { for (const d of chain) nearestTsconfigCache.set(d, cand); return cand; }
if (dir.length <= WORKSPACE_ROOT.length) { for (const d of chain) nearestTsconfigCache.set(d, null); return null; }
dir = path.dirname(dir);
}
}
/** The alias map governing imports written in `fromFile`. */
function aliasesFor(fromFile) {
const ts = findNearestTsconfig(path.dirname(fromFile));
return ts ? aliasMapFor(ts) : baseAliases;
}
// ---------------------------------------------------------------------------
// Enumerate all source + asset files
// ---------------------------------------------------------------------------
/** @type {Set<string>} absolute paths of every file we know about */
const fileSet = new Set();
/** @type {string[]} scannable source files (we read these for imports) */
const sourceFiles = [];
function walk(dir) {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (e.name.startsWith('.') && e.name !== '.') continue;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (IGNORE_DIRS.has(e.name)) continue;
walk(full);
} else if (e.isFile()) {
const ext = path.extname(e.name);
if (SOURCE_EXTS.includes(ext)) { fileSet.add(full); sourceFiles.push(full); }
else if (ASSET_EXTS.includes(ext)) { fileSet.add(full); }
}
}
}
// ---------------------------------------------------------------------------
// Module resolution
// ---------------------------------------------------------------------------
/** Resolve a candidate path (without extension) to a real file in fileSet. */
function resolveFile(candidate) {
if (fileSet.has(candidate)) return candidate;
// asset with explicit extension already tried above; try source extensions
for (const ext of RESOLVE_EXTS) {
if (fileSet.has(candidate + ext)) return candidate + ext;
}
for (const ext of RESOLVE_EXTS) {
const idx = path.join(candidate, 'index' + ext);
if (fileSet.has(idx)) return idx;
}
return null;
}
/** Resolve an import specifier from `fromFile` to an absolute file, or null if external. */
function resolveImport(spec, fromFile) {
// strip query/hash
spec = spec.replace(/[?#].*$/, '');
if (spec.startsWith('.') || spec.startsWith('/')) {
const base = spec.startsWith('/') ? path.join(WORKSPACE_ROOT, spec) : path.resolve(path.dirname(fromFile), spec);
// asset with explicit extension
if (ASSET_EXTS.includes(path.extname(base))) return fileSet.has(base) ? base : null;
return resolveFile(base);
}
// alias resolution uses the tsconfig closest to the importing file
const al = aliasesFor(fromFile);
const exact = al.exact.get(spec);
if (exact) return fileSet.has(exact) ? exact : resolveFile(exact.replace(/\.[jt]sx?$/, ''));
// wildcard alias
for (const { prefix, target } of al.wildcard) {
if (spec.startsWith(prefix)) {
const rest = spec.slice(prefix.length);
return resolveFile(path.join(target, rest));
}
}
return null; // bare package → external, ignore
}
// ---------------------------------------------------------------------------
// Import extraction + reverse graph
// ---------------------------------------------------------------------------
// Matches: import ... from 'x' | export ... from 'x' | import 'x' | require('x') | import('x')
const IMPORT_RE =
/(?:import|export)\s[^'"]*?from\s*['"]([^'"]+)['"]|(?:import|require)\s*\(?\s*['"]([^'"]+)['"]\s*\)?/g;
/** reverse graph: importee (absolute) -> Set<importer (absolute)> */
const reverseGraph = new Map();
function addEdge(importee, importer) {
let set = reverseGraph.get(importee);
if (!set) reverseGraph.set(importee, (set = new Set()));
set.add(importer);
}
function buildGraph() {
let edges = 0;
for (const file of sourceFiles) {
let src;
try { src = fs.readFileSync(file, 'utf8'); } catch { continue; }
// Note: barrels use only `export … from`, so we must check for `export` too.
if (!src.includes('import') && !src.includes('export') && !src.includes('require')) continue;
IMPORT_RE.lastIndex = 0;
let m;
while ((m = IMPORT_RE.exec(src))) {
const spec = m[1] || m[2];
if (!spec) continue;
const target = resolveImport(spec, file);
if (target && target !== file) { addEdge(target, file); edges++; }
}
}
log(`graph: ${sourceFiles.length} source files, ${fileSet.size} total nodes, ${edges} edges`);
}
// ---------------------------------------------------------------------------
// Projects
// ---------------------------------------------------------------------------
/** @type {Array<{name:string, root:string, absRoot:string, type:'app'|'e2e'|'lib'}>} */
let projects = [];
async function loadProjects() {
// Read from Nx's cached project graph — it already knows every project
// (including inferred / package.json-based ones), their roots, types, and
// targets. Fall back to building the graph when there's no warm cache (CI).
let graph;
try {
graph = readCachedProjectGraph();
} catch {
graph = await createProjectGraphAsync({ exitOnError: false });
}
projects = Object.values(graph.nodes)
// keep parity with the file walk, which only scans apps/ and libs/
.filter((n) => SOURCE_DIRS.some((d) => n.data.root === d || n.data.root.startsWith(d + '/')))
.map((n) => {
const root = n.data.root;
const targets = n.data.targets ?? {};
// Nx marks e2e suites as applications with an `e2e` target.
const type = n.data.projectType === 'application' ? ('e2e' in targets ? 'e2e' : 'app') : 'lib';
return { name: n.name, root, absRoot: path.join(WORKSPACE_ROOT, root), type };
});
// longest root first so nested projects win the prefix match
projects.sort((a, b) => b.absRoot.length - a.absRoot.length);
log(`loaded ${projects.length} projects`);
}
/** Find the project that owns an absolute file path. */
function projectOf(absFile) {
for (const p of projects) {
if (absFile === p.absRoot || absFile.startsWith(p.absRoot + path.sep)) return p;
}
return null;
}
// ---------------------------------------------------------------------------
// Git — changed files
// ---------------------------------------------------------------------------
function git(...gitArgs) {
try {
return execFileSync('git', gitArgs, { cwd: WORKSPACE_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
} catch {
return '';
}
}
function changedFiles(base) {
let mergeBase = git('merge-base', base, 'HEAD').trim();
if (!mergeBase) mergeBase = base; // base may be a raw SHA with no common ancestor lookup
const rels = new Set();
const add = (line) => { const f = line.trim(); if (f) rels.add(f); };
git('diff', '--name-only', `${mergeBase}...HEAD`).split('\n').forEach(add);
// uncommitted + staged + untracked working-tree changes
git('status', '--porcelain').split('\n').forEach((line) => {
if (!line.trim()) return;
let p = line.slice(3).trim();
if (p.includes(' -> ')) p = p.split(' -> ')[1]; // renames
add(p);
});
return { mergeBase, files: [...rels] };
}
// ---------------------------------------------------------------------------
// Impact analysis (reverse BFS)
// ---------------------------------------------------------------------------
function computeAffected(changedAbs) {
const affected = new Set();
const queue = [];
for (const f of changedAbs) {
if (!affected.has(f)) { affected.add(f); queue.push(f); }
}
while (queue.length) {
const cur = queue.pop();
const importers = reverseGraph.get(cur);
if (!importers) continue;
for (const imp of importers) {
if (!affected.has(imp)) { affected.add(imp); queue.push(imp); }
}
}
return affected;
}
// ---------------------------------------------------------------------------
// Runner commands
// ---------------------------------------------------------------------------
function chunk(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
function runJest(relTestFiles) {
if (!relTestFiles.length) { console.log('No affected jest tests — skipping.'); return 0; }
let failed = 0;
for (const group of chunk(relTestFiles, 400)) {
try {
execFileSync('yarn', ['jest', '--runTestsByPath', ...group, '--cache', '--cacheDirectory=.jest/cache', '--watchman=false'], {
cwd: WORKSPACE_ROOT,
stdio: 'inherit',
});
} catch {
failed = 1;
}
}
return failed;
}
function runPlaywright(e2eProjects) {
if (!e2eProjects.length) { console.log('No affected e2e projects — skipping.'); return 0; }
let failed = 0;
for (const p of e2eProjects) {
const config = path.join(p.root, 'playwright.config.ts');
try {
execFileSync('yarn', ['playwright', 'test', '-c', config], { cwd: WORKSPACE_ROOT, stdio: 'inherit' });
} catch {
failed = 1;
}
}
return failed;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
await step('loadAliases', loadAliases);
await step('walk source dirs', () => SOURCE_DIRS.forEach((d) => walk(path.join(WORKSPACE_ROOT, d))));
await step('buildGraph', buildGraph);
await step('loadProjects', loadProjects);
// `--explain <file>` — diagnose the graph around one file (great for spotting
// barrel over-selection: a leaf with the whole world as its importers).
if (args.explain) {
const target = path.resolve(WORKSPACE_ROOT, args.explain);
const importers = reverseGraph.get(target);
const affected = computeAffected([target].filter((f) => fileSet.has(f)));
const affectedTests = [...affected].filter((f) => TEST_RE.test(f)).length;
console.log(`explain: ${path.relative(WORKSPACE_ROOT, target)}`);
console.log(` in graph ............ ${fileSet.has(target)}`);
console.log(` direct importers .... ${importers ? importers.size : 0}`);
console.log(` transitive affected . ${affected.size} files, ${affectedTests} tests`);
if (importers) [...importers].slice(0, 8).forEach((f) => console.log(` <- ${path.relative(WORKSPACE_ROOT, f)}`));
return;
}
const { mergeBase, files: changedRel } = await step('changedFiles (git)', () => changedFiles(args.base));
// global invalidation?
const globalHit = changedRel.filter((f) => GLOBAL_FILES.has(f));
const runEverything = globalHit.length > 0;
const changedAbs = changedRel
.map((f) => path.join(WORKSPACE_ROOT, f))
.filter((f) => fileSet.has(f)); // only files that participate in the graph
const affected = runEverything ? null : await step('computeAffected (reverse BFS)', () => computeAffected(changedAbs));
// affected test files
let affectedTestAbs;
if (runEverything) {
affectedTestAbs = sourceFiles.filter((f) => TEST_RE.test(f));
} else {
affectedTestAbs = [...affected].filter((f) => TEST_RE.test(f));
}
// partition tests into jest (unit) vs e2e (playwright)
const jestTestAbs = [];
const affectedProjectNames = new Set();
const affectedAppNames = new Set();
for (const f of affectedTestAbs) {
const p = projectOf(f);
if (!p) continue;
if (p.type === 'e2e') continue; // playwright handled via app dependency below
jestTestAbs.push(f);
affectedProjectNames.add(p.name);
}
// affected apps (for e2e): any app that owns an affected file
const affectedForApps = runEverything ? new Set(sourceFiles) : affected;
for (const f of affectedForApps) {
const p = projectOf(f);
if (p && p.type === 'app') affectedAppNames.add(p.name);
}
const e2eProjects = projects.filter(
(p) => p.type === 'e2e' && (runEverything || affectedAppNames.has(p.name.replace(/-e2e$/, ''))),
);
const jestTestRel = jestTestAbs.map((f) => path.relative(WORKSPACE_ROOT, f)).sort();
const testProjects = [...affectedProjectNames].sort();
const e2eProjectNames = e2eProjects.map((p) => p.name).sort();
const result = {
base: args.base,
mergeBase,
runEverything,
globalTriggers: globalHit,
changedFiles: changedRel.length,
changedInGraph: changedAbs.length,
affectedFiles: runEverything ? fileSet.size : affected.size,
affectedTestFiles: jestTestRel.length,
affectedTestProjects: testProjects,
affectedE2eProjects: e2eProjectNames,
tests: jestTestRel,
};
// persist for other tooling / other runners
fs.writeFileSync(path.join(__dirname, 'tia-affected.json'), JSON.stringify(result, null, 2));
if (args.json) {
console.log(JSON.stringify(result, null, 2));
} else {
printSummary(result);
}
log(`done in ${Date.now() - t0}ms`);
if (args.run) {
let code = 0;
if (args.runner === 'jest') code = runJest(jestTestRel);
else if (args.runner === 'playwright') code = runPlaywright(e2eProjects);
else {
console.error(`--run for runner "${args.runner}" is not wired to execute in this repo; use the printed command.`);
}
process.exit(code);
}
}
function printSummary(r) {
const line = '─'.repeat(64);
console.log(line);
console.log(`Test Impact Analysis (base: ${r.base} → ${r.mergeBase.slice(0, 12)})`);
console.log(line);
if (r.runEverything) {
console.log(`⚠ Global file changed → running EVERYTHING: ${r.globalTriggers.join(', ')}`);
}
console.log(`changed files ........ ${r.changedFiles} (${r.changedInGraph} in graph)`);
console.log(`affected files ....... ${r.affectedFiles}`);
console.log(`affected unit tests .. ${r.affectedTestFiles}`);
console.log(`unit test projects ... ${r.affectedTestProjects.length}${r.affectedTestProjects.length ? ':' : ''}`);
r.affectedTestProjects.forEach((p) => console.log(` • ${p}`));
console.log(`e2e projects ......... ${r.affectedE2eProjects.length}${r.affectedE2eProjects.length ? ':' : ''}`);
r.affectedE2eProjects.forEach((p) => console.log(` • ${p}`));
console.log(line);
console.log('Run the affected tests:');
if (r.affectedTestFiles) {
console.log(` # jest : node tools/tia/tia.mjs --run --runner jest`);
console.log(` # vitest : yarn vitest related $(node tools/tia/tia.mjs --json | jq -r '.tests[]')`);
console.log(` # rstest : yarn rstest --changed --related ... (feed .tests from tia-affected.json)`);
}
if (r.affectedE2eProjects.length) {
console.log(` # playwright: node tools/tia/tia.mjs --run --runner playwright`);
}
console.log(` (full list written to tools/tia/tia-affected.json)`);
console.log(line);
}
main().catch((err) => {
console.error('[tia] fatal:', err);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment