mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
282 lines
8.2 KiB
JavaScript
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();
|
|
}
|