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>
This commit is contained in:
OrcaWin
2026-10-28 00:11:04 -07:00
committed by GitHub
co-authored by OrcaWin
parent 1983946e25
commit 7cdfebdc1b
19 changed files with 204 additions and 2473 deletions
+3 -78
View File
@@ -93,51 +93,6 @@ pnpm dev:fresh [--keep] [--seed-from[=<profile dir>]] # 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=<ref>`). `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=<ms>` 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 <source-files>`](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:
+1 -3
View File
@@ -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`
+1 -272
View File
@@ -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();
@@ -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)}`)
}
@@ -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
)
+11 -93
View File
@@ -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`
)
}
@@ -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)
}
)
@@ -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=<ms> 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
}
@@ -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 }
])
)
})
+124 -216
View File
@@ -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=<ref>, --scope=branch|worktree, --stepTimeoutMs=<ms>, --maxWorkers=<n>.`
)
throw new Error(`Unknown option ${argument}. Use --force, --base=<ref>, --maxWorkers=<n>.`)
}
}
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=<ref>.')
}
@@ -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))
-407
View File
@@ -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)
@@ -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)
+1 -3
View File
@@ -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',
-1
View File
@@ -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",
+8 -8
View File
@@ -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))
@@ -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
@@ -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
}
}
@@ -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()
+53 -35
View File
@@ -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
}
}