From 973fc86030c4e84b3994f5d008edc23c6c44f332 Mon Sep 17 00:00:00 2001 From: os-warren Date: Mon, 14 Sep 2026 02:27:12 +0000 Subject: [PATCH 1/2] feat(qa): one shared vitest filter preflight for all eight project-declaring packages Claude-Session: https://claude.ai/code/session_01TbSMtGzMrtPwh925wDEZd5 Co-authored-by: Claude --- .../vitest-project-filter-preflight.test.ts | 277 ------------- packages/cli/tsconfig.test.json | 9 +- packages/cli/vitest.config.ts | 31 +- packages/core/vitest.config.ts | 32 ++ packages/objectql/vitest.config.ts | 32 ++ packages/qa/dogfood/vitest.config.ts | 33 ++ .../qa/vitest-filter-preflight/package.json | 34 ++ .../vitest-filter-preflight/src/index.ts} | 232 ++++++++--- .../test/config-wiring-sweep.test.ts | 202 ++++++++++ .../test/filter-preflight.test.ts | 368 ++++++++++++++++++ .../qa/vitest-filter-preflight/tsconfig.json | 19 + .../vitest-filter-preflight/vitest.config.ts | 22 ++ packages/rest/vitest.config.ts | 32 ++ packages/runtime/vitest.config.ts | 32 ++ packages/spec/vitest.config.ts | 32 ++ packages/types/vitest.config.ts | 32 ++ pnpm-lock.yaml | 12 + 17 files changed, 1090 insertions(+), 341 deletions(-) delete mode 100644 packages/cli/test/vitest-project-filter-preflight.test.ts create mode 100644 packages/qa/vitest-filter-preflight/package.json rename packages/{cli/vitest-filter-preflight.ts => qa/vitest-filter-preflight/src/index.ts} (55%) create mode 100644 packages/qa/vitest-filter-preflight/test/config-wiring-sweep.test.ts create mode 100644 packages/qa/vitest-filter-preflight/test/filter-preflight.test.ts create mode 100644 packages/qa/vitest-filter-preflight/tsconfig.json create mode 100644 packages/qa/vitest-filter-preflight/vitest.config.ts diff --git a/packages/cli/test/vitest-project-filter-preflight.test.ts b/packages/cli/test/vitest-project-filter-preflight.test.ts deleted file mode 100644 index 4e1ecc7797..0000000000 --- a/packages/cli/test/vitest-project-filter-preflight.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -// 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 90a6f8d34a..d5cb7cb93c 100644 --- a/packages/cli/tsconfig.test.json +++ b/packages/cli/tsconfig.test.json @@ -174,14 +174,15 @@ // ⛔ 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. + // it the moment that import goes. `vitest-filter-preflight.ts` was the fourth + // for that reason; #17978 moved that module to + // `packages/qa/vitest-filter-preflight`, which type-checks it in its own + // program, so the entry leaves with the file rather than dangling here. "include": [ "test/**/*", "vitest.config.ts", "vitest-tiers.ts", - "vitest-tiers.fixtures.ts", - "vitest-filter-preflight.ts" + "vitest-tiers.fixtures.ts" ], "exclude": ["node_modules", "dist"] } diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index b627fb7758..837c2d1319 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -644,7 +644,7 @@ import { defineConfig } from 'vitest/config'; import path from 'path'; import { parseCLI } from 'vitest/node'; -import { runFilterPreflight } from './vitest-filter-preflight.js'; +import { runFilterPreflight } from '../qa/vitest-filter-preflight/src/index.js'; import { integrationTestFiles, unitTestFiles } from './vitest-tiers.js'; // The two tiers, DERIVED from what the files DO — never written down — over @@ -656,18 +656,29 @@ 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. +// #17853 / #17978 — 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. The ONE shared +// transcription of vitest's `TestProject.filterFiles` — +// `packages/qa/vitest-filter-preflight` — carries both measurements, and it is +// imported by RELATIVE PATH rather than by its package name for a third measured +// reason recorded in its header. 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. +// +// ⭐ This package is the ONE of the eight that needs no walked population: both +// of its projects take an exact-path `include`, as a by-product of the tier walk +// it already performs for unrelated reasons (#13504 / #14554). So it hands the +// two arrays over directly and never calls `exactAndGlobPopulations`. That +// asymmetry is exactly why a port of this package's former local copy could not +// serve the other seven — #17978 carries the measurement. runFilterPreflight({ argv: process.argv, root: __dirname, + packageName: '@objectstack/cli', populations: { unit: UNIT_FILES, integration: INTEGRATION_FILES }, parse: parseCLI, }); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index cbe17ecde7..a8d233a874 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -3,6 +3,11 @@ import { configDefaults, defineConfig } from 'vitest/config'; import { readFileSync } from 'node:fs'; import path from 'path'; +import { parseCLI } from 'vitest/node'; +import { + exactAndGlobPopulations, + runFilterPreflight, +} from '../qa/vitest-filter-preflight/src/index.js'; // #16466 -- two vitest projects, two turbo tasks. `repo` owns the tests that read // outside this package (the list is vitest.repo-tests.json, which @@ -12,6 +17,33 @@ import path from 'path'; // repo. `extends: true` keeps the root options (aliases included) on both. const REPO_TESTS: string[] = JSON.parse(readFileSync(path.join(__dirname, 'vitest.repo-tests.json'), 'utf8')); +// #17853 / #17978 — say so when a path named on the command line will run no +// tests. 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. The ONE shared +// transcription of vitest's `TestProject.filterFiles` carries both measurements, +// and it is imported by RELATIVE PATH rather than by its package name for a +// third measured reason recorded in its header. It reads the argv through +// vitest's own exported parser and writes nothing whatever unless a named path +// selects nothing. +// +// `local` takes a GLOB `include`, so its population is derived as a deliberate +// SUPERSET — every test file under this package, minus `REPO_TESTS` — which +// makes a false accusation structurally impossible and leaves drift able only +// to under-report. ⛔ Not a second run of vitest's own glob engine. +runFilterPreflight({ + argv: process.argv, + root: __dirname, + packageName: '@objectstack/core', + populations: exactAndGlobPopulations({ + root: __dirname, + exact: { repo: REPO_TESTS }, + globProject: 'local', + }), + parse: parseCLI, +}); + export default defineConfig({ test: { // Each project re-declares the root block's test options: a ROOT-level diff --git a/packages/objectql/vitest.config.ts b/packages/objectql/vitest.config.ts index 6113c8ad2c..e82ee14bd0 100644 --- a/packages/objectql/vitest.config.ts +++ b/packages/objectql/vitest.config.ts @@ -43,6 +43,11 @@ import { configDefaults, defineConfig } from 'vitest/config'; import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { parseCLI } from 'vitest/node'; +import { + exactAndGlobPopulations, + runFilterPreflight, +} from '../qa/vitest-filter-preflight/src/index.js'; // #16466 -- two vitest projects, two turbo tasks. `repo` owns the tests that read // outside this package (the list is vitest.repo-tests.json, which @@ -52,6 +57,33 @@ import path from 'node:path'; // repo. `extends: true` keeps the root options (aliases included) on both. const REPO_TESTS: string[] = JSON.parse(readFileSync(path.join(__dirname, 'vitest.repo-tests.json'), 'utf8')); +// #17853 / #17978 — say so when a path named on the command line will run no +// tests. 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. The ONE shared +// transcription of vitest's `TestProject.filterFiles` carries both measurements, +// and it is imported by RELATIVE PATH rather than by its package name for a +// third measured reason recorded in its header. It reads the argv through +// vitest's own exported parser and writes nothing whatever unless a named path +// selects nothing. +// +// `local` takes a GLOB `include`, so its population is derived as a deliberate +// SUPERSET — every test file under this package, minus `REPO_TESTS` — which +// makes a false accusation structurally impossible and leaves drift able only +// to under-report. ⛔ Not a second run of vitest's own glob engine. +runFilterPreflight({ + argv: process.argv, + root: __dirname, + packageName: '@objectstack/objectql', + populations: exactAndGlobPopulations({ + root: __dirname, + exact: { repo: REPO_TESTS }, + globProject: 'local', + }), + parse: parseCLI, +}); + export default defineConfig({ test: { // Each project re-declares the root block's test options: a ROOT-level diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index ef6b2f3aba..54888efafa 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -40,6 +40,11 @@ // HERE, in the harness, where the test author can see it. import { defineConfig } from 'vitest/config'; import path from 'path'; +import { parseCLI } from 'vitest/node'; +import { + exactAndGlobPopulations, + runFilterPreflight, +} from '../vitest-filter-preflight/src/index.js'; // Files proven eligible for the worker-shared plain showcase stack. const SHARED_SHOWCASE = [ @@ -58,6 +63,34 @@ const SHARED_SHOWCASE = [ 'test/two-doors-permission.dogfood.test.ts', ]; +// #17853 / #17978 — say so when a path named on the command line will run no +// tests. 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. The ONE shared +// transcription of vitest's `TestProject.filterFiles` carries both measurements, +// and it is imported by RELATIVE PATH rather than by its package name for a +// third measured reason recorded in its header. It reads the argv through +// vitest's own exported parser and writes nothing whatever unless a named path +// selects nothing. +// +// `isolated` takes a GLOB `include`, so its population is derived as a +// deliberate SUPERSET — every test file under this package, minus +// `SHARED_SHOWCASE` — which makes a false accusation structurally impossible and +// leaves drift able only to under-report. ⛔ Not a second run of vitest's own +// glob engine. +runFilterPreflight({ + argv: process.argv, + root: __dirname, + packageName: '@objectstack/dogfood', + populations: exactAndGlobPopulations({ + root: __dirname, + exact: { 'shared-showcase': SHARED_SHOWCASE }, + globProject: 'isolated', + }), + parse: parseCLI, +}); + export default defineConfig({ test: { projects: [ diff --git a/packages/qa/vitest-filter-preflight/package.json b/packages/qa/vitest-filter-preflight/package.json new file mode 100644 index 0000000000..2e5b4ad258 --- /dev/null +++ b/packages/qa/vitest-filter-preflight/package.json @@ -0,0 +1,34 @@ +{ + "name": "@objectstack/vitest-filter-preflight", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "description": "The ONE transcription of vitest's `TestProject.filterFiles` (#17853, #17978). A positional file filter that selects nothing is dropped silently once any other filter selects something, so a narrowed run reads green while a named path ran no tests. Every package that declares vitest `projects` invokes this module from its own config; the whole point is that there is exactly one copy to re-read on a vitest upgrade. Not published, no build step, `exports` straight at `src` — but ⛔ consumers import the SOURCE PATH relatively, never this bare name: see the measurement in src/index.ts.", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "typescript": "^6.0.3", + "vitest": "^4.1.11" + }, + "keywords": [ + "objectstack", + "qa", + "testkit", + "vitest" + ], + "author": "ObjectStack", + "repository": { + "type": "git", + "url": "https://github.com/objectstack-ai/objectstack.git", + "directory": "packages/qa/vitest-filter-preflight" + }, + "homepage": "https://objectstack.ai/docs", + "bugs": "https://github.com/objectstack-ai/objectstack/issues" +} diff --git a/packages/cli/vitest-filter-preflight.ts b/packages/qa/vitest-filter-preflight/src/index.ts similarity index 55% rename from packages/cli/vitest-filter-preflight.ts rename to packages/qa/vitest-filter-preflight/src/index.ts index af155524bf..c0f429a687 100644 --- a/packages/cli/vitest-filter-preflight.ts +++ b/packages/qa/vitest-filter-preflight/src/index.ts @@ -2,28 +2,22 @@ /** * A vitest FILE FILTER that selects nothing must say so — even when the rest - * of the same run selects something (#17853). + * of the same run selects something (#17853, #17978). * * ## 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` + * together with the filters and the projects, and the run is red. * * ⭐ 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 + * are dropped with no diagnostic of any kind. The run prints the same + * `Test Files N passed` summary as the run that named only the surviving paths, + * 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. @@ -38,6 +32,55 @@ * `Test Core` with the failing assertion inside an integration-tier file that * the local run had discarded. * + * ⭐ AND IT IS NOT `--project`-SPECIFIC. A bare `vitest run ` with + * no `--project` at all takes the same code path: exit 0, `Test Files 1 passed`, + * the typo named nowhere. Detecting that needs exactly what detecting the + * narrowed case needs — knowledge of the package's FULL test population — which + * is why both live here rather than in two mechanisms. + * + * ## ⛔ WHY THERE IS EXACTLY ONE COPY OF THIS FILE (#17978) + * + * `matchesVitestFilter` below is a TRANSCRIPTION of a private vitest code path. + * Eight packages in this repo declare vitest `projects` and every one of them + * needs it. Copying the transcription per package means eight readings of + * vitest's internals drifting from vitest and from each other, and every drift + * fails SILENTLY GREEN — the same failure direction as the defect. One copy is + * the only version of this that survives a vitest bump: on an upgrade, re-read + * `TestProject.filterFiles` and `parseFilter` in `dist/chunks/cli-api.*.js` + * ONCE, here. + * + * ## ⛔ WHY CONSUMERS IMPORT THIS FILE BY RELATIVE PATH, not by its package name + * + * This package's `exports` points straight at `src`, so the obvious call site + * would be `import { … } from '@objectstack/vitest-filter-preflight'`. MEASURED + * on this tree and rejected: + * + * - Vite bundles a config's RELATIVE imports through esbuild, which transpiles + * TypeScript. It EXTERNALISES bare specifiers instead, resolving them to a + * real path and leaving Node to load it. Resolved here, that path is a + * `.ts` file. + * - Node ≥ 22.18 strips types by default, so the bare form loads — with no + * warning, which is exactly what makes it dangerous. Re-run with + * `NODE_OPTIONS=--no-experimental-strip-types` and the config does not load + * at all: `TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension + * ".ts"`, `failed to load config from …`. That is EVERY test in the + * consuming package, not a degraded diagnostic. + * - This repo declares `engines.node: ">=22.0.0"`. So on a supported Node the + * bare form turns a silent-drop defect into a total harness outage. + * + * ⛔ The other two escapes are worse, not better. Building this package to + * `dist` would make eight test harnesses' CONFIG LOAD depend on build state — + * and `pnpm --filter exec vitest run `, the very invocation this + * card is about, runs no build, so a missing `dist` is again a config-load + * crash. Authoring it as plain `.mjs` would drop the types, and the types are + * load-bearing: `CliParseResultOptions` is declared STRUCTURALLY so that a + * vitest upgrade renaming one of the three options it reads is a type error + * here instead of a silent decline. + * + * ⇒ Each consumer spells a relative path to `src/index.js`, which esbuild + * inlines and transpiles. The same mechanism `packages/cli/vitest-tiers.ts` + * already uses for `../../scripts/nightly-tiers.mjs`. + * * ## ⛔ WHY THIS IS NOT A REPORTER, which was the first thing tried * * A reporter is the obvious seam — `Vitest.filenamePattern` is the CLI filter @@ -53,8 +96,8 @@ * 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: + * So `reporters: ['default', preflight]` has two costs, one measured 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 @@ -74,42 +117,35 @@ * 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. + * The other input is each project's POPULATION. How a caller obtains one is the + * one thing that differs across the eight packages, and it is the reason a + * straight port of the `packages/cli` original could not serve any of the other + * seven — see `exactAndGlobPopulations` below. * * ## ⛔ 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. + * `--project` value that is not one of the population 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. + * `test/filter-preflight.test.ts` pins both directions and + * `test/config-wiring-sweep.test.ts` pins that every config that declares + * `projects` still invokes this module, because a preflight nobody invoked is + * the same phantom check in a new place. */ +import { readdirSync } from 'node:fs'; import { isAbsolute, join, relative, resolve } from 'node:path'; -/** Populations to match a filter against, by name — `{ unit, integration }`. */ +/** Populations to match a filter against, by project name — `{ repo, local }`. */ export type Populations = Readonly>; /** One positional filter: what the caller typed, and the path vitest matches with. */ @@ -145,6 +181,17 @@ export interface LostFilter { const EMPTY: Invocation = { filters: [], projects: [], opaque: true }; +/** + * The test-file family vitest's own `configDefaults.include` names — + * `**\/*.{test,spec}.?(c|m)[jt]s?(x)` — as a basename predicate. + * + * ⛔ Deliberately NOT vitest's glob engine: see `testFilesUnder`. + */ +const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/; + +/** Directory names never walked. Both are in vitest's default `exclude` too. */ +const NEVER_WALKED: readonly string[] = ['node_modules', 'dist']; + /** `parseFilter`, vitest 4.1.11: a trailing `:` is a line number. */ export function splitLineSuffix(filter: string): CliFilter { const colon = filter.lastIndexOf(':'); @@ -175,6 +222,77 @@ export function matchesVitestFilter(relFile: string, filter: string, root: strin ); } +/** + * Every test file under `root`, package-root-relative, POSIX-separated, sorted. + * + * ⭐ THIS IS DELIBERATELY A SUPERSET of what any one glob project will collect, + * and the superset direction is the whole safety argument (#17978). A population + * that is too BIG can only ever make this preflight say LESS than it could: a + * filter is reported lost only when it matches NOTHING in the population, and + * matching nothing in a superset implies matching nothing in the real set. ⇒ a + * false accusation against a healthy run is structurally impossible, and any + * drift between this walk and vitest's collection can only under-report. A + * population that were too SMALL would have the opposite, unacceptable failure + * mode. + * + * ⛔ NOT vitest's own glob engine (tinyglobby) with the project's own patterns. + * That would be a SECOND inheritance of vitest's internals — the exact thing + * this module exists to stop multiplying — and it would buy nothing, because the + * superset already cannot accuse. + * + * Only `node_modules` and `dist` are skipped. Both are in vitest's default + * `exclude`, so skipping them cannot drop a file vitest would collect; skipping + * FEWER directories than vitest is always safe here, skipping more is not. + */ +export function testFilesUnder(root: string): string[] { + const found: string[] = []; + const walk = (dir: string, prefix: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (NEVER_WALKED.includes(entry.name)) continue; + walk(join(dir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name); + } else if (entry.isFile() && TEST_FILE.test(entry.name)) { + found.push(prefix ? `${prefix}/${entry.name}` : entry.name); + } + } + }; + walk(root, ''); + return found.sort(); +} + +/** + * Populations for the shape SEVEN of the eight packages have: one project whose + * `include` is an EXPLICIT LIST `L`, paired with one project whose `include` is + * a GLOB that excludes `L`. + * + * ⚠️ This is the component the `packages/cli` original had no need of and could + * not express, which is why porting that file was not an option for any of the + * other seven: its `Populations` is a record of CONCRETE paths and a glob + * pattern cannot be a member of one. `packages/cli` satisfies the concrete-path + * contract only as a by-product of a tier walk it already performed for + * unrelated reasons (#13504 / #14554), so it passes its two exact arrays to + * `runFilterPreflight` directly and never calls this function. + * + * The exact project's population is `L` itself — exact, not a superset, because + * `L` IS the `include`. The glob project's is `testFilesUnder(root)` minus every + * exact list, which is a superset of whatever the glob collects for the reason + * that function's docblock gives. + */ +export function exactAndGlobPopulations(options: { + readonly root: string; + /** The exact-`include` projects, by name — usually `{ repo: REPO_TESTS }`. */ + readonly exact: Populations; + /** The name of the glob project, e.g. `'local'`. */ + readonly globProject: string; +}): Populations { + const { root, exact, globProject } = options; + const claimed = new Set(Object.values(exact).flat()); + return { + ...exact, + [globProject]: testFilesUnder(root).filter((rel) => !claimed.has(rel)), + }; +} + /** * Read the command line with vitest's own parser. * @@ -205,8 +323,8 @@ export function parseInvocation(argv: readonly string[], parse: CliParse): Invoc * ⛔ 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. + * option here as a type error. `test/filter-preflight.test.ts` passes the real + * `parseCLI` in unaltered, so this compatibility is pinned, not assumed. */ export interface CliParseResultOptions { readonly project?: string | string[] | undefined; @@ -238,7 +356,7 @@ export function lostFilters( 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 + // A `--project` value that is not a plain project name (a negation, a glob) is // not modelled here. ⛔ Decline rather than half-answer. if (selected.some((name) => !names.includes(name))) return []; @@ -259,11 +377,18 @@ export function lostFilters( * ⛔ 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. + * + * ⚠️ `packageName` is a PARAMETER because the original hardcoded + * `@objectstack/cli` in the `run it:` line (#17978 carries that finding): a + * shared notice that prints another package's filter name would send the reader + * to a command that runs the wrong suite — a wrong answer, which is worse here + * than no answer. */ export function renderLostFilterNotice( lost: readonly LostFilter[], totalFilters: number, selectedProjects: readonly string[], + packageName: string, ): string { if (lost.length === 0) return ''; @@ -280,12 +405,12 @@ export function renderLostFilterNotice( lines.push(` ${filter.spelled}`); lines.push( foundIn.length - ? ` lives in the ${foundIn.map((n) => `\`${n}\``).join(' / ')} tier; this run selected ${selected}.` + ? ` lives in the ${foundIn.map((n) => `\`${n}\``).join(' / ')} project; 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}`, + ` run it: pnpm --filter ${packageName} exec vitest run --project ${foundIn[0]} ${filter.path}`, ); } lines.push(''); @@ -294,8 +419,8 @@ export function renderLostFilterNotice( 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', + ' To run every project, which is what CI runs:', + ` pnpm --filter ${packageName} test`, '', ); return lines.join('\n'); @@ -303,13 +428,17 @@ export function renderLostFilterNotice( /** * 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. + * run), never in a module-level binding: vitest loads a 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. + * + * ⚠️ The key is deliberately NOT package-scoped even though the module is now + * shared: one `vitest run` process loads the config of exactly one package, so + * a per-package key would only add a way for the guard to miss. */ -const ANNOUNCED = '__objectstackCliFilterPreflightAnnounced'; +const ANNOUNCED = '__objectstackVitestFilterPreflightAnnounced'; /** * Run the preflight for this process and, if anything will be lost, say so @@ -330,11 +459,13 @@ export function runFilterPreflight(options: { argv: readonly string[]; root: string; populations: Populations; + /** The name a `pnpm --filter` takes for the package being run. */ + packageName: string; parse: CliParse; write?: (text: string) => void; scope?: Record; }): string { - const { argv, root, populations, parse } = options; + const { argv, root, populations, packageName, 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); @@ -342,6 +473,7 @@ export function runFilterPreflight(options: { lostFilters(invocation, populations, root), invocation.filters.length, invocation.projects, + packageName, ); if (!notice) return ''; if (scope[ANNOUNCED]) return notice; diff --git a/packages/qa/vitest-filter-preflight/test/config-wiring-sweep.test.ts b/packages/qa/vitest-filter-preflight/test/config-wiring-sweep.test.ts new file mode 100644 index 0000000000..bb072a4f94 --- /dev/null +++ b/packages/qa/vitest-filter-preflight/test/config-wiring-sweep.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ⛔ A preflight nobody invoked is a phantom check — it evaluates never, and + * deleting it leaves every assertion in `filter-preflight.test.ts` just as green + * (#17853, #17978). + * + * ## Why this is a DERIVED sweep and not a list of eight packages + * + * The population is every package-root vitest config whose comment-masked source + * declares `projects:` — because `projects` IS the narrowing a positional filter + * can fall outside of, and the silent drop is available in exactly those + * packages and nowhere else. Deriving it means the NINTH package to declare + * `projects` is caught on the PR that adds it, instead of joining the population + * silently the way the original seven did. A hand-written list would have had to + * be right about a set that had already grown from one to eight. + * + * Measured at `a26a114d7`: 82 package roots scanned, **8** declaring `projects` + * — `cli`, `core`, `objectql`, `qa/dogfood`, `rest`, `runtime`, `spec`, `types` + * — which reproduces the #17978 census exactly. The count is asserted as a + * FLOOR, not an equality: a new package joining the population must fail on its + * own missing wiring, not on this number. + * + * ## What is asserted per config, and why each half fails separately + * + * 1. **It imports this module by a RELATIVE path.** Not by the package's bare + * name: `../src/index.ts`'s header carries the measurement — the bare form + * resolves to a `.ts` file that Node only loads via ≥ 22.18 type-stripping, + * and with that off the config does not load at all, which is every test in + * that package rather than a degraded diagnostic. + * 2. **It invokes `runFilterPreflight` in CODE position**, with vitest's own + * `parseCLI` as the parser and a `packageName`. A config that imports it and + * never calls it is the phantom this file exists to refuse. + * 3. **⛔ It does NOT name `test.reporters`.** The measured regression the + * preflight's shape avoids: naming that option replaces vitest's reporter + * defaulting instead of extending it — it pins `default` where an agent + * terminal gets `agent`, and it drops the `github-actions` reporter in CI, + * which no local control run can observe. + * 4. **Each exact-list project's `include` really is on disk**, and the walk + * finds every member of it. That is what makes `exactAndGlobPopulations`' + * `minus L` a real subtraction in each package rather than an argument: a + * stale entry in a `vitest.repo-tests.json` would silently widen the glob + * population instead of narrowing it. + * + * `packageName` is asserted to be the package's OWN manifest name, read from its + * `package.json` — the #17978 finding was a notice hardcoded to another + * package's name, and a sweep that accepted any string would not have caught it. + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; +import { testFilesUnder } from '../src/index.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** Walk up to the workspace root — the directory holding pnpm-workspace.yaml. */ +function findUp(predicate: (dir: string) => boolean): string { + let dir = HERE; + for (;;) { + if (predicate(dir)) return dir; + const parent = dirname(dir); + if (parent === dir) throw new Error('workspace root not found from ' + HERE); + dir = parent; + } +} +const REPO = findUp((dir) => existsSync(join(dir, 'pnpm-workspace.yaml'))); + +/** The config spellings `scripts/check-console-intercept-disarm.mjs` accepts. */ +const CONFIG_NAMES = [ + 'vitest.config.ts', + 'vitest.config.mts', + 'vitest.config.cts', + 'vitest.config.js', + 'vitest.config.mjs', + 'vitest.config.cjs', +]; + +/** Every directory in the tree holding a `package.json`. */ +function packageRoots(from: string): string[] { + const found: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) { + continue; + } + const abs = join(dir, entry.name); + if (existsSync(join(abs, 'package.json'))) found.push(abs); + walk(abs); + } + }; + walk(from); + return found.sort(); +} + +interface Subject { + readonly dir: string; + readonly rel: string; + readonly config: string; + readonly source: string; + readonly name: string; +} + +const SUBJECTS: Subject[] = packageRoots(REPO).flatMap((dir) => { + const config = CONFIG_NAMES.map((n) => join(dir, n)).find(existsSync); + if (!config) return []; + const source = maskComments(readFileSync(config, 'utf8')); + if (!/\bprojects\s*:/.test(source)) return []; + const name: unknown = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name; + return [ + { + dir, + rel: config.slice(REPO.length + 1), + config, + source, + name: String(name), + }, + ]; +}); + +describe('the derived population', () => { + it('finds the eight packages #17978 measured, at least', () => { + // ⛔ A floor, not an equality: a ninth package joining this population must + // fail on its own wiring below, never on this assertion. + expect(SUBJECTS.length).toBeGreaterThanOrEqual(8); + expect(SUBJECTS.map((s) => s.rel)).toEqual( + expect.arrayContaining([ + 'packages/cli/vitest.config.ts', + 'packages/core/vitest.config.ts', + 'packages/objectql/vitest.config.ts', + 'packages/qa/dogfood/vitest.config.ts', + 'packages/rest/vitest.config.ts', + 'packages/runtime/vitest.config.ts', + 'packages/spec/vitest.config.ts', + 'packages/types/vitest.config.ts', + ]), + ); + }); + + it('⛔ is not empty — a sweep that measured nothing is not a pass', () => { + // The failure direction this whole card is about: a verifier that silently + // degrades reports success (AGENTS.md, Route & surface ownership §3). + expect(SUBJECTS.length).toBeGreaterThan(0); + }); +}); + +describe.each(SUBJECTS.map((s) => [s.rel, s] as const))('%s', (_rel, subject) => { + it('imports the ONE shared preflight, by relative path', () => { + expect(subject.source).toMatch( + /from '(?:\.\.\/)+(?:qa\/)?vitest-filter-preflight\/src\/index\.js'/, + ); + // ⛔ The bare specifier is the form that crashes config load on a Node + // without default type-stripping. Never accept it here. + expect(subject.source).not.toContain("'@objectstack/vitest-filter-preflight'"); + }); + + it('imports vitest’s own argv parser rather than hand-rolling one', () => { + expect(subject.source).toContain("from 'vitest/node'"); + }); + + it('invokes it in code position, with the parser and its own package name', () => { + expect(subject.source).toMatch(/runFilterPreflight\(\{/); + expect(subject.source).toMatch(/parse:\s*parseCLI/); + expect(subject.source).toContain(`packageName: '${subject.name}'`); + }); + + it('⛔ does NOT name `test.reporters` — the measured regression this avoids', () => { + expect(subject.source).not.toMatch(/\breporters\s*:/); + }); +}); + +describe('every exact-`include` list a config hands the preflight is real on disk', () => { + // The only exact lists in the population are the `vitest.repo-tests.json` + // files and `packages/qa/dogfood`'s inline `SHARED_SHOWCASE`; `packages/cli` + // derives both of its projects and has no list to read. A stale entry here + // would make `minus L` subtract a path that is not in the walk, silently + // WIDENING the glob population instead of narrowing it. + const withLedger = SUBJECTS.filter((s) => existsSync(join(s.dir, 'vitest.repo-tests.json'))); + + it('finds a repo-tests ledger in the six packages that declare one', () => { + expect(withLedger.map((s) => s.rel).sort()).toEqual([ + 'packages/core/vitest.config.ts', + 'packages/objectql/vitest.config.ts', + 'packages/rest/vitest.config.ts', + 'packages/runtime/vitest.config.ts', + 'packages/spec/vitest.config.ts', + 'packages/types/vitest.config.ts', + ]); + }); + + it.each(withLedger.map((s) => [s.rel, s] as const))('%s', (_rel, subject) => { + const ledger: string[] = JSON.parse( + readFileSync(join(subject.dir, 'vitest.repo-tests.json'), 'utf8'), + ); + expect(ledger.length).toBeGreaterThan(0); + const walked = new Set(testFilesUnder(subject.dir)); + expect(ledger.filter((rel) => !walked.has(rel))).toEqual([]); + }); +}); diff --git a/packages/qa/vitest-filter-preflight/test/filter-preflight.test.ts b/packages/qa/vitest-filter-preflight/test/filter-preflight.test.ts new file mode 100644 index 0000000000..1de5efea02 --- /dev/null +++ b/packages/qa/vitest-filter-preflight/test/filter-preflight.test.ts @@ -0,0 +1,368 @@ +// 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, #17978). + * + * `../src/index.ts` carries the mechanism, the vitest readings, the measurement + * that rejected the reporter seam and the measurement that rejected consuming + * this package by its bare name. Pinned HERE are the directions the rulings on + * those two cards made non-negotiable, plus the decline paths — each separately, + * because they fail separately: + * + * 1. **LOST IS LOUD.** A filter that selects nothing is returned by + * `lostFilters`, attributed to the project it really lives in, and rendered + * into a notice naming the path, that project 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 project 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 NOTICE IS NOT PACKAGE-BOUND (#17978).** The `packages/cli` original + * hardcoded `@objectstack/cli` in the `run it:` line. A shared notice that + * names the wrong package sends the reader to a command that runs the wrong + * suite, so the filter name is a parameter and BOTH lines that carry it are + * asserted. + * + * 5. **THE GLOB PROJECT'S POPULATION IS A SUPERSET, and only ever a superset.** + * `testFilesUnder` is the component the original could not express, and the + * superset direction is its whole safety argument — so what is pinned is the + * family it collects, the directories it refuses to walk, and that + * `exactAndGlobPopulations` removes exactly the exact list from it. + * + * 6. **THE NO-`--project` CASE (#17978).** A real path beside a typo, no + * `--project` flag at all — the case the #17978 card body did not frame — is + * reported by the same code path off the same full-population knowledge. + * + * 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. The end-to-end reading is taken once, + * by hand, per consuming package, and recorded in the PR that landed this; + * `config-wiring-sweep.test.ts` is what keeps these pins from going phantom in + * the meantime. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { parseCLI } from 'vitest/node'; +import { + exactAndGlobPopulations, + lostFilters, + matchesVitestFilter, + parseInvocation, + renderLostFilterNotice, + runFilterPreflight, + splitLineSuffix, + testFilesUnder, + type CliParse, +} from '../src/index.js'; + +/** A stand-in package root; nothing in these cases touches the filesystem. */ +const PKG = '/repo/packages/example'; +const PKG_NAME = '@objectstack/example'; + +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, + packageName: PKG_NAME, + 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 project it actually lives in, and the project this run selected', () => { + expect(notice).toContain('lives in the `integration` project'); + 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 a 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, + packageName: PKG_NAME, + 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 another project from one in no project 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'); + }); +}); + +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 out-of-project 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 projects as the legitimate narrowing it is', () => { + // `test/` names files in both projects; 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'], PKG_NAME)).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 project 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 notice names the package it is running in, never a hardcoded one', () => { + it('carries the caller’s filter name in the `run it:` line', () => { + const { notice } = preflight('--project', 'unit', U1, I1); + expect(notice).toContain(`pnpm --filter ${PKG_NAME} exec vitest run --project integration`); + }); + + it('carries it in the run-everything line too', () => { + expect(preflight('--project', 'unit', U1, I1).notice).toContain( + `pnpm --filter ${PKG_NAME} test`, + ); + }); + + it('⛔ never mentions @objectstack/cli, the string the original hardcoded', () => { + // The whole finding #17978 carries: a shared notice bound to one package + // sends every other package's reader to the wrong suite. + const other = runFilterPreflight({ + argv: argv('--project', 'unit', U1, I1), + root: PKG, + populations: POPULATIONS, + packageName: '@objectstack/types', + parse, + scope: {}, + write: () => {}, + }); + expect(other).toContain('@objectstack/types'); + expect(other).not.toContain('@objectstack/cli'); + }); +}); + +describe('⑤ the glob project’s population is a SUPERSET, walked not globbed', () => { + const ROOT = mkdtempSync(join(tmpdir(), 'os-preflight-walk-')); + afterAll(() => rmSync(ROOT, { recursive: true, force: true })); + + const touch = (rel: string): void => { + const abs = join(ROOT, rel); + mkdirSync(join(abs, '..'), { recursive: true }); + writeFileSync(abs, ''); + }; + + // The whole `*.{test,spec}.?(c|m)[jt]s?(x)` family vitest's own + // `configDefaults.include` names, plus the shapes that must NOT be collected. + const COLLECTED = [ + 'src/a.test.ts', + 'src/b.spec.ts', + 'src/deep/c.test.tsx', + 'src/deep/d.spec.mts', + 'src/e.test.cjs', + 'src/f.spec.js', + 'src/g.test.jsx', + 'test/h.dogfood.test.ts', + 'scripts/i.test.ts', + ]; + const IGNORED = [ + 'src/not-a-test.ts', + 'src/tests.ts', + 'src/j.test.txt', + 'node_modules/pkg/k.test.ts', + 'dist/l.test.ts', + 'src/nested/node_modules/m.test.ts', + ]; + for (const rel of [...COLLECTED, ...IGNORED]) touch(rel); + + it('collects every member of the family, package-root-relative and sorted', () => { + expect(testFilesUnder(ROOT)).toEqual([...COLLECTED].sort()); + }); + + it('⛔ never walks node_modules or dist — the two vitest also excludes', () => { + const walked = testFilesUnder(ROOT); + expect(walked.filter((p) => p.includes('node_modules'))).toEqual([]); + expect(walked.filter((p) => p.startsWith('dist/'))).toEqual([]); + }); + + it('gives the exact project its list verbatim and the glob project the rest', () => { + const pops = exactAndGlobPopulations({ + root: ROOT, + exact: { repo: ['src/a.test.ts', 'scripts/i.test.ts'] }, + globProject: 'local', + }); + expect(pops.repo).toEqual(['src/a.test.ts', 'scripts/i.test.ts']); + // Exactly the walk minus the exact list — no file in both, none dropped. + expect(pops.local).toEqual( + [...COLLECTED].sort().filter((p) => p !== 'src/a.test.ts' && p !== 'scripts/i.test.ts'), + ); + expect([...(pops.repo ?? []), ...(pops.local ?? [])].sort()).toEqual([...COLLECTED].sort()); + }); + + it('an OVER-broad population can only under-report, never accuse', () => { + // The safety argument, asserted rather than only argued: a filter matching + // nothing in a superset matches nothing in the real subset either, so no + // healthy run can be accused. Here `local` holds MORE than a real glob + // project would, and the real file is still not reported. + const pops = exactAndGlobPopulations({ + root: ROOT, + exact: { repo: ['src/a.test.ts'] }, + globProject: 'local', + }); + const inv = parseInvocation(argv('--project', 'local', 'src/b.spec.ts'), parse); + expect(lostFilters(inv, pops, ROOT)).toEqual([]); + }); +}); + +describe('⑥ the no---project typo case — the shape the card body did not frame', () => { + it('reports a typo named with no --project flag at all', () => { + // MEASURED on vitest 4.1.11: `vitest run ` with no --project + // exits 0, prints `Test Files 1 passed (1)`, and names the typo nowhere. + const { notice, writes } = preflight(U1, 'test/no-such-file-here.test.ts'); + expect(writes).toEqual([notice]); + expect(notice).toContain('test/no-such-file-here.test.ts'); + expect(notice).toContain('matches no test file in this package at all'); + expect(notice).toContain('1 of the 2 path(s)'); + }); + + it('is still silent when both paths named without --project are real', () => { + expect(preflight(U1, I1).writes).toEqual([]); + }); +}); + +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]); + }); +}); diff --git a/packages/qa/vitest-filter-preflight/tsconfig.json b/packages/qa/vitest-filter-preflight/tsconfig.json new file mode 100644 index 0000000000..a5bcc360a2 --- /dev/null +++ b/packages/qa/vitest-filter-preflight/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "../../..", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*", + "test/**/*", + "vitest.config.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/qa/vitest-filter-preflight/vitest.config.ts b/packages/qa/vitest-filter-preflight/vitest.config.ts new file mode 100644 index 0000000000..1880f4562a --- /dev/null +++ b/packages/qa/vitest-filter-preflight/vitest.config.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ⛔ This config declares NO `projects`, and that is the reason it needs no +// preflight of its own: with a single project there is no narrowing for a +// positional filter to fall outside of, so vitest's already-loud +// `printNoTestFound()` covers every way a filter here can select nothing. +// `test/config-wiring-sweep.test.ts` derives that same condition from +// `pnpm-workspace.yaml` rather than from a list, so this package is exempt by +// measurement rather than by being written down. +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // A late console.* must not redden a green suite (#10374): vitest's worker + // forwards console output over RPC and discards the promise, and a write + // landing after teardown's rpcDone() snapshot is rejected into an unhandled + // error — a fully green run that exits 1. Disarming removes the mechanism. + // Mechanism + measured costs: examples/app-showcase/vitest.config.ts. + // Enforced repo-wide by scripts/check-console-intercept-disarm.mjs. + disableConsoleIntercept: true, + }, +}); diff --git a/packages/rest/vitest.config.ts b/packages/rest/vitest.config.ts index cbb4bbc058..8ba8bc6f56 100644 --- a/packages/rest/vitest.config.ts +++ b/packages/rest/vitest.config.ts @@ -3,6 +3,11 @@ import { configDefaults, defineConfig } from 'vitest/config'; import { readFileSync } from 'node:fs'; import path from 'path'; +import { parseCLI } from 'vitest/node'; +import { + exactAndGlobPopulations, + runFilterPreflight, +} from '../qa/vitest-filter-preflight/src/index.js'; // #16466 -- two vitest projects, two turbo tasks. `repo` owns the tests that read // outside this package (the list is vitest.repo-tests.json, which @@ -12,6 +17,33 @@ import path from 'path'; // repo. `extends: true` keeps the root options (aliases included) on both. const REPO_TESTS: string[] = JSON.parse(readFileSync(path.join(__dirname, 'vitest.repo-tests.json'), 'utf8')); +// #17853 / #17978 — say so when a path named on the command line will run no +// tests. 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. The ONE shared +// transcription of vitest's `TestProject.filterFiles` carries both measurements, +// and it is imported by RELATIVE PATH rather than by its package name for a +// third measured reason recorded in its header. It reads the argv through +// vitest's own exported parser and writes nothing whatever unless a named path +// selects nothing. +// +// `local` takes a GLOB `include`, so its population is derived as a deliberate +// SUPERSET — every test file under this package, minus `REPO_TESTS` — which +// makes a false accusation structurally impossible and leaves drift able only +// to under-report. ⛔ Not a second run of vitest's own glob engine. +runFilterPreflight({ + argv: process.argv, + root: __dirname, + packageName: '@objectstack/rest', + populations: exactAndGlobPopulations({ + root: __dirname, + exact: { repo: REPO_TESTS }, + globProject: 'local', + }), + parse: parseCLI, +}); + export default defineConfig({ test: { // Each project re-declares the root block's test options: a ROOT-level diff --git a/packages/runtime/vitest.config.ts b/packages/runtime/vitest.config.ts index c3462ff780..4af74cd08a 100644 --- a/packages/runtime/vitest.config.ts +++ b/packages/runtime/vitest.config.ts @@ -36,6 +36,11 @@ import { configDefaults, defineConfig } from 'vitest/config'; import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { parseCLI } from 'vitest/node'; +import { + exactAndGlobPopulations, + runFilterPreflight, +} from '../qa/vitest-filter-preflight/src/index.js'; // #16466 -- two vitest projects, two turbo tasks. `repo` owns the tests that read // outside this package (the list is vitest.repo-tests.json, which @@ -45,6 +50,33 @@ import path from 'node:path'; // repo. `extends: true` keeps the root options (aliases included) on both. const REPO_TESTS: string[] = JSON.parse(readFileSync(path.join(__dirname, 'vitest.repo-tests.json'), 'utf8')); +// #17853 / #17978 — say so when a path named on the command line will run no +// tests. 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. The ONE shared +// transcription of vitest's `TestProject.filterFiles` carries both measurements, +// and it is imported by RELATIVE PATH rather than by its package name for a +// third measured reason recorded in its header. It reads the argv through +// vitest's own exported parser and writes nothing whatever unless a named path +// selects nothing. +// +// `local` takes a GLOB `include`, so its population is derived as a deliberate +// SUPERSET — every test file under this package, minus `REPO_TESTS` — which +// makes a false accusation structurally impossible and leaves drift able only +// to under-report. ⛔ Not a second run of vitest's own glob engine. +runFilterPreflight({ + argv: process.argv, + root: __dirname, + packageName: '@objectstack/runtime', + populations: exactAndGlobPopulations({ + root: __dirname, + exact: { repo: REPO_TESTS }, + globProject: 'local', + }), + parse: parseCLI, +}); + export default defineConfig({ resolve: { // ARRAY form, not the object form: only the array form accepts a RegExp diff --git a/packages/spec/vitest.config.ts b/packages/spec/vitest.config.ts index 02138a90f1..0d419e8ae4 100644 --- a/packages/spec/vitest.config.ts +++ b/packages/spec/vitest.config.ts @@ -3,6 +3,11 @@ import { configDefaults, defineConfig } from 'vitest/config'; import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { parseCLI } from 'vitest/node'; +import { + exactAndGlobPopulations, + runFilterPreflight, +} from '../qa/vitest-filter-preflight/src/index.js'; // #16466 -- two vitest projects, two turbo tasks. `repo` owns the tests that read // outside this package (the list is vitest.repo-tests.json, which @@ -12,6 +17,33 @@ import path from 'node:path'; // repo. `extends: true` keeps the root options (aliases included) on both. const REPO_TESTS: string[] = JSON.parse(readFileSync(path.join(__dirname, 'vitest.repo-tests.json'), 'utf8')); +// #17853 / #17978 — say so when a path named on the command line will run no +// tests. 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. The ONE shared +// transcription of vitest's `TestProject.filterFiles` carries both measurements, +// and it is imported by RELATIVE PATH rather than by its package name for a +// third measured reason recorded in its header. It reads the argv through +// vitest's own exported parser and writes nothing whatever unless a named path +// selects nothing. +// +// `local` takes a GLOB `include`, so its population is derived as a deliberate +// SUPERSET — every test file under this package, minus `REPO_TESTS` — which +// makes a false accusation structurally impossible and leaves drift able only +// to under-report. ⛔ Not a second run of vitest's own glob engine. +runFilterPreflight({ + argv: process.argv, + root: __dirname, + packageName: '@objectstack/spec', + populations: exactAndGlobPopulations({ + root: __dirname, + exact: { repo: REPO_TESTS }, + globProject: 'local', + }), + parse: parseCLI, +}); + export default defineConfig({ test: { // Each project re-declares the root block's test options: a ROOT-level diff --git a/packages/types/vitest.config.ts b/packages/types/vitest.config.ts index fa434d9ee7..a5d708a1d8 100644 --- a/packages/types/vitest.config.ts +++ b/packages/types/vitest.config.ts @@ -7,6 +7,11 @@ import { configDefaults, defineConfig } from 'vitest/config'; import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { parseCLI } from 'vitest/node'; +import { + exactAndGlobPopulations, + runFilterPreflight, +} from '../qa/vitest-filter-preflight/src/index.js'; // #16466 -- two vitest projects, two turbo tasks. `repo` owns the tests that read // outside this package (the list is vitest.repo-tests.json, which @@ -16,6 +21,33 @@ import path from 'node:path'; // repo. `extends: true` keeps the root options (aliases included) on both. const REPO_TESTS: string[] = JSON.parse(readFileSync(path.join(__dirname, 'vitest.repo-tests.json'), 'utf8')); +// #17853 / #17978 — say so when a path named on the command line will run no +// tests. 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. The ONE shared +// transcription of vitest's `TestProject.filterFiles` carries both measurements, +// and it is imported by RELATIVE PATH rather than by its package name for a +// third measured reason recorded in its header. It reads the argv through +// vitest's own exported parser and writes nothing whatever unless a named path +// selects nothing. +// +// `local` takes a GLOB `include`, so its population is derived as a deliberate +// SUPERSET — every test file under this package, minus `REPO_TESTS` — which +// makes a false accusation structurally impossible and leaves drift able only +// to under-report. ⛔ Not a second run of vitest's own glob engine. +runFilterPreflight({ + argv: process.argv, + root: __dirname, + packageName: '@objectstack/types', + populations: exactAndGlobPopulations({ + root: __dirname, + exact: { repo: REPO_TESTS }, + globProject: 'local', + }), + parse: parseCLI, +}); + export default defineConfig({ test: { // Each project re-declares the root block's test options: a ROOT-level diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4168118429..981a61066d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2189,6 +2189,18 @@ importers: specifier: ^6.0.3 version: 6.0.3 + packages/qa/vitest-filter-preflight: + devDependencies: + '@types/node': + specifier: ^26.2.0 + version: 26.2.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.11 + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + packages/rest: dependencies: '@objectstack/core': From cd75179f3519c2c1e7d96f1ac3b564697c4974e9 Mon Sep 17 00:00:00 2001 From: os-warren Date: Mon, 14 Sep 2026 03:33:07 +0000 Subject: [PATCH 2/2] chore(qa): declare the wiring sweep's cross-package input radius and its turbo task Claude-Session: https://claude.ai/code/session_01TbSMtGzMrtPwh925wDEZd5 Co-authored-by: Claude --- .../test/config-wiring-sweep.test.ts | 21 +++++++-- scripts/cross-package-test-inputs.mjs | 44 +++++++++++++++++++ turbo.json | 17 +++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/qa/vitest-filter-preflight/test/config-wiring-sweep.test.ts b/packages/qa/vitest-filter-preflight/test/config-wiring-sweep.test.ts index bb072a4f94..2831c4907c 100644 --- a/packages/qa/vitest-filter-preflight/test/config-wiring-sweep.test.ts +++ b/packages/qa/vitest-filter-preflight/test/config-wiring-sweep.test.ts @@ -15,7 +15,7 @@ * silently the way the original seven did. A hand-written list would have had to * be right about a set that had already grown from one to eight. * - * Measured at `a26a114d7`: 82 package roots scanned, **8** declaring `projects` + * Measured at `a26a114d7`: 82 package roots under `packages/`, **8** declaring `projects` * — `cli`, `core`, `objectql`, `qa/dogfood`, `rest`, `runtime`, `spec`, `types` * — which reproduces the #17978 census exactly. The count is asserted as a * FLOOR, not an equality: a new package joining the population must fail on its @@ -78,7 +78,22 @@ const CONFIG_NAMES = [ 'vitest.config.cjs', ]; -/** Every directory in the tree holding a `package.json`. */ +/** + * Every directory under `packages/` holding a `package.json`. + * + * ⚠️ SCOPED TO `packages/`, deliberately and with a named cost. Every one of the + * eight is there, and it is where a library harness goes; scoping keeps this + * suite's declared input radius (`scripts/cross-package-test-inputs.mjs`) to + * three globs under one root instead of opening `examples/` and `apps/` roots in + * `ci.yml`'s `crosspkg` filter as well. What it costs: a config OUTSIDE + * `packages/` that grew `projects` would not be swept. That is an + * UNDER-report — the direction this whole card resolves uncertainty in — and the + * app-showcase demo's own package-root config, the only other one of any size, + * declares no `projects` today. (⛔ That path is described rather than spelled: + * `scripts/cross-package-test-inputs.mjs` collects quoted paths out of comments + * too, and spelling it would open an `examples/` root in ci.yml's `crosspkg` + * filter for a file this suite does not read.) + */ function packageRoots(from: string): string[] { const found: string[] = []; const walk = (dir: string): void => { @@ -104,7 +119,7 @@ interface Subject { readonly name: string; } -const SUBJECTS: Subject[] = packageRoots(REPO).flatMap((dir) => { +const SUBJECTS: Subject[] = packageRoots(join(REPO, 'packages')).flatMap((dir) => { const config = CONFIG_NAMES.map((n) => join(dir, n)).find(existsSync); if (!config) return []; const source = maskComments(readFileSync(config, 'utf8')); diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 259d34db0e..8f30a11dc7 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -1045,6 +1045,50 @@ export const CROSS_PACKAGE_TEST_INPUTS = { 'content/docs/protocol/kernel/realtime-protocol.mdx', ], }, + '@objectstack/vitest-filter-preflight': { + // test/config-wiring-sweep.test.ts is the anti-phantom sweep for the ONE + // shared vitest filter preflight (#17978). Its population is DERIVED, not + // listed: it walks `packages/` for package-root vitest configs, masks their + // comments, and requires every config that declares `projects` to invoke the + // preflight. So each package's own config, its manifest name and the exact + // `include` ledger it hands the preflight are real inputs to this suite's + // verdict — a ninth package declaring `projects` must re-run it, which is the + // whole reason the population is derived. + // + // Scoped to `packages/` by the test itself (its `packageRoots` docblock + // carries the trade): three globs under one already-declared root, rather + // than opening `examples/` and `apps/` roots in ci.yml's `crosspkg` filter + // for a config shape that exists in neither today. The residual is an + // UNDER-report, which is the direction this card resolves uncertainty in. + globs: [ + 'packages/**/vitest.config.ts', + 'packages/**/vitest.repo-tests.json', + 'packages/**/package.json', + // `maskComments` — the sweep reads config SOURCE, so a change to what + // counts as a comment changes what it sees in code position. + 'scripts/js-comment-mask.mjs', + // Named in the suite's prose rather than read: the config spellings it + // accepts come from the console-intercept gate, and this very table is + // cited for the radius trade. The literal collector takes quoted paths out + // of comments without parsing them, so a mention forces a declaration — + // and declaring a file under a root ci.yml's `crosspkg` filter already + // carries is cheaper than rewording prose to dodge the scanner. + 'scripts/check-console-intercept-disarm.mjs', + 'scripts/cross-package-test-inputs.mjs', + ], + heldBy: { + // Both are built by joining a LOOP VARIABLE (each swept package's own + // directory) to a bare filename, so the escape verdict resolves and the + // NAME does not — the trade `pathExpression` documents. The sweep reads + // one of each per package in its population. + 'packages/**/vitest.repo-tests.json': [ + 'packages/qa/vitest-filter-preflight/test/config-wiring-sweep.test.ts', + ], + 'packages/**/package.json': [ + 'packages/qa/vitest-filter-preflight/test/config-wiring-sweep.test.ts', + ], + }, + }, '@objectstack/formula': { // src/rls-predicate.test.ts pins spec's RLS zod source against the // predicate compiler; src/skill-catalog-sync.test.ts pins the published diff --git a/turbo.json b/turbo.json index 65ae1fe55c..68bd63765d 100644 --- a/turbo.json +++ b/turbo.json @@ -390,6 +390,23 @@ "$TURBO_ROOT$/packages/spec/src/identity/**" ] }, + "@objectstack/vitest-filter-preflight#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "!**/node_modules/**", + "$TURBO_ROOT$/packages/**/vitest.config.ts", + "$TURBO_ROOT$/packages/**/vitest.repo-tests.json", + "$TURBO_ROOT$/packages/**/package.json", + "$TURBO_ROOT$/scripts/js-comment-mask.mjs", + "$TURBO_ROOT$/scripts/check-console-intercept-disarm.mjs", + "$TURBO_ROOT$/scripts/cross-package-test-inputs.mjs" + ] + }, "@objectstack/dogfood#test": { "dependsOn": ["^build"], "outputs": [],