From 8f13c90d88c3916afc3b6c19d1b032142ee90fb7 Mon Sep 17 00:00:00 2001 From: objectstack-dev Date: Sun, 13 Sep 2026 07:40:42 +0000 Subject: [PATCH 1/4] fix(cli): report a vitest filter that selected no test file `vitest run` drops a positional filter that matches nothing without any diagnostic, as long as some other filter matched: five paths in, four counted, the discarded name printed nowhere. The output is byte-identical to the run that named only the four, so it is indistinguishable from "ran and passed". Wire a reporter that reads vitest's own resolved filters and collected specifications and names every path that selected nothing, with the tier it lives in and a command that runs it. It writes zero bytes when nothing was lost. Rewrite the tier comment block, which printed its "nothing is skipped" reassurance three lines above the two commands that can lose things. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- .../vitest-project-filter-preflight.test.ts | 157 +++++++++++ packages/cli/tsconfig.test.json | 13 +- packages/cli/vitest-filter-preflight.ts | 266 ++++++++++++++++++ packages/cli/vitest.config.ts | 60 +++- 4 files changed, 488 insertions(+), 8 deletions(-) create mode 100644 packages/cli/test/vitest-project-filter-preflight.test.ts create mode 100644 packages/cli/vitest-filter-preflight.ts diff --git a/packages/cli/test/vitest-project-filter-preflight.test.ts b/packages/cli/test/vitest-project-filter-preflight.test.ts new file mode 100644 index 0000000000..056fe30477 --- /dev/null +++ b/packages/cli/test/vitest-project-filter-preflight.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A named path that selects no test file is reported, and a run that loses + * nothing is byte-identical (#17853). + * + * `../vitest-filter-preflight.ts` carries the mechanism, the vitest readings + * and the reason the reading is taken from a reporter rather than from + * `process.argv`. What is pinned HERE is the pair of directions the ruling on + * this card made non-negotiable, plus the wiring, and the three are pinned + * separately because they fail separately: + * + * 1. **LOST IS LOUD.** A filter that selected nothing is returned by + * `lostFilters`, attributed to the tier it really lives in, and rendered + * into a notice that names the path, that tier and a command that runs it. + * ⛔ Asserting only that the notice is non-empty would pass on a notice + * that says nothing useful, so each of the three is asserted by name. + * + * 2. **HEALTHY IS SILENT — the half that makes this accurate instead of merely + * loud.** `renderLostFilterNotice` returns the EMPTY STRING whenever no + * filter was lost, and the reporter writes only what that function returns. + * Zero bytes is the whole contract: a narrowed run that loses nothing must + * read exactly as it read before this module existed. + * + * 3. **THE WIRING.** A preflight that nobody registered is a phantom check — + * it evaluates never, and deleting it leaves every assertion in this file + * just as green. So the config's own source is read, comments masked, and + * the registration asserted in CODE position. This is the same instrument + * `vitest-tiers-partition.test.ts` uses on the same file for the same + * reason. + * + * ⚠️ What this file deliberately does NOT do is spawn vitest. An end-to-end + * pin would have to run vitest inside vitest, which the tier predicate + * classifies as `integration` (SPAWN) — a permanently expensive file in the + * tier this very card is not authorised to reshape. The end-to-end reading is + * taken once, by hand, and recorded in the PR that landed this; case 3 is what + * keeps the unit-level pins from going phantom in the meantime. + * + * Runs in the `unit` tier: pure functions plus one source read, no child + * process and no kernel. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { maskComments } from '../../../scripts/js-comment-mask.mjs'; +import { + lostFilters, + matchesVitestFilter, + renderLostFilterNotice, +} from '../vitest-filter-preflight.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PKG = resolve(HERE, '..'); + +const UNIT = ['src/utils/format.exit-code.test.ts', 'test/commands.test.ts']; +const INTEGRATION = ['test/i18n-extract-companion-orphan.test.ts']; +const POPULATIONS = { unit: UNIT, integration: INTEGRATION }; +const abs = (rel: string): string => resolve(PKG, rel); + +describe('direction ① — a filter that selected nothing is reported by name', () => { + // The exact shape #16872 delivered on: several unit-tier paths that DID + // match, one integration-tier path that did not, and `--project unit`. + const collected = UNIT.map(abs); + const filters = [...UNIT, ...INTEGRATION]; + const lost = lostFilters(filters, collected, POPULATIONS, PKG); + + it('returns exactly the filter that selected nothing', () => { + expect(lost.map((l) => l.filter)).toEqual(INTEGRATION); + }); + + it('attributes it to the tier it actually lives in', () => { + expect(lost[0]?.foundIn).toEqual(['integration']); + }); + + it('renders a notice naming the path, its tier and a command that runs it', () => { + const notice = renderLostFilterNotice(lost, filters.length, ['unit']); + expect(notice).toContain(INTEGRATION[0]); + expect(notice).toContain('`integration`'); + expect(notice).toContain(`--project integration ${INTEGRATION[0]}`); + // The count must describe the caller's command line, not the lost set. + expect(notice).toContain(`1 of the ${filters.length} path(s)`); + }); + + it('says so even when EVERY filter was lost, and still names the selected project', () => { + // vitest is already red here, but its own message is generic; this run + // collected nothing, so a selected-project list inferred from the collected + // set would wrongly read "every project". + const allLost = lostFilters(INTEGRATION, [], POPULATIONS, PKG); + expect(allLost).toHaveLength(1); + expect(renderLostFilterNotice(allLost, 1, ['unit'])).toContain('this run selected project `unit`'); + }); + + it('distinguishes a path that is in no tier at all from one in the other tier', () => { + const [typo] = lostFilters(['test/no-such-file.test.ts'], collected, POPULATIONS, PKG); + expect(typo?.foundIn).toEqual([]); + expect(renderLostFilterNotice([typo!], 1, ['unit'])).toContain('matches no test file in this package'); + }); +}); + +describe('direction ② — a run that loses nothing contributes zero bytes', () => { + it('finds nothing lost when every named path selected a file', () => { + expect(lostFilters(UNIT, UNIT.map(abs), POPULATIONS, PKG)).toEqual([]); + }); + + it('renders the EMPTY STRING, which is what keeps the output byte-identical', () => { + expect(renderLostFilterNotice([], 2, ['unit'])).toBe(''); + }); + + it('finds nothing lost when no filter was given at all', () => { + expect(lostFilters([], [...UNIT, ...INTEGRATION].map(abs), POPULATIONS, PKG)).toEqual([]); + }); + + it('treats a directory-ish prefix spanning both tiers as a legitimate narrowing', () => { + // `test/` selects files in both tiers; under `--project unit` only the unit + // ones are collected, and that is the caller asking for exactly that. + // ⛔ Refusing this would make the gate louder and wrong. + expect(lostFilters(['test/'], [abs('test/commands.test.ts')], POPULATIONS, PKG)).toEqual([]); + }); +}); + +describe('the matcher mirrors vitest 4.1.11 `TestProject.filterFiles`', () => { + it('matches a substring of the project-relative path', () => { + expect(matchesVitestFilter(abs('test/commands.test.ts'), 'commands', PKG)).toBe(true); + }); + + it('matches case-insensitively, as vitest does', () => { + expect(matchesVitestFilter(abs('test/commands.test.ts'), 'COMMANDS.TEST', PKG)).toBe(true); + }); + + it('matches an absolute filter by prefix', () => { + expect(matchesVitestFilter(abs('test/commands.test.ts'), abs('test'), PKG)).toBe(true); + }); + + it('does not match an unrelated path', () => { + expect(matchesVitestFilter(abs('test/commands.test.ts'), 'i18n-extract', PKG)).toBe(false); + }); +}); + +describe('the preflight is still registered — ⛔ an unregistered one is a phantom check', () => { + const config = maskComments(readFileSync(join(PKG, 'vitest.config.ts'), 'utf8')); + + it('imports the preflight in code position', () => { + expect(config).toContain("from './vitest-filter-preflight.js'"); + }); + + it('registers it as a reporter alongside the default one', () => { + expect(config).toMatch(/reporters:\s*\[\s*'default',\s*tierFilterPreflight\(/); + }); + + it('feeds it the SAME derived arrays the projects use as their `include`', () => { + // ⛔ A second derivation here would be a copy of a fact already on disk and + // would go stale exactly where the first one cannot. + expect(config).toMatch(/populations:\s*\{\s*unit:\s*UNIT_FILES,\s*integration:\s*INTEGRATION_FILES\s*\}/); + }); +}); diff --git a/packages/cli/tsconfig.test.json b/packages/cli/tsconfig.test.json index 53b30370ed..90a6f8d34a 100644 --- a/packages/cli/tsconfig.test.json +++ b/packages/cli/tsconfig.test.json @@ -171,6 +171,17 @@ "@objectstack/verify": ["../verify/src/index.ts"] } }, - "include": ["test/**/*", "vitest.config.ts", "vitest-tiers.ts", "vitest-tiers.fixtures.ts"], + // ⛔ Every package-root harness module is named here ONE BY ONE, because the + // `include` above reaches `test/` and nothing else: a root module that is only + // reachable through a test's import is in the program by accident and leaves + // it the moment that import goes. `vitest-filter-preflight.ts` (#17853) is the + // fourth for that reason, not because a test happens to import it. + "include": [ + "test/**/*", + "vitest.config.ts", + "vitest-tiers.ts", + "vitest-tiers.fixtures.ts", + "vitest-filter-preflight.ts" + ], "exclude": ["node_modules", "dist"] } diff --git a/packages/cli/vitest-filter-preflight.ts b/packages/cli/vitest-filter-preflight.ts new file mode 100644 index 0000000000..f95f95b8d0 --- /dev/null +++ b/packages/cli/vitest-filter-preflight.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A vitest FILE FILTER that selected nothing must say so — even when the rest + * of the same run selected something (#17853). + * + * ## The defect this closes, and the half vitest already covers + * + * `vitest run` reads bare positional arguments as FILE FILTERS. When EVERY + * filter selects nothing, vitest is already loud, and this module adds nothing + * to that case: `printNoTestFound()` prints `No test files found, exiting with + * code 1` together with the filters and the projects, and the run is red. + * Measured on this tree with vitest 4.1.11: + * + * pnpm --filter @objectstack/cli exec vitest run --project unit \ + * test/i18n-extract-companion-orphan.test.ts + * => exit 1, `No test files found, exiting with code 1` + * + * ⭐ THE GAP IS THE PARTIAL CASE, and it is the one that costs dispatch rounds. + * Once at least one filter selects a file, the filters that selected NOTHING + * are dropped with no diagnostic of any kind. Measured on this tree, same + * binary, four unit-tier files plus one integration-tier file, `--project + * unit`: + * + * Test Files 3 failed | 1 passed (4) <- FIVE paths named, four counted + * Tests 4 passed (4) + * + * The run naming only the FOUR unit-tier paths prints those same two lines. + * `diff` over the two captures is empty but for timestamps, durations and file + * ordering, and the discarded path's name appears NOWHERE in vitest's own + * output — the one occurrence in a captured terminal is pnpm's own echo of the + * argv in `ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL`, which pnpm prints only when the + * run was already red and which is therefore absent from exactly the green run + * that needed it. + * + * ⇒ "I named five paths and one was silently discarded" is byte-identical to + * "I named four paths", which is indistinguishable from "ran and passed". That + * is the failure direction AGENTS.md ranks BELOW having no verifier at all + * (Route & surface ownership §3, "Absence must be loud"): a verifier that + * silently degrades reports success. + * + * ⚠️ It has been paid for once already. #16872's delivering dev verified with + * `--project unit` over a named set, read green, pushed, and CI went red on + * `Test Core` with the failing assertion inside an integration-tier file that + * the local run had discarded. + * + * ## The seam, and why it is a reporter rather than an argv parse + * + * The obvious implementation — parse `process.argv` in `vitest.config.ts` — + * cannot be made correct without restating vitest's option table: a bare token + * is a filter only if the token before it did not consume it, and which flags + * consume a value is vitest's business and changes with vitest. A predicate + * built on a guess is exactly the artifact this card was filed about. + * + * So nothing is guessed. Vitest hands both halves over: + * + * - `Vitest.filenamePattern` IS the CLI filter list. `Vitest.start(filters)` + * assigns it before resolving any specification, so it is already set when + * reporters are called. + * - `onTestRunStart(specifications)` IS the resolved set — what vitest + * actually collected, after `include`, after `--project`, after filtering. + * + * ⛔ `onInit` is TOO EARLY and must not be used for the reading: `start()` + * reports `onInit` in a `finally` block that runs BEFORE `filenamePattern` is + * assigned, so a reporter that reads it there sees the previous run's value or + * nothing at all. `onInit` here only captures the Vitest instance. + * + * `vitest list` does not go through `start()` and runs no reporters, so this + * module is inert for it — which is what keeps `test/vitest-tiers-partition.test.ts`, + * whose whole instrument is `vitest list --filesOnly`, reading exactly what it + * read before. + * + * ## The matcher is vitest's own, mirrored rather than approximated + * + * `matchesVitestFilter` below is a transcription of `TestProject.filterFiles` + * as shipped in vitest 4.1.11 (`dist/chunks/cli-api.*.js`): case-insensitive + * substring of the project-relative path, plus the absolute-prefix and + * relative-spelling arms. It is transcribed, not invented, because the whole + * value of this preflight is that its idea of "selected" equals vitest's. When + * vitest is upgraded, re-read that function; a divergence here can only ever + * produce a wrong diagnostic, never a wrong run. + * + * ⚠️ Two boundaries, stated rather than discovered later: + * - The win32 `slash()` normalisation vitest applies to filters is NOT + * mirrored; this repo's CI and containers are Linux, and the omission can + * only mis-attribute a diagnostic on a platform the suite is not run on. + * - `--changed` / `--related` runs set no `filenamePattern`, so they are + * outside this preflight entirely, which is correct: nothing was named. + * + * ## Direction ②, which is the half that makes this accurate rather than loud + * + * `renderLostFilterNotice` returns the EMPTY STRING when no filter was lost, + * and the reporter writes nothing at all in that case. A normal run — no + * filters, or filters that all selected something — therefore contributes zero + * bytes of new output. `test/vitest-project-filter-preflight.test.ts` pins both + * directions of that, and pins that this module is still wired into + * `vitest.config.ts`'s `reporters`, because a preflight nobody registered is + * the same phantom check in a new place. + */ + +import { isAbsolute, join, relative, resolve } from 'node:path'; + +/** The shape of a vitest `TestSpecification` this module reads. */ +export interface SpecificationLike { + readonly moduleId: string; +} + +/** The shape of a vitest `TestProject` this module reads. */ +export interface ProjectLike { + readonly name?: string | undefined; +} + +/** The shape of the `Vitest` instance this module reads. */ +export interface VitestLike { + readonly filenamePattern?: readonly string[] | undefined; + readonly projects?: readonly ProjectLike[] | undefined; +} + +/** A CLI filter that selected no test file, and where its target actually lives. */ +export interface LostFilter { + /** The filter exactly as the caller spelled it on the command line. */ + readonly filter: string; + /** + * Names of the declared populations (here: tier / project names) that DO + * contain a file this filter would have selected. Empty means the filter + * matches nothing in this package at all — a typo, or a path that moved. + */ + readonly foundIn: readonly string[]; +} + +/** Populations to attribute a lost filter to, by name — `{ unit, integration }`. */ +export type Populations = Readonly>; + +/** + * `TestProject.filterFiles`, vitest 4.1.11, transcribed. `absFile` is an + * absolute module id; `root` is the project root the paths are relative to. + */ +export function matchesVitestFilter(absFile: string, filter: string, root: string): boolean { + const testFile = relative(root, absFile).toLocaleLowerCase(); + if (isAbsolute(filter) && absFile.startsWith(filter)) return true; + const relativePath = filter.endsWith('/') + ? join(relative(root, filter), '/') + : relative(root, filter); + return ( + testFile.includes(filter.toLocaleLowerCase()) || + testFile.includes(relativePath.toLocaleLowerCase()) + ); +} + +/** + * Which of `filters` selected none of `collected`, and which declared + * population each of those would have matched instead. + * + * Pure: `node:path` only, no filesystem and no vitest. `collected` is the + * absolute module id of every specification vitest actually resolved; + * `populations` maps a name to package-root-relative paths. + */ +export function lostFilters( + filters: readonly string[], + collected: readonly string[], + populations: Populations, + root: string, +): LostFilter[] { + return filters + .filter((f) => !collected.some((abs) => matchesVitestFilter(abs, f, root))) + .map((f) => ({ + filter: f, + foundIn: Object.entries(populations) + .filter(([, rels]) => rels.some((rel) => matchesVitestFilter(resolve(root, rel), f, root))) + .map(([name]) => name), + })); +} + +/** + * The diagnostic, or the EMPTY STRING when nothing was lost. + * + * ⛔ The empty string is the contract, not an implementation detail: it is what + * makes a normal run byte-identical. Anything that would print on a healthy run + * belongs somewhere else. + */ +export function renderLostFilterNotice( + lost: readonly LostFilter[], + totalFilters: number, + selectedProjects: readonly string[], +): string { + if (lost.length === 0) return ''; + + const selected = selectedProjects.length + ? `project ${selectedProjects.map((p) => `\`${p}\``).join(' + ')}` + : 'every project'; + const lines: string[] = [ + '', + ` !! FILTER SELECTED NOTHING — ${lost.length} of the ${totalFilters} path(s) you named ran no tests.`, + '', + ]; + + for (const { filter, foundIn } of lost) { + lines.push(` ${filter}`); + lines.push( + foundIn.length + ? ` lives in the ${foundIn.map((n) => `\`${n}\``).join(' / ')} tier; this run selected ${selected}.` + : ` matches no test file in this package at all — check the path.`, + ); + if (foundIn.length) { + lines.push( + ` run it: pnpm --filter @objectstack/cli exec vitest run --project ${foundIn[0]} ${filter}`, + ); + } + lines.push(''); + } + + lines.push( + ' ⛔ Nothing below counts the path(s) above: the file count, the pass/fail', + ' totals and the exit code are about the OTHER named paths only.', + ' To run every tier, which is what CI runs:', + ' pnpm --filter @objectstack/cli test', + '', + ); + return lines.join('\n'); +} + +/** + * The vitest reporter. Writes the notice to stderr at the START of the run (so + * it precedes the results) and again at the END (so it survives the summary, + * which is where a reader looks for `Test Files N passed`). Writes nothing — + * not one byte — when no filter was lost. + */ +export function tierFilterPreflight(options: { root: string; populations: Populations }): { + onInit(vitest: VitestLike): void; + onTestRunStart(specifications: readonly SpecificationLike[]): void; + onTestRunEnd(): void; +} { + const { root, populations } = options; + let vitest: VitestLike | undefined; + let notice = ''; + + return { + onInit(v) { + // ⛔ `filenamePattern` is NOT readable yet — see this file's header. + vitest = v; + }, + onTestRunStart(specifications) { + const filters = vitest?.filenamePattern ?? []; + notice = ''; + if (filters.length === 0) return; + const collected = [...new Set(specifications.map((s) => s.moduleId))]; + // ⛔ Read the selected projects from vitest, never from what was collected: + // when EVERY filter is lost nothing is collected, and an inferred list + // would then report "every project" for a run that named one. + // `--project X` narrows `Vitest.projects` itself — `printNoTestFound` + // reads the same array to print its own `|unit|` block. + const selectedProjects = (vitest?.projects ?? []) + .map((p) => p.name) + .filter((n): n is string => Boolean(n)); + notice = renderLostFilterNotice( + lostFilters(filters, collected, populations, root), + filters.length, + selectedProjects, + ); + if (notice) process.stderr.write(notice); + }, + onTestRunEnd() { + if (notice) process.stderr.write(notice); + }, + }; +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index fe4eb15745..3f7b9d1de0 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -496,16 +496,46 @@ // ## THE TWO TIERS (#13504, #14554) — `unit` and `integration`, DERIVED population // // Maintainer ruling (2026-09-01): split this suite into two NAMED tiers — a -// unit-fast tier that is the local default and does not monopolise the shared +// unit-fast tier that is fast to run locally and does not monopolise the shared // verify lock, and a real-kernel integration tier that is CI-mandatory and run -// locally on demand. Nothing is skipped, weakened, deleted or doubled: every -// test file in this package still runs under `pnpm test`, because `vitest run` -// with no `--project` runs every project. The tiers only change what a NARROWED -// local run selects. +// locally on demand. // -// pnpm --filter @objectstack/cli exec vitest run --project unit # fast, local default +// pnpm --filter @objectstack/cli test # BOTH tiers — what CI runs +// pnpm --filter @objectstack/cli exec vitest run --project unit # fast; runs ONLY the unit tier // pnpm --filter @objectstack/cli exec vitest run --project integration # the real thing, on demand -// pnpm --filter @objectstack/cli test # both — what CI runs +// +// ⛔ `--project` NARROWS THE RUN, AND A PATH YOU NAME OUTSIDE THE SELECTED TIER +// IS DISCARDED RATHER THAN RUN (#17853). The split itself skips, weakens, +// deletes and doubles nothing — `vitest run` with no `--project` runs every +// project, so the POPULATION is intact. ⛔ That sentence is about the +// population and says nothing whatever about one narrowed invocation, and this +// block used to print it three lines above the narrowed commands, which is +// precisely how it got read as a guarantee about the reader's own command line. +// The safe invocation is now printed FIRST, above the two that can lose things. +// +// Naming an integration-tier file while passing `--project unit` selects +// nothing for that path — the two tiers are a partition (`:583`) and each +// project's `include` is an exact-path list (`:613`), so the intersection is +// empty BY CONSTRUCTION, not by accident. Measured on this tree, vitest 4.1.11: +// if that path was the ONLY one you named, vitest is already loud — +// `No test files found, exiting with code 1`. If you named OTHER paths that did +// match, it is dropped in SILENCE: five paths in, `Test Files … (4)` out, the +// discarded name printed nowhere in vitest's own output, and the whole run +// byte-identical to the one that named only the four. +// +// ⇒ A false green in the worst direction, and it has already cost one dispatch +// round (#16872): that dev verified with `--project unit`, read green, pushed, +// and CI went red on `Test Core` with the failing assertion inside the very +// integration-tier file the local run had discarded. +// +// ⇒ `vitest-filter-preflight.ts` is wired into `reporters` below and now says +// so: every named path that selected no test file is reported BY NAME, with the +// tier it really lives in and the command that runs it. It prints nothing at +// all when every named path selected something, so a healthy narrowed run is +// byte-identical to what it was before this existed. ⛔ Before accepting a +// narrowed run as pre-delivery verification, read that line — or run the full +// `test` target above, which is the only one of the three whose green is a +// statement about this package rather than about a subset you chose. // // ⛔ THE PREDICATE IS WHAT A FILE DOES, NOT WHAT IT IS CALLED. The ACCEPT on // #13504 fixed that the `*.e2e.test.ts` name disagrees with behaviour, so a @@ -613,6 +643,7 @@ // `node_modules` exclusion: an exact-path list matches nothing it does not name. import { defineConfig } from 'vitest/config'; import path from 'path'; +import { tierFilterPreflight } from './vitest-filter-preflight.js'; import { integrationTestFiles, unitTestFiles } from './vitest-tiers.js'; // The two tiers, DERIVED from what the files DO — never written down — over @@ -701,6 +732,21 @@ export default defineConfig({ external: [/packages[\/]types[\/]dist/], }, }, + // #17853 — the preflight the tier header above describes. `'default'` is + // vitest's own default reporter, restated because naming `reporters` at all + // replaces the default list rather than extending it; the second entry adds + // output ONLY when a path named on the command line selected no test file, + // so a run that loses nothing is byte-identical to one without it. The two + // populations are the SAME derived arrays the projects below use as their + // `include` — ⛔ never a second derivation, which would be a copy of a fact + // and would go stale exactly where this one cannot. + reporters: [ + 'default', + tierFilterPreflight({ + root: __dirname, + populations: { unit: UNIT_FILES, integration: INTEGRATION_FILES }, + }), + ], // The two tiers (#13504) — see the header section of the same name, and // "THE NIGHTLY TIERS" for the population both read. Both `extends: true` // so each project inherits the `resolve.alias` table and the From 079cdc46d845b882a042bfde6b83720c23d04f61 Mon Sep 17 00:00:00 2001 From: objectstack-dev Date: Sun, 13 Sep 2026 07:50:58 +0000 Subject: [PATCH 2/4] fix(cli): run the filter preflight at config load, not as a reporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Naming `test.reporters` replaces vitest's own reporter defaulting instead of extending it: it pins `default` where an agent terminal gets `agent` (measured: a healthy control run gained two lines, so direction ② failed) and it would drop the `github-actions` reporter on every CI run, removing the failure annotations in the one environment a local control cannot observe. Read the command line through vitest's own exported `parseCLI` instead, at config load, over the same two derived tier arrays the projects take as their `include`. Every case the preflight cannot model — an argv the parser refuses, a `--project` that is not a tier name, a `--changed` run — declines silently rather than guessing. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- .../vitest-project-filter-preflight.test.ts | 250 +++++++++---- packages/cli/vitest-filter-preflight.ts | 354 ++++++++++-------- packages/cli/vitest.config.ts | 40 +- 3 files changed, 395 insertions(+), 249 deletions(-) diff --git a/packages/cli/test/vitest-project-filter-preflight.test.ts b/packages/cli/test/vitest-project-filter-preflight.test.ts index 056fe30477..60b02a2f11 100644 --- a/packages/cli/test/vitest-project-filter-preflight.test.ts +++ b/packages/cli/test/vitest-project-filter-preflight.test.ts @@ -1,40 +1,49 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * A named path that selects no test file is reported, and a run that loses - * nothing is byte-identical (#17853). + * A named path that will run no tests is reported, and a run that loses nothing + * is byte-identical (#17853). * - * `../vitest-filter-preflight.ts` carries the mechanism, the vitest readings - * and the reason the reading is taken from a reporter rather than from - * `process.argv`. What is pinned HERE is the pair of directions the ruling on - * this card made non-negotiable, plus the wiring, and the three are pinned - * separately because they fail separately: + * `../vitest-filter-preflight.ts` carries the mechanism, the vitest readings, + * and the measurement that rejected the reporter seam. Pinned HERE are the two + * directions the ruling on this card made non-negotiable, the decline paths, + * and the wiring — pinned separately because they fail separately: * - * 1. **LOST IS LOUD.** A filter that selected nothing is returned by + * 1. **LOST IS LOUD.** A filter that selects nothing is returned by * `lostFilters`, attributed to the tier it really lives in, and rendered - * into a notice that names the path, that tier and a command that runs it. + * into a notice naming the path, that tier and a command that runs it. * ⛔ Asserting only that the notice is non-empty would pass on a notice - * that says nothing useful, so each of the three is asserted by name. + * that says nothing useful, so each part is asserted by name. * * 2. **HEALTHY IS SILENT — the half that makes this accurate instead of merely - * loud.** `renderLostFilterNotice` returns the EMPTY STRING whenever no - * filter was lost, and the reporter writes only what that function returns. - * Zero bytes is the whole contract: a narrowed run that loses nothing must - * read exactly as it read before this module existed. + * loud.** `runFilterPreflight` returns the EMPTY STRING and calls its writer + * ZERO times whenever nothing was lost. Zero bytes is the whole contract: a + * narrowed run that loses nothing must read exactly as it read before. * - * 3. **THE WIRING.** A preflight that nobody registered is a phantom check — - * it evaluates never, and deleting it leaves every assertion in this file - * just as green. So the config's own source is read, comments masked, and - * the registration asserted in CODE position. This is the same instrument - * `vitest-tiers-partition.test.ts` uses on the same file for the same - * reason. + * 3. **EVERY UNCERTAINTY DECLINES.** An argv the parser refuses, a `--project` + * that is not a tier name, a `--changed` run: each returns nothing at all. + * ⛔ These are the cases where a guess would turn a diagnostic into a lie, + * and silence is exactly the status quo, so declining cannot make a run + * worse than it is today. * - * ⚠️ What this file deliberately does NOT do is spawn vitest. An end-to-end - * pin would have to run vitest inside vitest, which the tier predicate - * classifies as `integration` (SPAWN) — a permanently expensive file in the - * tier this very card is not authorised to reshape. The end-to-end reading is - * taken once, by hand, and recorded in the PR that landed this; case 3 is what - * keeps the unit-level pins from going phantom in the meantime. + * 4. **THE WIRING**, both halves. A preflight nobody invoked is a phantom + * check — it evaluates never, and deleting it leaves every assertion above + * just as green — so the config's own source is read, comments masked, and + * the call asserted in CODE position. The NEGATIVE half is equally + * load-bearing and is the measured regression this file exists downstream + * of: the config must NOT name `test.reporters`, because naming it replaces + * vitest's reporter defaulting (`agent` vs `default`, and the + * `github-actions` reporter in CI) instead of extending it. + * + * The argv cases drive vitest's REAL exported `parseCLI`, not a stand-in, so + * what is pinned is the spelling an agent actually types. + * + * ⚠️ What this file deliberately does NOT do is spawn vitest. An end-to-end pin + * would have to run vitest inside vitest, which the tier predicate classifies + * as `integration` (SPAWN) — a permanently expensive file in the tier this card + * is not authorised to reshape. The end-to-end reading is taken once, by hand, + * and recorded in the PR that landed this; case 4 is what keeps these pins from + * going phantom in the meantime. * * Runs in the `unit` tier: pure functions plus one source read, no child * process and no kernel. @@ -44,114 +53,193 @@ import { readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { parseCLI } from 'vitest/node'; import { maskComments } from '../../../scripts/js-comment-mask.mjs'; import { lostFilters, matchesVitestFilter, + parseInvocation, renderLostFilterNotice, + runFilterPreflight, + splitLineSuffix, + type CliParse, } from '../vitest-filter-preflight.js'; const HERE = dirname(fileURLToPath(import.meta.url)); const PKG = resolve(HERE, '..'); -const UNIT = ['src/utils/format.exit-code.test.ts', 'test/commands.test.ts']; -const INTEGRATION = ['test/i18n-extract-companion-orphan.test.ts']; -const POPULATIONS = { unit: UNIT, integration: INTEGRATION }; -const abs = (rel: string): string => resolve(PKG, rel); +const U1 = 'src/utils/format.exit-code.test.ts'; +const U2 = 'test/commands.test.ts'; +const I1 = 'test/i18n-extract-companion-orphan.test.ts'; +const POPULATIONS = { unit: [U1, U2], integration: [I1] }; +const parse = parseCLI as unknown as CliParse; -describe('direction ① — a filter that selected nothing is reported by name', () => { - // The exact shape #16872 delivered on: several unit-tier paths that DID - // match, one integration-tier path that did not, and `--project unit`. - const collected = UNIT.map(abs); - const filters = [...UNIT, ...INTEGRATION]; - const lost = lostFilters(filters, collected, POPULATIONS, PKG); +/** A real `vitest run …` command line, as `process.argv` would carry it. */ +const argv = (...args: string[]): string[] => ['/usr/bin/node', '/x/vitest.mjs', 'run', ...args]; + +/** Run the preflight with a capturing writer instead of stderr. */ +function preflight(...args: string[]): { notice: string; writes: string[] } { + const writes: string[] = []; + const notice = runFilterPreflight({ + argv: argv(...args), + root: PKG, + populations: POPULATIONS, + parse, + write: (text) => void writes.push(text), + }); + return { notice, writes }; +} + +describe('① a path that will run no tests is reported by name', () => { + const { notice, writes } = preflight('--project', 'unit', U1, U2, I1); + + it('writes the notice rather than returning it silently', () => { + expect(writes).toEqual([notice]); + expect(notice).not.toBe(''); + }); - it('returns exactly the filter that selected nothing', () => { - expect(lost.map((l) => l.filter)).toEqual(INTEGRATION); + it('names the discarded path', () => { + expect(notice).toContain(I1); }); - it('attributes it to the tier it actually lives in', () => { - expect(lost[0]?.foundIn).toEqual(['integration']); + it('names the tier it actually lives in, and the project this run selected', () => { + expect(notice).toContain('lives in the `integration` tier'); + expect(notice).toContain('this run selected project `unit`'); }); - it('renders a notice naming the path, its tier and a command that runs it', () => { - const notice = renderLostFilterNotice(lost, filters.length, ['unit']); - expect(notice).toContain(INTEGRATION[0]); - expect(notice).toContain('`integration`'); - expect(notice).toContain(`--project integration ${INTEGRATION[0]}`); - // The count must describe the caller's command line, not the lost set. - expect(notice).toContain(`1 of the ${filters.length} path(s)`); + it('names a command that would actually run it', () => { + expect(notice).toContain(`--project integration ${I1}`); }); - it('says so even when EVERY filter was lost, and still names the selected project', () => { - // vitest is already red here, but its own message is generic; this run - // collected nothing, so a selected-project list inferred from the collected - // set would wrongly read "every project". - const allLost = lostFilters(INTEGRATION, [], POPULATIONS, PKG); - expect(allLost).toHaveLength(1); - expect(renderLostFilterNotice(allLost, 1, ['unit'])).toContain('this run selected project `unit`'); + it('counts against the command line, not against the lost set', () => { + expect(notice).toContain('1 of the 3 path(s)'); }); - it('distinguishes a path that is in no tier at all from one in the other tier', () => { - const [typo] = lostFilters(['test/no-such-file.test.ts'], collected, POPULATIONS, PKG); - expect(typo?.foundIn).toEqual([]); - expect(renderLostFilterNotice([typo!], 1, ['unit'])).toContain('matches no test file in this package'); + it('still reports when EVERY path is lost — where vitest is red but generic', () => { + const only = preflight('--project', 'unit', I1); + expect(only.notice).toContain(I1); + expect(only.notice).toContain('this run selected project `unit`'); + }); + + it('separates a path in the other tier from one in no tier at all', () => { + const typo = preflight('--project', 'unit', U1, 'test/no-such-file.test.ts'); + expect(typo.notice).toContain('matches no test file in this package at all'); + expect(typo.notice).not.toContain('lives in the'); + }); + + it('reports a path that is in no tier even with no --project at all', () => { + expect(preflight(U1, 'test/no-such-file.test.ts').notice).toContain('no test file in this package'); }); }); -describe('direction ② — a run that loses nothing contributes zero bytes', () => { - it('finds nothing lost when every named path selected a file', () => { - expect(lostFilters(UNIT, UNIT.map(abs), POPULATIONS, PKG)).toEqual([]); +describe('② a run that loses nothing contributes zero bytes', () => { + it('is silent when every named path selects something', () => { + const { notice, writes } = preflight('--project', 'unit', U1, U2); + expect(notice).toBe(''); + expect(writes).toEqual([]); + }); + + it('is silent when no path was named at all', () => { + expect(preflight('--project', 'unit').writes).toEqual([]); }); - it('renders the EMPTY STRING, which is what keeps the output byte-identical', () => { + it('is silent for the full-suite run CI performs', () => { + expect(preflight().writes).toEqual([]); + }); + + it('is silent when an integration path is named WITHOUT --project', () => { + // Both projects are selected, so the path is collected. ⛔ Warning here + // would make the gate louder and wrong. + expect(preflight(U1, I1).writes).toEqual([]); + }); + + it('treats a prefix spanning both tiers as the legitimate narrowing it is', () => { + // `test/` names files in both tiers; under `--project unit` the unit ones + // are collected, which is exactly what the caller asked for. + expect(preflight('--project', 'unit', 'test/').writes).toEqual([]); + }); + + it('renders the EMPTY STRING for an empty lost set — the byte-identity contract', () => { expect(renderLostFilterNotice([], 2, ['unit'])).toBe(''); }); +}); - it('finds nothing lost when no filter was given at all', () => { - expect(lostFilters([], [...UNIT, ...INTEGRATION].map(abs), POPULATIONS, PKG)).toEqual([]); +describe('③ every uncertainty declines instead of guessing', () => { + it('declines an argv the parser refuses', () => { + const boom: CliParse = () => { + throw new Error('unparseable'); + }; + const inv = parseInvocation(argv('--project', 'unit', I1), boom); + expect(inv.opaque).toBe(true); + expect(lostFilters(inv, POPULATIONS, PKG)).toEqual([]); }); - it('treats a directory-ish prefix spanning both tiers as a legitimate narrowing', () => { - // `test/` selects files in both tiers; under `--project unit` only the unit - // ones are collected, and that is the caller asking for exactly that. - // ⛔ Refusing this would make the gate louder and wrong. - expect(lostFilters(['test/'], [abs('test/commands.test.ts')], POPULATIONS, PKG)).toEqual([]); + it('declines a --project value that is not a tier name', () => { + // A negation or a glob is not modelled; ⛔ half-answering it would be a lie. + expect(preflight('--project', '!integration', I1).writes).toEqual([]); + }); + + it('declines a --changed run, where no path was named by hand', () => { + expect(parseInvocation(argv('--changed'), parse).opaque).toBe(true); }); }); -describe('the matcher mirrors vitest 4.1.11 `TestProject.filterFiles`', () => { +describe('the reading mirrors vitest 4.1.11', () => { it('matches a substring of the project-relative path', () => { - expect(matchesVitestFilter(abs('test/commands.test.ts'), 'commands', PKG)).toBe(true); + expect(matchesVitestFilter(U2, 'commands', PKG)).toBe(true); }); it('matches case-insensitively, as vitest does', () => { - expect(matchesVitestFilter(abs('test/commands.test.ts'), 'COMMANDS.TEST', PKG)).toBe(true); + expect(matchesVitestFilter(U2, 'COMMANDS.TEST', PKG)).toBe(true); }); it('matches an absolute filter by prefix', () => { - expect(matchesVitestFilter(abs('test/commands.test.ts'), abs('test'), PKG)).toBe(true); + expect(matchesVitestFilter(U2, join(PKG, 'test'), PKG)).toBe(true); }); it('does not match an unrelated path', () => { - expect(matchesVitestFilter(abs('test/commands.test.ts'), 'i18n-extract', PKG)).toBe(false); + expect(matchesVitestFilter(U2, 'i18n-extract', PKG)).toBe(false); + }); + + it('splits a `:LINE` suffix off for matching and keeps the spelling for the reader', () => { + expect(splitLineSuffix(`${U2}:12`)).toEqual({ spelled: `${U2}:12`, path: U2 }); + expect(splitLineSuffix(U2)).toEqual({ spelled: U2, path: U2 }); + }); + + it('reads the positional filters and the project out of a real command line', () => { + const inv = parseInvocation(argv('--project', 'unit', '--maxWorkers=2', U1), parse); + expect(inv.opaque).toBe(false); + expect(inv.projects).toEqual(['unit']); + expect(inv.filters.map((f) => f.path)).toEqual([U1]); }); }); -describe('the preflight is still registered — ⛔ an unregistered one is a phantom check', () => { +describe('the preflight is still wired — ⛔ an uninvoked one is a phantom check', () => { const config = maskComments(readFileSync(join(PKG, 'vitest.config.ts'), 'utf8')); - it('imports the preflight in code position', () => { + it('imports it, and imports vitest’s own parser for the argv', () => { expect(config).toContain("from './vitest-filter-preflight.js'"); + expect(config).toContain("from 'vitest/node'"); }); - it('registers it as a reporter alongside the default one', () => { - expect(config).toMatch(/reporters:\s*\[\s*'default',\s*tierFilterPreflight\(/); + it('invokes it in code position', () => { + expect(config).toMatch(/runFilterPreflight\(\{/); + expect(config).toMatch(/parse:\s*parseCLI/); }); it('feeds it the SAME derived arrays the projects use as their `include`', () => { - // ⛔ A second derivation here would be a copy of a fact already on disk and - // would go stale exactly where the first one cannot. - expect(config).toMatch(/populations:\s*\{\s*unit:\s*UNIT_FILES,\s*integration:\s*INTEGRATION_FILES\s*\}/); + // ⛔ A second derivation would be a copy of a fact already on disk and would + // go stale exactly where this one cannot. + expect(config).toMatch( + /populations:\s*\{\s*unit:\s*UNIT_FILES,\s*integration:\s*INTEGRATION_FILES\s*\}/, + ); + }); + + it('⛔ does NOT name `test.reporters` — the measured regression this avoids', () => { + // Naming it replaces vitest's reporter defaulting rather than extending it: + // it pins `default` where an agent terminal gets `agent` (measured: a + // healthy run gained two lines) and drops the `github-actions` reporter in + // CI, which no local control run can observe. + expect(config).not.toMatch(/\breporters\s*:/); }); }); diff --git a/packages/cli/vitest-filter-preflight.ts b/packages/cli/vitest-filter-preflight.ts index f95f95b8d0..89e6fc35af 100644 --- a/packages/cli/vitest-filter-preflight.ts +++ b/packages/cli/vitest-filter-preflight.ts @@ -1,16 +1,16 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * A vitest FILE FILTER that selected nothing must say so — even when the rest - * of the same run selected something (#17853). + * A vitest FILE FILTER that selects nothing must say so — even when the rest + * of the same run selects something (#17853). * * ## The defect this closes, and the half vitest already covers * * `vitest run` reads bare positional arguments as FILE FILTERS. When EVERY - * filter selects nothing, vitest is already loud, and this module adds nothing - * to that case: `printNoTestFound()` prints `No test files found, exiting with - * code 1` together with the filters and the projects, and the run is red. - * Measured on this tree with vitest 4.1.11: + * filter selects nothing, vitest is already loud and this module adds nothing: + * `printNoTestFound()` prints `No test files found, exiting with code 1` + * together with the filters and the projects, and the run is red. Measured on + * this tree, vitest 4.1.11: * * pnpm --filter @objectstack/cli exec vitest run --project unit \ * test/i18n-extract-companion-orphan.test.ts @@ -19,125 +19,153 @@ * ⭐ THE GAP IS THE PARTIAL CASE, and it is the one that costs dispatch rounds. * Once at least one filter selects a file, the filters that selected NOTHING * are dropped with no diagnostic of any kind. Measured on this tree, same - * binary, four unit-tier files plus one integration-tier file, `--project - * unit`: + * binary, two unit-tier files plus one integration-tier file, `--project unit`: + * the run prints the same `Test Files (2)` summary as the run naming only the + * two, and `diff` over the two captures is empty but for timestamps and + * durations. The discarded path's name appears NOWHERE in vitest's own output + * — the one occurrence in a captured terminal is pnpm's echo of the argv in + * `ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL`, which pnpm prints only when the run was + * already red and which is therefore absent from exactly the green run that + * needed it. * - * Test Files 3 failed | 1 passed (4) <- FIVE paths named, four counted - * Tests 4 passed (4) - * - * The run naming only the FOUR unit-tier paths prints those same two lines. - * `diff` over the two captures is empty but for timestamps, durations and file - * ordering, and the discarded path's name appears NOWHERE in vitest's own - * output — the one occurrence in a captured terminal is pnpm's own echo of the - * argv in `ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL`, which pnpm prints only when the - * run was already red and which is therefore absent from exactly the green run - * that needed it. - * - * ⇒ "I named five paths and one was silently discarded" is byte-identical to - * "I named four paths", which is indistinguishable from "ran and passed". That - * is the failure direction AGENTS.md ranks BELOW having no verifier at all - * (Route & surface ownership §3, "Absence must be loud"): a verifier that - * silently degrades reports success. + * ⇒ "I named three paths and one was silently discarded" is byte-identical to + * "I named two paths", which is indistinguishable from "ran and passed" — the + * failure direction AGENTS.md ranks BELOW having no verifier at all (Route & + * surface ownership §3: a verifier that silently degrades reports success). * * ⚠️ It has been paid for once already. #16872's delivering dev verified with * `--project unit` over a named set, read green, pushed, and CI went red on * `Test Core` with the failing assertion inside an integration-tier file that * the local run had discarded. * - * ## The seam, and why it is a reporter rather than an argv parse + * ## ⛔ WHY THIS IS NOT A REPORTER, which was the first thing tried + * + * A reporter is the obvious seam — `Vitest.filenamePattern` is the CLI filter + * list and `onTestRunStart(specifications)` is the resolved set, both read from + * vitest rather than derived. It was built that way first and MEASURED, and the + * measurement rejected it: naming `test.reporters` at all replaces vitest's own + * defaulting rather than extending it, and that defaulting is not a constant. + * `resolveConfig` does this ONLY when `reporters` is left empty: + * + * if (!resolved.reporters.length) { + * resolved.reporters.push([isAgent ? 'agent' : 'default', {}]); + * if (process.env.GITHUB_ACTIONS === 'true') + * resolved.reporters.push(['github-actions', {}]); + * } * - * The obvious implementation — parse `process.argv` in `vitest.config.ts` — - * cannot be made correct without restating vitest's option table: a bare token - * is a filter only if the token before it did not consume it, and which flags - * consume a value is vitest's business and changes with vitest. A predicate - * built on a guess is exactly the artifact this card was filed about. + * So `reporters: ['default', preflight]` has two costs, one measured here and + * one that would only have shown up in CI: + * - it pins `default` where an agent terminal would have got `agent`, which + * changed a control run's output by two lines — a direct violation of the + * requirement that a healthy narrowed run stay byte-identical; and + * - it would have DROPPED the `github-actions` reporter on every CI run, + * silently removing the failure annotations, in the one environment the + * control run above cannot observe. * - * So nothing is guessed. Vitest hands both halves over: + * ⇒ The reporter list belongs to vitest. This preflight runs at CONFIG LOAD + * instead and touches no vitest seam at all. * - * - `Vitest.filenamePattern` IS the CLI filter list. `Vitest.start(filters)` - * assigns it before resolving any specification, so it is already set when - * reporters are called. - * - `onTestRunStart(specifications)` IS the resolved set — what vitest - * actually collected, after `include`, after `--project`, after filtering. + * ## What it reads, and why nothing here parses argv by hand * - * ⛔ `onInit` is TOO EARLY and must not be used for the reading: `start()` - * reports `onInit` in a `finally` block that runs BEFORE `filenamePattern` is - * assigned, so a reporter that reads it there sees the previous run's value or - * nothing at all. `onInit` here only captures the Vitest instance. + * `process.argv` is parsed by `parseCLI` from `vitest/node` — vitest's OWN + * exported parser, the same one the `vitest` binary uses — so which flags + * consume a following token is vitest's business and stays vitest's business. + * ⛔ A hand-rolled scan cannot be made correct without restating vitest's option + * table, and a predicate built on a guess is exactly the artifact this card was + * filed about. * - * `vitest list` does not go through `start()` and runs no reporters, so this - * module is inert for it — which is what keeps `test/vitest-tiers-partition.test.ts`, - * whose whole instrument is `vitest list --filesOnly`, reading exactly what it - * read before. + * The other input is the two tier arrays the config already derives and hands + * to the projects as their `include`. That is what makes the derivation exact + * rather than approximate: each project's `include` IS an exact-path list (the + * config header's `:613`) and the two are a partition (`:583`), so what a run + * will collect for a filter is computable from the same arrays vitest is about + * to be given. ⛔ No second derivation and no second walk — a copy of a fact + * already on disk is how the frozen tier list went stale before #14554. * - * ## The matcher is vitest's own, mirrored rather than approximated + * `matchesVitestFilter` is a transcription of `TestProject.filterFiles` as + * shipped in vitest 4.1.11 (`dist/chunks/cli-api.*.js`), and the `:LINE` suffix + * is stripped exactly as `parseFilter` strips it. Transcribed, not invented: + * the whole value of this preflight is that its idea of "selected" equals + * vitest's. On a vitest upgrade, re-read those two functions. * - * `matchesVitestFilter` below is a transcription of `TestProject.filterFiles` - * as shipped in vitest 4.1.11 (`dist/chunks/cli-api.*.js`): case-insensitive - * substring of the project-relative path, plus the absolute-prefix and - * relative-spelling arms. It is transcribed, not invented, because the whole - * value of this preflight is that its idea of "selected" equals vitest's. When - * vitest is upgraded, re-read that function; a divergence here can only ever - * produce a wrong diagnostic, never a wrong run. + * ## ⛔ Every uncertainty resolves to SILENCE, never to a guess * - * ⚠️ Two boundaries, stated rather than discovered later: - * - The win32 `slash()` normalisation vitest applies to filters is NOT - * mirrored; this repo's CI and containers are Linux, and the omission can - * only mis-attribute a diagnostic on a platform the suite is not run on. - * - `--changed` / `--related` runs set no `filenamePattern`, so they are - * outside this preflight entirely, which is correct: nothing was named. + * The preflight declines — printing nothing, leaving the run exactly as it was + * — whenever it cannot model the invocation: an argv `parseCLI` refuses, a + * `--project` value that is not one of the tier names (a negation or a glob), + * or a `--changed` / `--related` run, where nothing was named by hand. Silence + * is the status quo, so declining can never make a run worse than it is today; + * a guess could. * - * ## Direction ②, which is the half that makes this accurate rather than loud + * ## Direction ②, the half that makes this accurate rather than merely loud * * `renderLostFilterNotice` returns the EMPTY STRING when no filter was lost, - * and the reporter writes nothing at all in that case. A normal run — no - * filters, or filters that all selected something — therefore contributes zero - * bytes of new output. `test/vitest-project-filter-preflight.test.ts` pins both - * directions of that, and pins that this module is still wired into - * `vitest.config.ts`'s `reporters`, because a preflight nobody registered is - * the same phantom check in a new place. + * and nothing is written in that case. A normal run — no filters, or filters + * that all selected something — contributes zero bytes of new output. + * `test/vitest-project-filter-preflight.test.ts` pins both directions and pins + * that this module is still wired into `vitest.config.ts`, because a preflight + * nobody invoked is the same phantom check in a new place. */ import { isAbsolute, join, relative, resolve } from 'node:path'; -/** The shape of a vitest `TestSpecification` this module reads. */ -export interface SpecificationLike { - readonly moduleId: string; -} +/** Populations to match a filter against, by name — `{ unit, integration }`. */ +export type Populations = Readonly>; -/** The shape of a vitest `TestProject` this module reads. */ -export interface ProjectLike { - readonly name?: string | undefined; +/** One positional filter: what the caller typed, and the path vitest matches with. */ +export interface CliFilter { + /** Exactly as spelled on the command line, `:LINE` suffix included. */ + readonly spelled: string; + /** The filename half, which is what vitest matches against. */ + readonly path: string; } -/** The shape of the `Vitest` instance this module reads. */ -export interface VitestLike { - readonly filenamePattern?: readonly string[] | undefined; - readonly projects?: readonly ProjectLike[] | undefined; +/** What this preflight could make of the command line. */ +export interface Invocation { + readonly filters: readonly CliFilter[]; + /** Project names selected by `--project`; empty means every project. */ + readonly projects: readonly string[]; + /** + * True when the invocation is scoped by something this preflight does not + * model. ⛔ An opaque invocation is reported on by staying silent. + */ + readonly opaque: boolean; } -/** A CLI filter that selected no test file, and where its target actually lives. */ +/** A filter that selected nothing, and where its target actually lives. */ export interface LostFilter { - /** The filter exactly as the caller spelled it on the command line. */ - readonly filter: string; + readonly filter: CliFilter; /** - * Names of the declared populations (here: tier / project names) that DO - * contain a file this filter would have selected. Empty means the filter - * matches nothing in this package at all — a typo, or a path that moved. + * Population names that DO hold a file this filter would have selected. + * Empty means it matches nothing in this package at all — a typo, or a path + * that moved. */ readonly foundIn: readonly string[]; } -/** Populations to attribute a lost filter to, by name — `{ unit, integration }`. */ -export type Populations = Readonly>; +const EMPTY: Invocation = { filters: [], projects: [], opaque: true }; + +/** `parseFilter`, vitest 4.1.11: a trailing `:` is a line number. */ +export function splitLineSuffix(filter: string): CliFilter { + const colon = filter.lastIndexOf(':'); + if (colon === -1) return { spelled: filter, path: filter }; + const tail = filter.slice(colon + 1); + return /^\d+$/.test(tail) + ? { spelled: filter, path: filter.slice(0, colon) } + : { spelled: filter, path: filter }; +} /** - * `TestProject.filterFiles`, vitest 4.1.11, transcribed. `absFile` is an - * absolute module id; `root` is the project root the paths are relative to. + * `TestProject.filterFiles`, vitest 4.1.11, transcribed. `relFile` is a + * project-root-relative test path; `root` is that project root. + * + * ⚠️ The win32 `slash()` normalisation vitest applies to filters is NOT + * mirrored — this repo's CI and containers are Linux, and the omission could + * only ever mis-aim a diagnostic, never a run. */ -export function matchesVitestFilter(absFile: string, filter: string, root: string): boolean { - const testFile = relative(root, absFile).toLocaleLowerCase(); - if (isAbsolute(filter) && absFile.startsWith(filter)) return true; +export function matchesVitestFilter(relFile: string, filter: string, root: string): boolean { + const testFile = relFile.toLocaleLowerCase(); + if (isAbsolute(filter) && resolve(root, relFile).startsWith(filter)) return true; const relativePath = filter.endsWith('/') ? join(relative(root, filter), '/') : relative(root, filter); @@ -148,26 +176,64 @@ export function matchesVitestFilter(absFile: string, filter: string, root: strin } /** - * Which of `filters` selected none of `collected`, and which declared + * Read the command line with vitest's own parser. + * + * ⛔ Any refusal returns an OPAQUE invocation rather than a partial reading: + * `parseCLI` throws on an argv it does not recognise, and a preflight that + * guessed past that would be the very defect this module exists to report. + */ +export function parseInvocation(argv: readonly string[], parse: CliParse): Invocation { + let parsed: { filter: string[]; options: Record }; + try { + parsed = parse(['vitest', ...argv.slice(2)], { allowUnknownOptions: true }); + } catch { + return EMPTY; + } + const { filter, options } = parsed; + // `--changed` / `--related` select by provenance, not by a path anyone typed. + if (options.changed || options.related) return EMPTY; + const project = options.project; + const projects = + project === undefined ? [] : (Array.isArray(project) ? project : [project]).map(String); + return { filters: filter.map(splitLineSuffix), projects, opaque: false }; +} + +/** The shape of `parseCLI` from `vitest/node` that this module uses. */ +export type CliParse = ( + argv: string[], + config?: { allowUnknownOptions?: boolean }, +) => { filter: string[]; options: Record }; + +/** + * Which of the invocation's filters will select no test file at all, and which * population each of those would have matched instead. * - * Pure: `node:path` only, no filesystem and no vitest. `collected` is the - * absolute module id of every specification vitest actually resolved; - * `populations` maps a name to package-root-relative paths. + * Pure: `node:path` only, no filesystem, no vitest, no process. Returns the + * empty array whenever the invocation is opaque or names a project this + * preflight cannot place — ⛔ declining is a verdict about the preflight, never + * about the run. */ export function lostFilters( - filters: readonly string[], - collected: readonly string[], + invocation: Invocation, populations: Populations, root: string, ): LostFilter[] { + const { filters, projects, opaque } = invocation; + if (opaque || filters.length === 0) return []; + const names = Object.keys(populations); + const selected = projects.length ? projects : names; + // A `--project` value that is not a plain tier name (a negation, a glob) is + // not modelled here. ⛔ Decline rather than half-answer. + if (selected.some((name) => !names.includes(name))) return []; + + const inSelected = selected.flatMap((name) => populations[name] ?? []); return filters - .filter((f) => !collected.some((abs) => matchesVitestFilter(abs, f, root))) - .map((f) => ({ - filter: f, - foundIn: Object.entries(populations) - .filter(([, rels]) => rels.some((rel) => matchesVitestFilter(resolve(root, rel), f, root))) - .map(([name]) => name), + .filter(({ path }) => !inSelected.some((rel) => matchesVitestFilter(rel, path, root))) + .map((filter) => ({ + filter, + foundIn: names.filter((name) => + (populations[name] ?? []).some((rel) => matchesVitestFilter(rel, filter.path, root)), + ), })); } @@ -175,8 +241,8 @@ export function lostFilters( * The diagnostic, or the EMPTY STRING when nothing was lost. * * ⛔ The empty string is the contract, not an implementation detail: it is what - * makes a normal run byte-identical. Anything that would print on a healthy run - * belongs somewhere else. + * keeps a healthy run byte-identical. Anything that would print on a healthy + * run belongs somewhere else. */ export function renderLostFilterNotice( lost: readonly LostFilter[], @@ -190,20 +256,20 @@ export function renderLostFilterNotice( : 'every project'; const lines: string[] = [ '', - ` !! FILTER SELECTED NOTHING — ${lost.length} of the ${totalFilters} path(s) you named ran no tests.`, + ` !! FILTER SELECTED NOTHING — ${lost.length} of the ${totalFilters} path(s) you named will run no tests.`, '', ]; for (const { filter, foundIn } of lost) { - lines.push(` ${filter}`); + lines.push(` ${filter.spelled}`); lines.push( foundIn.length ? ` lives in the ${foundIn.map((n) => `\`${n}\``).join(' / ')} tier; this run selected ${selected}.` - : ` matches no test file in this package at all — check the path.`, + : ' matches no test file in this package at all — check the path.', ); if (foundIn.length) { lines.push( - ` run it: pnpm --filter @objectstack/cli exec vitest run --project ${foundIn[0]} ${filter}`, + ` run it: pnpm --filter @objectstack/cli exec vitest run --project ${foundIn[0]} ${filter.path}`, ); } lines.push(''); @@ -219,48 +285,38 @@ export function renderLostFilterNotice( return lines.join('\n'); } +let armed = false; + /** - * The vitest reporter. Writes the notice to stderr at the START of the run (so - * it precedes the results) and again at the END (so it survives the summary, - * which is where a reader looks for `Test Files N passed`). Writes nothing — - * not one byte — when no filter was lost. + * Run the preflight for this process and, if anything was lost, say so twice: + * once now — config load, so it precedes vitest's banner — and once from an + * `exit` listener, so it also lands BELOW the summary, which is where a reader + * looks for `Test Files N passed`. Writes nothing at all, and registers no + * listener, when nothing was lost. + * + * ⛔ `process.on('exit')` rather than a reporter hook: see this file's header + * on what naming `test.reporters` costs. */ -export function tierFilterPreflight(options: { root: string; populations: Populations }): { - onInit(vitest: VitestLike): void; - onTestRunStart(specifications: readonly SpecificationLike[]): void; - onTestRunEnd(): void; -} { - const { root, populations } = options; - let vitest: VitestLike | undefined; - let notice = ''; - - return { - onInit(v) { - // ⛔ `filenamePattern` is NOT readable yet — see this file's header. - vitest = v; - }, - onTestRunStart(specifications) { - const filters = vitest?.filenamePattern ?? []; - notice = ''; - if (filters.length === 0) return; - const collected = [...new Set(specifications.map((s) => s.moduleId))]; - // ⛔ Read the selected projects from vitest, never from what was collected: - // when EVERY filter is lost nothing is collected, and an inferred list - // would then report "every project" for a run that named one. - // `--project X` narrows `Vitest.projects` itself — `printNoTestFound` - // reads the same array to print its own `|unit|` block. - const selectedProjects = (vitest?.projects ?? []) - .map((p) => p.name) - .filter((n): n is string => Boolean(n)); - notice = renderLostFilterNotice( - lostFilters(filters, collected, populations, root), - filters.length, - selectedProjects, - ); - if (notice) process.stderr.write(notice); - }, - onTestRunEnd() { - if (notice) process.stderr.write(notice); - }, - }; +export function runFilterPreflight(options: { + argv: readonly string[]; + root: string; + populations: Populations; + parse: CliParse; + write?: (text: string) => void; +}): string { + const { argv, root, populations, parse } = options; + const write = options.write ?? ((text: string) => void process.stderr.write(text)); + const invocation = parseInvocation(argv, parse); + const notice = renderLostFilterNotice( + lostFilters(invocation, populations, root), + invocation.filters.length, + invocation.projects, + ); + if (!notice) return ''; + write(notice); + if (!armed) { + armed = true; + process.on('exit', () => write(notice)); + } + return notice; } diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 3f7b9d1de0..b627fb7758 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -528,10 +528,10 @@ // and CI went red on `Test Core` with the failing assertion inside the very // integration-tier file the local run had discarded. // -// ⇒ `vitest-filter-preflight.ts` is wired into `reporters` below and now says -// so: every named path that selected no test file is reported BY NAME, with the +// ⇒ `vitest-filter-preflight.ts` runs at config load below and now says so: +// every named path that will select no test file is reported BY NAME, with the // tier it really lives in and the command that runs it. It prints nothing at -// all when every named path selected something, so a healthy narrowed run is +// all when every named path selects something, so a healthy narrowed run is // byte-identical to what it was before this existed. ⛔ Before accepting a // narrowed run as pre-delivery verification, read that line — or run the full // `test` target above, which is the only one of the three whose green is a @@ -643,7 +643,8 @@ // `node_modules` exclusion: an exact-path list matches nothing it does not name. import { defineConfig } from 'vitest/config'; import path from 'path'; -import { tierFilterPreflight } from './vitest-filter-preflight.js'; +import { parseCLI } from 'vitest/node'; +import { runFilterPreflight } from './vitest-filter-preflight.js'; import { integrationTestFiles, unitTestFiles } from './vitest-tiers.js'; // The two tiers, DERIVED from what the files DO — never written down — over @@ -655,6 +656,22 @@ import { integrationTestFiles, unitTestFiles } from './vitest-tiers.js'; export const INTEGRATION_FILES = integrationTestFiles(__dirname); export const UNIT_FILES = unitTestFiles(__dirname, INTEGRATION_FILES); +// #17853 — say so when a path named on the command line will run no tests. It +// is invoked HERE, at config load, and ⛔ deliberately NOT as a `test.reporters` +// entry: naming that option replaces vitest's own reporter defaulting instead +// of extending it, which measurably changes a healthy run's output and would +// drop the `github-actions` reporter in CI. `vitest-filter-preflight.ts` carries +// both measurements. It reads the argv through vitest's own exported parser and +// the SAME two derived arrays the projects below take as their `include` — ⛔ +// never a second derivation — and writes nothing whatever unless a named path +// selects nothing. +runFilterPreflight({ + argv: process.argv, + root: __dirname, + populations: { unit: UNIT_FILES, integration: INTEGRATION_FILES }, + parse: parseCLI, +}); + export default defineConfig({ resolve: { // Array form with an ANCHORED pattern, per the trap the gate documents: @@ -732,21 +749,6 @@ export default defineConfig({ external: [/packages[\/]types[\/]dist/], }, }, - // #17853 — the preflight the tier header above describes. `'default'` is - // vitest's own default reporter, restated because naming `reporters` at all - // replaces the default list rather than extending it; the second entry adds - // output ONLY when a path named on the command line selected no test file, - // so a run that loses nothing is byte-identical to one without it. The two - // populations are the SAME derived arrays the projects below use as their - // `include` — ⛔ never a second derivation, which would be a copy of a fact - // and would go stale exactly where this one cannot. - reporters: [ - 'default', - tierFilterPreflight({ - root: __dirname, - populations: { unit: UNIT_FILES, integration: INTEGRATION_FILES }, - }), - ], // The two tiers (#13504) — see the header section of the same name, and // "THE NIGHTLY TIERS" for the population both read. Both `extends: true` // so each project inherits the `resolve.alias` table and the From 9c7feb015c8b8748b8bea4e3bf735f52645cd041 Mon Sep 17 00:00:00 2001 From: objectstack-dev Date: Sun, 13 Sep 2026 07:53:51 +0000 Subject: [PATCH 3/4] fix(cli): type the parseCLI contract structurally so no cast is needed vitest's `CliOptions` is an interface, so it is not assignable to `Record`; the cast that would hide that is also what would stop a vitest upgrade from surfacing a renamed option as a type error. Declare the three fields this module reads instead, and hand the real `parseCLI` to the pin unaltered so the compatibility is pinned. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- .../vitest-project-filter-preflight.test.ts | 4 +++- packages/cli/vitest-filter-preflight.ts | 20 +++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/cli/test/vitest-project-filter-preflight.test.ts b/packages/cli/test/vitest-project-filter-preflight.test.ts index 60b02a2f11..087cd6b169 100644 --- a/packages/cli/test/vitest-project-filter-preflight.test.ts +++ b/packages/cli/test/vitest-project-filter-preflight.test.ts @@ -72,7 +72,9 @@ const U1 = 'src/utils/format.exit-code.test.ts'; const U2 = 'test/commands.test.ts'; const I1 = 'test/i18n-extract-companion-orphan.test.ts'; const POPULATIONS = { unit: [U1, U2], integration: [I1] }; -const parse = parseCLI as unknown as CliParse; +// ⛔ No cast: vitest's real `parseCLI` is handed in unaltered, so this line is +// itself the pin that `CliParse` still describes the parser vitest ships. +const parse: CliParse = parseCLI; /** A real `vitest run …` command line, as `process.argv` would carry it. */ const argv = (...args: string[]): string[] => ['/usr/bin/node', '/x/vitest.mjs', 'run', ...args]; diff --git a/packages/cli/vitest-filter-preflight.ts b/packages/cli/vitest-filter-preflight.ts index 89e6fc35af..8503c90e44 100644 --- a/packages/cli/vitest-filter-preflight.ts +++ b/packages/cli/vitest-filter-preflight.ts @@ -183,7 +183,7 @@ export function matchesVitestFilter(relFile: string, filter: string, root: strin * guessed past that would be the very defect this module exists to report. */ export function parseInvocation(argv: readonly string[], parse: CliParse): Invocation { - let parsed: { filter: string[]; options: Record }; + let parsed: { filter: string[]; options: CliParseResultOptions }; try { parsed = parse(['vitest', ...argv.slice(2)], { allowUnknownOptions: true }); } catch { @@ -198,11 +198,27 @@ export function parseInvocation(argv: readonly string[], parse: CliParse): Invoc return { filters: filter.map(splitLineSuffix), projects, opaque: false }; } +/** + * The three `parseCLI` options this module reads, typed STRUCTURALLY so that + * vitest's own `CliOptions` satisfies it without a cast. + * + * ⛔ Not `Record`: an interface has no index signature, so + * vitest's `CliOptions` is not assignable to one, and the cast that would paper + * over it is exactly what stops a vitest upgrade from reporting a renamed + * option here as a type error. `packages/cli/test/…preflight.test.ts` passes the + * real `parseCLI` in unaltered, so this compatibility is pinned, not assumed. + */ +export interface CliParseResultOptions { + readonly project?: string | string[] | undefined; + readonly changed?: boolean | string | undefined; + readonly related?: string | string[] | undefined; +} + /** The shape of `parseCLI` from `vitest/node` that this module uses. */ export type CliParse = ( argv: string[], config?: { allowUnknownOptions?: boolean }, -) => { filter: string[]; options: Record }; +) => { filter: string[]; options: CliParseResultOptions }; /** * Which of the invocation's filters will select no test file at all, and which From cc49814d84f0bfa21851f1731d3207cbb5614983 Mon Sep 17 00:00:00 2001 From: objectstack-dev Date: Sun, 13 Sep 2026 08:12:54 +0000 Subject: [PATCH 4/4] fix(cli): announce the filter preflight once per process, not once per load vitest loads this config once per project and each load is its own module instance, so the module-level flag guarded nothing: measured, three loads printed the notice three times above the banner and three more at exit. Move the guard onto a scope object defaulting to globalThis, and pin it by calling twice on one scope. A repeat call still returns the notice to its caller; returning the empty string there would read as "nothing lost". Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- .../vitest-project-filter-preflight.test.ts | 32 ++++++++++++++++- packages/cli/vitest-filter-preflight.ts | 34 +++++++++++++------ 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/packages/cli/test/vitest-project-filter-preflight.test.ts b/packages/cli/test/vitest-project-filter-preflight.test.ts index 087cd6b169..4e1ecc7797 100644 --- a/packages/cli/test/vitest-project-filter-preflight.test.ts +++ b/packages/cli/test/vitest-project-filter-preflight.test.ts @@ -79,14 +79,19 @@ const parse: CliParse = parseCLI; /** A real `vitest run …` command line, as `process.argv` would carry it. */ const argv = (...args: string[]): string[] => ['/usr/bin/node', '/x/vitest.mjs', 'run', ...args]; -/** Run the preflight with a capturing writer instead of stderr. */ +/** + * Run the preflight with a capturing writer and a FRESH once-guard scope, so + * each case behaves like its own process. + */ function preflight(...args: string[]): { notice: string; writes: string[] } { const writes: string[] = []; + const scope: Record = {}; const notice = runFilterPreflight({ argv: argv(...args), root: PKG, populations: POPULATIONS, parse, + scope, write: (text) => void writes.push(text), }); return { notice, writes }; @@ -123,6 +128,31 @@ describe('① a path that will run no tests is reported by name', () => { expect(only.notice).toContain('this run selected project `unit`'); }); + it('announces ONCE per process, however many times the config is loaded', () => { + // ⛔ vitest loads this config once per project and each load is its own + // module instance, so a module-level flag guards nothing: before the scope + // guard existed, three loads printed three notices at the top and three + // more at exit. The guard is pinned here by calling twice on one scope. + const writes: string[] = []; + const scope: Record = {}; + const once = (): string => + runFilterPreflight({ + argv: argv('--project', 'unit', U1, I1), + root: PKG, + populations: POPULATIONS, + parse, + scope, + write: (text) => void writes.push(text), + }); + const first = once(); + const second = once(); + expect(first).not.toBe(''); + // ⛔ The second call still REPORTS the notice to its caller — it just does + // not write it again. Returning '' would read as "nothing was lost". + expect(second).toBe(first); + expect(writes).toHaveLength(1); + }); + it('separates a path in the other tier from one in no tier at all', () => { const typo = preflight('--project', 'unit', U1, 'test/no-such-file.test.ts'); expect(typo.notice).toContain('matches no test file in this package at all'); diff --git a/packages/cli/vitest-filter-preflight.ts b/packages/cli/vitest-filter-preflight.ts index 8503c90e44..af155524bf 100644 --- a/packages/cli/vitest-filter-preflight.ts +++ b/packages/cli/vitest-filter-preflight.ts @@ -301,17 +301,30 @@ export function renderLostFilterNotice( return lines.join('\n'); } -let armed = false; +/** + * The once-guard key. ⛔ It lives on a SCOPE OBJECT (`globalThis` in a real + * run), never in a module-level binding: vitest loads this config once per + * project, each load gets its own module instance, and a module-level flag + * therefore guards nothing. Measured before this existed — three projects' + * worth of loads printed the notice three times at the top and three more at + * exit, six copies of one diagnostic. + */ +const ANNOUNCED = '__objectstackCliFilterPreflightAnnounced'; /** - * Run the preflight for this process and, if anything was lost, say so twice: - * once now — config load, so it precedes vitest's banner — and once from an - * `exit` listener, so it also lands BELOW the summary, which is where a reader - * looks for `Test Files N passed`. Writes nothing at all, and registers no - * listener, when nothing was lost. + * Run the preflight for this process and, if anything will be lost, say so + * twice: once now — config load, so it precedes vitest's banner — and once from + * an `exit` listener, so it also lands BELOW the summary, which is where a + * reader looks for `Test Files N passed`. Writes nothing at all, and registers + * no listener, when nothing is lost. * * ⛔ `process.on('exit')` rather than a reporter hook: see this file's header * on what naming `test.reporters` costs. + * + * `scope` is the once-guard's home and defaults to `globalThis`, so repeated + * config loads in ONE process announce once. A caller passing a fresh object + * gets a fresh process's behaviour, which is how the pin exercises both a first + * announcement and a repeat. */ export function runFilterPreflight(options: { argv: readonly string[]; @@ -319,9 +332,11 @@ export function runFilterPreflight(options: { populations: Populations; parse: CliParse; write?: (text: string) => void; + scope?: Record; }): string { const { argv, root, populations, parse } = options; const write = options.write ?? ((text: string) => void process.stderr.write(text)); + const scope = options.scope ?? (globalThis as unknown as Record); const invocation = parseInvocation(argv, parse); const notice = renderLostFilterNotice( lostFilters(invocation, populations, root), @@ -329,10 +344,9 @@ export function runFilterPreflight(options: { invocation.projects, ); if (!notice) return ''; + if (scope[ANNOUNCED]) return notice; + scope[ANNOUNCED] = true; write(notice); - if (!armed) { - armed = true; - process.on('exit', () => write(notice)); - } + process.on('exit', () => write(notice)); return notice; }