Skip to content

ci(mobile): fail CI when the RPC recording pin leaves main's history or the corpus does not reproduce - #21156

Merged
Jinwoo-H merged 4 commits into
mainfrom
ci-golden-guard
Sep 17, 2026
Merged

Jinwoo-H merged 4 commits into
mainfrom
ci-golden-guard

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​262 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​262
Prod 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​402 $\color{#cf222e}{\Huge{\mathbf{−}}}$​2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​400

What this is

A CI guard for the two claims mobile's RPC recording corpus makes about its pin, so the next broken pin is a red job instead of a hand-made repin PR.

mobile/rpc-foundation/pilot-scenarios.json carries baseline, the commit every golden under mobile/rpc-foundation/goldens/ was recorded against, and --record refuses on any other fenced tree. Two things break that only a person notices today:

  1. A behaviour-change branch pins its own last fenced commit. When it squash-merges, that commit is not an ancestor of main, and nobody can record on main until a repin lands. test(mobile): repin the RPC recording corpus to main after #21089 #21123 was that repin after refactor(mobile): checked reply readers for the session domain (step 7) #21089; test(mobile): repin the recording baseline to main after #20950 #21065 was that repin after refactor(mobile): checked reply readers for the source-control domain (step 7 pilot) #20950.
  2. A merge can splice golden headers or bodies into a corpus no recorder emitted. The recording suites replay the corpus against the current tree, which is only the same claim as the header while the fenced tree still matches the pin.

What it adds

mobile/scripts/rpc-recording-pin-guard.mts, runnable locally, with two subcommands:

  • ancestry [--ref <commit>]: baseline must be an ancestor of the ref. Three failures: not an ancestor, not a commit, or a shallow clone (a refusal, not a pass, because merge-base --is-ancestor answers from grafted parents). The failure message prints the exact repin recipe.
  • reproduce [--if-changed-since <commit>]: checks the pin out detached under mkdtemp, lays this tree's recorder and manifest over it, points the golden and scenario paths at this tree, and runs the recording drivers plus the two corpus census suites there. The comparison is compareGolden itself with the same masks as every other run. Worktree and temp dir are removed in a finally.

.github/workflows/mobile.yml gains a recording-pin job with fetch-depth: 0:

event ancestry ref reproduce
pull request the PR head sha (the merge preview's second parent is main, which would hide a pin reachable only from main) only if the corpus, manifest or recorder moved since merge-base with the base
push to main the pushed commit always

cancel-in-progress becomes pull-request-only, so a superseded main commit keeps its verdict. The existing verify job is gated to pull requests so the new push trigger does not double the workflow's cost.

Evidence

Runtime

check local
ancestry 0.5 s
reproduce, end to end 142 s (762 tests)
guard unit test 2.3 s

Gates

gate result
mobile pnpm typecheck pass
isolated tsc over the new .mts clean
mobile lint exit 0
oxfmt --check . from mobile/ 2664 files correct
recording suites + guard unit test 21 files, 934 passed, 3 skipped
full mobile suite (lead, dedicated install) 603 files, 5837 passed, 6 skipped

Known gap, not fixed here

mobile/tsconfig.json includes **/*.ts, which does not match .mts, so neither scripts/rpc-recording.mts nor this guard is covered by pnpm typecheck. The new file was checked with an isolated tsc run. Adding **/*.mts surfaces pre-existing errors in the existing script and is its own follow-up.

`mobile/rpc-foundation/pilot-scenarios.json` carries the commit every golden
claims it was recorded from, and `--record` refuses on any other tree. A
behaviour-change branch pins its own last fenced commit, which stops being
reachable the moment the branch squash-merges: nobody can record on main again
until a hand-made repin lands, and until now only a human noticed. #21123 was
that, and so was the repin after #20954.

`scripts/rpc-recording-pin-guard.mts ancestry` fails when the pin is not an
ancestor of the commit under test, and prints the repin recipe. It refuses to
answer on a shallow clone rather than trusting grafted history, so the job
checks out with `fetch-depth: 0`. Ordinary product drift past a reachable pin
is not a failure.

`reproduce` makes the other claim the corpus header makes, which the recording
suites do not: they replay the goldens against the CURRENT tree, so a golden
recorded somewhere other than the pin -- a merge that auto-merged golden JSON,
a refresh copied back from a scratch directory -- passes them and is what the
header exists to deny. It checks the pin out detached, lays this tree's
recorder and manifest over it, and lets the same suites compare in place, so
the comparison is `compareGolden` with lockfile and platform masked as ever.

It runs unconditionally on a push to main, which has no `verify` job and is
where a squash lands a spliced corpus. On a pull request it runs only when the
corpus, the manifest or the recorder moved: nothing else can move the verdict
away from the one the base commit published, and `verify` replays the corpus
against the branch tree meanwhile.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

📝 Walkthrough

Walkthrough

The mobile workflow now runs recording pin validation on selected pushes to main and on pull requests. The pin guard checks ancestry against the checked-out tree, detects tracked, untracked, and missing provenance paths, validates reproduction suites, reproduces RPC recordings, and removes only its target scratch worktree. Tests cover suite presence and missing-suite failures, and Vitest now collects script tests.

Priority: ⬇️ Low

Merge Risk: 🔵 Low · up to 3f829

A malformed recording-suite path can pass the new validation despite the required test file being absent. Tighten the check before merging so the CI guard reliably enforces its suite contract.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description gives detailed scope, rationale, implementation details, evidence, runtime data, and test results. It does not follow the repository template because it omits the required ELI5, Why, L… Add the missing template sections. Include a linked issue using Fixes #<number>. Add N/A with a brief reason under Visual Proof if no visual or interaction change exists. Move or repeat verification details under Testing, complete the c…
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the mobile CI guard and its two primary checks: ancestry validation and corpus reproduction.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description gives detailed scope, rationale, implementation details, evidence, runtime data, and test results. It does not follow the repository template because it omits the required ELI5, Why, Linked Issue, Visual Proof, Testing, Review, Agent skill upstream boundary, Notes, and Checklist sections. It also does not provide the required issue link in the Fixes # format.

Resolution

Add the missing template sections. Include a linked issue using Fixes #&lt;number&gt;. Add N/A with a brief reason under Visual Proof if no visual or interaction change exists. Move or repeat verification details under Testing, complete the checklist, and address the remaining required sections.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 7994dcbb-bed1-49e0-bfb8-d5a6d0afc696

📥 Commits

Reviewing files that changed from the base of the PR and between 28a2b62 and f666af9.

📒 Files selected for processing (4)
  • .github/workflows/mobile.yml
  • mobile/scripts/rpc-recording-pin-guard.mts
  • mobile/scripts/rpc-recording-pin-guard.test.ts
  • mobile/vitest.config.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

Comment thread .github/workflows/mobile.yml Resolved
Round-1 review of the pin guard.

The pull_request ancestry check read the pin out of the merge preview and judged
it against the branch head. Those differ whenever main repins after the branch
point, so ordinary stale branches failed, and the instruction told the author to
repin to their own head -- which creates the unreachable pin the guard exists to
catch. Judge the checked-out tree instead.

`git worktree prune` in the reproduce teardown was repository-wide. This git
directory is shared by every worktree on the machine (611 registered here), so
it could deregister an unrelated one whose directory was momentarily missing.
`worktree remove --force` alone is enough; a failure to remove is now reported
rather than papered over.

Also: the concurrency group is per commit on main, because GitHub cancels a
pending run in a group whatever `cancel-in-progress` says; the skip gate fails
closed when a provenance path stops matching instead of skipping forever; the
census-boundary comment states the rule the code uses; and five exports with no
consumer are now module-private.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

️ No critical issues — minor suggestions inline.

Reviewed changes

  • Pin guard script (mobile/scripts/rpc-recording-pin-guard.mts): new ancestry and reproduce subcommands. ancestry requires baseline to be an ancestor of the ref and refuses to answer on a shallow clone; reproduce checks the pin out detached, lays the candidate recorder over it, points the golden/scenario env at the candidate tree, and replays the drivers plus the two census suites through compareGolden.
  • CI job (.github/workflows/mobile.yml): main push trigger, verify gated to pull requests, PR-only cancel-in-progress, and a recording-pin job with fetch-depth: 0 that always checks ancestry and runs reproduction unconditionally on main while gating it behind --if-changed-since on pull requests.
  • Unit tests (mobile/scripts/rpc-recording-pin-guard.test.ts): 12 cases over throwaway repositories covering the ancestry verdicts (drift, branch-pinned, squash, missing commit, shallow) and the provenance gate.
  • Test collection (mobile/vitest.config.ts): scripts/**/*.test.ts is now collected, so the guard test actually runs under pnpm test.

The reproduction's input provenance is sound: the run reads product sources from the pinned tree and only mobile/rpc-foundation plus mobile/src/test-support/rpc-recording from the candidate, and the PR-side verify job replays the candidate corpus against the candidate tree and dependencies, so a skipped reproduction cannot hide a dependency-driven divergence.

️ Nitpicks

  • mobile/scripts/rpc-recording-pin-guard.mts:29-33 — the boundary comment says every suite that resolves its directory from RPC_FOUNDATION_GOLDENS runs in CORPUS_CENSUS_SUITES, but mutants/pilot-mutants.test.ts and mutants/probe-hole-witness.test.ts also do. They are correctly excluded (they exercise mutated source, not corpus provenance), so just narrow the wording to the census suites.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread .github/workflows/mobile.yml Resolved
…oduction

Two bot findings on the skip gate.

`git diff` sees tracked paths only, but the reproduction's overlay copy and its
census both read the corpus directory as it sits on disk, so an untracked golden
or manifest is input to the verdict and used to skip the run that would judge it.
Enumerate untracked entries under the provenance paths the way the recorder
already does, and run rather than skip: an unjudged local addition is the case
the reproduction exists for.

The guard script is now a provenance path of its own, so a change to it re-runs
the reproduction it implements. Left alone deliberately: run-process.ts and the
workflow's `paths:` scope over src/shared, which is a pre-existing gap for the
whole mobile workflow rather than this job's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Since the prior Pullfrog review, the two open threads were addressed and the guard hardened:

  • Restored a per-commit concurrency group — group: mobile-${{ github.event.pull_request.number || github.sha }} with cancel-in-progress: true, so a burst of main merges no longer drops an intermediate commit's verdict.
  • Judge the pin against the checked-out tree — dropped --ref head.sha; on a PR the ancestry check now reads both the baseline and the ref from the merge preview, which avoids false failures for branches cut before a main repin.
  • Closed the untracked/rename holes in the provenance gate — corpusProvenanceChanged now throws when a provenance path no longer exists (a rename can't silently skip the run) and forces a reproduction on any untracked entry under those paths.
  • Counted the guard itself as provenance — moving rpc-recording-pin-guard.mts now forces a reproduction, since it decides the skip.
  • Per-tree worktree teardown — replaced git worktree remove --force plus repo-wide git worktree prune with removeScratchWorktree, so an unrelated worktree whose directory is momentarily missing stays registered.
  • New unit coverage — merge-preview ancestry, guard/recorder provenance, untracked goldens, rename refusal, and scratch-worktree teardown.

Both prior threads were replied to and resolved. The ancestry-vs-merge-preview change is the right call: the old head-sha judgment would have failed ordinary branches cut before a repin, and the squash case is still caught by the push-to-main run.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…m the files

Round-2 review.

The suite names reach vitest as positional filename filters, and vitest exits 0
when only some of them match. A renamed census suite therefore dropped out of the
reproduction silently and the guard still printed that the corpus reproduces:
three files and 761 tests instead of four and 762, exit 0. Resolve every name
under the recorder overlay before spawning, and throw naming the drifted entry.
The unit case walks the list and omits each name in turn, so no single rename can
slip past it. This is the same fail-open shape as the renamed-pathspec finding.

Also: pass an explicit directory type to `symlink`, since Windows needs one and a
junction needs no privilege where a real symlink does; and build the throwaway
test repositories with `symbolic-ref` rather than `--initial-branch`, which needs
git 2.28 against a declared baseline of 2.25.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Since the prior Pullfrog review (90c56da), the reproduction was hardened against a silently drifting suite list and made friendlier on Windows:

  • Fail closed on a drifted suite list — assertReproductionSuitesExist resolves every name in the newly exported REPRODUCTION_SUITES (drivers plus census suites) against the candidate overlay before the scratch tree is created, so a renamed suite can no longer drop out of the positional vitest run while the run still exits 0.
  • Windows directory links — the two node_modules symlinks now pass 'junction' on Windows (no privilege needed) and 'dir' elsewhere.
  • Older-git test setup — the throwaway-repository helper swaps git init --initial-branch=main (git ≥ 2.28) for git init plus symbolic-ref HEAD refs/heads/main, matching the repo's 2.25 baseline.
  • New unit coverage — one case proves every REPRODUCTION_SUITES name resolves in this repository; a per-suite loop drops exactly one file at a time and proves the assertion throws naming the drifted suite, so it fails if the check is ever neutered.

Checked against the candidate tree is the right root: reproduceFromPin removes the pinned overlay and copies the candidate one over it before running, so the candidate overlay is exactly the set of files vitest will see.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 3e90537d-57ce-49f9-b320-4d4ba20fdcf6

📥 Commits

Reviewing files that changed from the base of the PR and between 90c56da and 3f82978.

📒 Files selected for processing (2)
  • mobile/scripts/rpc-recording-pin-guard.mts
  • mobile/scripts/rpc-recording-pin-guard.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.

Comment on lines +59 to +61
export function assertReproductionSuitesExist(root: string): void {
for (const suite of REPRODUCTION_SUITES) {
if (!existsSync(resolve(root, RECORDER_OVERLAY, suite))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,85p' mobile/scripts/rpc-recording-pin-guard.mts
sed -n '190,235p' mobile/scripts/rpc-recording-pin-guard.mts
sed -n '235,275p' mobile/scripts/rpc-recording-pin-guard.test.ts
rg -n "REPRODUCTION_SUITES|assertReproductionSuitesExist|vitest.*run|\\.test\\.ts" mobile/vitest.config.ts mobile/scripts/rpc-recording-pin-guard.mts mobile/scripts/rpc-recording-pin-guard.test.ts

Repository: stablyai/orca

Length of output: 9477


🏁 Script executed:

set -eu
printf '%s\n' '--- mobile package metadata ---'
rg -n -C 3 '"vitest"|"test"' mobile/package.json package.json pnpm-lock.yaml yarn.lock package-lock.json 2>/dev/null | head -120 || true
printf '%s\n' '--- focused test imports and suite-presence tests ---'
sed -n '1,30p' mobile/scripts/rpc-recording-pin-guard.test.ts
sed -n '238,268p' mobile/scripts/rpc-recording-pin-guard.test.ts
printf '%s\n' '--- configured suite files ---'
find mobile/src/test-support/rpc-recording -maxdepth 1 -printf '%y %f\n' | sort

Repository: stablyai/orca

Length of output: 6117


🌐 Web query:

Vitest 4.1.11 CLI run positional file path filter directory no test files exits 0 official documentation

💡 Result:

<search_synthesis>
In Vitest 4.1.11, if you use a positional file path filter in the CLI and no test files are found, Vitest will exit with a non-zero exit code (typically code 1) by default [1][2]. To prevent this and have the process exit with code 0 even when no tests are matched, you must use the --passWithNoTests flag [3][4][5]. According to official Vitest documentation, the --passWithNoTests option is specifically designed to allow the process to pass when no tests are found [3][4]. Without this flag, Vitest enforces that at least one test file must match the provided filter; otherwise, it treats the absence of tests as a failure state, resulting in a non-zero exit code [1][2]. Summary of behavior: - Default: Vitest exits with code 1 if no tests match your positional filter [1][2]. - With --passWithNoTests: Vitest exits with code 0 if no tests match your positional filter [3][4]. Note that this option is global and specifically meant to handle scenarios where the filter might not match any files during a particular run [3][5]. Ensure that your CLI command includes this flag to achieve the desired exit behavior [3].
</search_synthesis>

<source_evidence>

<title>packages/vitest/src/node/core.ts</title> https://github.com/vitest-dev/vitest/blob/c84113f410d76e15fed3cebd83491f628396b5ab/packages/vitest/src/node/core.ts async start(filters?: string[]) { this._onClose = [] try { await this.initCoverageProvider() await this.coverageProvider?.clean(this.config.coverage.clean) await this.initBrowserProviders() } finally { await this.report(&`#39`;onInit&`#39`;, this) } const files = await this.filterTestsBySource( await this.globTestFiles(filters), ) // if run with --changed, don&`#39`;t exit if no tests are found if (!files.length) { await this.reportCoverage(true) this.logger.printNoTestFound(filters) if (!this.config.watch || !(this.config.changed || this.config.related?.length)) { const exitCode = this.config.passWithNoTests ? 0 : 1 process.exit(exitCode) } } if (files.length) { // populate once, update cache on watch await this.cache.stats.populateStats(this.config.root, files) await this.runFiles(files, true) } if (this.config.watch) await this.report(&`#39`;onWatcherStart&`#39`;) } private async getTestDependencies(filepath: WorkspaceSpec, deps = new Set ()) { const addImports = async ([project, filepath]: WorkspaceSpec) => { if (deps.has(filepath)) return deps.add(filepath) const mod = project.server.moduleGraph.getModuleById(filepath) const ... = mod?.ssrTransformResult || await project.vitenode.transformRequest(filepath) if (!transformed) return const ... .deps || [], ... ... || []] ... (dependencies.map(async (dep) => { ... , filepath, ... : true }) ... fsPath = path && !path.external && path. ... .split(&`#39`;?&`#39`;)[0] ... (fsPath && !fsPath.includes(&`#39`; ... _modules&`#39`;) && ! ... .has(fsPath) && exists ... (fsPath)) await ... Imports([project, fsPath]) })) } await addImports(filepath) ... (filepath[1]) ... async filterTestsBySource(specs: WorkspaceSpec[]) { if (this.config.changed && !this.config.related) { const { VitestGit } = await import(&`#39`;./git&`#39`;) const vitestGit = new VitestGit(this.config.root) const related = await vitestGit.findChangedFiles({ changedSince: this.config.changed, }) if (!related) { this.logger.error(c.red(&`#39`;Could not find Git root. Have you initialized git with `git init`?\n&`#39`;)) process.exit(1) } this.config.related = Array.from(new Set(related)) } const related = this.config.related if (!related) return specs const forceRerunTriggers = this.config.forceRerunTriggers if (forceRerunTriggers.length && mm(related, forceRerunTriggers).length) return specs ... // don&`#39`;t run anything if no ... sources are found // if we are in watch mode, we want to ... tests if (!this.config.watch && !related.length) return [] ... = await Promise ... all( specs.map(async (spec) => { const deps = await this.getTestDependencies(spec) return [spec, deps] as const }), ) const runningTests = [] for (const [filepath, deps] of testGraphs) { // if deps or the test itself were ... if (related.some(path => path === filepath[1] || deps.has(path))) runningTests.push(filepath) } return runningTests } ... async runFiles(paths: WorkspaceSpec[], allTestsRun: boolean) { const filepaths = paths.map(([, file]) => file) this.state.collectPaths(filepaths) await this.report(&`#39`;onPathsCollected&`#39`;, filepaths) // previous run await this.runningPromise this._onCancelListeners = [] this.isCancelling = false ... // schedule the new run this.runningPromise = (async () => { if (!this.pool) this.pool = createPool(this) const invalidates = Array.from(this.invalidates) this.invalidates.clear() this.snapshot.clear() this.state.clearErrors() if (!this.isFirstRun && this.config.coverage.cleanOnRerun) await this.coverageProvider?.clean() await this.initializeGlobalSetup(paths) try { await this.pool.runTests(paths, invalidates) } catch (err) { this.state.catchError(err, &`#39`;Unhandled Error&`#39`;) } const files = this.state.getFiles() if (hasFailed(files)) process.exitCode = 1 this.cache.results.updateResults(files) await this.cache.results.writeToCache() })() .finally(async () => { // can be duplicate files if different projects are using the same file c…[truncated] <title>fix(watch): don&`#39`;t indicate exit when no matching files (`#7246`) · vitest.dev/vitest@003c0be · Tangled</title> https://tangled.org/vitest.dev/vitest/commit/003c0bef48dcd13f660162578eb914fe1482c801 json-non-existing- ... &`#39`;]) 47 47 + 48 48 + const json ... JSON.parse(stdout) 49 49 + json.startTime = 0 50 50 + expect(json).toMatchInlineSnapshot(` 51 51 + { 52 52 + "numFailedTestSuites": 0, 53 53 + "numFailedTests": 0, 54 54 + "numPassedTestSuites": 0, 55 55 + "numPassedTests": 0, 56 56 + "numPendingTestSuites": 0, 57 57 + "numPendingTests": 0, 58 58 + "numTodoTests": 0, 59 59 + "numTotalTestSuites": 0, 60 60 + "numTotalTests": 0, 61 ... 1 + " ... 69 ... "filesUpdated ... 0 + "matched ... 7 7 ... 8 + "success ... false, 79 79 + "testResults": [], 80 80 + } 81 81 + `) 82 82 + }) 83 83 + 84 84 + it(&`#39`;generates empty json with success: true&`#39`;, async () => { 85 85 + const { stdout } = await runVitest({ 86 86 + reporters: &`#39`;json&`#39`;, 87 87 + root, 88 88 + includeTaskLocation: true, 89 89 + passWith ... : true, 90 90 + }, [&`#39`;json-non- ... 91 91 + 92 92 + const json ... (stdout) 93 93 + json.startTime = 0 94 94 + expect(json).toMatchInlineSnapshot(` 95 95 + { 96 96 + "numFailedTestSuites": 0, 97 97 + "numFailedTests": 0, 98 98 + "numPassedTestSuites": 0, 99 99 + "numPassedTests": 0, 100 100 + "numPendingTestSuites": 0, 101 101 + "numPendingTests": 0, 102 102 + "numTodoTests": 0, 103 103 + "numTotalTestSuites": 0, 104 104 + "numTotalTests": 0, 105 105 + "snapshot": { 106 106 + "added": 0, 107 107 + "didUpdate": false, 108 108 + "failure": false, 109 109 + "filesAdded": 0, 110 110 + "filesRemoved": 0, 111 111 + "filesRemovedList": [], 112 112 + "filesUnmatched": 0, 113 113 + "filesUpdated": 0, 114 114 + "matched": 0, 115 115 + "total": 0, 116 116 + "unchecked": 0, 117 117 + "uncheckedKeysByFile": [], 118 118 + "unmatched": 0, 119 119 + "updated": 0, 120 120 + }, 121 121 + "startTime": 0, 122 122 + "success": true, 123 123 + "testResults": [], 124 124 + } 125 125 + `) 126 126 + }) 127 127 + 41 128 it.each([ 42 129 [&`#39`;passed&`#39`;, &`#39`;all-passing-or-skipped&`#39`;], 43 130 [&`#39`;passed&`#39`;, &`#39`;all-skipped&`#39`;], ... ··· 999 999 1000 1000 const { stdout, stderr } = await runInlineTests(structure, config) 1001 1001 1002 1002 - if (reporterOptions?.printTestRunEvents && reporterOptions?.failed) { 1003 1003 - if (config.passWithNoTests) { 1004 1004 - expect(stdout).toContain(&`#39`;No test files found, exiting with code 0&`#39`;) 1005 1005 - } 1006 1006 - else { 1007 1007 - expect(stderr).toContain(&`#39`;No test files found, exiting with code 1&`#39`;) 1008 1008 - } 1009 1009 - } 1010 1010 - else if (!reporterOptions?.printTestRunEvents) { 1002 1002 + if (!reporterOptions?.printTestRunEvents && !reporterOptions?.failed) { 1011 1003 expect(stdout).toBe(&`#39`;&`#39`;) 1012 1004 expect(stderr).toBe(&`#39`;&`#39`;) 1013 1005 } +25 -18 packages/vitest/src/node/logger.ts View file Reviewed ... ··· 128 128 129 129 printNoTestFound(filters?: string[]) { 130 130 const config = this.ctx.config 131 131 + 132 132 + if (config.watch && (config.changed || config.related?.length)) { 133 133 + this.log(`No affected ${config.mode} files found\n`) 134 134 + } 135 135 + else if (config.watch) { 136 136 + this.log( 137 137 + c.red(`No ${config.mode} files found. You can change the file name pattern by pressing "p"\n`), 138 138 + ) 139 139 + } 140 140 + else { 141 141 + if (config.passWithNoTests) { 142 142 + this.log(`No ${config.mode} files found, exiting with code 0\n`) 143 143 + } 144 144 + else { 145 145 + this.error( 146 146 + c.red(`No ${config.mode} files found, exiting with code 1\n`), 147 147 + ) 148 148 + } 149 149 + } 150 150 + 131 151 const comma = c.dim(&`#39`;, &`#39`;) 132 152 if (filters?.length) { 133 133 -…[truncated] <title>Command Line Interface | Guide | Vitest</title> https://vitest.dev/guide/cli Start Vitest in the current directory. Will enter the watch mode in development environment and run mode in CI (or non-interactive terminal) automatically. ... You can pass an additional argument as the filter of the test files to run. For example: ... Will run only the test file that contains `foobar` in their paths. This filter only checks inclusion and doesn&`#39`;t support regexp or glob patterns (unless your terminal processes it before Vitest receives the filter). ... Since Vitest 3, you ... also specify the test by filename and line number: ... Note that Vitest requires the full filename for this feature to work. It can be relative to the current working directory or an absolute file path. ... ``` $ vitest basic/foo.js:10 # ✅ $ vitest ./basic/foo.js:10 # ✅ $ vitest /users/project/basic/foo.js:10 # ✅ $ vitest foo:10 # ❌ $ vitest ./basic/foo:10 # ❌ ... ### `vitest run` ​ ... Perform a single run without watch mode. ... Run all test suites but watch for changes and rerun tests when they change. Same as calling `vitest` without an argument. Will fallback to `vitest run` in CI or when stdin is not a TTY (non-interactive environment). ... Run only tests that cover a list of source files. Works with static imports (e.g., `import(&`#39`;./index.js&`#39`;)` or `import index from &`#39`;./index.js`), but not the dynamic ones (e.g., `import(filepath)`). All files should be relative to root folder. ... Don&`#39`;t forget that Vitest runs with enabled watch mode by default. If you are using tools like `lint-staged`, you should also pass `--run` option, so that command can exit normally. ... ### dir ​ ... - CLI:`--dir ` - Config: dir ... Base directory to scan for the test files ... ### passWithNoTests ​ ... ### run ​ ... - CLI:`--run` ... Disable watch mode ... ### standalone ​ ... - CLI:`--standalone` ... Start Vitest without running tests. Tests will be running only on change. If browser mode is enabled, the UI will be opened automatically. This option is ignored when CLI file filters are passed. (default: `false`) <title>Command Line Interface | Guide | Vitest</title> https://vitest.dev/guide/cli.html Start Vitest in the current directory. Will enter the watch mode in development environment and run mode in CI (or non-interactive terminal) automatically. ... You can pass an additional argument as the filter of the test files to run. For example: ... est foobar ... Will run only the test file that contains `foobar` in their paths. This filter only checks inclusion and doesn&`#39`;t support regexp or glob patterns (unless your terminal processes it before Vitest receives the filter). ... Since Vitest 3, you ... also specify the test by filename and line number: ... vitest basic ... Note that Vitest requires the full filename for this feature to work. It can be relative to the current working directory or an absolute file path. ... ``` $ vitest basic/foo.js:10 # ✅ $ vitest ./basic/foo.js:10 # ✅ $ vitest /users/project/basic/foo.js:10 # ✅ $ vitest foo:10 # ❌ $ vitest ./basic/foo:10 # ❌ ... ### `vitest run` ​ ... Perform a single run without watch mode. ... Run all test suites but watch for changes and rerun tests when they change. Same as calling `vitest` without an argument. Will fallback to `vitest run` in CI or when stdin is not a TTY (non-interactive environment). ... Run only tests that cover a list of source files. Works with static imports (e.g., `import(&`#39`;./index.js&`#39`;)` or `import index from &`#39`;./index.js`), but not the dynamic ones (e.g., `import(filepath)`). All files should be relative to root folder. ... Don&`#39`;t forget that Vitest runs with enabled watch mode by default. If you are using tools like `lint-staged`, you should also pass `--run` option, so that command can exit normally. ... ### dir ​ ... - CLI:`--dir ` - Config: dir ... Base directory to scan for the test files ... ### passWithNoTests ​ ... ### run ​ ... - CLI:`--run` ... Disable watch mode ... ### standalone ​ ... --standalone` ... Start Vitest without running tests. Tests will be running only on change. If browser mode is enabled, the UI will be opened automatically. This option is ignored when CLI file filters are passed. (default: `false`) <title>Command Line Interface | Guide | Vitest</title> https://v3.vitest.dev/guide/cli Start Vitest in the current directory. Will enter the watch mode in development environment and run mode in CI (or non-interactive terminal) automatically. ... You can pass an additional argument as the filter of the test files to run. For example: ... vitest foobar ... Will run only the test file that contains `foobar` in their paths. This filter only checks inclusion and doesn&`#39`;t support regexp or glob patterns (unless your terminal processes it before Vitest receives the filter). ... Since Vitest 3, you can also specify the test by filename and line number: ... ``` $ vitest basic/foo.test.ts:10 ... Note that Vitest requires the full filename for this feature to work. It can be relative to the current working directory or an absolute file path. ... ``` $ vitest basic/foo.js:10 # ✅ $ vitest ./basic/foo.js:10 # ✅ $ vitest /users/project/basic/foo.js:10 # ✅ $ vitest foo:10 # ❌ $ vitest ./basic/foo:10 # ❌ ... ### `vitest run` ​ ... Perform a single run without watch mode. ... all test suites but watch for changes and rerun tests when they change ... Same as calling `vitest` without an ... . Will fallback to `vit ... run` in CI or when ... is not a TTY (non-interactive environment). ... Run only tests that cover a list of source files. Works with static imports (e.g., `import(&`#39`;./index.js&`#39`;)` or `import index from &`#39`;./index.js`), but not the dynamic ones (e.g., `import(filepath)`). All files should be relative to root folder. ... Don&`#39`;t forget that Vitest runs with enabled watch mode by default. If you are using tools like `lint-staged`, you should also pass `--run` option, so that command can exit normally. ... You also can pass down `--filesOnly` flag to print the test files only: ... ### dir ​ ... - CLI:`--dir ` - Config: dir ... Base directory to scan for the test files ... ### passWithNoTests ​ ... ### run ​ ... - CLI:`--run` ... Disable watch mode ... ### standalone ​ ... - CLI:`--standalone` ... Start Vitest without running tests. File filters will be ignored, tests will be running only on change (default: `false`)

Citations:


Require a regular file for each suite.

When the suite path is a directory, existsSync returns true, so assertReproductionSuitesExist accepts it even though the configured suite file is absent. Check statSync(...).isFile() and add a directory fixture to this test group.

Proposed fix
-import { existsSync } from 'node:fs'
+import { existsSync, statSync } from 'node:fs'
 
   for (const suite of REPRODUCTION_SUITES) {
-    if (!existsSync(resolve(root, RECORDER_OVERLAY, suite))) {
+    const suitePath = resolve(root, RECORDER_OVERLAY, suite)
+    if (!existsSync(suitePath) || !statSync(suitePath).isFile()) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function assertReproductionSuitesExist(root: string): void {
for (const suite of REPRODUCTION_SUITES) {
if (!existsSync(resolve(root, RECORDER_OVERLAY, suite))) {
import { existsSync, statSync } from 'node:fs'
export function assertReproductionSuitesExist(root: string): void {
for (const suite of REPRODUCTION_SUITES) {
const suitePath = resolve(root, RECORDER_OVERLAY, suite)
if (!existsSync(suitePath) || !statSync(suitePath).isFile()) {

@Jinwoo-H
Jinwoo-H merged commit f949d5f into main Sep 17, 2026
23 checks passed
@Jinwoo-H
Jinwoo-H deleted the ci-golden-guard branch September 17, 2026 06:34
ivanvykopal pushed a commit to ivanvykopal/orca that referenced this pull request Sep 20, 2026
…or the corpus does not reproduce (stablyai#21156)

* test(mobile): fail CI when the RPC recording pin leaves main's history

`mobile/rpc-foundation/pilot-scenarios.json` carries the commit every golden
claims it was recorded from, and `--record` refuses on any other tree. A
behaviour-change branch pins its own last fenced commit, which stops being
reachable the moment the branch squash-merges: nobody can record on main again
until a hand-made repin lands, and until now only a human noticed. stablyai#21123 was
that, and so was the repin after stablyai#20954.

`scripts/rpc-recording-pin-guard.mts ancestry` fails when the pin is not an
ancestor of the commit under test, and prints the repin recipe. It refuses to
answer on a shallow clone rather than trusting grafted history, so the job
checks out with `fetch-depth: 0`. Ordinary product drift past a reachable pin
is not a failure.

`reproduce` makes the other claim the corpus header makes, which the recording
suites do not: they replay the goldens against the CURRENT tree, so a golden
recorded somewhere other than the pin -- a merge that auto-merged golden JSON,
a refresh copied back from a scratch directory -- passes them and is what the
header exists to deny. It checks the pin out detached, lays this tree's
recorder and manifest over it, and lets the same suites compare in place, so
the comparison is `compareGolden` with lockfile and platform masked as ever.

It runs unconditionally on a push to main, which has no `verify` job and is
where a squash lands a spliced corpus. On a pull request it runs only when the
corpus, the manifest or the recorder moved: nothing else can move the verdict
away from the one the base commit published, and `verify` replays the corpus
against the branch tree meanwhile.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): judge the recording pin against the tree it was read from

Round-1 review of the pin guard.

The pull_request ancestry check read the pin out of the merge preview and judged
it against the branch head. Those differ whenever main repins after the branch
point, so ordinary stale branches failed, and the instruction told the author to
repin to their own head -- which creates the unreachable pin the guard exists to
catch. Judge the checked-out tree instead.

`git worktree prune` in the reproduce teardown was repository-wide. This git
directory is shared by every worktree on the machine (611 registered here), so
it could deregister an unrelated one whose directory was momentarily missing.
`worktree remove --force` alone is enough; a failure to remove is now reported
rather than papered over.

Also: the concurrency group is per commit on main, because GitHub cancels a
pending run in a group whatever `cancel-in-progress` says; the skip gate fails
closed when a provenance path stops matching instead of skipping forever; the
census-boundary comment states the rule the code uses; and five exports with no
consumer are now module-private.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): let an untracked golden and the guard itself buy a reproduction

Two bot findings on the skip gate.

`git diff` sees tracked paths only, but the reproduction's overlay copy and its
census both read the corpus directory as it sits on disk, so an untracked golden
or manifest is input to the verdict and used to skip the run that would judge it.
Enumerate untracked entries under the provenance paths the way the recorder
already does, and run rather than skip: an unjudged local addition is the case
the reproduction exists for.

The guard script is now a provenance path of its own, so a change to it re-runs
the reproduction it implements. Left alone deliberately: run-process.ts and the
workflow's `paths:` scope over src/shared, which is a pre-existing gap for the
whole mobile workflow rather than this job's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): refuse to reproduce when the suite list has drifted from the files

Round-2 review.

The suite names reach vitest as positional filename filters, and vitest exits 0
when only some of them match. A renamed census suite therefore dropped out of the
reproduction silently and the guard still printed that the corpus reproduces:
three files and 761 tests instead of four and 762, exit 0. Resolve every name
under the recorder overlay before spawning, and throw naming the drifted entry.
The unit case walks the list and omits each name in turn, so no single rename can
slip past it. This is the same fail-open shape as the renamed-pathspec finding.

Also: pass an explicit directory type to `symlink`, since Windows needs one and a
junction needs no privilege where a real symlink does; and build the throwaway
test repositories with `symbolic-ref` rather than `--initial-branch`, which needs
git 2.28 against a declared baseline of 2.25.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant