Files
iptvnator/tools/coverage/unit-coverage-scope.mjs
T
4grayandClaude Opus 5.5 e8902f472a perf(ci): skip unit coverage on PRs that cannot reach it and persist the Jest cache (#1711)
Pull requests whose changes cannot reach any Tier A test (allowlist checked against declared Tier A inputs and an AST scan of cross-project reads) skip the unit coverage suite; master pushes always run it. Jest's transform cache is persisted with actions/cache: PRs restore only, master pushes start empty and save. Paired CI runs: Tier A 9m04s cold -> 6m09s warm. Nx Cloud is intentionally not used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 07:50:07 +02:00

232 lines
8.4 KiB
JavaScript

/**
* Decides whether a pull request needs the Tier A unit coverage suite.
*
* The suite runs every Tier A project and merges one report, so it cannot run
* a subset (the merged ratchet and Codecov baseline need every project). What
* it can do is not run at all when a change cannot reach any Tier A test.
*
* `nx affected` is not used for this decision: Tier A tests also depend on
* files no project owns (`jest.preset.js`, `jest.web-esm.workspace.ts`,
* `tsconfig.base.json`, `tools/testing/`, `patches/`), and a change to an
* unowned file affects no project, which would skip tests it does break.
* The rule here is the reverse: skip only when every changed file matches an
* allowlist of paths no Tier A test reads. Anything unknown runs the suite.
*
* Usage:
* git diff --name-only <base>...HEAD |
* node tools/coverage/unit-coverage-scope.mjs [--base <base>] [--github-output]
*/
import { execFileSync } from 'node:child_process';
import { appendFileSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Paths that no Tier A test imports or reads. Tier A specs only read files
* inside their own project (checked 2026-09-26), so this list is about
* whole areas, not individual files.
*/
export const SKIPPABLE_PATTERNS = [
// Documentation, notes, plans and agent guidance.
/\.md$/,
/^docs\//,
/^\.changes\//,
/^\.plans\//,
/^\.codex\//,
/^\.claude\//,
// Workflows other than ci.yml (which runs everything) and build-and-make
// (an electron-backend spec asserts its embedded-MPV steps).
/^\.github\/(?!workflows\/(ci\.yml|build-and-make\.yaml)$)/,
// Apps outside Tier A with their own validation.
/^apps\/website\//,
/^apps\/web-e2e\//,
/^apps\/electron-backend-e2e\//,
/^apps\/xtream-mock-server\//,
/^apps\/stalker-mock-server\//,
// Tooling with its own Tier B tests or checks that run in every job.
// tools/embedded-mpv is deliberately absent: electron-backend imports its
// runtime contracts and its specs read the staging scripts.
/^tools\/(release|packaging|skills|performance|i18n|eslint|dependencies|nx)\//,
// Website tests and the packaged-app smoke launcher; run-web-esm-lib-tests.mjs
// in the same directory is a Tier A input and stays out of this list.
/^tools\/testing\/(website-|launch-packaged-electron\.mjs$)/,
/^LICENSE$/,
];
/**
* `package.json` feeds Tier A through dependencies, the `@package` version
* import and Jest/Nx configuration, but not through `scripts`, with one
* exception: the `coverage:*` scripts are the Tier A invocation itself, and a
* skipped suite would never exercise an edit to them.
*/
export function isScriptsOnlyChange(basePackageJson, headPackageJson) {
let base;
let head;
try {
base = JSON.parse(basePackageJson);
head = JSON.parse(headPackageJson);
} catch {
return false;
}
const withoutScripts = ({ scripts: _scripts, ...rest }) => rest;
if (
JSON.stringify(withoutScripts(base)) !==
JSON.stringify(withoutScripts(head))
) {
return false;
}
const baseScripts = base.scripts ?? {};
const headScripts = head.scripts ?? {};
const changedScripts = new Set(
[...Object.keys(baseScripts), ...Object.keys(headScripts)].filter(
(name) => baseScripts[name] !== headScripts[name]
)
);
return ![...changedScripts].some((name) => name.startsWith('coverage:'));
}
/**
* Files outside a project that a Tier A test target declares as inputs
* (`{workspaceRoot}/...` in its project.json). Nx already knows the test
* reads them, so they always need the suite, whatever the allowlist says.
*/
export function declaredWorkspaceInputs(tierAProjects, readProjectJson) {
const inputs = [];
for (const project of tierAProjects) {
const testTarget = readProjectJson(project)?.targets?.test;
for (const input of testTarget?.inputs ?? []) {
if (typeof input !== 'string') continue;
const match = /^\{workspaceRoot\}\/(.+)$/.exec(input);
if (match) inputs.push(match[1]);
}
}
return inputs;
}
export function isSkippable(file) {
return SKIPPABLE_PATTERNS.some((pattern) => pattern.test(file));
}
/**
* Converts an Nx input glob to a regular expression. The glob is tokenised
* rather than rewritten with chained replaces, so the `.*` produced for `**`
* cannot be rewritten again by the single-`*` rule.
*/
export function globToRegExp(glob) {
let pattern = '';
for (let index = 0; index < glob.length; index += 1) {
const char = glob[index];
if (char === '*' && glob[index + 1] === '*') {
if (glob[index + 2] === '/') {
pattern += '(?:.*/)?';
index += 2;
} else {
pattern += '.*';
index += 1;
}
} else if (char === '*') {
pattern += '[^/]*';
} else if (char === '?') {
pattern += '[^/]';
} else {
pattern += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
}
return new RegExp(`^${pattern}$`);
}
export function decideUnitCoverageScope(
files,
{ packageJsonScriptsOnly = false, declaredInputs = [] } = {}
) {
const declared = declaredInputs.map(globToRegExp);
const changed = files.map((file) => file.trim()).filter(Boolean);
if (changed.length === 0) {
return {
run: true,
reason: 'No changed files were listed; running the suite to be safe.',
blocking: [],
};
}
const blocking = changed.filter(
(file) =>
declared.some((pattern) => pattern.test(file)) ||
(!isSkippable(file) &&
!(file === 'package.json' && packageJsonScriptsOnly))
);
if (blocking.length > 0) {
return {
run: true,
reason: `${blocking.length} of ${changed.length} changed files can affect Tier A tests.`,
blocking,
};
}
return {
run: false,
reason: `All ${changed.length} changed files are outside Tier A test inputs (docs, notes, other workflows, non-Tier-A apps and tooling).`,
blocking: [],
};
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
function gitShow(ref, file) {
try {
return execFileSync('git', ['show', `${ref}:${file}`], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
} catch {
return null;
}
}
if (isMain) {
const argv = process.argv.slice(2);
const baseIndex = argv.indexOf('--base');
const base = baseIndex === -1 ? null : argv[baseIndex + 1];
const files = readFileSync(0, 'utf8').split('\n');
let packageJsonScriptsOnly = false;
if (base && files.some((file) => file.trim() === 'package.json')) {
const basePackageJson = gitShow(base, 'package.json');
const headPackageJson = readFileSync('package.json', 'utf8');
packageJsonScriptsOnly =
basePackageJson !== null &&
isScriptsOnlyChange(basePackageJson, headPackageJson);
console.log(
`package.json changed ${packageJsonScriptsOnly ? 'only in scripts' : 'outside scripts'} relative to ${base}.`
);
}
const policy = JSON.parse(
readFileSync('tools/coverage/coverage-policy.json', 'utf8')
);
const declaredInputs = declaredWorkspaceInputs(
policy.unitCoverage.tierA,
(project) =>
JSON.parse(
readFileSync(path.join(project.root, 'project.json'), 'utf8')
)
);
const decision = decideUnitCoverageScope(files, {
packageJsonScriptsOnly,
declaredInputs,
});
console.log(`Tier A unit coverage: ${decision.run ? 'run' : 'skip'}. ${decision.reason}`);
for (const file of decision.blocking.slice(0, 20)) {
console.log(` needs tests: ${file}`);
}
if (decision.blocking.length > 20) {
console.log(` … and ${decision.blocking.length - 20} more`);
}
if (process.argv.includes('--github-output')) {
const outputFile = process.env.GITHUB_OUTPUT;
if (!outputFile) {
console.error('--github-output needs GITHUB_OUTPUT to be set.');
process.exit(1);
}
appendFileSync(outputFile, `run=${decision.run}\n`);
}
}