/* 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) => `- <code>${escapeHtml(reason)}</code>`).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 }}
/* 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) => `- <code>${escapeHtml(reason)}</code>`).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
}
}