Files
orca/config/scripts/preflight/changed-checks/check-changed-selection.mjs
Brennan Benson 890ad4d8aa Prevent new blocking work on Electron main in CI (#41263)
* WIP: guard blocking main-process APIs with file ratchets

* Cover declared SQLite receivers and preserve daemon guards

* Cover SQL iteration and shared synchronous IPC in ratchets
2026-11-11 16:24:55 -08:00

399 lines
15 KiB
JavaScript

import path from 'node:path'
import { affectsLocalizationExtraction } from '../../localization/extraction-policy/localization-extraction-change-scope.mjs'
import {
classifyPrJobs,
isDocsOnlyPath
} from '../../workflow/source-routing/pr-code-change-scope.mjs'
import { listRatchetScripts } from '../../static-analysis/ratchet-runner/run-ratchets.mjs'
// Any of these can change what every check reports, so they select all of them.
const SELECT_ALL_FILES = new Set(['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'])
const SELECT_ALL_PREFIXES = [
'config/scripts/check-changed',
'config/scripts/preflight/changed-checks/check-changed',
'config/scripts/pr-code-change-scope',
'config/scripts/workflow/source-routing/pr-code-change-scope'
]
// Mirrors forceRerunTriggers in config/vitest.config.ts: one such path in a `related` run selects
// every test. That full run is CI's job; locally these paths run only their own colocated tests.
const FULL_SUITE_TRIGGER =
/(?:^|\/)(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|vitest\.config\.[^/]+|vite\.config\.[^/]+)$|(?:^|\/)(?:config|patches)\/|^src\/packages\//
// Only module-graph files can make Vitest's `related` selection meaningful.
const IMPORTABLE = /\.(?:[cm]?[jt]sx?|json|css|scss|svg|html|wasm|ya?ml|txt|sql)$/
const UNIT_TEST_FILE = /\.test\.[cm]?[jt]sx?$/
const SOURCE_ROOTS = ['src/', 'mobile/', 'tests/', 'config/']
const TEST_FILE = /\.(?:test|spec|e2e|bench)\.[cm]?[jt]sx?$/
const LOCALES = 'src/renderer/src/i18n/'
// Ratchets default to SOURCE_ROOTS; list narrower inputs only where a scan is clearly scoped.
const RATCHET_INPUTS = {
'check-daemon-blocking-call-ratchet.mjs': ['src/main/'],
'check-e2e-typecheck-ratchet.mjs': ['tests/', 'config/tsconfig.e2e.json'],
'check-focus-read-ratchet.mjs': ['src/renderer/src/', 'src/shared/'],
'check-host-list-render-ratchet.mjs': ['src/renderer/src/'],
'check-id-only-store-write-ratchet.mjs': ['src/main/persistence/'],
'check-layout-writer-ratchets.mjs': ['src/main/', 'src/renderer/src/'],
'check-main-sync-fs-ratchet.mjs': [
'src/main/',
'src/shared/',
'config/main-blocking-worker-entries.json',
'config/scripts/static-analysis/process-boundaries/main-blocking-source-paths.mjs'
],
'check-main-sync-spawn-ratchet.mjs': [
'src/main/',
'src/shared/',
'src/preload/',
'config/main-blocking-worker-entries.json',
'config/scripts/static-analysis/process-boundaries/main-blocking-source-paths.mjs'
],
'check-react-compiler-compat-ratchet.mjs': ['src/renderer/src/'],
'check-recurring-timer-ratchet.mjs': [
'src/main/',
'src/renderer/src/',
'src/shared/',
'src/cli/'
],
'check-render-path-translate-ratchet.mjs': ['src/renderer/src/'],
'check-repo-host-read-ratchet.mjs': ['src/cli/', 'src/main/', 'src/shared/'],
'check-runtime-electron-ratchet.mjs': ['src/main/', 'src/cli/', 'src/shared/'],
'check-tab-map-seam-ratchet.mjs': ['src/renderer/src/'],
'check-terminal-parking-ownership-ratchet.mjs': ['src/renderer/src/'],
'check-unproven-pty-retirement-ratchet.mjs': ['src/main/'],
'check-workspace-record-read-ratchet.mjs': ['src/renderer/src/'],
'check-worktree-id-composer-ratchet.mjs': ['src/', 'mobile/src/']
}
// The lint-only verifiers from package.json `lint`, keyed to the inputs they read. A check also
// runs when its own script changes.
export const VERIFY_CHECKS = [
{
name: 'rpc params catalog',
args: [
'config/scripts/plugin-authoring/rpc-catalog/generate-rpc-params-catalog.mjs',
'--check'
],
inputs: ['src/main/runtime/rpc/', 'src/shared/']
},
{
name: 'acp protocol',
args: ['config/scripts/acp/generate-protocol.mjs', '--check'],
inputs: ['src/main/acp/generated/', 'config/scripts/acp/']
},
{
name: 'bundled skill guides',
args: ['config/scripts/skills/bundle-generation/generate-bundled-skill-guides.mjs', '--check'],
inputs: [
'skills/',
'config/scripts/skill-stub-composition',
'config/scripts/skills/bundle-generation/skill-stub-composition'
]
},
{
name: 'skill bundle manifest',
args: ['config/scripts/skills/bundle-generation/generate-skill-bundle-manifest.mjs'],
inputs: ['skills/', 'resources/skills/']
},
{
name: 'localization catalogs',
runtime: 'bun',
args: ['config/scripts/localization/catalog-validation/verify-localization-catalogs.mjs'],
inputs: [
'src/renderer/',
'src/shared/',
LOCALES,
'config/scripts/verify-localization-catalog',
'config/scripts/localization/catalog-validation/verify-localization-catalog'
]
},
{
name: 'localization coverage',
runtime: 'bun',
args: [
'config/scripts/localization/source-extraction/audit-localization-coverage.mjs',
'--check'
],
inputs: [
'src/renderer/src/',
LOCALES,
'config/scripts/localization/source-extraction/audit-localization-coverage',
'config/scripts/localization/source-extraction/localization-visible-object-keys'
]
},
{
name: 'startup and worktree localization coverage',
args: [
'config/scripts/localization/source-extraction/audit-localization-coverage.mjs',
'--check',
'--native-dialog-fields',
'--source-root',
'src/main/startup',
'--source-root',
'src/shared/worktree',
'--allowlist',
'config/startup-worktree-localization-allowlist.json'
],
inputs: [
'src/main/startup/',
'src/shared/worktree/',
'config/scripts/localization/source-extraction/audit-localization-coverage',
'config/scripts/localization/source-extraction/localization-visible-object-keys',
'config/startup-worktree-localization-allowlist.json'
]
},
{
name: 'mobile localization',
args: ['config/scripts/localization/catalog-validation/verify-mobile-localization.mjs'],
inputs: [
'mobile/app/',
'mobile/src/',
'mobile/web-entry/',
'mobile/app.json',
'mobile/locales/',
'src/shared/ui-locale',
'config/scripts/verify-mobile-localization',
'config/scripts/localization/catalog-validation/verify-mobile-localization',
'config/scripts/verify-localization-',
'config/scripts/localization/catalog-validation/verify-localization-',
'config/scripts/localization/source-extraction/verify-localization-',
'config/scripts/generate-runtime-required-english-catalog',
'config/scripts/localization/catalog-validation/generate-runtime-required-english-catalog',
'config/scripts/localization-surfaces',
'config/scripts/localization/catalog-validation/localization-surfaces'
]
},
{
name: 'mobile localization coverage',
args: [
'config/scripts/localization/source-extraction/audit-localization-coverage.mjs',
'--check',
'--source-root',
'mobile/app',
'--source-root',
'mobile/src',
'--source-root',
'mobile/web-entry',
'--allowlist',
'config/mobile-localization-coverage-allowlist.json'
],
inputs: [
'mobile/app/',
'mobile/src/',
'mobile/web-entry/',
'config/mobile-localization-coverage-allowlist.json',
'config/scripts/audit-localization-coverage',
'config/scripts/localization/source-extraction/audit-localization-coverage',
'config/scripts/localization/source-extraction/localization-visible-object-keys'
]
},
{
// CI's own predicate, so local and CI select this check identically.
name: 'localization extraction',
args: ['config/scripts/localization/source-extraction/verify-localization-extraction.mjs'],
matches: (files) => affectsLocalizationExtraction(files)
},
{
name: 'reliability gates',
args: ['config/scripts/static-analysis/reliability-policy/check-reliability-gates.mjs'],
inputs: ['config/reliability-gates.jsonc']
},
{
name: 'node runtime pin',
args: ['config/scripts/node-runtime/pin-policy/check-node-runtime-pin.mjs'],
inputs: [
'src/shared/node-runtime-pin',
'config/scripts/node-dist-archive-name',
'config/scripts/node-runtime/archive-identity/node-dist-archive-name'
]
},
{
name: 'test file runners',
args: ['config/scripts/ci/unit-selection/check-test-file-runners.mjs'],
inputs: [
'config/scripts/ci/unit-selection/dependency-graph/ci-unit-files.mjs',
'tests/playwright.config.ts',
'mobile/vitest.config.ts'
],
matches: (files) =>
files.some((file) => TEST_FILE.test(file) || /vitest[^/]*\.config/.test(file))
},
{
name: 'mobile shared dependency versions',
args: [
'config/scripts/package-manager/lockfile-contracts/check-mobile-shared-dependency-versions.mjs'
],
inputs: ['mobile/package.json', 'mobile/pnpm-lock.yaml']
},
{
name: 'readme local links',
args: ['config/scripts/static-analysis/repository-layout/check-readme-local-links.mjs'],
inputs: ['README.md', 'docs/readme/']
},
{
// Compiles Tailwind (~1.5s), so it runs only for inputs that can change the emitted CSS.
name: 'compiled :has() restyle scope',
args: ['config/scripts/static-analysis/renderer-styles/check-has-restyle-scope.mjs'],
inputs: ['electron.vite.config.ts'],
matches: (files) =>
files.some((file) => /^src\/(?:renderer\/.*\.(?:css|tsx?)|shared\/.*\.css)$/.test(file))
},
{
name: 'import boundaries',
runtime: 'bun',
args: ['config/scripts/static-analysis/process-boundaries/check-import-boundaries.mjs'],
inputs: [...SOURCE_ROOTS, 'config/process-host-direct-import-baseline.txt']
},
{
name: 'dead design-system classes',
oxlint: ['--config', 'config/oxlint-dead-classes.json', 'src/renderer'],
inputs: ['src/renderer/', 'config/oxlint-dead-classes.json']
}
]
// Projects of run-typecheck-projects-in-parallel.mjs and the source trees whose types reach them.
const TYPECHECK_INPUTS = [
{
config: 'config/tsconfig.node.json',
inputs: [
'src/main/',
'src/preload/',
'src/shared/',
'src/renderer/',
'src/cli/',
'src/wsl-guest/',
'config/'
]
},
{
config: 'config/tsconfig.tc.web.json',
inputs: ['src/renderer/', 'src/preload/', 'src/shared/', 'src/main/']
},
{ config: 'config/tsconfig.tc.cli.json', inputs: ['src/cli/', 'src/shared/', 'src/main/'] },
{
config: 'src/packages/process-host/tsconfig.test.json',
inputs: ['src/packages/process-host/']
}
]
const TYPECHECK_ALL_PREFIXES = ['src/packages/', 'src/types/', 'config/tsconfig', 'tsconfig.json']
// JSON reaches the type graph only through src/ imports (resolveJsonModule).
const isTypedSource = (file) =>
/\.[cm]?tsx?$/.test(file) || (file.startsWith('src/') && file.endsWith('.json'))
function matchesPrefix(file, prefixes) {
return prefixes.some((prefix) => file === prefix || file.startsWith(prefix))
}
// A script's stem also matches its tests and sibling modules (`verify-localization-catalog*`).
function ownScriptStem(check) {
return check.args?.[0].replace(/\.mjs$/, '')
}
export function selectTypecheckProjects(files) {
if (
files.some((file) => SELECT_ALL_FILES.has(file) || matchesPrefix(file, TYPECHECK_ALL_PREFIXES))
) {
return TYPECHECK_INPUTS.map(({ config }) => config)
}
const typed = files.filter(isTypedSource)
return TYPECHECK_INPUTS.filter(({ inputs }) =>
typed.some((file) => matchesPrefix(file, inputs))
).map(({ config }) => config)
}
// Shared analysis modules select every ratchet built on them.
const RATCHET_SHARED_MODULES = {
'config/scripts/static-analysis/process-boundaries/gated-call-use-collector.mjs': [
'check-main-blocking-api-ratchet.mjs',
'check-daemon-blocking-call-ratchet.mjs',
'check-main-sync-fs-ratchet.mjs',
'check-main-sync-spawn-ratchet.mjs',
'check-recurring-timer-ratchet.mjs'
],
'config/scripts/static-analysis/ratchets/react-compiler/react-compiler-hazards.mjs': [
'check-react-compiler-compat-ratchet.mjs'
],
'config/react-compiler-uncompiled-files.txt': ['check-react-compiler-compat-ratchet.mjs'],
'config/scripts/localization/reactive-translate/render-path-translate.mjs': [
'check-render-path-translate-ratchet.mjs'
]
}
function listRatchetChecks(scriptsDir) {
const scripts = new Map(listRatchetScripts(scriptsDir).map((file) => [path.basename(file), file]))
// Local lint runs these outside run-ratchets.mjs.
scripts.set(
'check-main-sync-spawn-ratchet.mjs',
'static-analysis/process-boundaries/check-main-sync-spawn-ratchet.mjs'
)
scripts.set(
'check-flaky-test-pattern-ratchet.mjs',
'static-analysis/ratchets/test-quality/check-flaky-test-pattern-ratchet.mjs'
)
return [...scripts]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([script, file]) => ({
name: script.replace(/^check-|\.mjs$/g, ''),
args: [`config/scripts/${file.split(path.sep).join('/')}`],
inputs: RATCHET_INPUTS[script] ?? SOURCE_ROOTS,
matches: (files) =>
files.some(
(file) =>
/^config\/[^/]+-baseline\.txt$/.test(file) ||
RATCHET_SHARED_MODULES[file]?.includes(script)
)
}))
}
function isSelected(check, files) {
const stem = ownScriptStem(check)
return (
(stem !== undefined && files.some((file) => file.startsWith(stem))) ||
files.some((file) => matchesPrefix(file, check.inputs ?? [])) ||
(check.matches?.(files) ?? false)
)
}
// `related` paths go through Vitest's import graph; `colocated` are explicit test files, either
// changed tests under a trigger path or the `<stem>.test.*` / `<stem>.*.test.*` siblings of one.
export function splitUnitTestInputs(files, listDirectory = () => []) {
const related = files.filter(
(file) => IMPORTABLE.test(file) && !FULL_SUITE_TRIGGER.test(file) && !isDocsOnlyPath(file)
)
const colocated = new Set()
for (const file of files.filter((candidate) => FULL_SUITE_TRIGGER.test(candidate))) {
if (UNIT_TEST_FILE.test(file)) {
colocated.add(file)
continue
}
const slash = file.lastIndexOf('/')
const directory = file.slice(0, slash + 1)
const stem = file.slice(slash + 1).replace(/\.[^.]+$/, '')
for (const name of listDirectory(directory).map(String)) {
if (UNIT_TEST_FILE.test(name) && name.startsWith(`${stem}.`)) {
colocated.add(directory + name)
}
}
}
return { related, colocated: [...colocated].sort() }
}
/** Decides which checks a set of changed paths needs; CI's classifier gates the expensive jobs. */
export function planChangedChecks(files, { scriptsDir, listDirectory }) {
const jobs = classifyPrJobs(files)
const selectAll = files.some(
(file) => SELECT_ALL_FILES.has(file) || matchesPrefix(file, SELECT_ALL_PREFIXES)
)
const checks = [...listRatchetChecks(scriptsDir), ...VERIFY_CHECKS]
return {
staticAnalysis: jobs.static_analysis,
typecheckProjects: jobs.typecheck ? selectTypecheckProjects(files) : [],
unitTests: jobs.test
? splitUnitTestInputs(files, listDirectory)
: { related: [], colocated: [] },
checks: jobs.static_analysis
? checks.filter((check) => selectAll || isSelected(check, files))
: []
}
}