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 00000000000..4e1ecc77978 --- /dev/null +++ b/packages/cli/test/vitest-project-filter-preflight.test.ts @@ -0,0 +1,277 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * 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 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 selects nothing is returned by + * `lostFilters`, attributed to the tier it really lives in, and rendered + * 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 part is asserted by name. + * + * 2. **HEALTHY IS SILENT — the half that makes this accurate instead of merely + * 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. **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. + * + * 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. + */ + +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 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] }; +// ⛔ 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]; + +/** + * 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 }; +} + +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('names the discarded path', () => { + expect(notice).toContain(I1); + }); + + 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('names a command that would actually run it', () => { + expect(notice).toContain(`--project integration ${I1}`); + }); + + it('counts against the command line, not against the lost set', () => { + expect(notice).toContain('1 of the 3 path(s)'); + }); + + 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('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'); + 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('② 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('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(''); + }); +}); + +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('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 reading mirrors vitest 4.1.11', () => { + it('matches a substring of the project-relative path', () => { + expect(matchesVitestFilter(U2, 'commands', PKG)).toBe(true); + }); + + it('matches case-insensitively, as vitest does', () => { + expect(matchesVitestFilter(U2, 'COMMANDS.TEST', PKG)).toBe(true); + }); + + it('matches an absolute filter by prefix', () => { + expect(matchesVitestFilter(U2, join(PKG, 'test'), PKG)).toBe(true); + }); + + it('does not match an unrelated path', () => { + 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 wired — ⛔ an uninvoked one is a phantom check', () => { + const config = maskComments(readFileSync(join(PKG, 'vitest.config.ts'), 'utf8')); + + 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('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 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/tsconfig.test.json b/packages/cli/tsconfig.test.json index 53b30370ede..90a6f8d34a7 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 00000000000..af155524bf3 --- /dev/null +++ b/packages/cli/vitest-filter-preflight.ts @@ -0,0 +1,352 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * 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: + * `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 + * => 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, 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. + * + * ⇒ "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. + * + * ## ⛔ 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', {}]); + * } + * + * 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. + * + * ⇒ The reporter list belongs to vitest. This preflight runs at CONFIG LOAD + * instead and touches no vitest seam at all. + * + * ## What it reads, and why nothing here parses argv by hand + * + * `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. + * + * 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. + * + * `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. + * + * ## ⛔ Every uncertainty resolves to SILENCE, never to a guess + * + * 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 ②, the half that makes this accurate rather than merely loud + * + * `renderLostFilterNotice` returns the EMPTY STRING when no filter was lost, + * 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'; + +/** Populations to match a filter against, by name — `{ unit, integration }`. */ +export type Populations = Readonly>; + +/** 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; +} + +/** 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 filter that selected nothing, and where its target actually lives. */ +export interface LostFilter { + readonly filter: CliFilter; + /** + * 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[]; +} + +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. `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(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); + return ( + testFile.includes(filter.toLocaleLowerCase()) || + testFile.includes(relativePath.toLocaleLowerCase()) + ); +} + +/** + * 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: CliParseResultOptions }; + 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 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: CliParseResultOptions }; + +/** + * 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, 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( + 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(({ path }) => !inSelected.some((rel) => matchesVitestFilter(rel, path, root))) + .map((filter) => ({ + filter, + foundIn: names.filter((name) => + (populations[name] ?? []).some((rel) => matchesVitestFilter(rel, filter.path, root)), + ), + })); +} + +/** + * The diagnostic, or the EMPTY STRING when nothing was lost. + * + * ⛔ The empty string is the contract, not an implementation detail: it is what + * keeps a healthy 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 will run no tests.`, + '', + ]; + + for (const { filter, foundIn } of lost) { + 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.', + ); + if (foundIn.length) { + lines.push( + ` run it: pnpm --filter @objectstack/cli exec vitest run --project ${foundIn[0]} ${filter.path}`, + ); + } + 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 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 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[]; + root: string; + 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), + invocation.filters.length, + invocation.projects, + ); + if (!notice) return ''; + if (scope[ANNOUNCED]) return notice; + scope[ANNOUNCED] = true; + write(notice); + process.on('exit', () => write(notice)); + return notice; +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index fe4eb15745e..b627fb77586 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` 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 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 +// 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,8 @@ // `node_modules` exclusion: an exact-path list matches nothing it does not name. import { defineConfig } from 'vitest/config'; import path from 'path'; +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 @@ -624,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: