diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d012f9111..ffa605359 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: - run: bun run check:no-product-tenancy - run: bun run check:browser-safe-subpaths - run: bun run check:web-utilities + - run: bun run check:tailwind-source - run: bun run check:ui-vocabulary - run: bun run check:react-ui-drift - run: bun run check:react-ui-pin diff --git a/apps/web/src/tailwind.css b/apps/web/src/tailwind.css index 8c123f89b..9f0f02424 100644 --- a/apps/web/src/tailwind.css +++ b/apps/web/src/tailwind.css @@ -32,4 +32,6 @@ @source "../../../packages/artifact-ui/src"; @source "../../../packages/bench-ui/src"; @source "../../../packages/chat-ui/src"; +@source "../../../packages/plugins-ui/src"; @source "../../../packages/settings-ui/src"; +@source "../../../packages/tasks-ui/src"; diff --git a/package.json b/package.json index a3c245b09..8031bda72 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "check:no-product-tenancy": "bun run scripts/checks/no-product-tenancy.ts", "check:browser-safe-subpaths": "bun run scripts/checks/browser-safe-subpaths.ts", "check:web-utilities": "bun run scripts/checks/web-tailwind-utilities.ts", + "check:tailwind-source": "bun run scripts/checks/tailwind-source.ts", "check:ui-vocabulary": "bun run scripts/checks/ui-vocabulary.ts", "check:react-ui-drift": "bun run scripts/checks/react-ui-drift.ts", "check:react-ui-pin": "bun run scripts/checks/react-ui-pin.ts", diff --git a/scripts/checks/tailwind-source.ts b/scripts/checks/tailwind-source.ts new file mode 100644 index 000000000..ac2fc30e5 --- /dev/null +++ b/scripts/checks/tailwind-source.ts @@ -0,0 +1,77 @@ +// check:tailwind-source — every package whose stylesheet apps/web/src/app.css +// imports must also be scanned by apps/web/src/tailwind.css's @source list. +// A workspace UI package is source-only: its .tsx files carry Tailwind +// utility classes directly, and nothing but @source scanning generates the +// CSS for them (see tailwind.css's own header comment). Importing a +// package's prebuilt styles.css says nothing about whether its component +// tree also leans on Tailwind utilities — CL-6490 found @corbits/plugins-ui +// imported without a matching @source entry, so `data-[state=connected]: +// bg-success`, `min-h-16`, and `[&>*:last-child]:border-b-0` never made it +// into the built CSS. Nothing errored: the classes just silently did not +// exist, and the failure only showed up as broken layout in production. +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + emptyReport, + reportAndExit, + rootFromArgs, + type CheckReport, +} from "./lib/repo"; + +const APP_CSS = "apps/web/src/app.css"; +const TAILWIND_CSS = "apps/web/src/tailwind.css"; + +const IMPORT_PATTERN = /@import\s+"@corbits\/([a-z0-9-]+)\/styles\.css";/g; +const SOURCE_PATTERN = + /@source\s+"\.\.\/\.\.\/\.\.\/packages\/([a-z0-9-]+)\/src";/g; + +/** Package names behind every `@corbits//styles.css` import. */ +export function importedStylesheetPackages(css: string): string[] { + return [...css.matchAll(IMPORT_PATTERN)].map((match) => match[1] as string); +} + +/** Package names behind every `@source ".../packages//src"` entry. */ +export function sourcedPackages(css: string): string[] { + return [...css.matchAll(SOURCE_PATTERN)].map((match) => match[1] as string); +} + +/** + * A package whose stylesheet app.css imports must appear in tailwind.css's + * @source list — a package sourced without being imported (e.g. artifact-ui, + * which has no prebuilt stylesheet of its own) is not a violation, since + * @source scanning is the only thing that package ever relies on. + */ +export function auditTailwindSource( + imported: readonly string[], + sourced: readonly string[], +): CheckReport { + const report = emptyReport(); + const sourcedSet = new Set(sourced); + for (const name of imported) { + if (sourcedSet.has(name)) continue; + report.violations.push( + `${TAILWIND_CSS}: no @source entry for @corbits/${name} even though ` + + `${APP_CSS} imports its styles.css — that package's component tree ` + + `is scanned for nothing, so any Tailwind utility class it uses ` + + `silently does not exist in the built CSS instead of erroring. ` + + `Add \`@source "../../../packages/${name}/src";\` to ${TAILWIND_CSS}.`, + ); + } + return report; +} + +async function main(): Promise { + const root = rootFromArgs(Bun.argv.slice(2)); + const appCss = readFileSync(path.join(root, APP_CSS), "utf8"); + const tailwindCss = readFileSync(path.join(root, TAILWIND_CSS), "utf8"); + const imported = importedStylesheetPackages(appCss); + const sourced = sourcedPackages(tailwindCss); + const report = auditTailwindSource(imported, sourced); + report.notes.push( + `${imported.length} stylesheet import(s) in ${APP_CSS}, ` + + `${sourced.length} @source entr(y/ies) in ${TAILWIND_CSS}`, + ); + reportAndExit("check:tailwind-source", report); +} + +if (import.meta.main) await main(); diff --git a/scripts/checks/test/tailwind-source.test.ts b/scripts/checks/test/tailwind-source.test.ts new file mode 100644 index 000000000..a3e31f6cc --- /dev/null +++ b/scripts/checks/test/tailwind-source.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test"; +import { + auditTailwindSource, + importedStylesheetPackages, + sourcedPackages, +} from "../tailwind-source"; + +test("importedStylesheetPackages reads @corbits/*/styles.css imports only", () => { + const css = [ + '@import "@corbits/chat-ui/styles.css";', + '@import "@corbits/plugins-ui/styles.css";', + "html { font-size: 15px; }", + ].join("\n"); + expect(importedStylesheetPackages(css)).toEqual(["chat-ui", "plugins-ui"]); +}); + +test("sourcedPackages reads @source packages//src entries only", () => { + const css = [ + '@source "../../../packages/bench-ui/src";', + '@source "../../../packages/chat-ui/src";', + ].join("\n"); + expect(sourcedPackages(css)).toEqual(["bench-ui", "chat-ui"]); +}); + +test("a stylesheet import with no matching @source entry is a violation", () => { + const report = auditTailwindSource(["chat-ui", "plugins-ui"], ["chat-ui"]); + expect(report.violations).toHaveLength(1); + expect(report.violations[0]).toContain("plugins-ui"); + expect(report.violations[0]).toContain("tailwind.css"); + expect(report.violations[0]).toContain("silently"); +}); + +test("every import matched by a @source entry passes", () => { + const report = auditTailwindSource( + ["chat-ui", "plugins-ui"], + ["chat-ui", "plugins-ui"], + ); + expect(report.violations).toEqual([]); +}); + +test("a @source entry with no matching import is not a violation", () => { + const report = auditTailwindSource(["chat-ui"], ["artifact-ui", "chat-ui"]); + expect(report.violations).toEqual([]); +});