/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { execFileSync } from 'node:child_process' import { appendFileSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' const workflowContractTest = 'scripts/ci/pr-gate-workflow.test.ts' const defaultManifest = JSON.parse( readFileSync(new URL('./change-impact.json', import.meta.url), 'utf8') ) function matchesPath(path, pattern) { const source = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replaceAll('**/', '\u0000') .replaceAll('**', '\u0001') .replaceAll('*', '[^/]*') .replaceAll('\u0000', '(?:.*/)?') .replaceAll('\u0001', '.*') return new RegExp(`^${source}$`).test(path) } const statusNames = { A: 'added', C: 'copied', D: 'deleted', M: 'modified', R: 'renamed', T: 'type-changed', U: 'unmerged' } export function parseNameStatus(value) { const fields = value.split('\0') const changes = [] let index = 0 while (index < fields.length && fields[index]) { const rawStatus = fields[index++] const statusCode = rawStatus[0] const status = statusNames[statusCode] ?? 'unknown' if (statusCode === 'R' || statusCode === 'C') { const previousPath = fields[index++] const path = fields[index++] changes.push({ path, previousPath, status }) } else { changes.push({ path: fields[index++], status }) } } return changes } function visitCapability(manifest, capabilityId, path, lanes, reasonChains, visiting) { if (visiting.has(capabilityId)) { throw new Error(`Change-impact capability cycle: ${[...path, capabilityId].join(' -> ')}`) } const capability = manifest.capabilities[capabilityId] if (!capability) throw new Error(`Unknown change-impact capability: ${capabilityId}`) const nextPath = [...path, capabilityId] reasonChains.add(nextPath.join(' -> ')) for (const lane of capability.lanes) lanes.add(lane) const nextVisiting = new Set(visiting).add(capabilityId) for (const consumer of capability.consumers) { visitCapability(manifest, consumer, nextPath, lanes, reasonChains, nextVisiting) } } export function classifyChanges(changes, manifest = defaultManifest) { const lanes = new Set(manifest.alwaysLanes) const reasonChains = new Set() const roots = new Set() let mode = 'selective' const documentationRule = manifest.rules.find((rule) => rule.id === 'documentation') const selectFullPlan = (root, reason) => { mode = 'full' roots.add(root) reasonChains.add(reason) for (const lane of manifest.laneOrder) lanes.add(lane) } const visitRule = (rule, path) => { roots.add(rule.id) for (const capability of rule.capabilities) { const reasonPath = rule.id === capability ? [path] : [path, rule.id] visitCapability(manifest, capability, reasonPath, lanes, reasonChains, new Set()) } } for (const change of changes) { const paths = new Set([change.path, change.previousPath].filter(Boolean)) const destructivePath = ['deleted', 'renamed', 'type-changed', 'unmerged', 'unknown'].includes( change.status ) ? [...paths].find( (path) => !documentationRule?.paths.some((pattern) => matchesPath(path, pattern)) ) : undefined if (destructivePath) { selectFullPlan('destructive_change', `${destructivePath} -> destructive change -> full`) continue } for (const path of paths) { // This Vitest contract verifies the workflow; it is not an executable CI input. if (path === workflowContractTest) { roots.add('ci_workflow_contract_test') reasonChains.add(`${path} -> workflow contract -> direct portable test`) for (const lane of ['format', 'lint', 'typecheck_node', 'unit_macos']) lanes.add(lane) continue } const rules = manifest.rules.filter((rule) => rule.paths.some((pattern) => matchesPath(path, pattern)) ) if (rules.length === 0) { selectFullPlan('unknown', `${path} -> unknown -> full`) continue } for (const rule of rules) { if (!['global', 'owner', 'overlay'].includes(rule.role)) { throw new Error(`Unknown change-impact rule role for ${rule.id}: ${rule.role}`) } } const globalRules = rules.filter((rule) => rule.role === 'global' || rule.mode === 'full') if (globalRules.length > 0) { for (const rule of globalRules) { selectFullPlan(rule.id, `${path} -> ${rule.id} -> full`) } continue } const ownerRules = rules.filter((rule) => rule.role === 'owner') const specificOwners = ownerRules.filter((rule) => !rule.fallbackOwner) const fallbackOwners = ownerRules.filter((rule) => rule.fallbackOwner) const candidateOwners = specificOwners.length > 0 ? specificOwners : fallbackOwners if (candidateOwners.length === 0) { selectFullPlan('missing_owner', `${path} -> missing owner -> full`) continue } if (candidateOwners.length > 1) { const ownerIds = candidateOwners .map((rule) => rule.id) .sort() .join(', ') selectFullPlan('owner_ambiguity', `${path} -> owner ambiguity: ${ownerIds} -> full`) continue } visitRule(candidateOwners[0], path) for (const overlay of rules.filter((rule) => rule.role === 'overlay')) { visitRule(overlay, path) } } } const selectedLanes = manifest.laneOrder.filter((lane) => lanes.has(lane)) const selectedBundles = new Set() const declaredBundles = new Set(manifest.bundleOrder) for (const lane of selectedLanes) { const bundle = manifest.laneBundles[lane] if (!bundle) throw new Error(`Missing execution bundle for selected lane: ${lane}`) if (!declaredBundles.has(bundle)) { throw new Error(`Unknown execution bundle for selected lane ${lane}: ${bundle}`) } selectedBundles.add(bundle) } return { schemaVersion: manifest.schemaVersion, mode, roots: [...roots].sort(), lanes: selectedLanes, bundles: manifest.bundleOrder.filter((bundle) => selectedBundles.has(bundle)), reasonChains: [...reasonChains].sort() } } export const changeImpactManifestPath = fileURLToPath( new URL('./change-impact.json', import.meta.url) ) function argumentValue(arguments_, name) { const index = arguments_.indexOf(name) return index === -1 ? undefined : arguments_[index + 1] } function requireCommit(value, name) { if (!value || !/^[0-9a-f]{40}$/i.test(value)) { throw new Error(`${name} must be a full 40-character Git commit SHA`) } return value } function escapeHtml(value) { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", ''') } // Apply platform policy after dependency/consumer expansion, using trusted base code. export function platformExecutionPlan(plan, changes, event) { if (!['pull_request', 'merge_group'].includes(event)) return plan const paths = changes.flatMap(({ path, previousPath }) => [path, previousPath].filter((value) => value && value !== workflowContractTest) ) const criticalDesktopPaths = defaultManifest.rules.find( ({ id }) => id === 'critical_desktop_runtime' ).paths const nativeMainPaths = [ 'src/main/*shell*.ts', 'src/main/*process*.ts', 'src/main/menu*.ts', 'src/main/shortcut*.ts', 'src/main/native*.ts', 'src/main/protocol*.ts', 'src/main/file-save*.ts', 'src/main/net/**', 'src/main/platform/**', 'src/main/startup/**', 'src/main/app*.ts' ] const sensitive = paths.some((path) => [...criticalDesktopPaths, ...nativeMainPaths].some((pattern) => matchesPath(path, pattern)) ) || (plan.mode === 'full' && paths.some((path) => path.startsWith('src/main/'))) || paths.some((path) => /^(src\/preload\/|src\/shared\/(ipc|notebook|shell|runtime|window|keyboard|shortcut|sandbox|native)|packages\/(notebook-network-sandbox|process-tree-native|safe-file-publisher-native)\/|patches\/|resources\/|build\/|scripts\/|e2e\/|package(?:-lock)?\.json$|electron|playwright|tsconfig|vitest|vite\.|\.nvmrc$|\.github\/)/.test( path ) ) || changes.some(({ status }) => ['deleted', 'renamed', 'type-changed', 'unmerged', 'unknown'].includes(status) ) || plan.roots.some((root) => /unknown|unowned|unmatched|bootstrap/.test(root)) const lanes = new Set(plan.lanes) const hasDesktop = plan.bundles.includes('macos_e2e') if (hasDesktop && event === 'pull_request') { lanes.add('e2e_functional_windows') lanes.add('e2e_workspace_windows') lanes.add('e2e_browser_windows') } for (const lane of lanes) { if ( defaultManifest.laneBundles[lane] === 'macos_e2e' || (event === 'merge_group' && defaultManifest.laneBundles[lane] === 'windows_e2e') ) { lanes.delete(lane) } } if (event === 'merge_group' && hasDesktop) { lanes.add('build') lanes.add('e2e_smoke_macos') } const selectedLanes = defaultManifest.laneOrder.filter((lane) => lanes.has(lane)) const bundles = new Set(selectedLanes.map((lane) => defaultManifest.laneBundles[lane])) return { ...plan, lanes: selectedLanes, bundles: defaultManifest.bundleOrder.filter((bundle) => bundles.has(bundle)), macosProfile: sensitive ? 'expanded' : 'smoke', reasonChains: [ ...plan.reasonChains, event === 'pull_request' ? 'pull_request: portable tests and Windows business E2E; Mac validation deferred to merge queue' : `merge_group: one Mac core job${sensitive ? ' with native checks' : ''}; complete Mac regression scheduled twice daily` ] } } // Derive groups after module/consumer expansion and event-specific execution selection. export function macosGroupsForPlan(plan) { if (!plan.bundles?.includes('macos_e2e')) return [] if ( plan.macosProfile === 'smoke' || (plan.lanes.includes('e2e_smoke_macos') && plan.lanes.every( (lane) => defaultManifest.laneBundles[lane] !== 'macos_e2e' || ['build', 'e2e_smoke_macos'].includes(lane) )) ) { return ['journeys'] } if (plan.mode === 'full') return ['journeys', 'presentation', 'regressions', 'delegation'] const groups = { journeys: ['e2e_smoke_macos', 'build', 'e2e_functional_macos', 'e2e_workspace_macos'], presentation: ['e2e_accessibility_macos', 'e2e_visual_macos'], regressions: ['e2e_regressions_macos'], delegation: ['e2e_delegation_macos'] } return Object.entries(groups) .filter(([, lanes]) => lanes.some((lane) => plan.lanes.includes(lane))) .map(([group]) => group) } export function toGitHubOutputPlan(plan) { const output = { schemaVersion: plan.schemaVersion, mode: plan.mode, roots: [...plan.roots], lanes: [...plan.lanes], macosGroups: macosGroupsForPlan(plan) } if (plan.macosProfile) output.macosProfile = plan.macosProfile if (Array.isArray(plan.bundles)) output.bundles = [...plan.bundles] return output } export function formatPlanSummary(plan) { const roots = plan.roots.length === 0 ? '_none_' : plan.roots.map(escapeHtml).join(', ') const lanes = plan.lanes.length === 0 ? '_none_' : plan.lanes.map(escapeHtml).join(', ') const bundles = plan.bundles?.length > 0 ? plan.bundles.map(escapeHtml).join(', ') : '_none_' const reasons = plan.reasonChains.length === 0 ? '- _No changed paths_' : plan.reasonChains.map((reason) => `- ${escapeHtml(reason)}`).join('\n') return `## PR Gate preflight - Mode: **${escapeHtml(plan.mode)}** - Roots: ${roots} - Selected lanes: ${lanes} - Execution bundles: ${bundles} ### Reason chains ${reasons} ` } export function runClassifierCli(arguments_ = process.argv.slice(2), environment = process.env) { const base = requireCommit(argumentValue(arguments_, '--base') ?? environment.BASE_SHA, '--base') const head = requireCommit(argumentValue(arguments_, '--head') ?? environment.HEAD_SHA, '--head') const diff = execFileSync('git', ['diff', '--name-status', '-z', base, head]) const plan = classifyChanges(parseNameStatus(diff.toString('utf8'))) const planJson = JSON.stringify(plan) const outputPlanJson = JSON.stringify(toGitHubOutputPlan(plan)) const lanesJson = JSON.stringify(plan.lanes) if (environment.GITHUB_OUTPUT) { appendFileSync(environment.GITHUB_OUTPUT, `plan=${outputPlanJson}\nlanes=${lanesJson}\n`) } else { process.stdout.write(`${planJson}\n`) } if (environment.GITHUB_STEP_SUMMARY) { appendFileSync(environment.GITHUB_STEP_SUMMARY, formatPlanSummary(plan)) } return plan } const isDirectExecution = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) if (isDirectExecution) { try { runClassifierCli() } catch (error) { console.error(error instanceof Error ? error.message : error) process.exitCode = 1 } }