veritas-kanban/scripts/select-ci-test-scope.mjs

282 lines
8.2 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { appendFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const COVERAGE_POLICY = JSON.parse(
readFileSync(
fileURLToPath(new URL('../docs/testing/critical-path-coverage.json', import.meta.url)),
'utf8'
)
);
const WORKSPACE_NAMES = COVERAGE_POLICY.packages.map(({ id }) => id);
const ALL_WORKSPACE_PATH_PATTERNS = [
/^shared\//,
/^package\.json$/,
/^pnpm-lock\.yaml$/,
/^pnpm-workspace\.yaml$/,
/^eslint\.config\.[cm]?[jt]s$/,
/^(?:vitest|playwright|electron\.vite)\.config\.[cm]?[jt]s$/,
/^tsconfig(?:\.[^/]+)?\.json$/,
];
const DOCUMENTATION_PATH_PATTERNS = [
/\.md$/i,
/^docs\//,
/^prompt-registry\//,
/^\.github\/ISSUE_TEMPLATE\//,
/^\.github\/PULL_REQUEST_TEMPLATE\.md$/,
/^(?:CODE_OF_CONDUCT|LICENSE|SECURITY)(?:\.[^/]+)?$/,
];
const DEPENDENCY_FREE_SCOPE_CONTROL_PATH_PATTERNS = [
/^scripts\/check-delivery-cadence(?:\.test)?\.mjs$/,
];
const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
function normalizeFiles(files) {
return [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort();
}
export function isDocumentationPath(file) {
return DOCUMENTATION_PATH_PATTERNS.some((pattern) => pattern.test(file));
}
export function isDependencyFreeScopeControlPath(file) {
return DEPENDENCY_FREE_SCOPE_CONTROL_PATH_PATTERNS.some((pattern) => pattern.test(file));
}
export function affectedWorkspaces(files) {
const selected = new Set();
for (const file of files) {
if (ALL_WORKSPACE_PATH_PATTERNS.some((pattern) => pattern.test(file))) {
for (const workspace of WORKSPACE_NAMES) selected.add(workspace);
}
const workspace = WORKSPACE_NAMES.find((name) => file.startsWith(`${name}/`));
if (workspace) selected.add(workspace);
}
return WORKSPACE_NAMES.filter((name) => selected.has(name));
}
export function coverageWorkspaces(_files, scope = 'focused') {
if (scope === 'full') return [...WORKSPACE_NAMES];
return [];
}
export function classifyCiTestScope({
eventName,
manualScope = '',
labels = [],
changedFiles = [],
deletedFiles = [],
}) {
const files = normalizeFiles(changedFiles);
const deleted = normalizeFiles(deletedFiles);
const packages = affectedWorkspaces([...files, ...deleted]);
if (eventName === 'schedule') {
return {
scope: 'full',
packages: WORKSPACE_NAMES,
files,
reason: 'Scheduled CI is the authoritative recurring full-suite drift gate.',
};
}
if (eventName === 'workflow_dispatch' && manualScope === 'full') {
return {
scope: 'full',
packages: WORKSPACE_NAMES,
files,
reason: 'Manual dispatch explicitly requested the full verification tier.',
};
}
if (eventName === 'pull_request' && labels.includes('ci:full')) {
return {
scope: 'full',
packages: WORKSPACE_NAMES,
files,
reason: 'The pull request carries the ci:full release or high-risk verification label.',
};
}
if (eventName === 'workflow_dispatch' && manualScope === 'focused') {
if (packages.length === 0) {
return {
scope: 'none',
packages: [],
files,
reason: 'The manually selected range does not affect a testable workspace.',
};
}
return {
scope: 'focused',
packages,
files,
reason: `Manual dispatch explicitly requested focused diagnostics for: ${packages.join(', ')}.`,
};
}
if (files.length === 0) {
return {
scope: 'none',
packages: [],
files,
reason:
'The selected base/head range contains no added, copied, modified, renamed, or deleted files.',
};
}
if (files.every((file) => isDocumentationPath(file) || isDependencyFreeScopeControlPath(file))) {
return {
scope: 'none',
packages: [],
files,
reason:
'Only documentation or dependency-free policy controls changed; workspace tests are reserved for explicit milestones.',
};
}
return {
scope: 'none',
packages,
files,
reason:
packages.length > 0
? `Ordinary changes defer workspace tests and coverage to an explicit milestone. Affected packages: ${packages.join(', ')}.`
: 'This ordinary change is not an explicit test milestone; workspace tests and coverage are deferred.',
};
}
export function diffRangeFor(baseSha, headSha, eventName) {
if (!baseSha || !headSha || baseSha === headSha) return '';
if (!COMMIT_SHA_PATTERN.test(baseSha) || !COMMIT_SHA_PATTERN.test(headSha)) {
throw new Error('CI base and head revisions must be hexadecimal commit IDs.');
}
return eventName === 'push' ? `${baseSha}..${headSha}` : `${baseSha}...${headSha}`;
}
export function changedFilesForRange(baseSha, headSha, eventName, cwd = process.cwd()) {
const range = diffRangeFor(baseSha, headSha, eventName);
if (!range) return [];
const output = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACMRD', range], {
cwd,
encoding: 'utf8',
});
return normalizeFiles(output.split('\n'));
}
export function deletedFilesForRange(baseSha, headSha, eventName, cwd = process.cwd()) {
const range = diffRangeFor(baseSha, headSha, eventName);
if (!range) return [];
const output = execFileSync('git', ['diff', '--name-only', '--diff-filter=D', range], {
cwd,
encoding: 'utf8',
});
return normalizeFiles(output.split('\n'));
}
function parseLabels(value) {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.filter((label) => typeof label === 'string') : [];
} catch {
throw new Error('CI_PR_LABELS must be a JSON array of label names.');
}
}
export function githubOutputLines(result, input) {
const diffRange = diffRangeFor(input.baseSha, input.headSha, input.eventName);
const coveragePackages = coverageWorkspaces(result.files, result.scope);
return [
`scope=${result.scope}`,
`packages=${result.packages.join(',')}`,
`coverage_packages=${coveragePackages.join(',')}`,
`base_sha=${input.baseSha}`,
`diff_range=${diffRange}`,
`reason=${result.reason}`,
].join('\n');
}
function summaryMarkdown(result, input) {
const focusedDecision =
result.scope === 'focused'
? `run for ${result.packages.map((name) => `\`${name}\``).join(', ')}`
: `skip because scope is \`${result.scope}\``;
const fullDecision =
result.scope === 'full'
? 'run the complete release-grade suite'
: `skip because scope is \`${result.scope}\``;
return [
'### CI test scope',
'',
'| Evidence | Value |',
'| --- | --- |',
`| Event | \`${input.eventName}\` |`,
`| Base | \`${input.baseSha || 'not required'}\` |`,
`| Head | \`${input.headSha || 'not required'}\` |`,
`| Changed paths | ${result.files.length} |`,
`| Selected scope | \`${result.scope}\` |`,
`| Reason | ${result.reason.replaceAll('|', '\\|')} |`,
'',
'| Test tier | Decision |',
'| --- | --- |',
`| Changed Tests | ${focusedDecision} |`,
`| Workspace Unit Tests | ${fullDecision} |`,
'',
].join('\n');
}
async function main() {
const input = {
eventName: process.env.CI_EVENT_NAME || '',
baseSha: process.env.CI_BASE_SHA || '',
headSha: process.env.CI_HEAD_SHA || '',
manualScope: process.env.CI_MANUAL_SCOPE || '',
labels: parseLabels(process.env.CI_PR_LABELS || '[]'),
};
if (!input.eventName) {
throw new Error('CI_EVENT_NAME is required.');
}
const changedFiles = changedFilesForRange(input.baseSha, input.headSha, input.eventName);
const deletedFiles = deletedFilesForRange(input.baseSha, input.headSha, input.eventName);
const result = classifyCiTestScope({ ...input, changedFiles, deletedFiles });
if (process.env.GITHUB_OUTPUT) {
await appendFile(process.env.GITHUB_OUTPUT, `${githubOutputLines(result, input)}\n`);
}
if (process.env.GITHUB_STEP_SUMMARY) {
await appendFile(process.env.GITHUB_STEP_SUMMARY, summaryMarkdown(result, input));
}
console.log(
JSON.stringify(
{
...result,
baseSha: input.baseSha,
headSha: input.headSha,
},
null,
2
)
);
}
if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) {
await main();
}