From 7cdfebdc1becc12ae969bd34ea98186b7f3947ff Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 28 Oct 2026 00:11:04 -0700 Subject: [PATCH] Revert iteration checks and custom Vitest selection (#34834) * revert: remove iteration checks and custom Vitest selection * chore: use upstream affected-test selection --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .github/CONTRIBUTING.md | 81 +--- AGENTS.md | 4 +- config/patches/vitest@5.0.5.patch | 273 +----------- .../check-changed-dependency-evidence.mjs | 128 ------ .../scripts/check-changed-lifecycle.test.mjs | 378 ---------------- config/scripts/check-changed-pass-cache.mjs | 104 +---- .../scripts/check-changed-pass-cache.test.mjs | 193 +-------- config/scripts/check-changed-task-runner.mjs | 220 ---------- .../check-changed-task-runner.test.mjs | 113 ----- config/scripts/check-changed.mjs | 340 ++++++--------- config/scripts/check-changed.test.mjs | 407 ------------------ config/scripts/vitest-affected-tests.test.mjs | 263 ----------- config/vitest.config.ts | 4 +- package.json | 1 - pnpm-lock.yaml | 16 +- src/packages/process-host/src/process-spec.ts | 4 - .../process-host/src/run-process-sync.ts | 48 --- .../run-process-termination-failure.test.ts | 12 +- src/packages/process-host/src/run-process.ts | 88 ++-- 19 files changed, 204 insertions(+), 2473 deletions(-) delete mode 100644 config/scripts/check-changed-dependency-evidence.mjs delete mode 100644 config/scripts/check-changed-lifecycle.test.mjs delete mode 100644 config/scripts/check-changed-task-runner.mjs delete mode 100644 config/scripts/check-changed-task-runner.test.mjs delete mode 100644 config/scripts/check-changed.test.mjs delete mode 100644 config/scripts/vitest-affected-tests.test.mjs delete mode 100644 src/packages/process-host/src/run-process-sync.ts diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 1a9a0ee43b9..400ed2719d3 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -93,51 +93,6 @@ pnpm dev:fresh [--keep] [--seed-from[=]] # temporary profile, rem ### Targeting Local Tests -During iteration, use `pnpm check:iteration --maxWorkers=2` to select checks for staged, -unstaged, and untracked edits against HEAD. Before handoff, run -`pnpm check:changed --maxWorkers=2` for the whole branch against its merge base with -`origin/main` (override with `--base=`). `check:changed --scope=worktree` is the -explicit form of `check:iteration`. A clean worktree has nothing to check in iteration -mode, even if the branch has committed changes. Both commands check the current -working-tree bytes; they do not validate a different version stored only in the index. - -Both gates print each step's command, deadline, start, and completion, emit a heartbeat -every 15 seconds, and stream Vitest progress. Each step has a five-minute deadline; -pass `--stepTimeoutMs=` to increase it for a larger check. A timeout fails the gate -and requests termination of the step's process group/tree. Process shutdown can take -additional time. `--maxWorkers` also caps concurrent check tasks, while typecheck and -Vitest retain their own bounded worker pools. The deadline applies to each step, -not to the total command, and does not apply to direct `pnpm test` commands. -Passing results are cached separately for branch and worktree scope; a timeout, failure, -or cancellation never records a pass. Starting a real recheck invalidates the earlier -pass for that scope, including with `--force`. The cache includes runtime and effective -test-selection context. Inputs are rehashed before saving a pass; an edit detected -during checks fails the gate and requires a retry. Keep edits paused during checks: -this is not an immutable filesystem snapshot. -For gates with unit tests, reuse also requires matching dependency-record evidence. -New incomplete records arriving during checks prevent a pass; a successful run can -cache its own passing record updates immediately. Unreadable or oversized evidence -disables caching without discarding otherwise successful checks. - -Stream the command's output to observe progress. If you redirect it to a log, tail that -log while the same command runs. A tool's output-yield interval can be short, but its -execution deadline must allow the selected steps and process shutdown to finish; poll -the existing process/session instead of launching a replacement on an observation timeout. -For example, start `pnpm check:iteration --maxWorkers=2` with a short tool-output yield, -then read that same running session until it exits and preserve its exit status. -Uncatchable termination of the gate, or descendants that escape its POSIX process group, -can require separate cleanup; these are not proof of a completed or passing check. -Closed output consumers cancel active checks. Slow consumers can omit live progress -with a notice; a final output drain that cannot finish fails the command. - -Iteration checks use the CI unit inventory, leaving shell, Electron, and cross-version -tests to their dedicated CI jobs. Ordinary `pnpm test` and the branch handoff gate retain -their broader local discovery, and explicit test commands can still run those suites. -Both gates use the installed incremental dependency recording and its cold-cache static -fallback described below. Deleting a related source runs the full inventory for that -gate's scope, because a cold graph cannot reliably resolve a removed import. Neither -gate replaces CI's full coverage of global inputs. - Use a test file or directory when you know the relevant suites. For dependency-based feedback, Vitest can select tests affected by your edits: @@ -152,39 +107,9 @@ also includes committed changes since the branch's merge base. Update that ref w you need a newer baseline. Both accept the same path filters and options as `pnpm test`. When Git reports no changed files at all, the launcher exits before starting Vitest. -Local runs enable Vitest's [dependency recording](https://vitest.dev/config/experimental#experimental-recorddependencies). -Ordinary test runs populate the cache; later changed-file runs read it instead of -transforming every test's import graph. Records are separate for the Bun and Node -projects and live in each checkout's `node_modules/.vite`. A test without a record -runs when at most 100 such tests are in scope, which records them. Beyond that, as in -a new worktree, unrecorded tests are selected with Vitest's static import graph -instead of running all of them. Keep Vite's local test cache between runs. Use -`--experimental.recordDependencies=false` to use the static import graph for every test. -The cold fallback still scans test import graphs; it limits test execution, not all -transformation work. -Recorded source closures, configuration, and runtime context are checked for freshness; -stale records use the same cold-cache fallback. Failed or partial suites retain the -union of known dependencies and run again until they complete successfully. Sources -modified during a run cannot receive a fresh record for that run. - -This is local feedback, not a complete regression check. Vitest records loaded -imports, including executed dynamic imports, but cannot infer arbitrary filesystem -reads, subprocess inputs, or externalized module dependencies. Explicitly run tests -for changed fixtures, generated files, CLI entry points, and other indirect inputs. -Configuration, lockfile, patch, and workspace-package changes passed directly to -Vitest's affected-test command rerun every test within the requested scope. The check -gates instead select colocated tests for these global inputs and rely on CI for full -coverage. CI does not enable recording; -the existing CI selection policy is unchanged. The static graph has the same blind spots -and also misses dynamic imports with computed specifiers, so the first changed-file run -in a new worktree is a little less complete than later ones. - -`pnpm build:packages` (which `pnpm test` runs first) skips compiling workspace packages -when their sources, build script, and compiler are unchanged and `dist` is intact. - -Vitest also supports [`related --run `](https://vitest.dev/guide/cli#vitest-related) -and dependency-aware watch mode. `pnpm test --changed` uses Git to provide the source -inputs automatically and exits after one run. +Affected tests use Vitest's static import graph. Run suites explicitly for computed +dynamic imports, fixture files, subprocess inputs, or other dependencies outside that +graph. Local affected tests do not replace CI. To reduce main-process rebuild CPU and memory when you do not need its source maps, set `ORCA_DEV_MAIN_SOURCEMAPS=0` before `pnpm dev`. In Bash/zsh: diff --git a/AGENTS.md b/AGENTS.md index 1c636aa7f97..30482aa1a88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,6 @@ # Useful Commands -- **While editing**: `pnpm check:iteration --maxWorkers=2` for repeated feedback on uncommitted edits against HEAD (CI unit inventory). -- **Before handoff**: `pnpm check:changed --maxWorkers=2` once for the whole branch (format, lint, typecheck, affected tests, ratchets; cached when nothing changed). An iteration pass does not replace this gate. -- Stream check output and poll the same running process/session for progress. Redirecting to a file hides progress unless you tail it; keep any outer execution deadline longer than the check's step budgets plus shutdown time. +- **Before handoff**: `pnpm check:changed --maxWorkers=2` (format, lint, typecheck, affected tests, ratchets; cached when nothing changed) - **Typecheck**: `pnpm tc` (or `tc:node` / `tc:cli` / `tc:web`) - **Test**: `pnpm test [path/to/file.test.ts]`; affected only: `pnpm test:changed [path] --maxWorkers=2` ([details](.github/CONTRIBUTING.md#targeting-local-tests)) - **Lint**: `oxlint`; format: `pnpm format` diff --git a/config/patches/vitest@5.0.5.patch b/config/patches/vitest@5.0.5.patch index 63c2caf9d79..7a8746da87d 100644 --- a/config/patches/vitest@5.0.5.patch +++ b/config/patches/vitest@5.0.5.patch @@ -1,270 +1,7 @@ diff --git a/dist/chunks/cli-api.De07GUeT.js b/dist/chunks/cli-api.De07GUeT.js -index ff6e63043ff8fa0b821d4b0b382d1fd998d85dbb..3b75a028bee91dd4e354224cec9918829edbc169 100644 --- a/dist/chunks/cli-api.De07GUeT.js +++ b/dist/chunks/cli-api.De07GUeT.js -@@ -6108,7 +6108,70 @@ async function getFileStamp(path) { - } - - const debug = createDebugger("vitest:cache:dependencies"); --const DEPENDENCIES_VERSION = 1; -+const DEPENDENCIES_VERSION = 2; -+const MAX_RECORDED_SOURCE_FILES = 50_000; -+const MAX_RECORDED_SOURCE_BYTES = 8 * 1024 * 1024; -+class RecordedSourceFreshness { -+ files = /* @__PURE__ */ new Map(); -+ contexts = /* @__PURE__ */ new Map(); -+ constructor(vitest, startedAt = Infinity) { -+ this.vitest = vitest; -+ this.startedAt = startedAt; -+ } -+ source(file) { -+ if (this.files.has(file)) return this.files.get(file); -+ if (this.files.size >= MAX_RECORDED_SOURCE_FILES) return null; -+ let digest = null; -+ try { -+ const before = statSync(file, { throwIfNoEntry: false }); -+ if (!before) digest = "missing"; -+ else if (before.isFile() && before.mtimeMs < this.startedAt && before.ctimeMs < this.startedAt && before.size <= MAX_RECORDED_SOURCE_BYTES) { -+ const bytes = readFileSync(file); -+ const after = statSync(file); -+ if (before.ino === after.ino && before.size === bytes.length && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs) digest = createHash("sha256").update(bytes).digest("hex"); -+ } -+ } catch {} -+ this.files.set(file, digest); -+ return digest; -+ } -+ context(project) { -+ if (this.contexts.has(project)) return this.contexts.get(project); -+ const root = this.vitest.config.root; -+ const inputs = [...new Set([ -+ ...this.vitest.vite.config.configFileDependencies || [], -+ ...project.vite.config.configFileDependencies || [], -+ ...getEnvFiles(project), -+ ...['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'config/.bun-version'].map((file) => resolve(root, file)) -+ ])].sort(); -+ const sources = inputs.map((file) => [file, this.source(file)]); -+ const context = sources.some(([, digest]) => digest === null) ? null : createHash("sha256").update(JSON.stringify({ -+ runtime: [process.platform, process.arch, process.version, process.versions.bun, process.env.ORCA_TEST_NODE_EXECUTABLE, process.env.ORCA_TEST_NODE_VERSION], -+ project: [project.name, project.config.pool, project.config.environment, project.config.env, project.config.execArgv, project.config.setupFiles, project.config.globalSetup, project.config.experimental.viteModuleRunner], -+ mode: project.vite.config.mode, -+ define: [project.vite.config.define, project.config.defines, project.config._scriptDefines], -+ resolve: { conditions: project.vite.config.resolve.conditions, mainFields: project.vite.config.resolve.mainFields, extensions: project.vite.config.resolve.extensions, preserveSymlinks: project.vite.config.resolve.preserveSymlinks, alias: project.vite.config.resolve.alias.map(({ find, replacement }) => [String(find), replacement]) }, -+ env: project.vite.config.env, -+ moduleCache: [project.config.fsModuleCache, project.config.fsModuleCachePath], -+ inventory: process.env.ORCA_UNIT_INVENTORY, -+ balanced: process.env.ORCA_BALANCE_UNIT_SHARDS, -+ sources -+ })).digest("hex"); -+ this.contexts.set(project, context); -+ return context; -+ } -+ signature(project, files) { -+ const context = this.context(project); -+ if (context === null) return null; -+ const hash = createHash("sha256").update(context); -+ for (const file of [...new Set(files)].sort()) { -+ const digest = this.source(file); -+ if (digest === null || digest === "missing") return null; -+ hash.update(JSON.stringify([file, digest])); -+ } -+ return hash.digest("hex"); -+ } -+} -+ - /** - * Stores the modules that every test file loaded during its last run. - */ -@@ -6118,6 +6181,7 @@ class DependenciesCache { - // recorded during the current run, merged into `projects` by `update` - recordedFiles = /* @__PURE__ */ new Map(); - recordedGlobalSetup = /* @__PURE__ */ new Map(); -+ recordingFreshness; - path; - fileStamp; - constructor(vitest) { -@@ -6127,6 +6191,11 @@ class DependenciesCache { - get(project) { - return this.projects.get(project.name); - } -+ start(specifications, startTime) { -+ if (!this.path) return; -+ this.recordingFreshness = new RecordedSourceFreshness(this.vitest, startTime); -+ for (const project of new Set(specifications.map((spec) => spec.project))) this.recordingFreshness.context(project); -+ } - record(project, filepath, dependencies) { - if (!this.path) return; - let files = this.recordedFiles.get(project.name); -@@ -6150,13 +6219,16 @@ class DependenciesCache { - const root = this.vitest.config.root; - const result = /* @__PURE__ */ new Map(); - for (const name in projects) { -- const { modules, globalSetup, files } = projects[name]; -+ const { modules, globalSetup, files, signatures, contexts, incomplete } = projects[name]; - const absoluteModules = modules.map((file) => resolve(root, file)); - result.set(name, { - modules: absoluteModules, - moduleIndex: new Map(absoluteModules.map((file, index) => [file, index])), - globalSetup, -- files: new Map(Object.entries(files)) -+ files: new Map(Object.entries(files)), -+ signatures: new Map(Object.entries(signatures || {})), -+ contexts: new Map(Object.entries(contexts || {})), -+ incomplete: new Set(incomplete || []) - }); - } - this.projects = result; -@@ -6167,9 +6239,10 @@ class DependenciesCache { - async update(specifications) { - if (!this.recordedFiles.size && !this.recordedGlobalSetup.size) return; - await this.read(); -+ const freshness = this.recordingFreshness || new RecordedSourceFreshness(this.vitest, 0); - for (const [name, files] of this.recordedGlobalSetup) { - const project = this.getProjectDependencies(name); -- project.globalSetup = this.intern(project, files); -+ project.globalSetup = Array.from(new Set([...project.globalSetup || [], ...this.intern(project, files)])); - } - for (const specification of specifications) { - const files = this.recordedFiles.get(specification.project.name)?.get(specification.moduleId); -@@ -6178,10 +6251,20 @@ class DependenciesCache { - const project = this.getProjectDependencies(specification.project.name); - const file = getRootRelativePath(this.vitest.config.root, specification.moduleId); - const previous = project.files.get(file); -- // a partial run skips tests, so it can miss their dynamic imports, -- // without a previous record the file keeps having none and always runs -- if (!isPartialRun(this.vitest, specification, task)) project.files.set(file, this.intern(project, files)); -- else if (previous) project.files.set(file, Array.from(/* @__PURE__ */ new Set([...previous, ...this.intern(project, files)]))); -+ const current = this.intern(project, files); -+ // Nonpassing or partial suites cannot prove that later dynamic imports disappeared. -+ if (task.result?.state !== "pass" || isPartialRun(this.vitest, specification, task)) { -+ project.files.set(file, Array.from(new Set([...previous || [], ...current]))); -+ project.incomplete.add(file); -+ project.signatures.delete(file); -+ project.contexts.delete(file); -+ } else { -+ project.files.set(file, current); -+ project.incomplete.delete(file); -+ const closure = [specification.moduleId, ...files, ...(project.globalSetup || []).map((index) => project.modules[index])]; -+ project.signatures.set(file, freshness.signature(specification.project, closure)); -+ project.contexts.set(file, freshness.context(specification.project)); -+ } - } - this.recordedFiles.clear(); - this.recordedGlobalSetup.clear(); -@@ -6235,7 +6318,10 @@ class DependenciesCache { - project = { - modules: [], - moduleIndex: /* @__PURE__ */ new Map(), -- files: /* @__PURE__ */ new Map() -+ files: /* @__PURE__ */ new Map(), -+ signatures: /* @__PURE__ */ new Map(), -+ contexts: /* @__PURE__ */ new Map(), -+ incomplete: /* @__PURE__ */ new Set() - }; - this.projects.set(name, project); - } -@@ -6245,10 +6331,13 @@ class DependenciesCache { - if (!this.path) return; - const root = this.vitest.config.root; - const projects = Object.create(null); -- for (const [name, { modules, globalSetup, files }] of this.projects) projects[name] = { -+ for (const [name, { modules, globalSetup, files, signatures, contexts, incomplete }] of this.projects) projects[name] = { - modules: modules.map((file) => getRootRelativePath(root, file)), - globalSetup, -- files: Object.fromEntries(files) -+ files: Object.fromEntries(files), -+ signatures: Object.fromEntries(signatures), -+ contexts: Object.fromEntries(contexts), -+ incomplete: Array.from(incomplete) - }; - const dependencies = { - version: DEPENDENCIES_VERSION, -@@ -10250,12 +10339,15 @@ function groupFilters(filters) { - * Finds test specifications that loaded any of the changed files during their last recorded run. - * A specification without a record is always affected. - */ -+const UNRECORDED_RUN_LIMIT = 100; - class RecordedModulesResolver { - vitest; - related; -+ freshness; - constructor(vitest, related) { - this.vitest = vitest; - this.related = related; -+ this.freshness = new RecordedSourceFreshness(vitest); - } - async resolve(specs) { - if (this.dependsOnConfig(this.vitest.vite.config.configFileDependencies)) return specs; -@@ -10267,13 +10359,30 @@ class RecordedModulesResolver { - projectSpecs.push(spec); - } - const affected = /* @__PURE__ */ new Set(); -- for (const [project, projectSpecs] of specsByProject) this.findAffectedInProject(project, projectSpecs).forEach((spec) => affected.add(spec)); -+ const unrecorded = []; -+ for (const [project, projectSpecs] of specsByProject) { -+ const result = this.findAffectedInProject(project, projectSpecs); -+ result.affected.forEach((spec) => affected.add(spec)); -+ unrecorded.push(...result.unrecorded); -+ } -+ // Orca: running a few unrecorded files records them; many (a fresh checkout) use the static graph instead of running everything. -+ const unrecordedAffected = unrecorded.length > UNRECORDED_RUN_LIMIT ? await new AffectedModulesResolver(this.vitest, this.related).resolve(unrecorded) : unrecorded; -+ unrecordedAffected.forEach((spec) => affected.add(spec)); - return specs.filter((spec) => affected.has(spec)); - } - findAffectedInProject(project, specs) { -- if (this.dependsOnConfig(project.vite.config.configFileDependencies) || this.dependsOnConfig(getEnvFiles(project))) return specs; -+ if (this.dependsOnConfig(project.vite.config.configFileDependencies) || this.dependsOnConfig(getEnvFiles(project))) return { -+ affected: specs, -+ unrecorded: [] -+ }; - const dependencies = this.vitest.cache._dependencies.get(project); -- if (!dependencies) return specs; -+ if (!dependencies) return project.config.globalSetup.length ? { -+ affected: specs, -+ unrecorded: [] -+ } : { -+ affected: [], -+ unrecorded: specs -+ }; - const changed = /* @__PURE__ */ new Set(); - for (const file of this.related) { - const index = dependencies.moduleIndex.get(file); -@@ -10281,13 +10390,30 @@ class RecordedModulesResolver { - } - if (project.config.globalSetup.length) { - const globalSetup = dependencies.globalSetup; -- if (!globalSetup || globalSetup.some((index) => changed.has(index))) return specs; -+ if (!globalSetup || globalSetup.some((index) => changed.has(index))) return { -+ affected: specs, -+ unrecorded: [] -+ }; - } - const root = this.vitest.config.root; -- return specs.filter((spec) => { -- const loaded = dependencies.files.get(getRootRelativePath(root, spec.moduleId)); -- return !loaded || loaded.some((index) => changed.has(index)); -- }); -+ const affected = []; -+ const unrecorded = []; -+ for (const spec of specs) { -+ const file = getRootRelativePath(root, spec.moduleId); -+ const loaded = dependencies.files.get(file); -+ if (loaded?.some((index) => changed.has(index))) affected.push(spec); -+ else if (loaded && (dependencies.incomplete.has(file) || this.freshness.context(project) !== dependencies.contexts.get(file))) affected.push(spec); -+ else if (!loaded) unrecorded.push(spec); -+ else { -+ const closure = [spec.moduleId, ...loaded, ...dependencies.globalSetup || []].map((value) => typeof value === "number" ? dependencies.modules[value] : value); -+ const signature = this.freshness.signature(project, closure); -+ if (signature === null || signature !== dependencies.signatures.get(file)) unrecorded.push(spec); -+ } -+ } -+ return { -+ affected, -+ unrecorded -+ }; - } - dependsOnConfig(configDependencies) { - return !!configDependencies?.some((file) => this.related.includes(file)); -@@ -11817,7 +11943,8 @@ class TestRun { +@@ -11817,7 +11817,8 @@ class TestRun { else error.stacks = parseErrorStacktrace(error, { frameFilter: project.config.onStackTrace, getSourceMap(file) { @@ -274,11 +11,3 @@ index ff6e63043ff8fa0b821d4b0b382d1fd998d85dbb..3b75a028bee91dd4e354224cec991882 const code = readFileSync(file, "utf-8"); return extractSourcemapFromFile(code, file)?.map; } -@@ -12860,6 +12987,7 @@ class Vitest { - this.runningPromise = (async () => { - try { - const startTime = Date.now(); -+ this.cache._dependencies.start(specs, startTime); - if (!this.pool) this.pool = createPool(this); - const invalidates = Array.from(this.watcher.invalidates); - this.watcher.invalidates.clear(); diff --git a/config/scripts/check-changed-dependency-evidence.mjs b/config/scripts/check-changed-dependency-evidence.mjs deleted file mode 100644 index 8883042deae..00000000000 --- a/config/scripts/check-changed-dependency-evidence.mjs +++ /dev/null @@ -1,128 +0,0 @@ -import { createHash } from 'node:crypto' -import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs' -import { createRequire } from 'node:module' -import path from 'node:path' -import { pathToFileURL } from 'node:url' - -export const DEPENDENCY_PATH_PREFIX = 'check:changed dependency path: ' -const MAX_EVIDENCE_BYTES = 32 * 1024 * 1024 - -const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex') -const dictionary = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) - -function projectEvidence(name, project, incomplete) { - if ( - !dictionary(project) || - !Array.isArray(project.modules) || - !project.modules.every((file) => typeof file === 'string') || - !dictionary(project.files) || - !dictionary(project.signatures) || - !dictionary(project.contexts) || - !Array.isArray(project.incomplete) || - !project.incomplete.every( - (file) => typeof file === 'string' && Object.hasOwn(project.files, file) - ) - ) { - throw new Error('Invalid dependency project') - } - const modules = (indices) => { - if ( - !Array.isArray(indices) || - !indices.every( - (index) => Number.isInteger(index) && index >= 0 && index < project.modules.length - ) - ) { - throw new Error('Invalid dependency indices') - } - return [...new Set(indices.map((index) => project.modules[index]))].sort() - } - const globalSetup = project.globalSetup === undefined ? null : modules(project.globalSetup) - const partial = new Set(project.incomplete) - const files = Object.keys(project.files) - .sort() - .map((file) => { - const signature = project.signatures[file] ?? null - const context = project.contexts[file] ?? null - if ([signature, context].some((value) => value !== null && typeof value !== 'string')) { - throw new Error('Invalid dependency signature') - } - const record = [file, modules(project.files[file]), signature, context, partial.has(file)] - if (partial.has(file)) { - incomplete.push([JSON.stringify([name, file]), digest([globalSetup, record])]) - } - return record - }) - return [name, globalSetup, files] -} - -// Attest selection evidence, not module numbering, unused interned modules, or file timestamps. -export function readDependencyEvidence(file) { - if (file === null) { - return { path: null, hash: 'disabled', incomplete: [] } - } - if (typeof file !== 'string' || !path.isAbsolute(file)) { - return null - } - let descriptor - try { - // A FIFO must not block before fstat can reject it. - descriptor = openSync( - file, - constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NONBLOCK) - ) - const before = fstatSync(descriptor) - if (!before.isFile() || before.size > MAX_EVIDENCE_BYTES) { - return null - } - const bytes = Buffer.alloc(before.size) - let offset = 0 - while (offset < bytes.length) { - const count = readSync(descriptor, bytes, offset, bytes.length - offset, offset) - if (!count) { - return null - } - offset += count - } - const after = fstatSync(descriptor) - if ( - before.size !== after.size || - before.mtimeMs !== after.mtimeMs || - before.ctimeMs !== after.ctimeMs - ) { - return null - } - const cache = JSON.parse(bytes.toString('utf8')) - if (cache.version !== 2 || !dictionary(cache.projects)) { - return null - } - const incomplete = [] - const projects = Object.keys(cache.projects) - .sort() - .map((name) => projectEvidence(name, cache.projects[name], incomplete)) - return { path: file, hash: digest(projects), incomplete } - } catch (error) { - return descriptor === undefined && error.code === 'ENOENT' - ? { path: file, hash: digest([]), incomplete: [] } - : null - } finally { - if (descriptor !== undefined) { - closeSync(descriptor) - } - } -} - -export function hasNewIncompleteEvidence(before, after) { - const previous = new Map(before?.incomplete ?? []) - return Boolean(after?.incomplete.some(([file, hash]) => previous.get(file) !== hash)) -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const require = createRequire(path.join(process.cwd(), 'package.json')) - const { resolveConfig } = await import(pathToFileURL(require.resolve('vitest/node')).href) - const config = await resolveConfig({ config: 'config/vitest.config.ts', watch: false }) - const file = - config.test.cache && config.test.experimental.recordDependencies - ? path.join(config.cacheDir, 'dependencies.json') - : null - console.log(`${DEPENDENCY_PATH_PREFIX}${JSON.stringify(file)}`) -} diff --git a/config/scripts/check-changed-lifecycle.test.mjs b/config/scripts/check-changed-lifecycle.test.mjs deleted file mode 100644 index f338ab87646..00000000000 --- a/config/scripts/check-changed-lifecycle.test.mjs +++ /dev/null @@ -1,378 +0,0 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import { runProcessSync, spawnProcess } from '@orca/process-host' -import { afterEach, expect, it, vi } from 'vitest' - -const node = process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath -const temporary = [] -const children = [] -const pids = new Set() - -function directory() { - const root = mkdtempSync(join(tmpdir(), 'check-changed-lifecycle-')) - temporary.push(root) - return root -} - -function alive(pid) { - try { - process.kill(pid, 0) - return ( - process.platform !== 'linux' || - !readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1].startsWith('Z') - ) - } catch { - return false - } -} - -function launch( - script, - cwd, - args = [], - { readStdout = true, detached = false, nodeArgs = [], ipc = false } = {} -) { - const child = spawnProcess({ - program: node, - args: [...nodeArgs, script, ...args], - cwd, - env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, - detached, - stdio: ['ignore', 'pipe', 'pipe', ...(ipc ? ['ipc'] : [])] - }) - children.push(child) - if (child.pid) { - pids.add(child.pid) - } - let stdout = '' - let stderr = '' - if (readStdout) { - child.stdout.on('data', (chunk) => { - stdout += chunk - }) - } - child.stderr.on('data', (chunk) => { - stderr += chunk - }) - const exited = new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error(`Outer command did not exit: ${stdout}\n${stderr}`)), - 12_000 - ) - child.once('error', (error) => { - clearTimeout(timer) - reject(error) - }) - child.once('exit', (code, signal) => { - clearTimeout(timer) - resolve({ code, signal }) - }) - }) - return { child, exited, stdout: () => stdout, stderr: () => stderr } -} - -function runner(root, body) { - const script = join(root, 'runner.mjs') - writeFileSync( - script, - ` - import { createRequire } from 'node:module' - import { pathToFileURL } from 'node:url' - import { readFileSync, writeFileSync } from 'node:fs' - const require = createRequire(${JSON.stringify(resolve('src/packages/process-host/package.json'))}) - const { tsImport } = await import(pathToFileURL(require.resolve('tsx/esm/api')).href) - const { runProcess } = await tsImport(${JSON.stringify(resolve('src/packages/process-host/src/run-process.ts'))}, import.meta.url) - const { createTaskOutput, runTask } = await import(${JSON.stringify(pathToFileURL(resolve('config/scripts/check-changed-task-runner.mjs')).href)}) - ${body} - ` - ) - return script -} - -afterEach(async () => { - for (const child of children.splice(0)) { - child.stdout?.resume() - child.kill('SIGKILL') - } - for (const pid of pids) { - try { - process.kill(pid, 'SIGKILL') - } catch {} - } - await vi.waitFor(() => expect([...pids].filter(alive)).toEqual([]), { timeout: 5000 }) - pids.clear() - temporary.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) -}) - -it('exits after timeout when an escaped descendant holds stderr after root exit', async () => { - const root = directory() - const marker = join(root, 'pids.json') - const leaf = `process.on('SIGTERM', () => {}); process.send('ready'); setInterval(() => {}, 1000)` - const wrapper = join(root, 'wrapper.cjs') - writeFileSync( - wrapper, - ` - const child = require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(leaf)}], { - detached: true, stdio: ['ignore', 'ignore', 'inherit', 'ipc'], windowsHide: true - }) - require('node:fs').writeFileSync(${JSON.stringify(marker)}, JSON.stringify([process.pid, child.pid])) - child.once('message', () => process.exit(0)) - ` - ) - const report = join(root, 'result.json') - const script = runner( - root, - ` - const result = await runTask({ name: 'held pipe', steps: [{ program: process.execPath, args: [${JSON.stringify(wrapper)}] }] }, - { root: ${JSON.stringify(root)}, env: process.env, runProcess, stepTimeoutMs: 1000 }) - writeFileSync(${JSON.stringify(report)}, JSON.stringify(result)) - process.exitCode = result.ok ? 0 : 1 - ` - ) - const run = launch(script, root) - await vi.waitFor(() => { - const recorded = JSON.parse(readFileSync(marker, 'utf8')) - recorded.forEach((pid) => pids.add(pid)) - }) - expect(await run.exited).toEqual({ code: 1, signal: null }) - const [wrapperPid, leafPid] = JSON.parse(readFileSync(marker, 'utf8')) - expect(alive(wrapperPid)).toBe(false) - expect(alive(leafPid)).toBe(true) - const result = JSON.parse(readFileSync(report, 'utf8')) - expect(result.output).toContain('Timed out after') - expect(result.output).toContain('Cleanup unverified') -}, 15_000) - -it('streams real stdout and stderr while retaining their bounded failure tails', async () => { - const root = directory() - const fixture = join(root, 'failure.cjs') - const report = join(root, 'result.json') - writeFileSync( - fixture, - `console.log('old\\n'.repeat(1000) + 'LAST_STDOUT_DIAGNOSTIC'); console.error('LAST_STDERR_DIAGNOSTIC'); process.exitCode = 2` - ) - const script = runner( - root, - ` - const result = await runTask({ name: 'diagnostics', steps: [{ program: process.execPath, args: [${JSON.stringify(fixture)}], liveOutput: true }] }, - { root: ${JSON.stringify(root)}, env: process.env, runProcess, stepTimeoutMs: 1000 }) - writeFileSync(${JSON.stringify(report)}, JSON.stringify(result)) - process.exitCode = result.ok ? 0 : 1 - ` - ) - const run = launch(script, root) - expect(await run.exited).toEqual({ code: 1, signal: null }) - expect(run.stdout()).toContain('LAST_STDOUT_DIAGNOSTIC') - expect(run.stderr()).toContain('LAST_STDERR_DIAGNOSTIC') - const result = JSON.parse(readFileSync(report, 'utf8')) - expect(result.output).toContain('LAST_STDOUT_DIAGNOSTIC') - expect(result.output).toContain('LAST_STDERR_DIAGNOSTIC') - expect(result.output.split('\n').length).toBeLessThan(210) -}) - -it('cancels all active checks when a live output consumer closes', async () => { - const root = directory() - const marker = join(root, 'pids.json') - const report = join(root, 'result.json') - const script = runner( - root, - ` - const abort = new AbortController() - const output = createTaskOutput(() => abort.abort()) - const recorded = [] - const launch = (spec, capture) => runProcess({ ...spec, onStderr: chunk => { - recorded.push(Number(chunk.toString().trim())) - writeFileSync(${JSON.stringify(marker)}, JSON.stringify(recorded)) - if (recorded.length === 2) output.stderr('BOTH_READY\\n') - spec.onStderr?.(chunk) - } }, capture) - const tasks = [0, 1].map(index => ({ name: 'check ' + index, steps: [{ program: process.execPath, - args: ['-e', 'process.stderr.write(String(process.pid)); setInterval(() => {}, 1000)'], liveOutput: true }] })) - const pending = tasks.map(task => runTask(task, { root: ${JSON.stringify(root)}, env: process.env, runProcess: launch, - stepTimeoutMs: 10000, signal: abort.signal, output })) - const timer = setInterval(() => { if (recorded.length === 2) output.stderr('after consumer closed\\n') }, 100) - try { writeFileSync(${JSON.stringify(report)}, JSON.stringify(await Promise.all(pending))) } - finally { clearInterval(timer); await output.dispose() } - process.exitCode = 1 - ` - ) - const run = launch(script, root) - await vi.waitFor(() => expect(run.stderr()).toContain('BOTH_READY')) - const recorded = JSON.parse(readFileSync(marker, 'utf8')) - recorded.forEach((pid) => pids.add(pid)) - run.child.stderr.destroy() - expect(await run.exited).toEqual({ code: 1, signal: null }) - expect(recorded).toHaveLength(2) - expect(recorded.map(alive)).toEqual([false, false]) - const results = JSON.parse(readFileSync(report, 'utf8')) - expect( - results.every((result) => !result.ok && result.output.includes('Output consumer failed')) - ).toBe(true) -}, 15_000) - -function gateFixture({ build = '', formatter = '' } = {}) { - const root = directory() - const git = (...args) => { - const result = runProcessSync({ program: 'git', args, cwd: root }) - expect(result.code, result.stderr).toBe(0) - } - git('init', '-q') - git('config', 'user.email', 'test@example.com') - git('config', 'user.name', 'Test') - writeFileSync(join(root, '.gitignore'), 'config\nnode_modules\n.cache\n*.json\n*.mjs\n') - writeFileSync(join(root, 'note.md'), '# Before\n') - git('add', '.') - git('commit', '-qm', 'base') - const scripts = join(root, 'config/scripts') - mkdirSync(scripts, { recursive: true }) - for (const name of [ - 'build-workspace-packages', - 'run-vitest', - 'ensure-native-runtime', - 'check-changed-code-quality' - ]) { - writeFileSync(join(scripts, `${name}.mjs`), name === 'build-workspace-packages' ? build : '') - } - for (const name of ['oxfmt', 'oxlint']) { - const packageDir = join(root, 'node_modules', name) - mkdirSync(packageDir, { recursive: true }) - writeFileSync( - join(packageDir, 'package.json'), - JSON.stringify({ name, bin: { [name]: 'cli.cjs' } }) - ) - writeFileSync(join(packageDir, 'cli.cjs'), name === 'oxfmt' ? formatter : '') - } - writeFileSync(join(root, 'note.md'), '# After\n') - return root -} - -it('bounds forwarding to a nonreading consumer and the actual gate still exits', async () => { - const root = gateFixture({ - build: `for (let i = 0; i < 320; i++) process.stdout.write('x'.repeat(65536)); setTimeout(() => {}, 50)` - }) - writeFileSync(join(root, 'input.ts'), 'export const value = 1\n') - const report = join(root, 'peak.json') - const preload = join(root, 'monitor.mjs') - writeFileSync( - preload, - ` - import { writeFileSync } from 'node:fs' - let peak = 0 - setInterval(() => { peak = Math.max(peak, process.stdout.writableLength) }, 1).unref() - process.on('exit', () => writeFileSync(${JSON.stringify(report)}, JSON.stringify({ peak }))) - ` - ) - const run = launch( - resolve('config/scripts/check-changed.mjs'), - root, - ['--scope=worktree', '--maxWorkers=2', '--stepTimeoutMs=10000'], - { readStdout: false, nodeArgs: ['--import', pathToFileURL(preload).href] } - ) - expect(await run.exited).toEqual({ code: 1, signal: null }) - const { peak } = JSON.parse(readFileSync(report, 'utf8')) - expect(peak).toBeGreaterThan(0) - expect(peak).toBeLessThanOrEqual(2 * 65536) -}, 15_000) - -it('reports omitted live output when the consumer resumes before the final flush', async () => { - const root = directory() - const script = runner( - root, - ` - const output = createTaskOutput(() => {}) - for (let index = 0; index < 320; index++) output.stdout('x'.repeat(65536)) - process.stderr.write('RESUME_READER\\n') - await new Promise(resolve => process.once('message', resolve)) - process.disconnect() - await output.dispose() - ` - ) - const run = launch(script, root, [], { readStdout: false, ipc: true }) - await vi.waitFor(() => expect(run.stderr()).toContain('RESUME_READER')) - let stdout = '' - run.child.stdout.on('data', (chunk) => { - stdout += chunk - }) - run.child.send('reading') - expect(await run.exited).toEqual({ code: 0, signal: null }) - expect(stdout).toContain('live output omitted for a slow consumer') -}) - -it('does not cache a pass when the final summary output fails', async () => { - const root = gateFixture() - const preload = join(root, 'close-summary.mjs') - writeFileSync( - preload, - ` - import { closeSync } from 'node:fs' - const write = process.stdout.write.bind(process.stdout) - process.stdout.write = (chunk, ...args) => { - if (String(chunk).includes('check(s) passed in')) closeSync(1) - return write(chunk, ...args) - } - ` - ) - const run = launch( - resolve('config/scripts/check-changed.mjs'), - root, - ['--scope=worktree', '--maxWorkers=2'], - { nodeArgs: ['--import', pathToFileURL(preload).href] } - ) - expect(await run.exited).toEqual({ code: 1, signal: null }) - expect(() => readFileSync(join(root, '.cache/check-changed.json'), 'utf8')).toThrow() -}) - -it.skipIf(process.platform === 'win32')( - 'cancels detached checks on terminal-group SIGHUP', - async () => { - const root = directory() - const git = (...args) => { - const result = runProcessSync({ program: 'git', args, cwd: root }) - expect(result.code, result.stderr).toBe(0) - } - git('init', '-q') - git('config', 'user.email', 'test@example.com') - git('config', 'user.name', 'Test') - writeFileSync(join(root, '.gitignore'), 'config\nnode_modules\n.cache\nformatter-pid\n') - writeFileSync(join(root, 'note.md'), '# Before\n') - git('add', '.') - git('commit', '-qm', 'base') - symlinkSync(resolve('config'), join(root, 'config'), 'dir') - for (const name of ['oxfmt', 'oxlint']) { - const packageDir = join(root, 'node_modules', name) - mkdirSync(packageDir, { recursive: true }) - writeFileSync( - join(packageDir, 'package.json'), - JSON.stringify({ name, bin: { [name]: 'cli.cjs' } }) - ) - writeFileSync( - join(packageDir, 'cli.cjs'), - name === 'oxfmt' - ? `require('node:fs').writeFileSync('formatter-pid', String(process.pid)); setInterval(() => {}, 1000)` - : '' - ) - } - writeFileSync(join(root, 'note.md'), '# After\n') - const run = launch( - resolve('config/scripts/check-changed.mjs'), - root, - ['--scope=worktree', '--maxWorkers=2', '--stepTimeoutMs=10000'], - { detached: true } - ) - let formatterPid - await vi.waitFor( - () => { - formatterPid = Number(readFileSync(join(root, 'formatter-pid'), 'utf8')) - pids.add(formatterPid) - }, - { timeout: 5000 } - ) - process.kill(-run.child.pid, 'SIGHUP') - expect(await run.exited).toEqual({ code: 1, signal: null }) - expect(alive(formatterPid)).toBe(false) - expect(run.stderr()).toContain('Cancelled.') - }, - 15_000 -) diff --git a/config/scripts/check-changed-pass-cache.mjs b/config/scripts/check-changed-pass-cache.mjs index 8c29900d8b3..6fd2f920f8d 100644 --- a/config/scripts/check-changed-pass-cache.mjs +++ b/config/scripts/check-changed-pass-cache.mjs @@ -1,13 +1,10 @@ import { execFileSync } from 'node:child_process' import { createHash } from 'node:crypto' import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { createRequire } from 'node:module' import path from 'node:path' -import darkBuildSwitches from '../dark-build-switches.cjs' -import { readDependencyEvidence } from './check-changed-dependency-evidence.mjs' export const PASS_CACHE_PATH = '.cache/check-changed.json' -const CACHE_VERSION = 4 +const CACHE_VERSION = 1 // Inputs outside the diff that still decide what the checks report. const HASHED_CONFIG_FILES = [ @@ -16,24 +13,11 @@ const HASHED_CONFIG_FILES = [ '.oxlintrc.json', '.oxfmtrc.json', 'config/vitest.config.ts', - 'config/.bun-version', - 'config/dark-build-switches.cjs', 'config/scripts/check-changed.mjs', 'config/scripts/check-changed-selection.mjs', - 'config/scripts/check-changed-pass-cache.mjs', - 'config/scripts/check-changed-dependency-evidence.mjs', - 'config/scripts/check-changed-task-runner.mjs' + 'config/scripts/check-changed-pass-cache.mjs' ] -export function changedCheckEnvironment(scope, env = process.env) { - return { - ...env, - ORCA_BACKGROUND_LAUNCH: '1', - ORCA_UNIT_INVENTORY: scope === 'worktree' ? 'ci' : 'local', - ORCA_BALANCE_UNIT_SHARDS: '0' - } -} - function git(root, args) { return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) } @@ -61,7 +45,7 @@ export function collectChangedFiles(root, mergeBase) { return [...new Set([...tracked, ...untracked])].sort() } -export function computePassHash(root, mergeBase, scope = 'branch', env = process.env) { +export function computePassHash(root, mergeBase) { const hash = createHash('sha256') const field = (label, value) => { hash.update(`${label}\0${value.length}\0`) @@ -70,24 +54,6 @@ export function computePassHash(root, mergeBase, scope = 'branch', env = process field('version', String(CACHE_VERSION)) field('runtime', `${process.platform} ${process.arch} ${process.version}`) field('base', mergeBase) - field('scope', scope) - const context = changedCheckEnvironment(scope, env) - field('inventory', context.ORCA_UNIT_INVENTORY) - field('ci', String(Boolean(context.CI))) - field('defines', JSON.stringify(darkBuildSwitches.darkBuildDefines(context))) - for (const name of ['ORCA_UNIT_MODULE_CACHE_PATH', 'ORCA_TEST_NODE_VERSION']) { - field(name, context[name] ?? '') - } - try { - const requireFromRoot = createRequire(path.join(root, 'package.json')) - field('bun', readIfFile(requireFromRoot.resolve('bun/package.json')) ?? '') - } catch { - field('bun', '') - } - if (scope === 'worktree') { - // A committed dependency can affect an unchanged local edit. - field('head', git(root, ['rev-parse', 'HEAD']).trim()) - } // Base plus each changed path's working-tree bytes pins the tree like the diff does, but stays // stable when an edit is staged or committed, so committing a passing change keeps its pass. for (const file of collectChangedFiles(root, mergeBase)) { @@ -101,68 +67,20 @@ export function computePassHash(root, mergeBase, scope = 'branch', env = process return hash.digest('hex') } -export function readPassHash(root, scope = 'branch') { +export function readPassHash(root) { try { - const { version, passes } = JSON.parse(readFileSync(path.join(root, PASS_CACHE_PATH), 'utf8')) - const evidence = passes?.[scope]?.dependencyEvidence - const current = readDependencyEvidence(evidence?.path) - return version === CACHE_VERSION && - typeof passes?.[scope]?.hash === 'string' && - current && - current.hash === evidence?.hash - ? passes[scope].hash - : null + const { version, hash } = JSON.parse(readFileSync(path.join(root, PASS_CACHE_PATH), 'utf8')) + return version === CACHE_VERSION && typeof hash === 'string' ? hash : null } catch { return null } } -function readPasses(root) { - try { - const cached = JSON.parse(readFileSync(path.join(root, PASS_CACHE_PATH), 'utf8')) - if ( - cached.version === CACHE_VERSION && - cached.passes && - typeof cached.passes === 'object' && - !Array.isArray(cached.passes) - ) { - return cached.passes - } - } catch { - // Missing or invalid caches require a fresh pass. - } - return {} -} - -export function invalidatePassHash(root, scope = 'branch', hash) { - const passes = readPasses(root) - if (Object.hasOwn(passes, scope) && (hash === undefined || passes[scope]?.hash === hash)) { - delete passes[scope] - savePasses(root, passes) - } -} - -export function writePassHash( - root, - hash, - scope = 'branch', - evidence = readDependencyEvidence(null) -) { - if (!evidence || readDependencyEvidence(evidence.path)?.hash !== evidence.hash) { - return false - } - const passes = readPasses(root) - passes[scope] = { - hash, - dependencyEvidence: { path: evidence.path, hash: evidence.hash }, - passedAt: new Date().toISOString() - } - savePasses(root, passes) - return true -} - -function savePasses(root, passes) { +export function writePassHash(root, hash) { const file = path.join(root, PASS_CACHE_PATH) mkdirSync(path.dirname(file), { recursive: true }) - writeFileSync(file, `${JSON.stringify({ version: CACHE_VERSION, passes })}\n`) + writeFileSync( + file, + `${JSON.stringify({ version: CACHE_VERSION, hash, passedAt: new Date().toISOString() })}\n` + ) } diff --git a/config/scripts/check-changed-pass-cache.test.mjs b/config/scripts/check-changed-pass-cache.test.mjs index 5c35b0aefe4..20c7490a8b6 100644 --- a/config/scripts/check-changed-pass-cache.test.mjs +++ b/config/scripts/check-changed-pass-cache.test.mjs @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { runProcessSync } from '@orca/process-host' @@ -6,15 +6,9 @@ import { afterEach, beforeEach, expect, it } from 'vitest' import { collectChangedFiles, computePassHash, - changedCheckEnvironment, - invalidatePassHash, readPassHash, writePassHash } from './check-changed-pass-cache.mjs' -import { - hasNewIncompleteEvidence, - readDependencyEvidence -} from './check-changed-dependency-evidence.mjs' let root let base @@ -74,188 +68,3 @@ it('keys the pass on every edit state and ignores its own cache file', () => { writeFileSync(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 10\n') expect(computePassHash(root, base)).not.toBe(beforeLockfile) }) - -it('limits worktree selection to edits after HEAD, including staged additions and deletions', () => { - writeFileSync(join(root, 'committed.ts'), 'export const committed = 1\n') - git('add', '.') - git('commit', '-qm', 'branch change') - expect(collectChangedFiles(root, base)).toEqual(['committed.ts']) - expect(collectChangedFiles(root, 'HEAD')).toEqual([]) - - rmSync(join(root, 'tracked.ts')) - writeFileSync(join(root, 'staged.ts'), 'export const staged = 1\n') - git('add', '.') - writeFileSync(join(root, 'untracked.ts'), 'export const untracked = 1\n') - expect(collectChangedFiles(root, 'HEAD')).toEqual(['staged.ts', 'tracked.ts', 'untracked.ts']) -}) - -it('keeps scope passes separate and invalidates iteration passes after a commit', () => { - writeFileSync(join(root, 'tracked.ts'), 'export const a = 2\n') - writeFileSync(join(root, 'committed.ts'), 'export const committed = 1\n') - const branchHash = computePassHash(root, base) - const iterationHash = computePassHash(root, base, 'worktree') - expect(branchHash).not.toBe(iterationHash) - writePassHash(root, iterationHash, 'worktree') - expect(readPassHash(root)).toBeNull() - writePassHash(root, branchHash) - expect(readPassHash(root, 'worktree')).toBe(iterationHash) - expect(readPassHash(root)).toBe(branchHash) - - const selected = collectChangedFiles(root, base) - git('add', 'committed.ts') - git('commit', '-qm', 'new dependency') - expect(collectChangedFiles(root, base)).toEqual(selected) - expect(computePassHash(root, base)).toBe(branchHash) - expect(computePassHash(root, base, 'worktree')).not.toBe(iterationHash) - expect(computePassHash(root, git('rev-parse', 'HEAD'), 'worktree')).not.toBe(iterationHash) -}) - -it('keys effective CI and build context while fixing inventory by scope', () => { - const local = { CI: '', ORCA_BALANCE_UNIT_SHARDS: '0', ORCA_UNIT_INVENTORY: 'local' } - const inherited = { ...local, ORCA_BALANCE_UNIT_SHARDS: '1', ORCA_UNIT_INVENTORY: 'ci' } - expect(changedCheckEnvironment('branch', inherited)).toMatchObject({ - ORCA_BALANCE_UNIT_SHARDS: '0', - ORCA_UNIT_INVENTORY: 'local' - }) - expect(changedCheckEnvironment('worktree', local).ORCA_UNIT_INVENTORY).toBe('ci') - const hash = computePassHash(root, base, 'branch', local) - expect(computePassHash(root, base, 'branch', inherited)).toBe(hash) - for (const context of [ - { CI: '1' }, - { ORCA_LAYOUT_OWNER: 'main' }, - { ORCA_SIDEBAR_INPUTS: 'main' }, - { ORCA_UNIT_MODULE_CACHE_PATH: '/other/cache' } - ]) { - expect(computePassHash(root, base, 'branch', { ...local, ...context })).not.toBe(hash) - } -}) - -it('invalidates only the scope that is being rechecked', () => { - writePassHash(root, 'branch pass') - writePassHash(root, 'iteration pass', 'worktree') - invalidatePassHash(root, 'branch', 'older pass') - expect(readPassHash(root)).toBe('branch pass') - invalidatePassHash(root) - expect(readPassHash(root)).toBeNull() - expect(readPassHash(root, 'worktree')).toBe('iteration pass') -}) - -it('selects materialized bytes when staged and unstaged changes cancel', () => { - const original = 'export const a = 1\n' - writeFileSync(join(root, 'tracked.ts'), 'export const a = 2\n') - git('add', 'tracked.ts') - writeFileSync(join(root, 'tracked.ts'), original) - expect(git('status', '--short')).toContain('MM tracked.ts') - expect(collectChangedFiles(root, base)).toEqual([]) -}) - -function dependencyRecord(overrides = {}) { - return { - version: 2, - projects: { - unit: { - modules: ['tracked.ts', 'consumer.test.ts'], - files: { 'consumer.test.ts': [0, 1] }, - signatures: { 'consumer.test.ts': 'source signature' }, - contexts: { 'consumer.test.ts': 'context signature' }, - incomplete: [], - ...overrides - } - } - } -} - -it('attests semantic dependency evidence and preserves the other scope when records strengthen', () => { - const file = join(root, '.cache/dependencies.json') - mkdirSync(join(root, '.cache')) - const write = (record) => writeFileSync(file, JSON.stringify(record)) - write(dependencyRecord()) - const evidence = readDependencyEvidence(file) - expect(writePassHash(root, 'tested sources', 'worktree', evidence)).toBe(true) - writePassHash(root, 'docs only branch') - write( - dependencyRecord({ - modules: ['unused.ts', 'consumer.test.ts', 'tracked.ts'], - files: { 'consumer.test.ts': [1, 2, 1] } - }) - ) - expect(readPassHash(root, 'worktree')).toBe('tested sources') - const incomplete = dependencyRecord({ incomplete: ['consumer.test.ts'] }) - write(incomplete) - expect(readPassHash(root, 'worktree')).toBeNull() - expect(hasNewIncompleteEvidence(evidence, readDependencyEvidence(file))).toBe(true) - expect(writePassHash(root, 'old evidence', 'worktree', evidence)).toBe(false) - expect(readPassHash(root)).toBe('docs only branch') - expect( - JSON.parse(readFileSync(join(root, '.cache/check-changed.json'), 'utf8')).passes.worktree.hash - ).toBe('tested sources') -}) - -it('accepts passing record updates in one invocation and distinguishes stable incomplete evidence', () => { - const file = join(root, '.cache/dependencies.json') - mkdirSync(join(root, '.cache')) - writeFileSync(file, JSON.stringify(dependencyRecord({ incomplete: ['consumer.test.ts'] }))) - const before = readDependencyEvidence(file) - expect(hasNewIncompleteEvidence(before, readDependencyEvidence(file))).toBe(false) - writeFileSync( - file, - JSON.stringify(dependencyRecord({ modules: ['other.ts', 'consumer.test.ts'] })) - ) - const passing = readDependencyEvidence(file) - expect(passing.hash).not.toBe(before.hash) - expect(hasNewIncompleteEvidence(before, passing)).toBe(false) - expect(writePassHash(root, 'passed once', 'branch', passing)).toBe(true) - expect(readPassHash(root)).toBe('passed once') - writeFileSync( - file, - JSON.stringify(dependencyRecord({ contexts: { 'consumer.test.ts': 'new context' } })) - ) - expect(readPassHash(root)).toBeNull() -}) - -it('refuses malformed, unsupported, oversized, and removed dependency evidence', () => { - const file = join(root, '.cache/dependencies.json') - mkdirSync(join(root, '.cache')) - writeFileSync(file, JSON.stringify(dependencyRecord())) - const evidence = readDependencyEvidence(file) - for (const contents of [ - '{', - JSON.stringify({ version: 1, projects: {} }), - JSON.stringify(dependencyRecord({ files: { 'consumer.test.ts': [99] } })) - ]) { - writeFileSync(file, contents) - expect(readDependencyEvidence(file)).toBeNull() - expect(writePassHash(root, 'unverifiable', 'branch', evidence)).toBe(false) - } - truncateSync(file, 32 * 1024 * 1024 + 1) - expect(readDependencyEvidence(file)).toBeNull() - writeFileSync(file, JSON.stringify(dependencyRecord())) - writePassHash(root, 'before clearing', 'branch', evidence) - rmSync(file) - expect(readPassHash(root)).toBeNull() -}) - -it.skipIf(process.platform === 'win32')( - 'rejects FIFO evidence without blocking cache lookup', - () => { - const file = join(root, 'dependencies.json') - const fifo = runProcessSync({ program: 'mkfifo', args: [file], cwd: root }) - expect(fifo.code, fifo.stderr).toBe(0) - const result = runProcessSync({ - program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath, - args: [ - '--input-type=module', - '-e', - ` - import { readDependencyEvidence } from ${JSON.stringify(new URL('./check-changed-dependency-evidence.mjs', import.meta.url).href)} - if (readDependencyEvidence(${JSON.stringify(file)}) !== null) process.exitCode = 1 - ` - ], - cwd: root, - env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, - timeoutMs: 1_000 - }) - expect(result.timedOut).toBe(false) - expect(result.code, result.stderr).toBe(0) - } -) diff --git a/config/scripts/check-changed-task-runner.mjs b/config/scripts/check-changed-task-runner.mjs deleted file mode 100644 index bbdcdd960ef..00000000000 --- a/config/scripts/check-changed-task-runner.mjs +++ /dev/null @@ -1,220 +0,0 @@ -const OUTPUT_TAIL_LINES = 200 -const HEARTBEAT_MS = 15_000 -const LIVE_OUTPUT_QUEUE_BYTES = 64 * 1024 -const OUTPUT_FLUSH_MS = 250 - -export function createTaskOutput(cancel) { - let failure - let blocked = false - let disposing = false - const targets = [process.stdout, process.stderr].map((stream) => { - let dropped = false - let closed = false - const fail = (error) => { - closed = true - failure ??= `Output consumer failed: ${error.message ?? error}` - cancel() - } - const onClose = () => { - if (!disposing) { - fail(new Error('stream closed')) - } - } - stream.on('error', fail) - stream.on('close', onClose) - const write = (chunk) => { - if (closed || stream.destroyed) { - return - } - if (stream.writableNeedDrain || stream.writableLength >= LIVE_OUTPUT_QUEUE_BYTES) { - dropped = true - return - } - try { - if (dropped) { - dropped = false - if ( - !stream.write( - '… live output omitted for a slow consumer; failure diagnostics are retained.\n' - ) - ) { - dropped = true - return - } - } - stream.write(chunk) - } catch (error) { - fail(error) - } - } - const drain = () => - new Promise((resolve) => { - if (closed || stream.destroyed || !stream.writableLength) { - setImmediate(resolve) - return - } - const timer = setTimeout(() => { - blocked = true - fail(new Error('stream did not drain')) - stream.unref?.() - setImmediate(resolve) - }, OUTPUT_FLUSH_MS) - stream.write('', () => { - clearTimeout(timer) - setImmediate(resolve) - }) - }) - const flush = async () => { - await drain() - if (dropped && !closed) { - write('') - await drain() - } - } - return { - write, - flush, - dispose: () => { - stream.off('error', fail) - stream.off('close', onClose) - } - } - }) - return { - get failure() { - return failure - }, - get blocked() { - return blocked - }, - stdout: targets[0].write, - stderr: targets[1].write, - log: (message) => targets[0].write(`${message}\n`), - error: (message) => targets[1].write(`${message}\n`), - flush: () => Promise.all(targets.map((target) => target.flush())), - async dispose() { - disposing = true - await Promise.all(targets.map((target) => target.flush())) - targets.forEach((target) => target.dispose()) - } - } -} - -function elapsed(startedAt) { - return `${((performance.now() - startedAt) / 1000).toFixed(1)}s` -} - -export function outputTail(text) { - const lines = text.trimEnd().split(/\r?\n/) - return lines.length <= OUTPUT_TAIL_LINES - ? lines.join('\n') - : [ - `… ${lines.length - OUTPUT_TAIL_LINES} earlier lines omitted`, - ...lines.slice(-OUTPUT_TAIL_LINES) - ].join('\n') -} - -export async function runTask( - task, - { root, env, runProcess, stepTimeoutMs, signal, output: sharedOutput } -) { - const abort = new AbortController() - const cancel = () => abort.abort() - signal?.addEventListener('abort', cancel, { once: true }) - if (signal?.aborted) { - cancel() - } - const live = sharedOutput ?? createTaskOutput(cancel) - const startedAt = performance.now() - let output = '' - try { - for (const [index, { program, args, liveOutput }] of task.steps.entries()) { - if (abort.signal.aborted) { - return { task, ok: false, output: `${output}\nCancelled.`, elapsed: elapsed(startedAt) } - } - const command = [program, ...args].map((argument) => JSON.stringify(argument)).join(' ') - const label = `${task.name} [${index + 1}/${task.steps.length}]` - live.log(`▶ ${label}: ${command} (deadline ${stepTimeoutMs / 1000}s)`) - const heartbeat = setInterval(() => { - live.log(`… ${label} still running (${elapsed(startedAt)} elapsed)`) - }, HEARTBEAT_MS) - try { - const result = await runProcess( - { - program, - args, - cwd: root, - env, - timeoutMs: stepTimeoutMs, - maxOutputBytes: 256 * 1024, - signal: abort.signal, - detached: process.platform !== 'win32', - terminationBarrier: true, - ...(liveOutput - ? { - stdio: ['ignore', 'pipe', 'pipe'], - onStdout: live.stdout, - onStderr: live.stderr - } - : {}) - }, - 'tail' - ) - output = outputTail(output + result.stdout + result.stderr) - if (!sharedOutput) { - await live.flush() - } - if ( - result.timedOut || - abort.signal.aborted || - result.code !== 0 || - result.terminationUnverified - ) { - const reason = result.timedOut - ? `Timed out after ${stepTimeoutMs / 1000}s; increase --stepTimeoutMs= for a longer run.` - : abort.signal.aborted - ? (live.failure ?? 'Cancelled.') - : `Exited with ${result.signal ?? result.code}.` - const cleanup = result.terminationUnverified - ? '\nCleanup unverified: output pipes remained open or tree termination was not confirmed; escaped descendants may remain.' - : '' - return { - task, - ok: false, - output: `${output}\n${reason}${cleanup}\nCommand: ${command}`, - elapsed: elapsed(startedAt) - } - } - } catch (error) { - return { - task, - ok: false, - output: `${output}\n${error.message ?? error}\nCommand: ${command}`, - elapsed: elapsed(startedAt) - } - } finally { - clearInterval(heartbeat) - } - } - live.log(`✓ ${task.name} (${elapsed(startedAt)})`) - return { task, ok: true, output, elapsed: elapsed(startedAt) } - } finally { - signal?.removeEventListener('abort', cancel) - if (!sharedOutput) { - await live.dispose() - } - } -} - -export async function runPool(tasks, concurrency, run) { - const results = [] - let next = 0 - await Promise.all( - Array.from({ length: Math.min(concurrency, tasks.length) }, async () => { - while (next < tasks.length) { - results.push(await run(tasks[next++])) - } - }) - ) - return results -} diff --git a/config/scripts/check-changed-task-runner.test.mjs b/config/scripts/check-changed-task-runner.test.mjs deleted file mode 100644 index 62342a19fcd..00000000000 --- a/config/scripts/check-changed-task-runner.test.mjs +++ /dev/null @@ -1,113 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { runProcess } from '@orca/process-host' -import { runPool, runTask } from './check-changed-task-runner.mjs' - -afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() -}) - -it('reports live progress, bounds the failure tail, and stops a task on timeout', async () => { - vi.useFakeTimers() - const logs = vi.fn() - const output = { log: logs, stdout: vi.fn(), stderr: vi.fn() } - let finish - const task = { - name: 'unit tests', - steps: [ - { program: 'test-runner', args: ['run'], liveOutput: true }, - { program: 'must-not-run', args: [] } - ] - } - const launch = vi.fn( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - const pending = runTask(task, { - root: '.', - env: {}, - runProcess: launch, - stepTimeoutMs: 30_000, - output - }) - await vi.advanceTimersByTimeAsync(15_000) - expect(logs.mock.calls.flat().join('\n')).toContain('still running') - expect(launch.mock.calls[0][0]).toMatchObject({ - timeoutMs: 30_000, - terminationBarrier: true, - detached: process.platform !== 'win32', - stdio: ['ignore', 'pipe', 'pipe'], - onStdout: expect.any(Function) - }) - finish({ - code: 0, - signal: null, - timedOut: true, - stdout: `${'old\n'.repeat(500)}last diagnostic\n`, - stderr: '' - }) - const result = await pending - expect(result.ok).toBe(false) - expect(result.output).toContain('last diagnostic') - expect(result.output).toContain('Timed out after 30s') - expect(result.output).toContain('Command: "test-runner" "run"') - expect(result.output.split('\n').length).toBeLessThan(210) - expect(launch).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) -}) - -it('cancels a real process and never starts subsequent steps', async () => { - vi.spyOn(console, 'log').mockImplementation(() => {}) - const abort = new AbortController() - const task = { - name: 'cancel', - steps: [ - { - program: process.execPath, - args: ['-e', 'process.stderr.write("ready"); setInterval(() => {}, 1000)'], - liveOutput: true - }, - { program: 'must-not-run', args: [] } - ] - } - const launch = (spec, capture) => runProcess({ ...spec, onStderr: () => abort.abort() }, capture) - const result = await runTask(task, { - root: process.cwd(), - env: process.env, - runProcess: launch, - stepTimeoutMs: 10_000, - signal: abort.signal - }) - expect(result.ok).toBe(false) - expect(result.output).toContain('ready') - expect(result.output).toContain('Cancelled.') -}) - -it('caps concurrency while continuing independent checks after a failure', async () => { - let active = 0 - let peak = 0 - const releases = [] - const pending = runPool([0, 1, 2], 2, async (task) => { - active += 1 - peak = Math.max(peak, active) - await new Promise((resolve) => releases.push(resolve)) - active -= 1 - return { task, ok: task !== 0 } - }) - expect(releases).toHaveLength(2) - releases[0]() - await vi.waitFor(() => expect(releases).toHaveLength(3)) - releases[1]() - releases[2]() - const results = await pending - expect(peak).toBe(2) - expect(results).toEqual( - expect.arrayContaining([ - { task: 0, ok: false }, - { task: 1, ok: true }, - { task: 2, ok: true } - ]) - ) -}) diff --git a/config/scripts/check-changed.mjs b/config/scripts/check-changed.mjs index 4005a211614..dae479fb20a 100644 --- a/config/scripts/check-changed.mjs +++ b/config/scripts/check-changed.mjs @@ -1,7 +1,6 @@ -// Branch handoff and worktree iteration checks share selection, deadlines, and progress. +// Pre-handoff gate: runs the checks a change's paths need, in parallel, printing only failures. import { execFileSync, spawnSync } from 'node:child_process' import { existsSync, readdirSync } from 'node:fs' -import { createRequire } from 'node:module' import { availableParallelism } from 'node:os' import path from 'node:path' import process from 'node:process' @@ -9,39 +8,21 @@ import { pathToFileURL } from 'node:url' import { collectChangedFiles, computePassHash, - changedCheckEnvironment, - invalidatePassHash, readPassHash, writePassHash } from './check-changed-pass-cache.mjs' import { planChangedChecks } from './check-changed-selection.mjs' -import { - DEPENDENCY_PATH_PREFIX, - hasNewIncompleteEvidence, - readDependencyEvidence -} from './check-changed-dependency-evidence.mjs' -import { - createTaskOutput, - outputTail as tail, - runPool, - runTask -} from './check-changed-task-runner.mjs' import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs' +import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' +const OUTPUT_TAIL_LINES = 200 // Windows caps a command line near 32K characters; past this, lint the whole tree instead. const MAX_PATH_ARGUMENT_CHARS = 24_000 const LINTABLE = /\.[cm]?[jt]sx?$/ const ANTI_SLOP_ROOTS = ['src/', 'config/', 'tests/', 'mobile/'] -const runsAsCommand = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href function parseArgs(argv) { - const options = { - force: false, - base: null, - maxWorkers: null, - scope: 'branch', - stepTimeoutMs: 300_000 - } + const options = { force: false, base: null, maxWorkers: null } for (const argument of argv) { if (argument === '--') { continue @@ -49,37 +30,12 @@ function parseArgs(argv) { options.force = true } else if (argument.startsWith('--base=')) { options.base = argument.slice('--base='.length) - if (!options.base) { - throw new Error('Invalid --base=: pass an existing commit or ref.') - } - } else if (argument.startsWith('--scope=')) { - options.scope = argument.slice('--scope='.length) - } else if (argument.startsWith('--stepTimeoutMs=')) { - options.stepTimeoutMs = Number(argument.slice('--stepTimeoutMs='.length)) } else if (/^--max-?workers=\d+$/i.test(argument)) { options.maxWorkers = argument.split('=')[1] } else { - throw new Error( - `Unknown option ${argument}. Use --force, --base=, --scope=branch|worktree, --stepTimeoutMs=, --maxWorkers=.` - ) + throw new Error(`Unknown option ${argument}. Use --force, --base=, --maxWorkers=.`) } } - if (!['branch', 'worktree'].includes(options.scope)) { - throw new Error('--scope must be branch or worktree.') - } - if (options.scope === 'worktree' && options.base) { - throw new Error('--base applies only to --scope=branch.') - } - if ( - !Number.isSafeInteger(options.stepTimeoutMs) || - options.stepTimeoutMs <= 0 || - options.stepTimeoutMs > 2_147_483_647 - ) { - throw new Error('--stepTimeoutMs must be a positive integer no larger than 2147483647.') - } - if (options.maxWorkers !== null && Number(options.maxWorkers) < 1) { - throw new Error('--maxWorkers must be positive.') - } return options } @@ -88,10 +44,7 @@ function git(root, args) { } function resolveMergeBase(root, requested) { - const candidates = requested - ? [requested] - : [process.env.ORCA_CODE_QUALITY_BASE, 'origin/main', 'main'] - for (const candidate of candidates) { + for (const candidate of [requested, process.env.ORCA_CODE_QUALITY_BASE, 'origin/main', 'main']) { if (!candidate) { continue } @@ -107,9 +60,6 @@ function resolveMergeBase(root, requested) { return { ref: candidate, mergeBase: git(root, ['merge-base', candidate, 'HEAD']) } } } - if (requested) { - throw new Error(`Invalid --base=${requested}: fetch that ref or pass an existing commit.`) - } throw new Error('No base: fetch origin/main or pass --base=.') } @@ -117,10 +67,37 @@ function seconds(startedAt) { return `${((performance.now() - startedAt) / 1000).toFixed(1)}s` } +function tail(text) { + const lines = text.trimEnd().split(/\r?\n/) + if (lines.length <= OUTPUT_TAIL_LINES) { + return lines.join('\n') + } + return [ + `… ${lines.length - OUTPUT_TAIL_LINES} earlier lines omitted`, + ...lines.slice(-OUTPUT_TAIL_LINES) + ].join('\n') +} + function fitsCommandLine(files) { return files.reduce((total, file) => total + file.length + 1, 0) <= MAX_PATH_ARGUMENT_CHARS } +// Why before importing @orca/process-host: its dist is what this builds, and a cache hit skips both. +function buildWorkspacePackages(root, env) { + const pnpm = resolvePnpmCliInvocation() + const result = spawnSync(pnpm.command, [...pnpm.prefixArgs, 'run', 'build:packages'], { + cwd: root, + env, + encoding: 'utf8', + shell: pnpm.shell, + windowsHide: true + }) + if (result.status !== 0) { + console.error(tail(`${result.stdout ?? ''}${result.stderr ?? result.error?.message ?? ''}`)) + throw new Error('pnpm run build:packages failed.') + } +} + function buildTasks(root, plan, { files, mergeBase, maxWorkers, bun }) { const node = process.execPath const oxlint = resolveOxcCliInvocation('oxlint', 'oxlint', root) @@ -142,22 +119,18 @@ function buildTasks(root, plan, { files, mergeBase, maxWorkers, bun }) { ] }) } - const related = plan.unitTests.related - const deletedRelated = related.some((file) => !existsSync(path.join(root, file))) + const related = plan.unitTests.related.filter((file) => existsSync(path.join(root, file))) const colocated = plan.unitTests.colocated.filter((file) => existsSync(path.join(root, file))) if (related.length > 0 || colocated.length > 0) { - const vitest = (selection) => ({ - ...step(node, [ + const vitest = (selection) => + step(node, [ 'config/scripts/run-vitest.mjs', ...selection, '--config', 'config/vitest.config.ts', '--passWithNoTests', - '--reporter=default', ...(maxWorkers ? [`--maxWorkers=${maxWorkers}`] : []) - ]), - liveOutput: true - }) + ]) tasks.push({ name: 'unit tests', steps: [ @@ -165,11 +138,9 @@ function buildTasks(root, plan, { files, mergeBase, maxWorkers, bun }) { ...(related.length > 0 ? [ vitest( - deletedRelated - ? ['run'] - : fitsCommandLine(related) - ? ['related', '--run', ...related] - : ['run', `--changed=${mergeBase}`] + fitsCommandLine(related) + ? ['related', '--run', ...related] + : ['run', `--changed=${mergeBase}`] ) ] : []), @@ -235,30 +206,55 @@ function buildTasks(root, plan, { files, mergeBase, maxWorkers, bun }) { return tasks } +async function runTask(task, { root, env, runProcess }) { + const startedAt = performance.now() + let output = '' + for (const { program, args } of task.steps) { + try { + const result = await runProcess({ program, args, cwd: root, env, timeoutMs: null }, 'tail') + output += result.stdout + result.stderr + if (result.code !== 0) { + return { task, ok: false, output, elapsed: seconds(startedAt) } + } + } catch (error) { + return { + task, + ok: false, + output: `${output}${error.message ?? error}`, + elapsed: seconds(startedAt) + } + } + } + return { task, ok: true, output, elapsed: seconds(startedAt) } +} + +async function runPool(tasks, concurrency, run) { + const results = [] + let next = 0 + await Promise.all( + Array.from({ length: Math.min(concurrency, tasks.length) }, async () => { + while (next < tasks.length) { + results.push(await run(tasks[next++])) + } + }) + ) + return results +} + export async function main(root, argv) { const startedAt = performance.now() const options = parseArgs(argv) - const { ref, mergeBase } = - options.scope === 'worktree' - ? { ref: 'HEAD (staged, unstaged, untracked)', mergeBase: git(root, ['rev-parse', 'HEAD']) } - : resolveMergeBase(root, options.base) + const { ref, mergeBase } = resolveMergeBase(root, options.base) const files = collectChangedFiles(root, mergeBase) if (files.length === 0) { console.log(`check:changed: no changes against ${ref}.`) return 0 } - const env = { - ...changedCheckEnvironment(options.scope), - // Spawned gates that resolve their own base must use the same pinned comparison. - ORCA_CODE_QUALITY_BASE: mergeBase, - ...(process.stdout.isTTY ? { FORCE_COLOR: '1' } : {}) - } - const hash = computePassHash(root, mergeBase, options.scope, env) - if (!options.force && readPassHash(root, options.scope) === hash) { - console.log(`check:changed (${options.scope}): nothing changed since last passing run.`) + const hash = computePassHash(root, mergeBase) + if (!options.force && readPassHash(root) === hash) { + console.log('Nothing changed since last passing run.') return 0 } - invalidatePassHash(root, options.scope) const plan = planChangedChecks(files, { scriptsDir: path.join(root, 'config', 'scripts'), @@ -270,142 +266,54 @@ export async function main(root, argv) { } } }) - const abort = new AbortController() - const cancel = () => abort.abort() - const signals = - process.platform === 'win32' ? ['SIGINT', 'SIGTERM'] : ['SIGINT', 'SIGTERM', 'SIGHUP'] - signals.forEach((signal) => process.on(signal, cancel)) - const output = createTaskOutput(cancel) - let completedPass = false - try { - output.log( - `check:changed (${options.scope}): ${files.length} changed file(s) since ${ref} (${mergeBase.slice(0, 10)}); unit inventory: ${options.scope === 'worktree' ? 'CI' : 'local'}.` - ) - // Load the source runner so even its own initial build has tree cancellation and a deadline. - const requireFromHost = createRequire( - new URL('../../src/packages/process-host/package.json', import.meta.url) - ) - const { tsImport } = await import(pathToFileURL(requireFromHost.resolve('tsx/esm/api')).href) - const { runProcess, runProcessSync } = await tsImport( - '../../src/packages/process-host/src/run-process.ts', - import.meta.url - ) - const run = async (task) => { - const result = await runTask(task, { - root, - env, - runProcess, - stepTimeoutMs: options.stepTimeoutMs, - signal: abort.signal, - output - }) - if (!result.ok) { - output.error(`\n✗ ${task.name} (${result.elapsed})\n${tail(result.output)}`) - } - return result - } - if ( - plan.typecheckProjects.length > 0 || - plan.unitTests.related.length + plan.unitTests.colocated.length > 0 - ) { - const build = await run({ - name: 'build workspace packages', - steps: [ - { - program: process.execPath, - args: ['config/scripts/build-workspace-packages.mjs'], - liveOutput: true - } - ] - }) - if (!build.ok) { - return 1 - } - } - const { resolvePinnedBunExecutable } = await import('./pinned-bun-executable.mjs') - const bun = () => resolvePinnedBunExecutable(root, runProcessSync) ?? 'bun' - const tasks = buildTasks(root, plan, { files, mergeBase, maxWorkers: options.maxWorkers, bun }) - let evidencePath = null - if (tasks.some((task) => task.name === 'unit tests')) { - const resolved = await run({ - name: 'dependency evidence', - steps: [ - { - program: bun(), - args: ['--bun', path.join(import.meta.dirname, 'check-changed-dependency-evidence.mjs')] - } - ] - }) - if (!resolved.ok) { - return 1 - } - const record = resolved.output - .split('\n') - .find((line) => line.startsWith(DEPENDENCY_PATH_PREFIX)) - evidencePath = record ? JSON.parse(record.slice(DEPENDENCY_PATH_PREFIX.length)) : undefined - } - const evidence = readDependencyEvidence(evidencePath) - output.log(`check:changed: running ${tasks.length} check(s).`) - // Typecheck and tests lead the queue so short checks fill in around them. - const concurrency = options.maxWorkers - ? Number(options.maxWorkers) - : Math.max(3, Math.floor(availableParallelism() / 3)) - const results = await runPool(tasks, concurrency, run) - await output.flush() - const failed = results.filter((result) => !result.ok) - if (failed.length > 0 || abort.signal.aborted) { - output.error( - `\n✗ ${failed.length} of ${results.length} check(s) failed in ${seconds(startedAt)}: ${failed.map((result) => result.task.name).join(', ')}` - ) - return 1 - } - output.log(`✓ ${results.length} check(s) passed in ${seconds(startedAt)}.`) - await output.flush() - if (abort.signal.aborted) { - return 1 - } - if (computePassHash(root, mergeBase, options.scope, env) !== hash) { - output.error('check:changed: inputs changed during checks; rerun before recording a pass.') - return 1 - } - const finalEvidence = readDependencyEvidence(evidencePath) - if (hasNewIncompleteEvidence(evidence, finalEvidence)) { - output.error( - 'check:changed: new incomplete dependency evidence arrived during checks; rerun before recording a pass.' - ) - return 1 - } - if (!evidence || !finalEvidence) { - output.log('check:changed: dependency evidence unavailable; passing checks were not cached.') - } else { - completedPass = writePassHash(root, hash, options.scope, finalEvidence) - if (!completedPass) { - output.error( - 'check:changed: dependency evidence changed while recording a pass; rerun checks.' - ) - return 1 - } - } - } finally { - let disposed = false - try { - await output.dispose() - disposed = true - } finally { - if (!completedPass || !disposed || abort.signal.aborted) { - invalidatePassHash(root, options.scope, hash) - } - signals.forEach((signal) => process.off(signal, cancel)) - if (output.blocked && runsAsCommand) { - // Node protects stdio from destroy(); exit only after owned checks have settled. - process.exit(1) - } - } + const env = { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + // Why: spawned gates that read the base themselves (flaky-test ratchet) must use --base too. + ORCA_CODE_QUALITY_BASE: mergeBase, + ...(process.stdout.isTTY ? { FORCE_COLOR: '1' } : {}) } - return abort.signal.aborted ? 1 : 0 + if ( + plan.typecheckProjects.length > 0 || + plan.unitTests.related.length + plan.unitTests.colocated.length > 0 + ) { + buildWorkspacePackages(root, env) + } + const { runProcess, runProcessSync } = await import('@orca/process-host') + const { resolvePinnedBunExecutable } = await import('./pinned-bun-executable.mjs') + const bun = () => resolvePinnedBunExecutable(root, runProcessSync) ?? 'bun' + const tasks = buildTasks(root, plan, { + files, + mergeBase, + maxWorkers: options.maxWorkers, + bun + }) + console.log( + `check:changed: ${files.length} changed file(s) since ${ref} (${mergeBase.slice(0, 10)}); running ${tasks.length} check(s).` + ) + + // Typecheck and tests lead the queue: they take longest, so the short checks fill in around them. + const concurrency = Math.max(3, Math.floor(availableParallelism() / 3)) + const results = await runPool(tasks, concurrency, async (task) => { + const result = await runTask(task, { root, env, runProcess }) + if (!result.ok) { + console.error(`\n✗ ${task.name} (${result.elapsed})\n${tail(result.output)}`) + } + return result + }) + const failed = results.filter((result) => !result.ok) + if (failed.length > 0) { + console.error( + `\n✗ ${failed.length} of ${results.length} check(s) failed in ${seconds(startedAt)}: ${failed.map((result) => result.task.name).join(', ')}` + ) + return 1 + } + writePassHash(root, hash) + console.log(`✓ ${results.length} check(s) passed in ${seconds(startedAt)}.`) + return 0 } -if (runsAsCommand) { +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim() process.exitCode = await main(root, process.argv.slice(2)) diff --git a/config/scripts/check-changed.test.mjs b/config/scripts/check-changed.test.mjs deleted file mode 100644 index 43a125b1834..00000000000 --- a/config/scripts/check-changed.test.mjs +++ /dev/null @@ -1,407 +0,0 @@ -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - symlinkSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { runProcess, runProcessSync } from '@orca/process-host' -import { expect, it, vi } from 'vitest' -import { readPassHash } from './check-changed-pass-cache.mjs' -import { resolvePinnedBunExecutable } from './pinned-bun-executable.mjs' - -it('keeps branch coverage after an iteration pass and never caches a timed-out gate', () => { - const root = mkdtempSync(join(tmpdir(), 'check-changed-gate-')) - function git(...args) { - const result = runProcessSync({ program: 'git', args, cwd: root }) - expect(result.code, result.stderr).toBe(0) - return result.stdout.trim() - } - function check(...args) { - return runProcessSync({ - program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath, - args: [resolve('config/scripts/check-changed.mjs'), ...args, '--maxWorkers=2'], - cwd: root, - env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, - timeoutMs: 30_000 - }) - } - try { - git('init', '-q') - git('config', 'user.email', 'test@example.com') - git('config', 'user.name', 'Test') - git('config', 'core.autocrlf', 'false') - writeFileSync(join(root, '.gitignore'), '.cache/\nconfig\nnode_modules\n') - git('add', '.') - git('commit', '-qm', 'base') - const base = git('rev-parse', 'HEAD') - symlinkSync(resolve('config'), join(root, 'config'), 'junction') - symlinkSync(resolve('node_modules'), join(root, 'node_modules'), 'junction') - writeFileSync(join(root, 'committed.md'), '# Branch change\n') - git('add', 'committed.md') - git('commit', '-qm', 'branch change') - - const cleanIteration = check('--scope=worktree') - expect(cleanIteration.code, cleanIteration.stderr).toBe(0) - expect(cleanIteration.stdout).toContain('no changes against HEAD') - - writeFileSync(join(root, 'local.md'), '# Local change\n') - const iteration = check('--scope=worktree') - expect(iteration.code, iteration.stderr).toBe(0) - expect(iteration.stdout).toContain('1 changed file(s) since HEAD') - const iterationPass = readFileSync(join(root, '.cache/check-changed.json'), 'utf8') - expect(check('--scope=worktree').stdout).toContain('nothing changed since last passing run') - - const timedOut = check(`--base=${base}`, '--stepTimeoutMs=1') - expect(timedOut.code).toBe(1) - expect(timedOut.stderr).toContain('Timed out after') - expect(readFileSync(join(root, '.cache/check-changed.json'), 'utf8')).toBe(iterationPass) - - const branch = check(`--base=${base}`) - expect(branch.code, branch.stderr).toBe(0) - expect(branch.stdout).toContain('2 changed file(s)') - expect(branch.stdout).not.toContain('nothing changed since last passing run') - expect(check(`--base=${base}`).stdout).toContain('nothing changed since last passing run') - expect(check('--scope=worktree').stdout).toContain('nothing changed since last passing run') - } finally { - rmSync(root, { recursive: true, force: true }) - } -}, 60_000) - -function gateFixture() { - const root = mkdtempSync(join(tmpdir(), 'check-changed-integrity-')) - function git(...args) { - const result = runProcessSync({ program: 'git', args, cwd: root }) - expect(result.code, result.stderr).toBe(0) - return result.stdout.trim() - } - function write(file, contents) { - mkdirSync(join(root, file, '..'), { recursive: true }) - writeFileSync(join(root, file), contents) - } - function checkSpec(args = [], env = {}) { - return { - program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath, - args: [resolve('config/scripts/check-changed.mjs'), '--maxWorkers=1', ...args], - cwd: root, - env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ...env }, - timeoutMs: 30_000 - } - } - git('init', '-q') - git('config', 'user.email', 'test@example.com') - git('config', 'user.name', 'Test') - git('config', 'core.autocrlf', 'false') - write('.gitignore', '.cache/\nconfig/\nnode_modules\n') - write('.oxfmtrc.json', '{"semi":false}\n') - write('sample.md', '# Original\n') - git('add', '.') - git('commit', '-qm', 'base') - const base = git('rev-parse', 'HEAD') - symlinkSync(resolve('node_modules'), join(root, 'node_modules'), 'junction') - write( - 'config/scripts/check-changed-code-quality.mjs', - ` - import { existsSync, readFileSync, writeFileSync } from 'node:fs' - import { setTimeout } from 'node:timers/promises' - if (process.env.ORCA_BALANCE_UNIT_SHARDS !== '0') throw Error('inherited sharding') - if (process.env.ORCA_UNIT_INVENTORY !== process.env.EXPECTED_INVENTORY) throw Error('inventory') - const mode = readFileSync('.cache/control', 'utf8') - if (mode === 'fail') process.exitCode = 1 - if (mode === 'mutate') writeFileSync('sample.md', '# Good\\n') - if (process.env.ORCA_GATE_DELAYED_FAILURE === '1' || process.env.ORCA_GATE_WAIT === '1') { - writeFileSync('.cache/waiting', '') - while (!existsSync('.cache/release')) await setTimeout(10) - if (process.env.ORCA_GATE_DELAYED_FAILURE === '1') process.exitCode = 1 - } - ` - ) - write('.cache/control', 'pass') - return { - root, - base, - git, - write, - check: (...args) => runProcessSync(checkSpec(...args)), - startCheck: (...args) => runProcess(checkSpec(...args)) - } -} - -function configureGateVitest({ root, write }) { - write('config/.bun-version', readFileSync(resolve('config/.bun-version'))) - for (const script of ['build-workspace-packages', 'ensure-native-runtime']) { - write(`config/scripts/${script}.mjs`, '') - } - const bun = resolvePinnedBunExecutable(process.cwd(), runProcessSync) - expect(bun).toBeTruthy() - write( - 'config/vitest.config.ts', - `export default { - root: ${JSON.stringify(root)}, cacheDir: ${JSON.stringify(join(root, '.cache/vitest'))}, - test: { include: ['tests/tools/*.test.mjs'], maxWorkers: 1, - experimental: { recordDependencies: true } } - }` - ) - write( - 'config/scripts/run-vitest.mjs', - ` - import { runProcessSync } from '@orca/process-host' - const result = runProcessSync({ - program: ${JSON.stringify(bun)}, - args: ['--bun', ${JSON.stringify(resolve('node_modules/vitest/vitest.mjs'))}, ...process.argv.slice(2)], - cwd: process.cwd(), env: process.env, timeoutMs: 10000 - }) - process.stdout.write(result.stdout) - process.stderr.write(result.stderr) - process.exitCode = result.code ?? 1 - ` - ) - return (...selection) => - runProcessSync({ - program: bun, - args: [ - '--bun', - resolve('node_modules/vitest/vitest.mjs'), - ...selection, - '--config', - 'config/vitest.config.ts' - ], - cwd: root, - env: { - ...process.env, - ORCA_UNIT_INVENTORY: 'ci', - ORCA_BALANCE_UNIT_SHARDS: '0', - ORCA_BACKGROUND_LAUNCH: '1' - }, - timeoutMs: 10_000 - }) -} - -it('removes a concurrent matching pass when an older forced check finishes with failure', async () => { - const { root, base, write, check, startCheck } = gateFixture() - const args = [`--base=${base}`] - const env = { EXPECTED_INVENTORY: 'local' } - let delayed - try { - write('sample.md', '# Changed\n') - const iteration = check(['--scope=worktree'], { EXPECTED_INVENTORY: 'ci' }) - expect(iteration.code, iteration.stderr).toBe(0) - const iterationHash = readPassHash(root, 'worktree') - delayed = startCheck([...args, '--force'], { ...env, ORCA_GATE_DELAYED_FAILURE: '1' }) - await vi.waitFor(() => expect(existsSync(join(root, '.cache/waiting'))).toBe(true), { - timeout: 10_000 - }) - const successful = check(args, env) - expect(successful.code, successful.stderr).toBe(0) - expect(readPassHash(root)).toBeTruthy() - write('.cache/release', '') - expect((await delayed).code).toBe(1) - expect(readPassHash(root)).toBeNull() - expect(readPassHash(root, 'worktree')).toBe(iterationHash) - } finally { - if (delayed) { - write('.cache/release', '') - await delayed - } - rmSync(root, { recursive: true, force: true }) - } -}, 60_000) - -it('rehashes changed inputs, separates CI passes, and invalidates a forced failed recheck', () => { - const fixture = gateFixture() - const { root, base, git, write } = fixture - const branchArgs = [`--base=${base}`] - const branchEnv = { EXPECTED_INVENTORY: 'local', ORCA_BALANCE_UNIT_SHARDS: '1', CI: '' } - const check = (args = branchArgs, env = {}) => fixture.check(args, { ...branchEnv, ...env }) - try { - write('sample.md', '# Changed\n') - const ci = check(branchArgs, { CI: '1' }) - expect(ci.code, ci.stderr).toBe(0) - const local = check() - expect(local.code, local.stderr).toBe(0) - expect(local.stdout).not.toContain('nothing changed since last passing run') - const iteration = check(['--scope=worktree'], { EXPECTED_INVENTORY: 'ci' }) - expect(iteration.code, iteration.stderr).toBe(0) - const iterationHash = readPassHash(root, 'worktree') - - write('.cache/control', 'fail') - expect(check([...branchArgs, '--force']).code).toBe(1) - expect(readPassHash(root)).toBeNull() - expect(readPassHash(root, 'worktree')).toBe(iterationHash) - const failedRetry = check() - expect(failedRetry.code).toBe(1) - expect(failedRetry.stdout).not.toContain('nothing changed since last passing run') - - write('.cache/control', 'mutate') - write('sample.md', '# Bad\n\n- bad\n') - const mutated = check() - expect(mutated.code).toBe(1) - expect(mutated.stderr).toContain('inputs changed during checks') - expect(readPassHash(root)).toBeNull() - write('.cache/control', 'pass') - write('sample.md', '# Bad\n\n- bad\n') - expect(check().code).toBe(1) - - git('update-ref', 'refs/remotes/origin/main', base) - const invalid = check(['--base=missing-review-base']) - expect(invalid.code).toBe(1) - expect(invalid.stderr).toContain('Invalid --base=missing-review-base') - const emptyBase = check(['--base=']) - expect(emptyBase.code).toBe(1) - expect(emptyBase.stderr).toContain('Invalid --base=') - } finally { - rmSync(root, { recursive: true, force: true }) - } -}, 60_000) - -it.each(['cold', 'recorded'])( - 'runs a deleted dependency consumer with %s records', - (records) => { - const fixture = gateFixture() - const { root, git, write, check } = fixture - try { - write('value.mjs', 'export const value = 1\n') - write( - 'tests/tools/deleted-consumer.test.mjs', - ` - import { expect, it } from 'vitest' - import { value } from '../../value.mjs' - it('loads its dependency', () => expect(value).toBe(1)) - ` - ) - git('add', 'value.mjs', 'tests') - git('commit', '-qm', 'consumer') - const vitest = configureGateVitest(fixture) - if (records === 'recorded') { - const warm = vitest('run') - expect(warm.code, warm.stderr).toBe(0) - } - rmSync(join(root, 'value.mjs')) - const result = check(['--scope=worktree'], { EXPECTED_INVENTORY: 'ci' }) - expect(result.code).toBe(1) - expect(result.stdout).toContain('unit tests') - expect(result.stderr).toContain('value.mjs') - expect(readPassHash(root, 'worktree')).toBeNull() - } finally { - rmSync(root, { recursive: true, force: true }) - } - }, - 60_000 -) - -function computedConsumerFixture() { - const fixture = gateFixture() - const { git, write } = fixture - write('value.mjs', 'export const value = 1\n') - write( - 'loader.mjs', - `export async function load() { const file = './value.mjs'; return (await import(file)).value }\n` - ) - write( - 'tests/tools/static.test.mjs', - `import {expect,it} from 'vitest'; import {value} from '../../value.mjs'; it('static consumer', () => expect(value).toBeGreaterThan(0))\n` - ) - write( - 'tests/tools/dynamic.test.mjs', - `import {expect,it} from 'vitest'; import {load} from '../../loader.mjs'; it('computed consumer', async () => expect(await load()).toBe(1))\n` - ) - for (let index = 0; index < 102; index++) { - write( - `tests/tools/cold-${index}.test.mjs`, - `import {it} from 'vitest'; it('unrelated', () => {})\n` - ) - } - git('add', 'value.mjs', 'loader.mjs', 'tests') - git('commit', '-qm', 'computed consumers') - const vitest = configureGateVitest(fixture) - write('value.mjs', 'export const value = 2\n') - return { ...fixture, vitest } -} - -it.each(['failure', 'timeout'])( - 'fails the gate when dependency path resolution has a %s', - (mode) => { - const fixture = gateFixture() - const { root, write, check } = fixture - try { - configureGateVitest(fixture) - write('value.mjs', 'export const value = 2\n') - write( - 'config/vitest.config.ts', - mode === 'failure' - ? `throw new Error('configuration resolution failed'); export default {}\n` - : `export default async () => { await new Promise(() => {}); return {} }\n` - ) - const result = check(['--scope=worktree', '--stepTimeoutMs=1000'], { - EXPECTED_INVENTORY: 'ci' - }) - expect(result.code).toBe(1) - expect(result.stderr).toContain('✗ dependency evidence') - expect(result.stderr).toContain( - mode === 'failure' ? 'configuration resolution failed' : 'Timed out after 1s' - ) - expect(result.stdout).not.toContain('▶ unit tests') - expect(readPassHash(root, 'worktree')).toBeNull() - } finally { - rmSync(root, { recursive: true, force: true }) - } - }, - 30_000 -) - -it('invalidates a cached gate when an unchanged computed consumer records a failure', () => { - const { root, check, vitest } = computedConsumerFixture() - const args = ['--scope=worktree'] - const env = { EXPECTED_INVENTORY: 'ci' } - try { - const initial = check(args, env) - expect(initial.code, initial.stderr).toBe(0) - expect(initial.stdout).toContain('1 of 104 test files') - // The gate's own successful dependency update must be cacheable immediately. - const cached = check(args, env) - expect(cached.code, cached.stderr).toBe(0) - expect(cached.stdout).toContain('nothing changed since last passing run') - expect(vitest('run', 'tests/tools/dynamic.test.mjs').code).toBe(1) - expect(readPassHash(root, 'worktree')).toBeNull() - const retry = check(args, env) - expect(retry.code).toBe(1) - expect(retry.stdout).not.toContain('nothing changed since last passing run') - expect(retry.stderr).toContain('computed consumer') - expect(readPassHash(root, 'worktree')).toBeNull() - } finally { - rmSync(root, { recursive: true, force: true }) - } -}, 60_000) - -it('rejects failed dependency evidence arriving after unit tests while another check is active', async () => { - const { root, write, check, startCheck, vitest } = computedConsumerFixture() - const args = ['--scope=worktree'] - const env = { EXPECTED_INVENTORY: 'ci' } - let pending - try { - pending = startCheck(args, { ...env, ORCA_GATE_WAIT: '1' }) - // With one worker, quality starts only after the selected static unit suite passes. - await vi.waitFor(() => expect(existsSync(join(root, '.cache/waiting'))).toBe(true), { - timeout: 10_000 - }) - expect(vitest('run', 'tests/tools/dynamic.test.mjs').code).toBe(1) - write('.cache/release', '') - const result = await pending - expect(result.code).toBe(1) - expect(result.stderr).toContain('new incomplete dependency evidence') - expect(readPassHash(root, 'worktree')).toBeNull() - const retry = check(args, env) - expect(retry.code).toBe(1) - expect(retry.stderr).toContain('computed consumer') - } finally { - if (pending) { - write('.cache/release', '') - await pending - } - rmSync(root, { recursive: true, force: true }) - } -}, 60_000) diff --git a/config/scripts/vitest-affected-tests.test.mjs b/config/scripts/vitest-affected-tests.test.mjs deleted file mode 100644 index 344906e3a5c..00000000000 --- a/config/scripts/vitest-affected-tests.test.mjs +++ /dev/null @@ -1,263 +0,0 @@ -import { runProcessSync } from '@orca/process-host' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join, relative, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import { expect, it } from 'vitest' - -it('uses recorded imports in both runtimes and retains cold, new, and global-input tests', () => { - const root = process.cwd() - mkdirSync(join(root, '.tmp'), { recursive: true }) - const directory = mkdtempSync(join(root, '.tmp', 'vitest-affected-')) - const config = join(directory, 'vitest.config.mjs') - const report = join(directory, 'report.json') - const transformed = join(directory, 'transformed.jsonl') - const input = join(directory, 'value.ts') - const runtimes = ['bun', 'node-runtime'] - const localPath = (file) => relative(root, file).replaceAll('\\', '/') - function writeTest(runtime, name, imported = false) { - const file = join(directory, runtime, `${name}.test.ts`) - writeFileSync( - file, - ` - import { expect, it } from 'vitest' - ${imported ? "import { value } from './value'" : 'const value = 1'} - it('runs under ${runtime}', () => { - expect(value).toBe(1) - expect(Boolean(process.versions.bun)).toBe(${runtime === 'bun'}) - }) - ` - ) - return file - } - try { - writeFileSync(input, 'export const value = 1') - const affected = [] - const unrelated = [] - const runtimeSources = [] - for (const runtime of runtimes) { - mkdirSync(join(directory, runtime)) - const source = join(directory, runtime, 'value.ts') - writeFileSync(source, "export { value } from '../value'") - runtimeSources.push(source) - affected.push(writeTest(runtime, 'affected', true)) - unrelated.push(writeTest(runtime, 'unrelated')) - } - writeFileSync( - config, - ` - import base from ${JSON.stringify(pathToFileURL(resolve('config/vitest.config.ts')).href)} - import { appendFileSync } from 'node:fs' - export default { - ...base, - cacheDir: ${JSON.stringify(join(directory, 'cache'))}, - define: { - ...base.define, - ORCA_AFFECTED_CONTEXT: JSON.stringify(process.env.ORCA_AFFECTED_CONTEXT ?? 'good') - }, - test: { - ...base.test, - maxWorkers: 1, - projects: base.test.projects - .filter(project => ${JSON.stringify(runtimes)}.includes(project.test.name)) - .map(project => ({ - ...project, - plugins: [...project.plugins, { - name: 'observe-affected-test-transforms', - transform(code, id) { - const file = id.split('?')[0].replaceAll('\\\\', '/') - if (file.startsWith(${JSON.stringify(`${directory.replaceAll('\\', '/')}/`)})) { - appendFileSync(${JSON.stringify(transformed)}, JSON.stringify({ - runtime: project.test.name, - file - }) + '\\n') - } - } - }], - define: { - ...project.define, - ORCA_AFFECTED_CONTEXT: JSON.stringify(process.env.ORCA_AFFECTED_CONTEXT ?? 'good') - }, - test: { - ...project.test, - include: [${JSON.stringify(localPath(directory))} + '/' + project.test.name + '/*.test.ts'], - exclude: [] - } - })) - } - } - ` - ) - function run(changed, { args, env, code = 0 } = {}) { - writeFileSync(transformed, '') - const result = runProcessSync({ - program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath, - args: [ - resolve('config/scripts/run-vitest.mjs'), - ...(args ?? ['related', '--run', localPath(changed)]), - '--config', - config, - '--reporter=json', - '--outputFile', - report - ], - cwd: root, - env: { ...process.env, CI: '', ORCA_BACKGROUND_LAUNCH: '1', ...env }, - timeoutMs: 60_000 - }) - expect(result.timedOut, result.stderr).toBe(false) - expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(code) - const output = JSON.parse(readFileSync(report, 'utf8')) - expect(output.success).toBe(code === 0) - return output.testResults.map(({ name }) => name.replaceAll('\\', '/')).sort() - } - const normalize = (files) => files.map((file) => file.replaceAll('\\', '/')).sort() - const readTransforms = () => - readFileSync(transformed, 'utf8') - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)) - function expectKnownTransforms() { - const observed = readTransforms() - for (const [index, runtime] of runtimes.entries()) { - expect(observed).toEqual( - expect.arrayContaining( - normalize([affected[index], runtimeSources[index]]).map((file) => ({ runtime, file })) - ) - ) - } - expect(observed.map(({ file }) => file)).toContain(normalize([input])[0]) - return observed - } - expect(run(input)).toEqual(normalize([...affected, ...unrelated])) - expectKnownTransforms() - expect(run(input)).toEqual(normalize(affected)) - // Force observable work in both warm projects while unrelated records stay reusable. - for (const file of [...affected, ...runtimeSources, input]) { - writeFileSync(file, `${readFileSync(file, 'utf8')}\n`) - } - expect(run(input)).toEqual(normalize(affected)) - const transforms = expectKnownTransforms().map(({ file }) => file) - for (const file of unrelated) { - expect(transforms).not.toContain(file.replaceAll('\\', '/')) - } - const fresh = writeTest(runtimes[0], 'new') - expect(run(input)).toEqual(normalize([...affected, fresh])) - expect(run(join(root, 'pnpm-lock.yaml'))).toEqual(normalize([...affected, ...unrelated, fresh])) - const coldAffected = writeTest(runtimes[0], 'cold-affected', true) - const coldUnrelated = [] - for (let index = 0; index < 101; index += 1) { - const runtime = runtimes[index % runtimes.length] - coldUnrelated.push({ - runtime, - file: writeTest(runtime, `cold-unrelated-${index}`).replaceAll('\\', '/') - }) - } - expect(run(input)).toEqual(normalize([...affected, coldAffected])) - const coldTransforms = readTransforms() - expect(coldTransforms).toEqual(expect.arrayContaining(coldUnrelated)) - expect(coldTransforms).toContainEqual({ - runtime: runtimes[0], - file: coldAffected.replaceAll('\\', '/') - }) - - const knownArgs = ['run', ...affected.map(localPath)] - for (const file of affected) { - writeFileSync( - file, - ` - import { expect, it } from 'vitest' - it('loads a computed dependency', async () => { - if (process.env.ORCA_AFFECTED_EARLY_FAIL === '1') throw Error('early failure') - const source = '../value' - expect((await import(source)).value).toBe(1) - }) - ` - ) - } - expect(run(input, { args: knownArgs })).toEqual(expect.arrayContaining(normalize(affected))) - expect( - run(input, { args: knownArgs, env: { ORCA_AFFECTED_EARLY_FAIL: '1' }, code: 1 }) - ).toEqual(expect.arrayContaining(normalize(affected))) - writeFileSync(input, 'export const value = 2') - // Incomplete specs must run even beside >100 cold files and without a static import edge. - expect(run(input, { code: 1 })).toEqual(expect.arrayContaining(normalize(affected))) - expect(run(join(directory, 'unrelated.ts'), { code: 1 })).toEqual( - expect.arrayContaining(normalize(affected)) - ) - writeFileSync(input, 'export const value = 1') - run(input, { args: knownArgs }) - expect(run(input, { args: [...knownArgs, '--testNamePattern=absent'] })).toEqual( - expect.arrayContaining(normalize(affected)) - ) - writeFileSync(input, 'export const value = 2') - expect(run(input, { code: 1 })).toEqual(expect.arrayContaining(normalize(affected))) - writeFileSync(input, 'export const value = 1') - run(input, { args: knownArgs }) - - const importer = join(directory, 'importer.ts') - writeFileSync(importer, 'export const value = 1') - function importerGit(...args) { - const result = runProcessSync({ program: 'git', args, cwd: directory }) - expect(result.code, result.stderr).toBe(0) - } - importerGit('init', '-q') - importerGit('config', 'user.email', 'test@example.com') - importerGit('config', 'user.name', 'Test') - importerGit('add', 'importer.ts') - importerGit('commit', '-qm', 'initial importer') - for (const file of affected) { - writeFileSync( - file, - ` - import { expect, it } from 'vitest' - import { value } from '../importer' - it('uses an importer', () => expect(value).toBe(1)) - ` - ) - } - run(input, { args: knownArgs }) - writeFileSync(importer, "export { value } from './value'") - importerGit('add', 'importer.ts') - importerGit('commit', '-qm', 'importer now depends on value') - writeFileSync(input, 'export const value = 2') - expect(run(input, { code: 1 })).toEqual(expect.arrayContaining(normalize(affected))) - writeFileSync(input, 'export const value = 1') - run(input, { args: knownArgs }) - - for (const file of affected) { - writeFileSync( - file, - ` - import { expect, it } from 'vitest' - it('uses the resolved config context', () => expect(ORCA_AFFECTED_CONTEXT).toBe('good')) - ` - ) - } - run(input, { args: knownArgs }) - expect(run(input, { env: { ORCA_AFFECTED_CONTEXT: 'bad' }, code: 1 })).toEqual( - expect.arrayContaining(normalize(affected)) - ) - - for (const [index, file] of affected.entries()) { - const runtimeImporter = join(directory, `mutated-importer-${index}.ts`) - writeFileSync(runtimeImporter, 'export const value = 1') - writeFileSync( - file, - ` - import { expect, it } from 'vitest' - import { writeFileSync } from 'node:fs' - import { value } from '../mutated-importer-${index}' - it('edits an importer while its old graph is executing', () => { - expect(value).toBe(1) - writeFileSync(${JSON.stringify(runtimeImporter)}, "export { value } from './value'") - }) - ` - ) - } - run(input, { args: knownArgs }) - writeFileSync(input, 'export const value = 2') - expect(run(input, { code: 1 })).toEqual(expect.arrayContaining(normalize(affected))) - } finally { - rmSync(directory, { recursive: true, force: true, maxRetries: 3 }) - } -}, 180_000) diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 29f72a7a1d8..f3091ce8e62 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -16,7 +16,6 @@ const nodeRuntimeInclude = process.versions.bun ? getNodeRuntimeInclude({ filters: readRunFilters() }) : [] const balancedShards = process.env.ORCA_BALANCE_UNIT_SHARDS === '1' -const ciUnitInventory = balancedShards || process.env.ORCA_UNIT_INVENTORY === 'ci' const moduleCachePath = process.env.ORCA_UNIT_MODULE_CACHE_PATH const measurementFile = 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-event-loop.test.ts' const paletteMeasurementFile = @@ -96,7 +95,7 @@ const testOptions = { execArgv: ['--no-experimental-webstorage', '--expose-gc'], setupFiles: UNIT_SETUP_FILES.map((file) => resolve(file)), include: UNIT_INCLUDE, - exclude: ciUnitInventory ? UNIT_EXCLUDE : defaultExclude, + exclude: balancedShards ? UNIT_EXCLUDE : defaultExclude, hookTimeout: 60_000, testTimeout: 30_000 } @@ -161,7 +160,6 @@ export default defineConfig({ ...transforms, test: { ...testOptions, - experimental: { recordDependencies: !process.env.CI }, forceRerunTriggers: [ ...configDefaults.forceRerunTriggers, '**/pnpm-lock.yaml', diff --git a/package.json b/package.json index 77f26c77062..e44cefc3133 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "audit:react-doctor": "node config/scripts/run-audit-tool.mjs react-doctor . --yes --no-supply-chain --no-telemetry --blocking none", "audit:dead-code": "node config/scripts/run-audit-tool.mjs knip --config config/knip.json", "check:changed": "node config/scripts/check-changed.mjs", - "check:iteration": "node config/scripts/check-changed.mjs --scope=worktree", "check:code-quality:changed": "node config/scripts/check-changed-code-quality.mjs", "lint:js-plugins": "oxlint --config config/oxlint-js-plugins.json src config tests mobile native .github", "lint:ast-grep": "ast-grep scan --config config/sgconfig.yml --min-severity warning src config tests mobile", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3773e4f44d1..4ee04f0c596 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,7 +187,7 @@ patchedDependencies: node-pty@1.1.0: 3072026a166d508c622ee025bfceb011ce97e2133efc93bd014e9374f3febf43 oxlint-plugin-anti-slop@0.1.2: c10c871702365a0c2c59e9f1c5fb92df97cae5755d534c0a3d83c6368f6feafe sprintf-js@1.1.3: 05316a8b01f155fe19a1e0cb4042c81d43d798a76755c407684e1199a18b1e08 - vitest@5.0.5: fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1 + vitest@5.0.5: a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db ws@8.22.0: 9393e04dd9a90b01eb7aa35fcd5b1431b5038ce999fbbd82651136193ba51f14 importers: @@ -326,7 +326,7 @@ importers: version: 3.14.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) '@testing-library/jest-dom': specifier: ^6.9.1 - version: 6.9.1(vitest@5.0.5(patch_hash=fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + version: 6.9.1(vitest@5.0.5(patch_hash=a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) '@testing-library/react': specifier: ^16.3.3 version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) @@ -626,7 +626,7 @@ importers: version: 8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) vitest: specifier: 5.0.5 - version: 5.0.5(patch_hash=fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 5.0.5(patch_hash=a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) vscode-oniguruma: specifier: ^2.0.1 version: 2.0.1 @@ -687,7 +687,7 @@ importers: version: 7.0.2 vitest: specifier: 5.0.5 - version: 5.0.5(patch_hash=fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1)(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(happy-dom@21.0.4)(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.5(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 5.0.5(patch_hash=a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db)(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(happy-dom@21.0.4)(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.5(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) packages: @@ -10453,7 +10453,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.9.1(vitest@5.0.5(patch_hash=fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))': + '@testing-library/jest-dom@6.9.1(vitest@5.0.5(patch_hash=a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))': dependencies: '@adobe/css-tools': 4.5.0 '@types/aria-query': 5.0.4 @@ -10463,7 +10463,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 5.0.5(patch_hash=fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + vitest: 5.0.5(patch_hash=a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)': dependencies: @@ -15184,7 +15184,7 @@ snapshots: transitivePeerDependencies: - '@types/picomatch' - vitest@5.0.5(patch_hash=fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1)(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(happy-dom@21.0.4)(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.5(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): + vitest@5.0.5(patch_hash=a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db)(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(happy-dom@21.0.4)(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.5(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.5(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.5(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) @@ -15209,7 +15209,7 @@ snapshots: - '@types/picomatch' - msw - vitest@5.0.5(patch_hash=fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): + vitest@5.0.5(patch_hash=a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.5(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) diff --git a/src/packages/process-host/src/process-spec.ts b/src/packages/process-host/src/process-spec.ts index d7340a7f1ce..0014b86718e 100644 --- a/src/packages/process-host/src/process-spec.ts +++ b/src/packages/process-host/src/process-spec.ts @@ -38,8 +38,6 @@ export type ProcessSpec = { terminationBarrier?: boolean | ProcessTerminationBarrier /** Called once when the child exits or tree termination is verified. */ onChildTerminated?: () => void - /** Every raw stdout chunk as it arrives, including any past the capture cap. */ - onStdout?: (chunk: Buffer | string) => void /** Every raw stderr chunk as it arrives, including any past the capture cap. */ onStderr?: (chunk: Buffer | string) => void } @@ -67,8 +65,6 @@ export type ProcessResult = { stderrBytes?: Buffer timedOut: boolean outputTruncated?: boolean - /** Shutdown settled without tree proof or output EOF; escaped descendants may remain. */ - terminationUnverified?: boolean } export const DEFAULT_PROCESS_TIMEOUT_MS = 30_000 diff --git a/src/packages/process-host/src/run-process-sync.ts b/src/packages/process-host/src/run-process-sync.ts deleted file mode 100644 index 44799af54b0..00000000000 --- a/src/packages/process-host/src/run-process-sync.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { spawnSync as nodeSpawnSync } from 'node:child_process' -import { resolveSpawn, type ResolvedSpawn } from './spawn-resolution' -import { hasSpawnObserver, notifySpawnObserver } from './spawn-observer' -import { DEFAULT_PROCESS_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES } from './process-spec' -import type { ProcessSpec, ProcessResult } from './process-spec' - -/** Prefer runProcess; reserve blocking execution for synchronous entry points and teardown. */ -export function runProcessSync(spec: ProcessSpec): ProcessResult { - const resolved = resolveSpawn(spec, process.platform) - // spawnSync blocks until exit; report that duration even if it throws. - const spawnStartedAt = hasSpawnObserver() ? performance.now() : null - try { - return runResolvedProcessSync(spec, resolved) - } finally { - if (spawnStartedAt !== null) { - notifySpawnObserver(resolved.file, resolved.args, performance.now() - spawnStartedAt) - } - } -} - -function runResolvedProcessSync(spec: ProcessSpec, resolved: ResolvedSpawn): ProcessResult { - const result = nodeSpawnSync(resolved.file, [...resolved.args], { - ...resolved.options, - input: spec.input, - timeout: spec.timeoutMs === null ? undefined : (spec.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS), - maxBuffer: spec.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, - // Keep raw output without applying the nonexistent 'buffer' encoding to string stdin. - encoding: null - }) - const timedOut = result.error - ? 'code' in result.error && result.error.code === 'ETIMEDOUT' - : false - if (result.error && !timedOut) { - throw result.error - } - return { - code: result.status, - signal: result.signal, - stdout: spec.captureStdoutAsBytes ? '' : (result.stdout?.toString('utf8') ?? ''), - ...(spec.captureStdoutAsBytes ? { stdoutBytes: result.stdout ?? Buffer.alloc(0) } : {}), - stderr: spec.captureStderrAsBytes ? '' : (result.stderr?.toString('utf8') ?? ''), - ...(spec.captureStderrAsBytes ? { stderrBytes: result.stderr ?? Buffer.alloc(0) } : {}), - // ENOBUFS is rethrown above, so truncated output never reaches this result. - outputTruncated: false, - // SIGTERM can be deliberate; only ETIMEDOUT proves a timeout. - timedOut - } -} diff --git a/src/packages/process-host/src/run-process-termination-failure.test.ts b/src/packages/process-host/src/run-process-termination-failure.test.ts index 076380c97fc..0da22908a99 100644 --- a/src/packages/process-host/src/run-process-termination-failure.test.ts +++ b/src/packages/process-host/src/run-process-termination-failure.test.ts @@ -134,17 +134,7 @@ describe('runProcess termination failure', () => { expect(onChildTerminated).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(10_000) - await expect(pending).resolves.toMatchObject({ - code: null, - timedOut: true, - terminationUnverified: true - }) - expect(child.stdin.destroyed).toBe(true) - expect(child.stdout.destroyed).toBe(true) - expect(child.stderr.destroyed).toBe(true) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(child.unref).toHaveBeenCalledOnce() + await expect(pending).resolves.toMatchObject({ code: null, timedOut: true }) expect(onChildTerminated).not.toHaveBeenCalled() child.emit('close', null, 'SIGKILL') expect(onChildTerminated).toHaveBeenCalledOnce() diff --git a/src/packages/process-host/src/run-process.ts b/src/packages/process-host/src/run-process.ts index 8c2fdb8df9f..6e634fb5d46 100644 --- a/src/packages/process-host/src/run-process.ts +++ b/src/packages/process-host/src/run-process.ts @@ -1,15 +1,16 @@ -import { spawn as nodeSpawn, type ChildProcess } from 'node:child_process' -import { resolveSpawn } from './spawn-resolution' +import { + spawn as nodeSpawn, + spawnSync as nodeSpawnSync, + type ChildProcess +} from 'node:child_process' +import { resolveSpawn, type ResolvedSpawn } from './spawn-resolution' import { forceTerminateProcessTree, signalProcessTree } from './process-tree-termination' import { inFlightWindowsTreeKill } from './windows-tree-kill' import { hasSpawnObserver, notifySpawnObserver } from './spawn-observer' import { createOutputSink } from './bounded-output-sink' import { createChildTerminationReporter } from './child-termination-reporter' -import { runProcessSync as runSynchronousProcess } from './run-process-sync' -// Keep the CommonJS export writable for process instrumentation. -export const runProcessSync = runSynchronousProcess export { WindowsCmdLineBreakError } from './windows-command-line' import type { @@ -64,8 +65,6 @@ export function runProcess( const stderr = createOutputSink(maxOutputBytes, outputCapture) let timedOut = false let settled = false - let stopping = false - let terminationUnverified = false let barrierStopping = false let barrierAttemptComplete = false let barrierTerminationVerified = false @@ -84,30 +83,16 @@ export function runProcess( clearTimeout(graceTimer) clearTimeout(barrierDeadlineTimer) spec.signal?.removeEventListener('abort', onAbort) - const openOutput = [child.stdout, child.stderr].some( - (stream) => stream && !stream.readableEnded && !stream.destroyed - ) - terminationUnverified = Boolean( - stopping && (openOutput || (spec.terminationBarrier && !barrierTerminationVerified)) - ) - child.stdout?.off('data', onStdout) - child.stderr?.off('data', onStderr) - for (const stream of [child.stdin, child.stdout, child.stderr]) { - stream?.destroy() - } - // A fallback releases our wait, not proof that the child or escaped descendants exited. - child.unref() act() } - const onStdout = (chunk: Buffer | string): void => { + child.stdout?.on('data', (chunk: Buffer | string) => { stdout.write(chunk) - spec.onStdout?.(chunk) if (spec.killOnOutputLimit && stdout.truncated()) { stopAndSettle() } - } - const onStderr = (chunk: Buffer | string): void => { + }) + child.stderr?.on('data', (chunk: Buffer | string) => { stderr.write(chunk) spec.onStderr?.(chunk) if (spec.killOnOutputLimit && stderr.truncated()) { @@ -116,9 +101,7 @@ export function runProcess( if (typeof spec.terminationBarrier === 'object') { spec.terminationBarrier.observeStderr?.(chunk) } - } - child.stdout?.on('data', onStdout) - child.stderr?.on('data', onStderr) + }) // Stream errors (including stdin EPIPE) are otherwise uncaught; the exit still reaches callers. for (const stream of [child.stdin, child.stdout, child.stderr]) { stream?.on('error', () => {}) @@ -147,7 +130,6 @@ export function runProcess( stderr: spec.captureStderrAsBytes ? '' : stderr.text(), ...(spec.captureStderrAsBytes ? { stderrBytes: stderr.buffer() } : {}), timedOut, - ...(terminationUnverified ? { terminationUnverified: true } : {}), outputTruncated: stdout.truncated() || stderr.truncated() }) ) @@ -175,9 +157,6 @@ export function runProcess( } const resolveBarrierIfSafe = (): void => { - if (settled) { - return - } const rootExit = deferredClose ?? deferredExit if (barrierTerminationVerified || (rootExitedBeforeBarrier && rootExit)) { settleBarrierOutcome() @@ -193,10 +172,6 @@ export function runProcess( // Barrier callers wait for termination evidence, bounded by a final deadline. const stopAndSettle = (): void => { - if (settled) { - return - } - stopping = true if (spec.terminationBarrier) { barrierStopping = true initialBarrierTermination ??= signalBarrierTree() @@ -321,3 +296,46 @@ function terminate(child: ChildProcess, signal?: NodeJS.Signals): void { /* already gone */ } } + +/** Prefer runProcess; reserve blocking execution for synchronous entry points and teardown. */ +export function runProcessSync(spec: ProcessSpec): ProcessResult { + const resolved = resolveSpawn(spec, process.platform) + // spawnSync blocks until exit; report that duration even if it throws. + const spawnStartedAt = hasSpawnObserver() ? performance.now() : null + try { + return runResolvedProcessSync(spec, resolved) + } finally { + if (spawnStartedAt !== null) { + notifySpawnObserver(resolved.file, resolved.args, performance.now() - spawnStartedAt) + } + } +} + +function runResolvedProcessSync(spec: ProcessSpec, resolved: ResolvedSpawn): ProcessResult { + const result = nodeSpawnSync(resolved.file, [...resolved.args], { + ...resolved.options, + input: spec.input, + timeout: spec.timeoutMs === null ? undefined : (spec.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS), + maxBuffer: spec.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, + // Keep raw output without applying the nonexistent 'buffer' encoding to string stdin. + encoding: null + }) + const timedOut = result.error + ? 'code' in result.error && result.error.code === 'ETIMEDOUT' + : false + if (result.error && !timedOut) { + throw result.error + } + return { + code: result.status, + signal: result.signal, + stdout: spec.captureStdoutAsBytes ? '' : (result.stdout?.toString('utf8') ?? ''), + ...(spec.captureStdoutAsBytes ? { stdoutBytes: result.stdout ?? Buffer.alloc(0) } : {}), + stderr: spec.captureStderrAsBytes ? '' : (result.stderr?.toString('utf8') ?? ''), + ...(spec.captureStderrAsBytes ? { stderrBytes: result.stderr ?? Buffer.alloc(0) } : {}), + // ENOBUFS is rethrown above, so truncated output never reaches this result. + outputTruncated: false, + // SIGTERM can be deliberate; only ETIMEDOUT proves a timeout. + timedOut + } +}