From 5a3f155f6bb47162162d3dda933b6d72ed227f91 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Sat, 17 Oct 2026 12:49:59 -0700 Subject: [PATCH 1/2] fix(T13874): minCount reads the JSON report vitest 5 announces vitest 5 with --reporter=json and no outputFile writes .vitest/json/output.json under its own root (the child's cwd) and prints 'JSON report written to '. Behind pnpm --filter exec that is the package, not the project root, so every minCount gate failed with 'emitted no machine-readable test report' (VidaPeps T2018-T2022, T781). The gate now also reads the path the run announces, inside the project only, under the same freshness rules (mtime and startTime postdate the spawn). Co-Authored-By: Claude Opus 5.5 --- .changeset/vitest5-announced-report.md | 17 ++++++++ .../src/tasks/__tests__/gate-runner.test.ts | 43 +++++++++++++++++++ packages/core/src/tasks/gate-runner.ts | 29 +++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 .changeset/vitest5-announced-report.md diff --git a/.changeset/vitest5-announced-report.md b/.changeset/vitest5-announced-report.md new file mode 100644 index 0000000000..754e09c865 --- /dev/null +++ b/.changeset/vitest5-announced-report.md @@ -0,0 +1,17 @@ +--- +id: vitest5-announced-report +tasks: [T13874] +kind: fix +summary: verify --run proves minCount from the JSON report vitest 5 writes under the package it ran in +--- + +With `--reporter=json` and no `outputFile`, vitest 5 writes its report to +`.vitest/json/output.json` under its own root, which is the child's cwd, and +prints `JSON report written to `. Behind `pnpm --filter exec` that +root is the package, so the gate looked at the project root, found nothing, +and failed every `minCount` gate with "emitted no machine-readable test report" +(VidaPeps T2018 to T2022 and T781). + +The gate now also reads the report path the run announces on stdout, as long +as it is inside the project. That report still has to postdate the spawn: +both its mtime and its `startTime` must be at or after the run started. diff --git a/packages/core/src/tasks/__tests__/gate-runner.test.ts b/packages/core/src/tasks/__tests__/gate-runner.test.ts index 7ef201cb63..2893ce5f71 100644 --- a/packages/core/src/tasks/__tests__/gate-runner.test.ts +++ b/packages/core/src/tasks/__tests__/gate-runner.test.ts @@ -961,6 +961,49 @@ describe('minCount reads a JSON report file this run wrote (T13782)', () => { }); expect(result?.result).toBe('error'); }); + + /** + * vitest 5 behind `pnpm --filter exec`: the report lands under the + * package (the child's cwd), and vitest names it on stdout (T13874). + */ + const fromPackage = (dir: string, startTime: string) => + `const p=require('node:path');process.chdir(${JSON.stringify(dir)});` + + `const t=p.resolve(${JSON.stringify(rel)});${writer('t', startTime)}` + + `console.log('JSON report written to ' + t);`; + + it('reads the report vitest wrote under the package it ran in (T13874)', async () => { + const pkg = join(projectRoot, 'lib', 'db'); + await mkdir(pkg, { recursive: true }); + try { + const [result] = await runGates([gate(fromPackage(pkg, 'Date.now()'))], { projectRoot }); + expect(result?.result, result?.errorMessage).toBe('pass'); + } finally { + await rm(join(projectRoot, 'lib'), { recursive: true, force: true }); + } + }); + + it('red: an announced report from before the run is refused (T13874)', async () => { + const pkg = join(projectRoot, 'lib', 'db'); + await mkdir(pkg, { recursive: true }); + try { + const [result] = await runGates([gate(fromPackage(pkg, 'Date.now() - 60000'))], { + projectRoot, + }); + expect(result?.result).toBe('error'); + } finally { + await rm(join(projectRoot, 'lib'), { recursive: true, force: true }); + } + }); + + it('red: an announced report outside the project is never read (T13874)', async () => { + const outside = await mkdtemp(join(tmpdir(), 'cleo-gate-outside-')); + try { + const [result] = await runGates([gate(fromPackage(outside, 'Date.now()'))], { projectRoot }); + expect(result?.result).toBe('error'); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); }); describe('gate runner immutable lifetime and bounded evidence', () => { diff --git a/packages/core/src/tasks/gate-runner.ts b/packages/core/src/tasks/gate-runner.ts index d05cfaa31c..c4849b2177 100644 --- a/packages/core/src/tasks/gate-runner.ts +++ b/packages/core/src/tasks/gate-runner.ts @@ -1813,6 +1813,31 @@ function structuredTestCounts(captured: ProcessCaptureResult): StructuredTestCou */ const REDIRECTED_JSON_REPORT = join('.vitest', 'json', 'output.json'); +/** + * The line vitest 5 prints after writing its JSON report to a file. With no + * `outputFile` it writes `.vitest/json/output.json` under its own root, which + * is the child's cwd (`pnpm --filter exec vitest` runs in the package), + * not the gate's (T13874). + */ +const REPORT_WRITTEN_LINE = /^JSON report written to (.+?)\s*$/gm; + +/** + * Report files the run's own output says it wrote (T13874), inside the + * project only: a path the child names outside the project is never read. + * + * @param stdout - The run's captured stdout. + * @param projectRoot - The project the gate belongs to. + * @returns Absolute report paths, in output order. + * @task T13874 + */ +function announcedReportFiles(stdout: string, projectRoot: string): string[] { + return [...stdout.matchAll(REPORT_WRITTEN_LINE)].flatMap((m) => { + const path = resolve(projectRoot, m[1]!); + const rel = relative(projectRoot, path); + return rel.startsWith('..') || isAbsolute(rel) ? [] : [path]; + }); +} + /** * Report files a gate's JSON reporter may write instead of stdout (T13782): * every `--outputFile[.json]` the gate names, and {@link REDIRECTED_JSON_REPORT}. @@ -1971,6 +1996,10 @@ async function runTestGate( ); // T13752: a structured report decides the verdict; the text heuristic only // runs without one, since report JSON and test names are full of "failure". + // T13874: a path the run announced has no pre-spawn stamp; mtime and the + // report's startTime still have to postdate the spawn. + for (const path of announcedReportFiles(captured.stdout, projectRoot)) + if (!before.has(path)) before.set(path, null); const counts = structuredTestCounts(captured) ?? (await freshReportCounts(before, startedAt)); // T12308: the counts come from this run's own validated report; only their From e730ff6efe3eaa8b48f7797fc9dc03eff9518057 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Sat, 17 Oct 2026 12:54:43 -0700 Subject: [PATCH 2/2] fix(T13883): announced report is realpathed and only the last announcement counts Containment was lexical while stat/readFile follow symlinks, so an in-project symlink to a fresh file outside the project was read. Both the announced path and the project root are now realpathed first. Only the last 'JSON report written to' line counts (vitest prints it at the end), so a test's earlier fabricated line cannot stand in for it. Also fixes the T13874 fake runner, which redeclared p/t and could not run. Co-Authored-By: Claude Opus 5.5 --- .../src/tasks/__tests__/gate-runner.test.ts | 44 +++++++++++++++++-- packages/core/src/tasks/gate-runner.ts | 33 +++++++++----- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/core/src/tasks/__tests__/gate-runner.test.ts b/packages/core/src/tasks/__tests__/gate-runner.test.ts index 2893ce5f71..df17f850c4 100644 --- a/packages/core/src/tasks/__tests__/gate-runner.test.ts +++ b/packages/core/src/tasks/__tests__/gate-runner.test.ts @@ -10,7 +10,7 @@ */ import { createHash } from 'node:crypto'; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import type { @@ -967,8 +967,8 @@ describe('minCount reads a JSON report file this run wrote (T13782)', () => { * package (the child's cwd), and vitest names it on stdout (T13874). */ const fromPackage = (dir: string, startTime: string) => - `const p=require('node:path');process.chdir(${JSON.stringify(dir)});` + - `const t=p.resolve(${JSON.stringify(rel)});${writer('t', startTime)}` + + `process.chdir(${JSON.stringify(dir)});` + + writer(`require('node:path').resolve(${JSON.stringify(rel)})`, startTime) + `console.log('JSON report written to ' + t);`; it('reads the report vitest wrote under the package it ran in (T13874)', async () => { @@ -995,6 +995,44 @@ describe('minCount reads a JSON report file this run wrote (T13782)', () => { } }); + it('red: an in-project symlink to a fresh report outside is never read (T13883)', async () => { + const outside = await mkdtemp(join(tmpdir(), 'cleo-gate-outside-')); + const link = join(projectRoot, 'lib', 'db', rel); + await mkdir(dirname(link), { recursive: true }); + await symlink(join(outside, 'output.json'), link); + try { + const source = + writer(JSON.stringify(join(outside, 'output.json')), 'Date.now()') + + `console.log('JSON report written to ' + ${JSON.stringify(link)});`; + const [result] = await runGates([gate(source)], { projectRoot }); + expect(result?.result).toBe('error'); + } finally { + await rm(join(projectRoot, 'lib'), { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + + it('red: an earlier fabricated announcement loses to the last one (T13883)', async () => { + const pkg = join(projectRoot, 'lib', 'db'); + await mkdir(pkg, { recursive: true }); + const fake = join(projectRoot, 'fake-report.json'); + // The fabricated report proves 3; vitest's own (announced last) proves 1. + const source = + writer(JSON.stringify(fake), 'Date.now()') + + `console.log('JSON report written to ' + ${JSON.stringify(fake)});` + + `const real=${JSON.stringify(join(pkg, rel))};` + + `fs.mkdirSync(p.dirname(real),{recursive:true});` + + `fs.writeFileSync(real, JSON.stringify(Object.assign(${report(1, 0)},{startTime:Date.now()})));` + + `console.log('JSON report written to ' + real);`; + try { + const [result] = await runGates([gate(source)], { projectRoot }); + expect(result?.result).toBe('fail'); + } finally { + await rm(join(projectRoot, 'lib'), { recursive: true, force: true }); + await rm(fake, { force: true }); + } + }); + it('red: an announced report outside the project is never read (T13874)', async () => { const outside = await mkdtemp(join(tmpdir(), 'cleo-gate-outside-')); try { diff --git a/packages/core/src/tasks/gate-runner.ts b/packages/core/src/tasks/gate-runner.ts index c4849b2177..8a1a35a701 100644 --- a/packages/core/src/tasks/gate-runner.ts +++ b/packages/core/src/tasks/gate-runner.ts @@ -1822,20 +1822,31 @@ const REDIRECTED_JSON_REPORT = join('.vitest', 'json', 'output.json'); const REPORT_WRITTEN_LINE = /^JSON report written to (.+?)\s*$/gm; /** - * Report files the run's own output says it wrote (T13874), inside the - * project only: a path the child names outside the project is never read. + * The report file the run's own output says it wrote (T13874), or null. Only + * the LAST announcement counts: vitest prints its line at the end, so an + * earlier one a test printed cannot stand in for it. The path is realpathed + * before the containment check, so a symlink inside the project that points + * outside it is refused. * * @param stdout - The run's captured stdout. * @param projectRoot - The project the gate belongs to. - * @returns Absolute report paths, in output order. + * @returns The announced report's real path inside the project, or null. * @task T13874 + * @task T13883 */ -function announcedReportFiles(stdout: string, projectRoot: string): string[] { - return [...stdout.matchAll(REPORT_WRITTEN_LINE)].flatMap((m) => { - const path = resolve(projectRoot, m[1]!); - const rel = relative(projectRoot, path); - return rel.startsWith('..') || isAbsolute(rel) ? [] : [path]; - }); +async function announcedReportFile(stdout: string, projectRoot: string): Promise { + const announced = [...stdout.matchAll(REPORT_WRITTEN_LINE)].at(-1)?.[1]; + if (announced === undefined) return null; + try { + const [root, path] = await Promise.all([ + realpath(projectRoot), + realpath(resolve(projectRoot, announced)), + ]); + const rel = relative(root, path); + return rel.startsWith('..') || isAbsolute(rel) ? null : path; + } catch { + return null; // missing or unreadable: not a report of this run + } } /** @@ -1998,8 +2009,8 @@ async function runTestGate( // runs without one, since report JSON and test names are full of "failure". // T13874: a path the run announced has no pre-spawn stamp; mtime and the // report's startTime still have to postdate the spawn. - for (const path of announcedReportFiles(captured.stdout, projectRoot)) - if (!before.has(path)) before.set(path, null); + const announced = await announcedReportFile(captured.stdout, projectRoot); + if (announced !== null && !before.has(announced)) before.set(announced, null); const counts = structuredTestCounts(captured) ?? (await freshReportCounts(before, startedAt)); // T12308: the counts come from this run's own validated report; only their