diff --git a/.changeset/8446-components-css-excludes-test-sources.md b/.changeset/8446-components-css-excludes-test-sources.md new file mode 100644 index 0000000000..d3ccfe5729 --- /dev/null +++ b/.changeset/8446-components-css-excludes-test-sources.md @@ -0,0 +1,48 @@ +--- +'@object-ui/components': minor +--- + +Stop scanning this package's own TEST files into the published stylesheet +(objectui#8446). + +**The bloat was the mild half — this was a false-green generator.** Tailwind v4 +scans source *text*, not an import graph, and `src/index.css` declared +`@source '../src/**/*.{ts,tsx}'` with no exclusion. All 243 test files under +`src/**/__tests__/` were therefore sources for the published `dist/index.css`, +so a class-shaped token written as a test's *expected value* compiled a real +utility into the shipped bundle — meaning a test could create the production +utility it was asserting on. Measured on #8435: `.\32 xl\:grid-cols-6` was +present in the sheet with the *unfixed* renderer on disk, sourced entirely from +assertion strings. Anyone reading "the class is in the stylesheet" as evidence +the renderer worked would have been reading their own test back. + +Two `@source not` lines are added, matching the spelling already used by +`fields`, `plugin-grid` and `plugin-kanban`. + +**Published bundle change — eight rules are removed**, measured by compiling +`src/index.css` through this package's own postcss + `@tailwindcss/postcss` +pipeline before and after, from the directory `pnpm build` runs in: + + .flex-grow .flex-nowrap .h-[125px] .isolate + .paused .shrink .text-green-500 .w-[250px] + +Nothing is added. Every one of the eight was named **only** by a test file: +`.h-[125px]` / `.w-[250px]` are `` fixture sizes in +`snapshot-critical.test.tsx`, and `.flex-grow` came from a *prose sentence in a +JSDoc comment* that mentions the CSS property `flex-grow: 50`. No non-test +source in the package names any of them. + +**Who could notice.** A consumer running their own Tailwind build (as every +example app here does) generates utilities from their own markup and is +unaffected. Authoring these classes in runtime page metadata was already +unsupported — the 2026-06-30 amendment to ADR-0080 under ADR-0065 states that a +utility class in page source "produces CSS only if that exact class happens to +already appear in objectui's own source", and `os validate` warns +`page-source-className-tailwind`. That incidental "happens to appear" is exactly +what is being removed. The narrow case that *can* regress is an app which +imports only the prebuilt `@object-ui/components/style.css`, runs no Tailwind of +its own, and hand-writes one of the eight in its own JSX; `.isolate`, +`.shrink`, `.flex-nowrap` and `.text-green-500` are plausible there. Scored +`minor` for that reason, not `patch`. `.flex-grow` and `.shrink` additionally +have surviving canonical spellings — `.grow` and `.shrink-0` remain in the +sheet, and this repo already migrated `flex-grow-N` → `grow-N` deliberately. diff --git a/packages/components/src/__tests__/index-css-scan-excludes-tests.test.ts b/packages/components/src/__tests__/index-css-scan-excludes-tests.test.ts new file mode 100644 index 0000000000..8d66141191 --- /dev/null +++ b/packages/components/src/__tests__/index-css-scan-excludes-tests.test.ts @@ -0,0 +1,103 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `src/index.css` must not scan this package's own TEST files — objectui#8446. + * + * ## What was wrong + * + * Tailwind v4 scans source TEXT, not an import graph. `src/index.css` declared + * `@source '../src/**' + '/*.{ts,tsx}'` with no exclusion, so every one of the + * 243 test files under `src/**' + '/__tests__/` was a source for the PUBLISHED + * `dist/index.css`. A class-shaped token written as a test's expected value — + * even one sitting in a prose comment — therefore compiled a real utility into + * the shipped bundle, which means a test could create the very production + * utility it was asserting on. Measured on #8435: `.\32 xl\:grid-cols-6` was + * in the sheet with the UNFIXED renderer, sourced entirely from assertion + * strings. + * + * ## The instrument + * + * A sentinel token that exists nowhere else in the repository, written in THIS + * file — which lives under `__tests__/` and is therefore exactly the kind of + * file the exclusion must keep out of the scan. If the exclusions are removed, + * this file becomes a source, the sentinel compiles, and the negative + * assertion below goes red. The probe is live only because it is self-hosted: + * it does not depend on any other test continuing to name a fixture class. + * + * ## Why the positive assertion is not optional + * + * "No test-sourced rules" is satisfied by a stylesheet compiled from NOTHING — + * an implementation strictly worse than the fix (delete the `@source` line + * outright) would pass a negative-only test. The positive half pins a + * production-sourced utility and a floor on the rule count, so an empty or + * gutted sheet fails. + * + * ## Why `base` is passed explicitly + * + * `src/index.css` opens with a bare `@import 'tailwindcss'`, so Tailwind's + * automatic source detection is ON and resolves against the PROCESS CWD. Vitest + * runs from the repo root while `pnpm build` runs from this package directory, + * and the two produce different stylesheets from the same bytes (measured: + * 3430 rules vs 1385). Pinning `base` to the package root makes this reading + * reproduce the artifact the BUILD produces, from either working directory. + * The CWD dependence itself is out of scope here and reported separately. + */ +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import postcss from 'postcss'; +import tailwindPostcss from '@tailwindcss/postcss'; +import { describe, expect, it } from 'vitest'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const entry = resolve(packageRoot, 'src/index.css'); + +/** + * Deliberately absent from every other file in the repository, and an + * arbitrary-value utility so no production source can ever legitimately name + * it. Its ONLY occurrence is this line, in a file under `__tests__/`. + */ +const SENTINEL = 'mt-[3.7331px]'; + +/** + * The part of the sentinel that Tailwind's selector escaping leaves alone + * (`.mt-\[3\.7331px\]` escapes the brackets and the dot, never the digits). + * Asserting on THIS rather than on a hand-escaped selector is what keeps the + * negative assertion from passing for the wrong reason. + */ +const SENTINEL_VALUE = '3.7331px'; + +async function compilePublishedStylesheet(): Promise<{ + css: string; + selectors: Set; +}> { + const source = await readFile(entry, 'utf8'); + const result = await postcss([tailwindPostcss({ base: packageRoot })]).process(source, { + from: entry, + }); + const selectors = new Set(); + postcss.parse(result.css, { from: entry }).walkRules((rule) => { + selectors.add(rule.selector); + }); + return { css: result.css, selectors }; +} + +describe('packages/components/src/index.css @source scan', () => { + it('does not compile tokens that only test files name, and still compiles shipped ones', async () => { + const { css, selectors } = await compilePublishedStylesheet(); + + // POSITIVE — a sheet compiled from nothing must not pass. + expect(selectors.has('.flex-col')).toBe(true); + expect(selectors.size).toBeGreaterThan(800); + + // NEGATIVE — this file is scanned only if the exclusions are gone. + expect(SENTINEL).toContain(SENTINEL_VALUE); + expect(css).not.toContain(SENTINEL_VALUE); + }, 60_000); +}); diff --git a/packages/components/src/index.css b/packages/components/src/index.css index fc999fc1c3..75153ac593 100644 --- a/packages/components/src/index.css +++ b/packages/components/src/index.css @@ -9,8 +9,24 @@ centralized app-shell styles directly from their own index.css. */ @custom-variant dark (&:where(.dark, .dark *)); -/* Scan sources for Tailwind classes */ +/* Scan sources for Tailwind classes. + * + * Only SHIPPED source. Tailwind v4 scans source TEXT, so any class-shaped + * token in a file that is scanned compiles a real rule into the published + * `dist/index.css` — including a token that only ever appears as a test's + * EXPECTED value. Without the exclusions below a test could therefore create + * the very production utility it asserts on, and a stylesheet reading would + * stop being evidence about the renderer (objectui#8446, hit live on #8435). + * + * The directory exclusion is the load-bearing one here: every test file in + * this package lives under a `__tests__/` directory, and two of them + * (`test-utils.tsx`, `page-header-action-ids.dist.spec.tsx`) are not named + * `*.test.*`. The filename exclusion carries no files today; it is kept to + * match the siblings (`fields`, `plugin-grid`, `plugin-kanban`) so a future + * co-located test is covered by construction rather than by luck. */ @source '../src/**/*.{ts,tsx}'; +@source not './**/*.test.{ts,tsx}'; +@source not './**/__tests__/**'; /* Tailwind plugin for animations */ @plugin 'tailwindcss-animate';