diff --git a/.changeset/doctor-tailwind4-diagnostics-3891.md b/.changeset/doctor-tailwind4-diagnostics-3891.md new file mode 100644 index 0000000000..66896e875b --- /dev/null +++ b/.changeset/doctor-tailwind4-diagnostics-3891.md @@ -0,0 +1,56 @@ +--- +"@object-ui/cli": patch +--- + +`objectui doctor` now diagnoses Tailwind 4 instead of Tailwind 3 + +The Tailwind section of `objectui doctor` was written against v3 and got every +question backwards on a v4 project — which is every project this repo ships. + +**It counted a missing `tailwind.config.js` as an issue.** In v4 that file is not +part of the setup: the engine reads CSS-first configuration (`@import +'tailwindcss'`, `@theme`, `@source`) and only loads a JS config when a stylesheet +opts in with `@config`. So the command reported a problem that did not exist and +pushed the reader toward creating a file Tailwind would never read. Measured on +`examples/console-starter`, a correct v4 app: before, `Found 1 issue(s)` — +`⚠️ tailwind.config.js not found`; after, `Everything looks good! ✨`. The repo's +own root reproduced it identically. + +**It then graded that file on its `content` array**, the v3 key `@source` +replaced. The two `tailwind.config.*` files still tracked here are exactly that +trap: `apps/console` and `examples/byo-backend-console` both declare a `content` +array, no stylesheet in the repo contains `@config`, so both files are inert — +and the old check answered `✓ Tailwind content paths configured` for them. A +false green on a dead file. `apps/console` before: `Everything looks good! ✨`; +after: one finding saying the config is inert and what to do about it. + +**It never checked `@tailwindcss/postcss`**, the one dependency a v4 build cannot +start without — v4 moved the PostCSS plugin out of `tailwindcss` into that +package, and naming the old `tailwindcss` key in a PostCSS config resolves to a +shim whose only job is to throw. That is the failure form objectui#3852 measured +on the generated app, and doctor printed `✓ Tailwind CSS installed` straight +through it. + +The checks are now the v4 contract, matching what `objectui init` scaffolds: +`@tailwindcss/postcss` declared or installed, a PostCSS config naming it rather +than the v3 `tailwindcss` key, and a CSS entry running `@import 'tailwindcss'` +(with `@source` acknowledged when present). The declared `tailwindcss` major is +read too, so a v3 range is named as migration debt instead of passing as +`✓ installed`. + +Two deliberate silences, because objectui#3891 is about doctor asserting things +it cannot see. A **missing** `tailwind.config.*` produces no finding of any level +— only a *present* one does, and only when nothing opts into it via `@config`. +And when no recognised CSS entry exists at all (a monorepo root, a bespoke +layout), the CSS verdicts are skipped rather than guessed. + +A v3-tolerant dual path — branching on the declared major and running two sets of +checks — was considered and deliberately not built: it widens the product surface +past this repo's v4-only posture. v3 spellings are diagnosed as migration debt, +not supported as a second mode. + +Internally `runDiagnostics(cwd)` now returns structured findings carrying a +stable `id`, and `doctor()` only renders and counts them. That split is what +makes the matrix testable against real fixture directories instead of scraped +console output; the tests pin verdicts by `id`, so wording can improve without +the coverage evaporating. diff --git a/packages/cli/src/__tests__/doctor.test.ts b/packages/cli/src/__tests__/doctor.test.ts new file mode 100644 index 0000000000..d8841526f8 --- /dev/null +++ b/packages/cli/src/__tests__/doctor.test.ts @@ -0,0 +1,339 @@ +/** + * 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. + */ + +/** + * `runDiagnostics` — the body of `objectui doctor` (objectui#3891). + * + * The command used to be written against Tailwind 3, so on a v4 project it + * reported a problem that did not exist (no `tailwind.config.js`), graded a + * file v4 never reads on a key v4 does not have (`content`), and stayed silent + * about the one dependency a v4 build cannot start without + * (`@tailwindcss/postcss`). These tests pin all three verdicts by `id`, so the + * wording can be improved without the coverage evaporating, and so a + * regression toward the v3 questions goes red rather than quiet. + * + * Fixtures are real directories under `os.tmpdir()` — deliberately not the repo + * tree, because `runDiagnostics` resolves `@tailwindcss/postcss` through Node + * from the directory it is handed, and a fixture nested inside this workspace + * would inherit the workspace's `node_modules` and make the resolution branch + * untestable. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { runDiagnostics, countIssues, type Diagnostic } from '../commands/doctor.js'; + +let cwd: string; + +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'objectui-doctor-')); +}); + +afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); +}); + +/** Write `content` to `rel` inside the fixture, creating parent dirs. */ +function write(rel: string, content: string): void { + const abs = join(cwd, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content, 'utf-8'); +} + +function writePkg(pkg: Record): void { + write('package.json', JSON.stringify(pkg, null, 2)); +} + +const ids = (results: readonly Diagnostic[]): string[] => results.map((r) => r.id); + +function find(results: readonly Diagnostic[], id: string): Diagnostic | undefined { + return results.find((r) => r.id === id); +} + +/** + * The shape `objectui init` scaffolds: v4 dependency set, a PostCSS config + * naming the v4 plugin, a CSS entry running the v4 import, and no + * `tailwind.config.*` at all. + */ +function scaffoldHealthyV4App(): void { + writePkg({ + name: 'fixture-app', + dependencies: { react: '^19.0.0', tailwindcss: '^4.3.3' }, + devDependencies: { '@tailwindcss/postcss': '^4.3.3', typescript: '^5.9.0' }, + }); + write('postcss.config.js', "export default { plugins: { '@tailwindcss/postcss': {} } };\n"); + write('src/index.css', "@import 'tailwindcss';\n@source '../src/**/*.{ts,tsx}';\n"); + write('tsconfig.json', '{}'); +} + +describe('runDiagnostics — a healthy Tailwind 4 project', () => { + it('reports zero issues for the shape `objectui init` generates', () => { + scaffoldHealthyV4App(); + const results = runDiagnostics(cwd); + + expect(countIssues(results)).toBe(0); + expect(results.every((r) => r.level === 'ok')).toBe(true); + expect(ids(results)).toContain('tailwind-postcss-declared'); + expect(ids(results)).toContain('postcss-plugin-v4'); + expect(ids(results)).toContain('css-entry-v4-import'); + expect(ids(results)).toContain('css-entry-source'); + }); + + it('does NOT mention tailwind.config at all when the file is absent', () => { + scaffoldHealthyV4App(); + const results = runDiagnostics(cwd); + + // The regression objectui#3891 is about: absence of a v4-irrelevant file + // must produce no finding of any level, not even an `ok` one. + expect(ids(results).filter((id) => id.startsWith('tailwind-config'))).toEqual([]); + expect(results.map((r) => r.message).join('\n')).not.toMatch(/tailwind\.config/); + }); +}); + +describe('runDiagnostics — @tailwindcss/postcss, the real v4 failure mode', () => { + it('errors when the plugin package is neither declared nor resolvable', () => { + scaffoldHealthyV4App(); + // Same project, minus the one dependency v4 cannot build without. + writePkg({ + name: 'fixture-app', + dependencies: { react: '^19.0.0', tailwindcss: '^4.3.3' }, + devDependencies: { typescript: '^5.9.0' }, + }); + + const results = runDiagnostics(cwd); + const finding = find(results, 'tailwind-postcss-missing'); + + expect(finding?.level).toBe('error'); + expect(finding?.message).toContain('@tailwindcss/postcss'); + expect(countIssues(results)).toBe(1); + }); + + it('accepts an undeclared plugin that a parent node_modules provides', () => { + // The workspace case: a leaf package.json stays silent while the install + // lives further up. Reporting that as missing would put objectui#3891's + // false positive straight back, one directory over. + scaffoldHealthyV4App(); + writePkg({ + name: 'fixture-app', + dependencies: { react: '^19.0.0', tailwindcss: '^4.3.3' }, + devDependencies: { typescript: '^5.9.0' }, + }); + mkdirSync(join(cwd, 'node_modules/@tailwindcss/postcss'), { recursive: true }); + + const results = runDiagnostics(cwd); + + expect(find(results, 'tailwind-postcss-installed')?.level).toBe('ok'); + expect(find(results, 'tailwind-postcss-missing')).toBeUndefined(); + expect(countIssues(results)).toBe(0); + }); + + it('is silent about the plugin when the project does not use Tailwind', () => { + writePkg({ name: 'fixture-app', dependencies: { react: '^19.0.0' } }); + write('tsconfig.json', '{}'); + + const results = runDiagnostics(cwd); + + // `tailwind-missing` says the one thing worth saying; the v4 build checks + // must not pile findings onto a project that never asked for Tailwind. + expect(ids(results)).toContain('tailwind-missing'); + expect(ids(results).filter((id) => id.startsWith('tailwind-postcss'))).toEqual([]); + expect(ids(results).filter((id) => id.startsWith('css-entry'))).toEqual([]); + expect(ids(results).filter((id) => id.startsWith('postcss-plugin'))).toEqual([]); + }); +}); + +describe('runDiagnostics — PostCSS config spelling', () => { + it('errors on the bare v3 `tailwindcss` plugin key, which throws under v4', () => { + scaffoldHealthyV4App(); + write('postcss.config.js', 'export default { plugins: { tailwindcss: {}, autoprefixer: {} } };\n'); + + const results = runDiagnostics(cwd); + const finding = find(results, 'postcss-plugin-v3'); + + expect(finding?.level).toBe('error'); + expect(find(results, 'postcss-plugin-v4')).toBeUndefined(); + }); + + it('errors on the quoted v3 spelling', () => { + scaffoldHealthyV4App(); + write('postcss.config.js', "export default { plugins: { 'tailwindcss': {} } };\n"); + + const results = runDiagnostics(cwd); + const finding = find(results, 'postcss-plugin-v3'); + + expect(finding?.level).toBe('error'); + expect(finding?.message).toContain('@tailwindcss/postcss'); + }); + + it("does not mistake `'@tailwindcss/postcss'` for the v3 `tailwindcss` key", () => { + scaffoldHealthyV4App(); + const results = runDiagnostics(cwd); + + // The v3 probe runs against the same text regardless of the v4 verdict, so + // this is a real read of the probe: the scoped package name has `@` before + // `tailwindcss` and `/` after it, defeating both alternatives. Without that + // the healthy scaffold every `objectui init` produces would self-report. + expect(find(results, 'postcss-plugin-v3')).toBeUndefined(); + expect(find(results, 'postcss-plugin-v4')?.level).toBe('ok'); + }); + + it('warns when a PostCSS config registers no Tailwind plugin at all', () => { + scaffoldHealthyV4App(); + write('postcss.config.js', 'export default { plugins: { autoprefixer: {} } };\n'); + + const results = runDiagnostics(cwd); + expect(find(results, 'postcss-plugin-absent')?.level).toBe('warn'); + }); + + it('flags the v3 entry even when the v4 plugin is listed alongside it', () => { + scaffoldHealthyV4App(); + write( + 'postcss.config.js', + "export default { plugins: { '@tailwindcss/postcss': {}, 'tailwindcss': {} } };\n", + ); + + const results = runDiagnostics(cwd); + expect(find(results, 'postcss-plugin-v3')?.level).toBe('error'); + }); +}); + +describe('runDiagnostics — the CSS entry is where v4 is configured', () => { + it('errors when the entry still uses the v3 @tailwind directives', () => { + scaffoldHealthyV4App(); + write('src/index.css', '@tailwind base;\n@tailwind components;\n@tailwind utilities;\n'); + + const results = runDiagnostics(cwd); + const finding = find(results, 'css-entry-v3-directives'); + + expect(finding?.level).toBe('error'); + expect(finding?.message).toContain("@import 'tailwindcss'"); + }); + + it('warns when a CSS entry exists but nothing starts Tailwind', () => { + scaffoldHealthyV4App(); + write('src/index.css', 'body { margin: 0; }\n'); + + const results = runDiagnostics(cwd); + const finding = find(results, 'css-entry-no-tailwind'); + + expect(finding?.level).toBe('warn'); + expect(finding?.message).toContain('src/index.css'); + }); + + it('accepts the import from any recognised entry, not just src/index.css', () => { + scaffoldHealthyV4App(); + rmSync(join(cwd, 'src/index.css')); + write('app/globals.css', "@import 'tailwindcss';\n"); + + const results = runDiagnostics(cwd); + expect(find(results, 'css-entry-v4-import')?.level).toBe('ok'); + expect(countIssues(results)).toBe(0); + }); + + it('stays silent when no recognised CSS entry exists (a monorepo root)', () => { + // This is the objectui#3891 headline case: the repo root declares + // `tailwindcss`, has no CSS entry and no `tailwind.config.*`. doctor must + // assert nothing about a layout it cannot see. + writePkg({ + name: 'fixture-monorepo-root', + dependencies: { react: '^19.0.0' }, + devDependencies: { tailwindcss: '^4.3.3', '@tailwindcss/postcss': '^4.3.3' }, + }); + write('postcss.config.mjs', "export default { plugins: { '@tailwindcss/postcss': {} } };\n"); + write('tsconfig.json', '{}'); + + const results = runDiagnostics(cwd); + + expect(ids(results).filter((id) => id.startsWith('css-entry'))).toEqual([]); + expect(ids(results).filter((id) => id.startsWith('tailwind-config'))).toEqual([]); + expect(countIssues(results)).toBe(0); + }); +}); + +describe('runDiagnostics — a present tailwind.config is judged by @config, not by `content`', () => { + it('warns that the config is inert when no stylesheet declares @config', () => { + scaffoldHealthyV4App(); + // Byte-for-byte the shape this repo still tracks in apps/console: a v3 + // `content` array that v4 never reads. The old check answered + // "✓ Tailwind content paths configured" here. + write( + 'tailwind.config.js', + "export default { content: ['./index.html', './src/**/*.{ts,tsx}'], theme: { extend: {} } };\n", + ); + + const results = runDiagnostics(cwd); + const finding = find(results, 'tailwind-config-inert'); + + expect(finding?.level).toBe('warn'); + expect(finding?.message).toContain('tailwind.config.js'); + expect(finding?.message).toContain('@config'); + // The verdict must not be reached via the v3 `content` key. + expect(finding?.message).not.toMatch(/content array/i); + }); + + it('accepts the config when a stylesheet opts into it with @config', () => { + scaffoldHealthyV4App(); + write('tailwind.config.ts', 'export default { theme: { extend: {} } };\n'); + write('src/index.css', "@import 'tailwindcss';\n@config '../tailwind.config.ts';\n"); + + const results = runDiagnostics(cwd); + + expect(find(results, 'tailwind-config-active')?.level).toBe('ok'); + expect(find(results, 'tailwind-config-inert')).toBeUndefined(); + expect(countIssues(results)).toBe(0); + }); +}); + +describe('runDiagnostics — non-Tailwind checks are unchanged', () => { + it('errors when package.json is absent', () => { + const results = runDiagnostics(cwd); + expect(find(results, 'package-json-missing')?.level).toBe('error'); + }); + + it('errors when package.json is not valid JSON', () => { + write('package.json', '{ not json'); + const results = runDiagnostics(cwd); + expect(find(results, 'package-json-unreadable')?.level).toBe('error'); + }); + + it('warns on a legacy TypeScript major but not on an unparseable range', () => { + writePkg({ name: 'a', devDependencies: { typescript: '^4.9.5' } }); + write('tsconfig.json', '{}'); + expect(find(runDiagnostics(cwd), 'typescript-legacy')?.level).toBe('warn'); + + writePkg({ name: 'a', devDependencies: { typescript: 'workspace:*' } }); + const results = runDiagnostics(cwd); + expect(find(results, 'typescript-legacy')).toBeUndefined(); + expect(find(results, 'typescript-version')?.level).toBe('ok'); + }); + + it('keeps the peer-dependency contracts', () => { + writePkg({ + name: 'a', + dependencies: { '@object-ui/react': '^17.0.0', '@object-ui/components': '^17.0.0' }, + }); + const results = runDiagnostics(cwd); + expect(ids(results)).toContain('peer-react'); + expect(ids(results)).toContain('peer-tailwind'); + }); +}); + +describe('countIssues', () => { + it('counts warn and error, never ok', () => { + expect( + countIssues([ + { id: 'a', level: 'ok', message: '' }, + { id: 'b', level: 'warn', message: '' }, + { id: 'c', level: 'error', message: '' }, + ]), + ).toBe(2); + }); +}); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 58eb9d03af..b7e513d6ad 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -6,118 +6,412 @@ * LICENSE file in the root directory of this source tree. */ +/** + * `objectui doctor` — project health diagnosis. + * + * ## Tailwind: v4-only, because this repo is v4-only (objectui#3891) + * + * The Tailwind section used to be written against Tailwind 3 and got every + * question backwards on a v4 project: + * + * - It counted a **missing `tailwind.config.js` as an issue**. In v4 that file + * is not part of the setup at all — the engine reads CSS-first configuration + * (`@import 'tailwindcss'`, `@theme`, `@source`) and only looks at a JS + * config when a stylesheet explicitly opts in with `@config`. So the advice + * pushed readers toward creating a file Tailwind would never read. Running + * `objectui doctor` at this repo's own root reproduced it: there is no root + * `tailwind.config.*`, and doctor reported a problem that did not exist. + * - It then graded that file on its **`content` array**, the v3 key that + * `@source` replaced. The two `tailwind.config.*` files still tracked in this + * repo (`apps/console`, `examples/byo-backend-console`) are exactly this + * trap: both declare `content: ['./src/**\/*.{ts,tsx}', …]`, no stylesheet + * anywhere in the repo contains `@config`, so both files are inert — and the + * old check answered "✓ Tailwind content paths configured" for them. A false + * green on a dead file is worse than silence. + * - It **never checked `@tailwindcss/postcss`**, which is the one dependency a + * v4 project genuinely cannot build without. v4 moved the PostCSS plugin out + * of `tailwindcss` into that separate package; naming the old `tailwindcss` + * key in a PostCSS config resolves to a shim whose only job is to throw. That + * is the failure form objectui#3852 measured on the generated app, and doctor + * happily printed "✓ Tailwind CSS installed" through it. + * + * So the checks below are the v4 contract, matching what `objectui init` + * scaffolds (see `utils/app-generator.ts`): `@tailwindcss/postcss` available, a + * PostCSS config that names it rather than the v3 `tailwindcss` key, and a CSS + * entry that runs `@import 'tailwindcss'`. A missing `tailwind.config.*` is + * **not** reported; a *present* one is reported only when nothing opts into it + * via `@config`, which is the case where it silently does nothing. + * + * A v3-tolerant dual path (branching on the declared `tailwindcss` major and + * running two sets of checks) was considered and deliberately not built — it + * widens the product surface past this repo's v4-only posture. v3 spellings are + * therefore diagnosed as *migration debt*, not supported as a second mode. + * + * ## Structure + * + * `runDiagnostics(cwd)` is the whole diagnosis and returns structured results; + * `doctor()` only renders them and counts. Splitting the two is what makes the + * matrix above testable against real fixture directories instead of scraped + * console output. + */ + import chalk from 'chalk'; import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; +import { dirname, join, resolve as resolvePath } from 'path'; -export async function doctor() { - console.log(chalk.bold('Object UI Doctor')); - console.log('Diagnosis in progress...\n'); - - const cwd = process.cwd(); - let issues = 0; +/** `ok` never counts toward the issue total; `warn` and `error` both do. */ +export type DiagnosticLevel = 'ok' | 'warn' | 'error'; + +export interface Diagnostic { + level: DiagnosticLevel; + /** The single line rendered for this finding. */ + message: string; + /** + * Stable identifier for the finding, so tests (and any future machine + * readable output) pin the *verdict* rather than prose wording. + */ + id: string; +} + +/** + * CSS entrypoint candidates, in the order doctor prefers them. `src/index.css` + * is first because that is what `objectui init` generates; the rest cover the + * common Vite / Next.js / plain layouts. Every candidate that exists is read — + * the Tailwind verdicts below ask "does *any* entry do this", so a project with + * several stylesheets is not judged on whichever one happens to sort first. + */ +const CSS_ENTRY_CANDIDATES = [ + 'src/index.css', + 'src/main.css', + 'src/styles.css', + 'src/style.css', + 'src/app.css', + 'src/global.css', + 'src/globals.css', + 'app/globals.css', + 'app/global.css', + 'styles/globals.css', + 'index.css', + 'styles.css', +]; + +const TAILWIND_CONFIG_CANDIDATES = [ + 'tailwind.config.js', + 'tailwind.config.cjs', + 'tailwind.config.mjs', + 'tailwind.config.ts', +]; + +const POSTCSS_CONFIG_CANDIDATES = [ + 'postcss.config.js', + 'postcss.config.cjs', + 'postcss.config.mjs', + 'postcss.config.ts', +]; + +/** `@import 'tailwindcss'` — the v4 entrypoint, with or without a `layer()`. */ +const V4_CSS_IMPORT = /@import\s+["']tailwindcss["']/; +/** `@tailwind base;` / `components` / `utilities` — the v3 directives. */ +const V3_CSS_DIRECTIVE = /@tailwind\s+(?:base|components|utilities|screens|variants)\b/; +const CSS_SOURCE_DIRECTIVE = /@source\s+["']/; +const CSS_CONFIG_DIRECTIVE = /@config\s+["']/; + +/** + * The v4 PostCSS plugin, in either object (`'@tailwindcss/postcss': {}`) or + * array (`require('@tailwindcss/postcss')`) form. + */ +const V4_POSTCSS_PLUGIN = /@tailwindcss\/postcss/; +/** + * The v3 spelling: `tailwindcss` named as a plugin itself, in either the + * quoted (`'tailwindcss': {}`, `require('tailwindcss')`) or the bare-key + * (`tailwindcss: {}`) form. + * + * Neither alternative can match inside `'@tailwindcss/postcss'`: the quoted one + * needs a quote immediately before `tailwindcss` and finds `@`, and the bare + * one needs a key boundary before it and a `:` after it, where the scoped name + * has `@` and `/`. That non-match is pinned by a test — it is the difference + * between diagnosing a v3 config and having every healthy v4 project + * self-report. + */ +const V3_POSTCSS_PLUGIN = /["']tailwindcss["']|(?:^|[{,\s])tailwindcss\s*:/m; + +function readIfExists(path: string): string | null { + try { + return existsSync(path) ? readFileSync(path, 'utf-8') : null; + } catch { + return null; + } +} + +/** First existing path from `candidates`, relative to `cwd`. */ +function findFile(cwd: string, candidates: readonly string[]): string | null { + for (const rel of candidates) { + const abs = join(cwd, rel); + if (existsSync(abs)) return abs; + } + return null; +} + +/** + * Leading major of a dependency range. Returns `null` for anything without a + * usable number — `workspace:*`, `catalog:`, a git URL, `latest` — because + * "cannot tell" must not be reported as "wrong version". + */ +function majorOf(range: string): number | null { + const match = /(\d+)/.exec(range); + if (!match) return null; + const major = Number.parseInt(match[1], 10); + return Number.isNaN(major) ? null : major; +} + +/** + * Is `specifier` installed for this project — i.e. present in a `node_modules` + * at `cwd` or any ancestor? + * + * Declaration in `package.json` is the primary signal, but it is not the only + * valid setup: inside a workspace the plugin is frequently installed by a + * parent while the leaf `package.json` stays silent. Checking the tree is what + * keeps this from replacing objectui#3891's false positive with a new one on + * every workspace package. + * + * Deliberately a manual walk rather than `createRequire(…).resolve()`, even + * though the latter looks more authoritative. Node's resolver also consults + * `NODE_PATH` and the global folders, and `NODE_PATH` is not hypothetical + * here: vitest sets it to pnpm's virtual store (`node_modules/.pnpm/…`), so + * under test *every* package in the monorepo resolves from *any* directory — + * including a fresh `os.tmpdir()` fixture with an empty `package.json`. That + * made the "plugin is missing" branch unreachable in tests and would have + * shipped it unverified. The walk below answers the question a bundler + * actually asks and depends on nothing but the filesystem. + */ +function isInstalledFrom(cwd: string, specifier: string): boolean { + let dir = resolvePath(cwd); + for (;;) { + if (existsSync(join(dir, 'node_modules', ...specifier.split('/')))) return true; + const parent = dirname(dir); + if (parent === dir) return false; + dir = parent; + } +} + +/** + * Run every diagnostic against `cwd` and return the findings in render order. + * Pure with respect to stdout — see the module docblock. + */ +export function runDiagnostics(cwd: string): Diagnostic[] { + const results: Diagnostic[] = []; + const ok = (id: string, message: string): void => { + results.push({ id, level: 'ok', message }); + }; + const warn = (id: string, message: string): void => { + results.push({ id, level: 'warn', message }); + }; + const error = (id: string, message: string): void => { + results.push({ id, level: 'error', message }); + }; - // 1. Check package.json + // ---------------------------------------------------------------- package.json const pkgPath = join(cwd, 'package.json'); - if (existsSync(pkgPath)) { + const pkgRaw = readIfExists(pkgPath); + let pkg: Record | undefined> | null = null; + + if (pkgRaw === null) { + error('package-json-missing', 'package.json not found'); + } else { try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); - - // Check React version - const reactVer = pkg.dependencies?.react || pkg.devDependencies?.react; - if (reactVer) { - console.log(chalk.green('✓ React installed')); + pkg = JSON.parse(pkgRaw); + } catch { + error('package-json-unreadable', 'Failed to parse package.json'); + } + } + + const deps: Record = pkg + ? { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) } + : {}; + + if (pkg) { + if (deps.react) { + ok('react-installed', 'React installed'); + } else { + warn('react-missing', 'React not found in dependencies'); + } + } + + // -------------------------------------------------------------------- Tailwind + // + // Everything below is gated on the project declaring `tailwindcss` at all. A + // directory that does not use Tailwind must not collect Tailwind findings — + // the "not found" line above already says the one thing worth saying. + const tailwindRange = pkg ? deps.tailwindcss : undefined; + + if (pkg && !tailwindRange) { + warn('tailwind-missing', 'Tailwind CSS not found in dependencies'); + } + + if (tailwindRange) { + const major = majorOf(tailwindRange); + if (major !== null && major < 4) { + warn( + 'tailwind-major-legacy', + `Tailwind CSS ${tailwindRange} detected — ObjectUI targets Tailwind 4. ` + + 'Upgrade with `npx @tailwindcss/upgrade`, then move configuration into ' + + "your CSS entry (`@import 'tailwindcss'`, `@theme`, `@source`).", + ); + } else { + ok('tailwind-installed', `Tailwind CSS ${tailwindRange} installed`); + } + + // 1. The v4 PostCSS plugin — the dependency a v4 build actually fails without. + if (deps['@tailwindcss/postcss']) { + ok('tailwind-postcss-declared', '@tailwindcss/postcss declared'); + } else if (isInstalledFrom(cwd, '@tailwindcss/postcss')) { + ok( + 'tailwind-postcss-installed', + '@tailwindcss/postcss installed (provided by the workspace)', + ); + } else { + error( + 'tailwind-postcss-missing', + 'Tailwind CSS 4 needs @tailwindcss/postcss, which is neither declared ' + + 'nor installed here. v4 moved the PostCSS plugin out of `tailwindcss` ' + + 'into its own package; without it PostCSS fails to load and no ' + + 'styles are emitted. Install it: `npm i -D @tailwindcss/postcss`.', + ); + } + + // 2. The PostCSS config, when there is one, must name the v4 plugin. + const postcssConfigPath = findFile(cwd, POSTCSS_CONFIG_CANDIDATES); + if (postcssConfigPath) { + const postcssConfig = readIfExists(postcssConfigPath) ?? ''; + const hasV4Plugin = V4_POSTCSS_PLUGIN.test(postcssConfig); + // Evaluated independently of `hasV4Plugin`, not as its `else` branch: a + // half-finished migration that lists both entries still throws on the + // v3 one, and that is the state worth naming. + const hasV3Plugin = V3_POSTCSS_PLUGIN.test(postcssConfig); + + if (hasV3Plugin) { + error( + 'postcss-plugin-v3', + 'PostCSS config names `tailwindcss` as a plugin — the Tailwind 3 ' + + 'spelling. In v4 that entry resolves to a shim whose only job is ' + + "to throw. Replace it with `'@tailwindcss/postcss': {}`.", + ); + } else if (hasV4Plugin) { + ok('postcss-plugin-v4', 'PostCSS config uses @tailwindcss/postcss'); } else { - console.log(chalk.yellow('⚠️ React not found in dependencies')); - issues++; + warn( + 'postcss-plugin-absent', + 'PostCSS config does not register a Tailwind plugin. Add ' + + "`'@tailwindcss/postcss': {}` so Tailwind runs during the build.", + ); } + } - // Check Tailwind - const tailwindVer = pkg.dependencies?.tailwindcss || pkg.devDependencies?.tailwindcss; - if (tailwindVer) { - console.log(chalk.green('✓ Tailwind CSS installed')); - } else { - console.log(chalk.yellow('⚠️ Tailwind CSS not found')); - issues++; + // 3. The CSS entry must start the v4 engine. Read every candidate that + // exists and ask whether *any* of them does — see CSS_ENTRY_CANDIDATES. + const cssEntries = CSS_ENTRY_CANDIDATES.map((rel) => ({ + rel, + content: readIfExists(join(cwd, rel)), + })).filter((entry): entry is { rel: string; content: string } => entry.content !== null); + + const importsTailwind = cssEntries.some((entry) => V4_CSS_IMPORT.test(entry.content)); + const usesV3Directives = cssEntries.some((entry) => V3_CSS_DIRECTIVE.test(entry.content)); + const declaresSource = cssEntries.some((entry) => CSS_SOURCE_DIRECTIVE.test(entry.content)); + const optsIntoJsConfig = cssEntries.some((entry) => CSS_CONFIG_DIRECTIVE.test(entry.content)); + + if (cssEntries.length === 0) { + // No recognised entrypoint — say nothing. A monorepo root or an app with + // a bespoke CSS layout is not evidence of a misconfiguration, and + // objectui#3891 is precisely about doctor asserting things it cannot see. + } else if (importsTailwind) { + ok('css-entry-v4-import', "CSS entry runs `@import 'tailwindcss'`"); + if (declaresSource) { + ok('css-entry-source', 'CSS entry declares `@source` scanning paths'); } - } catch (e) { - console.log(chalk.red('x Failed to read package.json')); - issues++; + } else if (usesV3Directives) { + error( + 'css-entry-v3-directives', + 'CSS entry still uses the Tailwind 3 `@tailwind base/components/' + + "utilities` directives. Tailwind 4 replaces all three with a single " + + "`@import 'tailwindcss';`.", + ); + } else { + warn( + 'css-entry-no-tailwind', + "No CSS entry runs `@import 'tailwindcss'`, so Tailwind never starts. " + + `Checked: ${cssEntries.map((entry) => entry.rel).join(', ')}.`, + ); } - } else { - console.log(chalk.red('x package.json not found')); - issues++; - } - // 2. Check tailwind.config.js - const tailwindConfigPath = join(cwd, 'tailwind.config.js'); - const tailwindTsPath = join(cwd, 'tailwind.config.ts'); - if (existsSync(tailwindConfigPath) || existsSync(tailwindTsPath)) { - const configFile = existsSync(tailwindConfigPath) ? 'tailwind.config.js' : 'tailwind.config.ts'; - const configPath = existsSync(tailwindConfigPath) ? tailwindConfigPath : tailwindTsPath; - console.log(chalk.green(`✓ ${configFile} found`)); - // Check content configuration - try { - const configContent = readFileSync(configPath, 'utf-8'); - if (configContent.includes('content') && (configContent.includes('./src') || configContent.includes('./app') || configContent.includes('./pages'))) { - console.log(chalk.green('✓ Tailwind content paths configured')); - } else { - console.log(chalk.yellow('⚠️ Tailwind content paths may not be configured. Ensure your content array includes source directories.')); - issues++; - } - } catch (_e) { - // File exists but can't be read - not critical - } - } else { - console.log(chalk.yellow('⚠️ tailwind.config.js not found')); - issues++; + // 4. `tailwind.config.*` — absence is correct in v4 and reported as nothing + // at all. Presence is reported only when it is inert. + const tailwindConfigPath = findFile(cwd, TAILWIND_CONFIG_CANDIDATES); + if (tailwindConfigPath && !optsIntoJsConfig) { + const rel = tailwindConfigPath.slice(cwd.length + 1); + warn( + 'tailwind-config-inert', + `${rel} exists but nothing opts into it — Tailwind 4 ignores a JS ` + + 'config unless a stylesheet declares `@config`. Anything it sets ' + + '(theme, content/@source paths, plugins) has no effect today. ' + + 'Either delete it and move the settings into your CSS entry, or add ' + + `\`@config '${rel}';\` there.`, + ); + } else if (tailwindConfigPath) { + ok('tailwind-config-active', 'JS Tailwind config is loaded via `@config`'); + } } - // 3. Check TypeScript version - const tsConfigPath = join(cwd, 'tsconfig.json'); - if (existsSync(tsConfigPath)) { - console.log(chalk.green('✓ tsconfig.json found')); - try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); - const tsVer = pkg.devDependencies?.typescript || pkg.dependencies?.typescript; - if (tsVer) { - const majorVer = parseInt(tsVer.replace(/[^0-9.]/g, '').split('.')[0], 10); - if (majorVer >= 5) { - console.log(chalk.green(`✓ TypeScript ${tsVer} (5.0+ required)`)); - } else { - console.log(chalk.yellow(`⚠️ TypeScript ${tsVer} detected — version 5.0+ recommended`)); - issues++; - } + // ------------------------------------------------------------------ TypeScript + if (existsSync(join(cwd, 'tsconfig.json'))) { + ok('tsconfig-found', 'tsconfig.json found'); + const tsRange = deps.typescript; + if (tsRange) { + const major = majorOf(tsRange); + if (major !== null && major < 5) { + warn('typescript-legacy', `TypeScript ${tsRange} detected — version 5.0+ recommended`); + } else { + ok('typescript-version', `TypeScript ${tsRange} (5.0+ required)`); } - } catch (_e) { - // Already checked package.json above } } else { - console.log(chalk.yellow('⚠️ tsconfig.json not found')); - issues++; + warn('tsconfig-missing', 'tsconfig.json not found'); } - // 4. Check peer dependencies - if (existsSync(pkgPath)) { - try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); - const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; - - // Check for common peer dependency issues - if (allDeps['@object-ui/react'] && !allDeps['react']) { - console.log(chalk.yellow('⚠️ @object-ui/react requires react as a peer dependency')); - issues++; - } - if (allDeps['@object-ui/components'] && !allDeps['tailwindcss']) { - console.log(chalk.yellow('⚠️ @object-ui/components requires tailwindcss as a peer dependency')); - issues++; - } - } catch (_e) { - // Already handled + // -------------------------------------------------------------- Peer contracts + if (pkg) { + if (deps['@object-ui/react'] && !deps.react) { + warn('peer-react', '@object-ui/react requires react as a peer dependency'); + } + if (deps['@object-ui/components'] && !deps.tailwindcss) { + warn('peer-tailwind', '@object-ui/components requires tailwindcss as a peer dependency'); + } + } + + return results; +} + +/** Number of findings that count as problems. `ok` findings never do. */ +export function countIssues(results: readonly Diagnostic[]): number { + return results.filter((result) => result.level !== 'ok').length; +} + +export async function doctor() { + console.log(chalk.bold('Object UI Doctor')); + console.log('Diagnosis in progress...\n'); + + const results = runDiagnostics(process.cwd()); + + for (const result of results) { + if (result.level === 'ok') { + console.log(chalk.green(`✓ ${result.message}`)); + } else if (result.level === 'warn') { + console.log(chalk.yellow(`⚠️ ${result.message}`)); + } else { + console.log(chalk.red(`x ${result.message}`)); } } - // Summary + const issues = countIssues(results); console.log('\nResult:'); if (issues === 0) { console.log(chalk.green('Everything looks good! ✨'));