import { affectsLocalizationExtraction } from './localization-extraction-change-scope.mjs' import { classifyPrJobs, isDocsOnlyPath } from './pr-code-change-scope.mjs' import { listRatchetScripts } from './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/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-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-spawn-ratchet.mjs': ['src/main/'], 'check-repo-host-read-ratchet.mjs': ['src/cli/', 'src/main/', 'src/shared/'], 'check-runtime-electron-ratchet.mjs': ['src/main/', 'src/cli/', 'src/shared/'], '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/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/generate-bundled-skill-guides.mjs', '--check'], inputs: ['skills/', 'config/scripts/skill-stub-composition'] }, { name: 'skill bundle manifest', args: ['config/scripts/generate-skill-bundle-manifest.mjs'], inputs: ['skills/', 'resources/skills/'] }, { name: 'localization catalogs', runtime: 'bun', args: ['config/scripts/verify-localization-catalogs.mjs'], inputs: ['src/renderer/', 'src/shared/', LOCALES, 'config/scripts/verify-localization-catalog'] }, { name: 'localization coverage', runtime: 'bun', args: ['config/scripts/audit-localization-coverage.mjs', '--check'], inputs: ['src/renderer/src/', LOCALES] }, { // CI's own predicate, so local and CI select this check identically. name: 'localization extraction', args: ['config/scripts/verify-localization-extraction.mjs'], matches: (files) => affectsLocalizationExtraction(files) }, { name: 'reliability gates', args: ['config/scripts/check-reliability-gates.mjs'], inputs: ['config/reliability-gates.jsonc'] }, { name: 'node runtime pin', args: ['config/scripts/check-node-runtime-pin.mjs'], inputs: ['src/shared/node-runtime-pin', 'config/scripts/node-dist-archive-name'] }, { name: 'test file runners', args: ['config/scripts/check-test-file-runners.mjs'], inputs: [ 'config/scripts/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/check-mobile-shared-dependency-versions.mjs'], inputs: ['mobile/package.json', 'mobile/pnpm-lock.yaml'] }, { name: 'readme local links', args: ['config/scripts/check-readme-local-links.mjs'], inputs: ['README.md', 'docs/readme/'] }, { name: 'import boundaries', runtime: 'bun', args: ['config/scripts/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) } function listRatchetChecks(scriptsDir) { const scripts = new Set(listRatchetScripts(scriptsDir)) // `pnpm lint` runs these although CI checks them in their own steps outside run-ratchets.mjs. scripts.add('check-main-sync-spawn-ratchet.mjs') scripts.add('check-flaky-test-pattern-ratchet.mjs') return [...scripts].sort().map((script) => ({ name: script.replace(/^check-|\.mjs$/g, ''), args: [`config/scripts/${script}`], inputs: RATCHET_INPUTS[script] ?? SOURCE_ROOTS, matches: (files) => files.some((file) => /^config\/[^/]+-baseline\.txt$/.test(file)) })) } 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 `.test.*` / `.*.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)) : [] } }