diff --git a/.changeset/11586-tailwind-source-none-console-runner.md b/.changeset/11586-tailwind-source-none-console-runner.md new file mode 100644 index 0000000000..e86b7652ac --- /dev/null +++ b/.changeset/11586-tailwind-source-none-console-runner.md @@ -0,0 +1,17 @@ +--- +'@object-ui/console': patch +'@object-ui/runner': patch +--- + +The console and runner stylesheets compile only from their declared `@source` +lines. Tailwind's automatic source detection is now off (`source(none)`), as it +already was for `@object-ui/components`. + +With detection on, Tailwind also scanned prose in each package's directory, and +`CHANGELOG.md` was part of it. A release writes every changeset body into that +file, so the release itself added utilities to the published CSS that no +component uses. On the 17.7.0 release head, that was 11 rules (about 9 kB) in the +console sheet and 5 in the runner sheet. Both sheets now compile to the same +rules wherever the build runs. The only rules dropped against the previous +build came from prose: one from a docs proposal in the console and two from +the runner's changelog. diff --git a/apps/console/src/index-css-scan-excludes-tests.test.ts b/apps/console/src/index-css-scan-excludes-tests.test.ts index a616493b54..044fa3f64e 100644 --- a/apps/console/src/index-css-scan-excludes-tests.test.ts +++ b/apps/console/src/index-css-scan-excludes-tests.test.ts @@ -51,16 +51,24 @@ * * ## Why `base` is passed explicitly * - * This entry opens with a bare `@import 'tailwindcss'`, so Tailwind's automatic - * source detection is ON and rooted at the PROCESS CWD -- vitest runs from the - * repo root while `pnpm --filter @object-ui/console build` runs from this app's - * directory. Passing `base` pins this reading to the directory the build runs - * in, so the test measures the stylesheet users are served rather than the one - * the test runner's CWD happens to produce. `base` governs only the automatic - * root; the `@source` lines always resolve against the entry's own directory. + * `base` is the root of Tailwind's AUTOMATIC source detection, and it defaults + * to the PROCESS CWD. vitest runs from the repo root, while + * `pnpm --filter @object-ui/console build` runs from this app's directory. + * Passing `base` pins this reading to the directory the build runs in. `base` + * governs only the automatic root; the `@source` lines always resolve against + * the entry's own directory. + * + * The entry now opens with `source(none)` (objectui#11586), so detection is off + * and `base` should change nothing. The third test below asserts exactly that: + * it compiles once from this app's directory and once from an empty one and + * requires the same rules. Before the switch, detection scanned this app's + * prose. On the 17.7.0 release head it compiled 11 rules out of the + * `CHANGELOG.md` that `pnpm changeset:version` writes, and one of them was + * components' sentinel, which turned the second test red. */ -import { readFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import postcss from 'postcss'; import tailwindPostcss from '@tailwindcss/postcss'; @@ -90,9 +98,11 @@ const SENTINEL_OTHER_TREE_VALUE = '3.7331px'; const SENTINEL_OTHER_TREE_HOST = 'packages/components/src/__tests__/index-css-scan-excludes-tests.test.ts'; -async function compileConsoleStylesheet(): Promise<{ css: string; selectors: Set }> { +async function compileConsoleStylesheet( + base: string = appRoot, +): Promise<{ css: string; selectors: Set }> { const source = await readFile(entry, 'utf8'); - const result = await postcss([tailwindPostcss({ base: appRoot })]).process(source, { + const result = await postcss([tailwindPostcss({ base })]).process(source, { from: entry, }); const selectors = new Set(); @@ -130,4 +140,30 @@ describe('apps/console/src/index.css @source scan', () => { expect(selectors.has('.bg-background')).toBe(true); expect(css).not.toContain(SENTINEL_OTHER_TREE_VALUE); }, 60_000); + + it('compiles the same rules from this app\'s directory as from an empty one — no rule comes from prose beside the source (objectui#11586)', async () => { + // Automatic detection is the only input that depends on `base`. If every + // rule survives a compile rooted at an empty directory, the `@source` lines + // supply all of them, so neither `CHANGELOG.md` nor `docs/` can add a rule. + // A negative test names one token. This one covers any file a future + // release writes. + const empty = await mkdtemp(join(tmpdir(), 'objectui-11586-')); + try { + const { selectors: fromApp } = await compileConsoleStylesheet(appRoot); + const { selectors: fromEmpty } = await compileConsoleStylesheet(empty); + + // CONTROL: comparing two empty compiles proves nothing. + expect(fromEmpty.size).toBeGreaterThan(2500); + + const onlyFromApp = [...fromApp].filter((s) => !fromEmpty.has(s)).sort(); + expect( + onlyFromApp, + 'these rules come from Tailwind\'s automatic detection scanning apps/console ' + + '(prose such as CHANGELOG.md or docs/) — keep `source(none)` on the entry', + ).toEqual([]); + expect([...fromEmpty].filter((s) => !fromApp.has(s))).toEqual([]); + } finally { + await rm(empty, { recursive: true, force: true }); + } + }, 60_000); }); diff --git a/apps/console/src/index.css b/apps/console/src/index.css index 58f6701cc8..ab355edfe5 100644 --- a/apps/console/src/index.css +++ b/apps/console/src/index.css @@ -1,4 +1,21 @@ -@import 'tailwindcss'; +/* + * `source(none)` turns off Tailwind's AUTOMATIC source detection, so the only + * inputs are the `@source` lines below (objectui#11586). It is the same switch + * `packages/components/src/index.css` took in objectui#9569, and that file's + * header records the mechanism. + * + * Detection roots at the process CWD, which is this app's directory when it + * builds, and it scans every non-ignored file there, prose included. + * `pnpm changeset:version` writes each pending changeset body into + * `CHANGELOG.md`, so on the 17.7.0 release head the console sheet gained 11 + * rules (9,450 bytes) compiled from changelog prose. Among them was the + * sentinel of components' own scan test, which the second test in + * `index-css-scan-excludes-tests.test.ts` asserts never reaches this sheet. + * On `main` it gained `.w-100`, out of a proposal under `docs/`. + * Ablated on objectui#11586: every rule that only detection supplied came from + * a `.md` file, and none came from source. + */ +@import 'tailwindcss' source(none); @import '@object-ui/app-shell/styles.css'; /* Typography plugin — generates the `prose` utilities. plugin-markdown's diff --git a/packages/runner/src/__tests__/published-stylesheet-sources.test.ts b/packages/runner/src/__tests__/published-stylesheet-sources.test.ts index f6949bd00d..c58b04c5e7 100644 --- a/packages/runner/src/__tests__/published-stylesheet-sources.test.ts +++ b/packages/runner/src/__tests__/published-stylesheet-sources.test.ts @@ -45,8 +45,9 @@ * uses no matter which directory the suite was launched from. */ import { describe, expect, it } from 'vitest'; -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import postcss from 'postcss'; import tailwindPostcss from '@tailwindcss/postcss'; @@ -87,8 +88,8 @@ function classesIn(selector: string): string[] { * package root is what makes this reading independent of where vitest started * (from the repo root the same bytes scan the whole monorepo instead). */ -async function compileClasses(css: string): Promise> { - const result = await postcss([tailwindPostcss({ base: PACKAGE_ROOT })]).process(css, { +async function compileClasses(css: string, base: string = PACKAGE_ROOT): Promise> { + const result = await postcss([tailwindPostcss({ base })]).process(css, { from: ENTRY, }); const classes = new Set(); @@ -177,4 +178,34 @@ describe('@object-ui/runner published stylesheet — source set', () => { expect(removed.length).toBeGreaterThan(0); expect([...shipped].filter((cls) => !withTestSources.has(cls))).toEqual([]); }); + + it('compiles the same classes from the package root as from an empty directory — no class comes from prose (objectui#11586)', async () => { + // `base` only changes what Tailwind's automatic detection scans. If the + // sheet is the same when detection would root at an empty directory, the + // `@source` lines supply every class, and this package's `CHANGELOG.md`, + // which `pnpm changeset:version` writes changeset prose into, adds none. + // Before `source(none)` it added `flex-shrink-0` and `isolate` on `main`, + // and on the 17.7.0 release head also `paused`, `invert` and `flex-nowrap`. + const empty = mkdtempSync(join(tmpdir(), 'objectui-11586-')); + try { + const fromEmpty = await compileClasses(entryCss, empty); + // CONTROL: comparing two empty compiles proves nothing. + expect(fromEmpty.has('bg-popover')).toBe(true); + expect([...shipped].filter((cls) => !fromEmpty.has(cls)).sort()).toEqual([]); + expect([...fromEmpty].filter((cls) => !shipped.has(cls)).sort()).toEqual([]); + } finally { + rmSync(empty, { recursive: true, force: true }); + } + }); + + it("does not compile a class that only this package's CHANGELOG names", () => { + // CONTROL: the door must still exist. `CHANGELOG.md` is append-only, so the + // entry announcing the `flex-shrink-0` → `shrink-0` rename keeps naming it. + // If this goes red, re-point the probe at a token the file still carries. + // Do not delete it. + expect(readFileSync(resolve(PACKAGE_ROOT, 'CHANGELOG.md'), 'utf8')).toContain('flex-shrink-0'); + // The deprecated Tailwind v3 alias. No shipped source emits it, and it was + // in this sheet only because detection read that changelog entry. + expect(shipped.has('flex-shrink-0')).toBe(false); + }); }); diff --git a/packages/runner/src/index.css b/packages/runner/src/index.css index dd5f1a187a..eeedf05a92 100644 --- a/packages/runner/src/index.css +++ b/packages/runner/src/index.css @@ -1,4 +1,17 @@ -@import 'tailwindcss'; +/* + * `source(none)` turns off Tailwind's AUTOMATIC source detection, so the only + * inputs are the `@source` lines below (objectui#11586). It is the same switch + * `packages/components/src/index.css` took in objectui#9569. + * + * Detection roots at `packages/runner`, where `pnpm build` runs, and scans every + * non-ignored file there, `CHANGELOG.md` included. `pnpm changeset:version` + * writes each pending changeset body into that file. On the 17.7.0 release head, + * detection compiled `paused`, `invert` and `flex-nowrap` back into this sheet + * from changelog prose. Those are three of the test-sourced classes the + * `@source not` lines below exist to keep out. On `main` it compiled + * `flex-shrink-0` and `isolate` the same way. + */ +@import 'tailwindcss' source(none); /* Class-based dark variant: follow `.dark` on /ancestor instead of the OS `prefers-color-scheme`. Inlined here because @object-ui/runner @@ -24,10 +37,9 @@ * That covers runner's own tree and nothing else, so the utilities its * dependencies need were simply missing from the published bundle: fixing the * four sibling paths takes it from 228 to 1456 compiled selectors (21 kB -> - * 136 kB). The automatic root is still on (there is no `source(none)` here, and - * that parity question is objectui#8455), which is why the first line below is - * a no-op today and still belongs here: it is the declared intent, and it is - * what keeps runner's own source scanned the day the root moves. + * 136 kB). Detection is now off (`source(none)` above, objectui#11586), so the + * first line below is load-bearing. It is the only thing that scans runner's + * own source. */ @source './**/*.{ts,tsx}'; @source '../../components/src/**/*.{ts,tsx}'; @@ -41,9 +53,8 @@ * not become a published byte. Same two-line spelling as * `packages/plugin-kanban/src/index.css`, anchored one level up because this * entry scans four sibling packages instead of only its own tree — `../../` - * bases these on `packages/`, which contains every tree named above AND the - * automatic-detection root, so runner's own tests are covered by the same two - * lines (measured: negations do apply to files the automatic root found). + * bases these on `packages/`, which contains every tree named above, runner's + * own included, so runner's own tests are covered by the same two lines. * * Both lines earn their place. The file line removes nine test-sourced classes * (`flex-grow`, `flex-nowrap`, `h-[125px]`, `h-[400px]`, `invert`, `paused`, diff --git a/packages/types/src/__tests__/base-bind-declared.test.ts b/packages/types/src/__tests__/base-bind-declared.test.ts index 871a601404..c65a698749 100644 --- a/packages/types/src/__tests__/base-bind-declared.test.ts +++ b/packages/types/src/__tests__/base-bind-declared.test.ts @@ -209,12 +209,18 @@ describe('`bind` is declared in exactly one place (objectui#6357)', () => { // have been the wrong repair — it puts a permanent hole in the scan at the // one path most likely to grow a copy of the pattern. const PATTERN = 'bind' + '?:'; + // Every `CHANGELOG.md` is excluded. `pnpm changeset:version` writes changeset + // prose into them, and a changeset that reports a local declaration being + // REMOVED quotes the pattern. On the 17.7.0 release head that made the + // plugin-dashboard and types CHANGELOGs read as two new declarations + // (objectui#11586). A CHANGELOG declares nothing. + const EXCLUDE_CHANGELOGS = ':!*CHANGELOG.md'; let out: string; try { out = execFileSync( 'git', - ['grep', '-n', '-F', '--', PATTERN, 'packages'], + ['grep', '-n', '-F', '--', PATTERN, 'packages', EXCLUDE_CHANGELOGS], { cwd: REPO_ROOT, encoding: 'utf8' }, ); } catch (err: any) { diff --git a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts index c674ecd0e6..735a8ca1e5 100644 --- a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts +++ b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts @@ -393,6 +393,14 @@ const GREP_ENGINE_FLAG = '-P'; /** The scanned tree. `examples/` is in it because an example app is exactly * where a fourth reader would plausibly appear. */ const SCAN_ROOTS = ['packages', 'apps', 'examples'] as const; +/** Every `CHANGELOG.md` is outside the scanned tree. `pnpm changeset:version` + * writes each pending changeset body into one, and the changeset for a + * retirement names the key it retires. Without this exclusion the release + * commit itself reads as five new readers of the array, which is what turned + * the 17.7.0 release head red (objectui#11586). A CHANGELOG is history, not + * code. The sibling censuses exclude it with the same spelling + * (`eventable-schema-retired-6497.test.ts`). */ +const SCAN_EXCLUDES = [':!*CHANGELOG.md'] as const; /** The engine, for the diagnostics below: a failure that says "the anchor is * dead" is only actionable if it also says WHICH git read it. */ @@ -414,7 +422,7 @@ const gitGrepFiles = (args: readonly string[]): string[] => { let out: string; let failure: Failure | undefined; try { - out = execFileSync('git', ['grep', ...args, '--', ...SCAN_ROOTS], { + out = execFileSync('git', ['grep', ...args, '--', ...SCAN_ROOTS, ...SCAN_EXCLUDES], { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index f07616711b..3c5dedfc41 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -446,6 +446,17 @@ export const ADJUDICATED = new Map([ 'packages/plugin-markdown/src/toc-anchor-parity.test.tsx', { reads: ['content/docs/utilities/runner.mdx'] }, ], + // objectui#11586. This reads a CHANGELOG as a CONTROL, the same shape as the + // components entry above. The test asserts that the published sheet lacks a + // class only that prose names, and it reads the file to prove the prose still + // names it. + [ + 'packages/runner/src/__tests__/published-stylesheet-sources.test.ts', + { + reads: ['packages/runner/CHANGELOG.md'], + notRead: ['CHANGELOG.md'], + }, + ], [ 'packages/types/src/__tests__/action-callback-retired-7068.test.ts', { @@ -522,6 +533,16 @@ export const ADJUDICATED = new Map([ // objectui#10872 batch 4. `taughtActionButton()` extracts the first json fence // under the quick-start's "### Add Actions" heading -- the taught // `action:button` node -- and judges it on both zod faces, in a page and alone. + // objectui#11586. Reads no markdown. Its only `.md` literal is the + // `:!*CHANGELOG.md` pathspec that keeps every CHANGELOG OUT of its `git grep` + // census. + [ + 'packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts', + { + reads: [], + walker: 'not-markdown: lists the zod mirror sources in `packages/types/src/zod`', + }, + ], [ 'packages/types/src/__tests__/held-public-block-arms-10872.test.ts', { reads: ['content/docs/guide/quick-start.md'] },