From 0e17eb9a47c8de80bf08f63a8ec35fdcc565e7da Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Fri, 3 Apr 2026 18:09:12 -0400 Subject: [PATCH 1/5] =?UTF-8?q?Add=20ghost=20v0.1=20=E2=80=94=20design=20d?= =?UTF-8?q?rift=20detection=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffold pnpm monorepo with two packages: - @ghost/core: detection engine with CSS/registry resolvers, values scanner (token-override, hardcoded-color, missing-token), structure scanner (component diffing), and CLI/JSON reporters - ghost-cli: CLI wrapper with `ghost scan` command 24 tests passing across resolvers, scanners, and e2e. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 + .npmrc | 2 + package.json | 21 + packages/ghost-cli/package.json | 18 + packages/ghost-cli/src/bin.ts | 60 + packages/ghost-cli/tsconfig.json | 12 + packages/ghost-core/package.json | 27 + packages/ghost-core/src/config.ts | 75 ++ packages/ghost-core/src/index.ts | 23 + packages/ghost-core/src/reporters/cli.ts | 111 ++ packages/ghost-core/src/reporters/json.ts | 5 + packages/ghost-core/src/resolvers/css.ts | 128 ++ packages/ghost-core/src/resolvers/registry.ts | 147 +++ packages/ghost-core/src/scan.ts | 102 ++ packages/ghost-core/src/scanners/structure.ts | 115 ++ packages/ghost-core/src/scanners/values.ts | 173 +++ packages/ghost-core/src/types.ts | 128 ++ packages/ghost-core/test/e2e/scan.test.ts | 65 + .../consumer-clean/components/ui/button.tsx | 45 + .../consumer-clean/components/ui/card.tsx | 26 + .../fixtures/consumer-clean/ghost.config.ts | 21 + .../consumer-clean/src/styles/main.css | 30 + .../consumer-drifted/components/ui/button.tsx | 47 + .../fixtures/consumer-drifted/ghost.config.ts | 21 + .../consumer-drifted/src/styles/main.css | 35 + .../test/fixtures/registry/out/r/button.json | 16 + .../test/fixtures/registry/out/r/card.json | 15 + .../fixtures/registry/out/r/styles-main.json | 13 + .../test/fixtures/registry/registry.json | 57 + .../registry/src/components/ui/button.tsx | 45 + .../registry/src/components/ui/card.tsx | 26 + .../fixtures/registry/src/styles/main.css | 30 + .../ghost-core/test/resolvers/css.test.ts | 78 ++ .../test/resolvers/registry.test.ts | 35 + .../test/scanners/structure.test.ts | 79 ++ .../ghost-core/test/scanners/values.test.ts | 112 ++ packages/ghost-core/tsconfig.json | 9 + pnpm-lock.yaml | 1070 +++++++++++++++++ pnpm-workspace.yaml | 2 + tsconfig.json | 19 + vitest.config.ts | 7 + 41 files changed, 3054 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 package.json create mode 100644 packages/ghost-cli/package.json create mode 100644 packages/ghost-cli/src/bin.ts create mode 100644 packages/ghost-cli/tsconfig.json create mode 100644 packages/ghost-core/package.json create mode 100644 packages/ghost-core/src/config.ts create mode 100644 packages/ghost-core/src/index.ts create mode 100644 packages/ghost-core/src/reporters/cli.ts create mode 100644 packages/ghost-core/src/reporters/json.ts create mode 100644 packages/ghost-core/src/resolvers/css.ts create mode 100644 packages/ghost-core/src/resolvers/registry.ts create mode 100644 packages/ghost-core/src/scan.ts create mode 100644 packages/ghost-core/src/scanners/structure.ts create mode 100644 packages/ghost-core/src/scanners/values.ts create mode 100644 packages/ghost-core/src/types.ts create mode 100644 packages/ghost-core/test/e2e/scan.test.ts create mode 100644 packages/ghost-core/test/fixtures/consumer-clean/components/ui/button.tsx create mode 100644 packages/ghost-core/test/fixtures/consumer-clean/components/ui/card.tsx create mode 100644 packages/ghost-core/test/fixtures/consumer-clean/ghost.config.ts create mode 100644 packages/ghost-core/test/fixtures/consumer-clean/src/styles/main.css create mode 100644 packages/ghost-core/test/fixtures/consumer-drifted/components/ui/button.tsx create mode 100644 packages/ghost-core/test/fixtures/consumer-drifted/ghost.config.ts create mode 100644 packages/ghost-core/test/fixtures/consumer-drifted/src/styles/main.css create mode 100644 packages/ghost-core/test/fixtures/registry/out/r/button.json create mode 100644 packages/ghost-core/test/fixtures/registry/out/r/card.json create mode 100644 packages/ghost-core/test/fixtures/registry/out/r/styles-main.json create mode 100644 packages/ghost-core/test/fixtures/registry/registry.json create mode 100644 packages/ghost-core/test/fixtures/registry/src/components/ui/button.tsx create mode 100644 packages/ghost-core/test/fixtures/registry/src/components/ui/card.tsx create mode 100644 packages/ghost-core/test/fixtures/registry/src/styles/main.css create mode 100644 packages/ghost-core/test/resolvers/css.test.ts create mode 100644 packages/ghost-core/test/resolvers/registry.test.ts create mode 100644 packages/ghost-core/test/scanners/structure.test.ts create mode 100644 packages/ghost-core/test/scanners/values.test.ts create mode 100644 packages/ghost-core/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a1f1f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.tsbuildinfo +.DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..f7bafc7 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +shamefully-hoist=false +strict-peer-dependencies=true diff --git a/package.json b/package.json new file mode 100644 index 0000000..44284e8 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "ghost", + "private": true, + "description": "Design drift detection system", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "build": "tsc --build", + "clean": "tsc --build --clean", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --build --noEmit" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/ghost-cli/package.json b/packages/ghost-cli/package.json new file mode 100644 index 0000000..350045b --- /dev/null +++ b/packages/ghost-cli/package.json @@ -0,0 +1,18 @@ +{ + "name": "ghost-cli", + "version": "0.1.0", + "description": "CLI for ghost design drift detection", + "license": "Apache-2.0", + "type": "module", + "bin": { + "ghost": "./dist/bin.js" + }, + "files": ["dist"], + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@ghost/core": "workspace:*", + "citty": "^0.1.6" + } +} diff --git a/packages/ghost-cli/src/bin.ts b/packages/ghost-cli/src/bin.ts new file mode 100644 index 0000000..d3a7bcd --- /dev/null +++ b/packages/ghost-cli/src/bin.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env node + +import { defineCommand, runMain } from "citty"; +import { loadConfig, scan, formatCLIReport, formatJSONReport } from "@ghost/core"; + +const scanCommand = defineCommand({ + meta: { + name: "scan", + description: "Scan for design drift", + }, + args: { + config: { + type: "string", + description: "Path to ghost config file", + alias: "c", + }, + format: { + type: "string", + description: "Output format: cli or json", + default: "cli", + }, + "no-color": { + type: "boolean", + description: "Disable colored output", + default: false, + }, + }, + async run({ args }) { + try { + const config = await loadConfig(args.config); + const report = await scan(config); + + const output = + args.format === "json" + ? formatJSONReport(report) + : formatCLIReport(report); + + process.stdout.write(output); + process.exit(report.summary.errors > 0 ? 1 : 0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }, +}); + +const main = defineCommand({ + meta: { + name: "ghost", + version: "0.1.0", + description: "Design drift detection", + }, + subCommands: { + scan: scanCommand, + }, +}); + +runMain(main); diff --git a/packages/ghost-cli/tsconfig.json b/packages/ghost-cli/tsconfig.json new file mode 100644 index 0000000..6e791d9 --- /dev/null +++ b/packages/ghost-cli/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"], + "references": [ + { "path": "../ghost-core" } + ] +} diff --git a/packages/ghost-core/package.json b/packages/ghost-core/package.json new file mode 100644 index 0000000..e7a0a6f --- /dev/null +++ b/packages/ghost-core/package.json @@ -0,0 +1,27 @@ +{ + "name": "@ghost/core", + "version": "0.1.0", + "description": "Design drift detection engine", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "diff": "^7.0.0", + "jiti": "^2.4.0", + "postcss": "^8.5.0" + }, + "devDependencies": { + "@types/diff": "^6.0.0" + } +} diff --git a/packages/ghost-core/src/config.ts b/packages/ghost-core/src/config.ts new file mode 100644 index 0000000..7ebcdfb --- /dev/null +++ b/packages/ghost-core/src/config.ts @@ -0,0 +1,75 @@ +import { createJiti } from "jiti"; +import { existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import type { GhostConfig } from "./types.js"; + +const CONFIG_FILES = [ + "ghost.config.ts", + "ghost.config.js", + "ghost.config.mjs", +]; + +const DEFAULT_CONFIG: Omit = { + scan: { values: true, structure: true, analysis: false }, + rules: { + "hardcoded-color": "error", + "token-override": "warn", + "missing-token": "warn", + "structural-divergence": "error", + "missing-component": "warn", + }, + ignore: [], +}; + +export function defineConfig(config: GhostConfig): GhostConfig { + return config; +} + +export async function loadConfig( + configPath?: string, + cwd: string = process.cwd(), +): Promise { + let resolvedPath: string | undefined; + + if (configPath) { + resolvedPath = resolve(cwd, configPath); + if (!existsSync(resolvedPath)) { + throw new Error(`Config file not found: ${resolvedPath}`); + } + } else { + for (const file of CONFIG_FILES) { + const candidate = resolve(cwd, file); + if (existsSync(candidate)) { + resolvedPath = candidate; + break; + } + } + if (!resolvedPath) { + throw new Error( + `No ghost config found. Create one of: ${CONFIG_FILES.join(", ")}`, + ); + } + } + + const jiti = createJiti(resolvedPath); + const mod = await jiti.import(resolvedPath); + const raw = (mod as { default?: GhostConfig }).default ?? (mod as GhostConfig); + + if (!raw.designSystems || !Array.isArray(raw.designSystems)) { + throw new Error("Config must include a designSystems array"); + } + + for (const ds of raw.designSystems) { + if (!ds.name) throw new Error("Each design system must have a name"); + if (!ds.registry) throw new Error(`Design system "${ds.name}" must have a registry path or URL`); + if (!ds.componentDir) throw new Error(`Design system "${ds.name}" must have a componentDir`); + if (!ds.styleEntry) throw new Error(`Design system "${ds.name}" must have a styleEntry`); + } + + return { + designSystems: raw.designSystems, + scan: { ...DEFAULT_CONFIG.scan, ...raw.scan }, + rules: { ...DEFAULT_CONFIG.rules, ...raw.rules }, + ignore: raw.ignore ?? DEFAULT_CONFIG.ignore, + }; +} diff --git a/packages/ghost-core/src/index.ts b/packages/ghost-core/src/index.ts new file mode 100644 index 0000000..ec3fc35 --- /dev/null +++ b/packages/ghost-core/src/index.ts @@ -0,0 +1,23 @@ +export { defineConfig, loadConfig } from "./config.js"; +export { scan } from "./scan.js"; +export { resolveRegistry } from "./resolvers/registry.js"; +export { parseCSS } from "./resolvers/css.js"; +export { formatReport as formatCLIReport } from "./reporters/cli.js"; +export { formatReport as formatJSONReport } from "./reporters/json.js"; +export type { + GhostConfig, + DesignSystemConfig, + Registry, + RegistryItem, + RegistryFile, + ResolvedRegistry, + CSSToken, + TokenCategory, + DriftReport, + DriftSummary, + DesignSystemReport, + ValueDrift, + StructureDrift, + ScanOptions, + RuleSeverity, +} from "./types.js"; diff --git a/packages/ghost-core/src/reporters/cli.ts b/packages/ghost-core/src/reporters/cli.ts new file mode 100644 index 0000000..39e0627 --- /dev/null +++ b/packages/ghost-core/src/reporters/cli.ts @@ -0,0 +1,111 @@ +import type { DriftReport, ValueDrift, StructureDrift } from "../types.js"; + +const useColor = + !process.env["NO_COLOR"] && + !process.argv.includes("--no-color") && + process.stdout.isTTY; + +const c = { + red: (s: string) => (useColor ? `\x1b[31m${s}\x1b[0m` : s), + yellow: (s: string) => (useColor ? `\x1b[33m${s}\x1b[0m` : s), + green: (s: string) => (useColor ? `\x1b[32m${s}\x1b[0m` : s), + cyan: (s: string) => (useColor ? `\x1b[36m${s}\x1b[0m` : s), + dim: (s: string) => (useColor ? `\x1b[2m${s}\x1b[0m` : s), + bold: (s: string) => (useColor ? `\x1b[1m${s}\x1b[0m` : s), +}; + +function severityTag(severity: string): string { + switch (severity) { + case "error": + return c.red("ERROR"); + case "warn": + return c.yellow(" WARN"); + default: + return c.cyan(" INFO"); + } +} + +function formatValueDrift(drift: ValueDrift): string { + const lines: string[] = []; + const location = drift.file + ? drift.line + ? `${drift.file}:${drift.line}` + : drift.file + : ""; + + lines.push( + ` ${severityTag(drift.severity)} ${c.dim(drift.rule)} ${c.dim(location)}`, + ); + lines.push(` ${drift.message}`); + + if (drift.registryValue && drift.consumerValue) { + lines.push( + ` ${c.dim("registry:")} ${drift.registryValue} ${c.dim("consumer:")} ${drift.consumerValue}`, + ); + } + + return lines.join("\n"); +} + +function formatStructureDrift(drift: StructureDrift): string { + const lines: string[] = []; + const location = drift.consumerFile ?? drift.component; + + lines.push( + ` ${severityTag(drift.severity)} ${c.dim(drift.rule)} ${c.dim(location)}`, + ); + lines.push(` ${drift.message}`); + + return lines.join("\n"); +} + +export function formatReport(report: DriftReport): string { + const lines: string[] = []; + + for (const system of report.systems) { + lines.push(""); + lines.push(c.bold(`ghost scan results for ${system.designSystem}`)); + lines.push(c.dim("═".repeat(50))); + + if (system.values.length > 0) { + lines.push(""); + lines.push(c.bold(`Values ${c.dim(`(${system.values.length} issues)`)}`)); + for (const drift of system.values) { + lines.push(formatValueDrift(drift)); + } + } + + if (system.structure.length > 0) { + lines.push(""); + lines.push( + c.bold(`Structure ${c.dim(`(${system.structure.length} issues)`)}`), + ); + for (const drift of system.structure) { + lines.push(formatStructureDrift(drift)); + } + } + + if (system.values.length === 0 && system.structure.length === 0) { + lines.push(""); + lines.push(c.green(" No drift detected.")); + } + } + + lines.push(""); + lines.push(c.dim("─".repeat(50))); + + const { errors, warnings, info } = report.summary; + const parts: string[] = []; + if (errors > 0) parts.push(c.red(`${errors} error${errors !== 1 ? "s" : ""}`)); + if (warnings > 0) parts.push(c.yellow(`${warnings} warning${warnings !== 1 ? "s" : ""}`)); + if (info > 0) parts.push(c.cyan(`${info} info`)); + + if (parts.length === 0) { + lines.push(c.green("No issues found.")); + } else { + lines.push(`Summary: ${parts.join(", ")}`); + } + + lines.push(""); + return lines.join("\n"); +} diff --git a/packages/ghost-core/src/reporters/json.ts b/packages/ghost-core/src/reporters/json.ts new file mode 100644 index 0000000..ee6e4a3 --- /dev/null +++ b/packages/ghost-core/src/reporters/json.ts @@ -0,0 +1,5 @@ +import type { DriftReport } from "../types.js"; + +export function formatReport(report: DriftReport): string { + return JSON.stringify(report, null, 2); +} diff --git a/packages/ghost-core/src/resolvers/css.ts b/packages/ghost-core/src/resolvers/css.ts new file mode 100644 index 0000000..c69e61c --- /dev/null +++ b/packages/ghost-core/src/resolvers/css.ts @@ -0,0 +1,128 @@ +import postcss, { type Root, type Rule, type AtRule, type Declaration } from "postcss"; +import type { CSSToken, TokenCategory } from "../types.js"; + +const CATEGORY_PREFIXES: [string, TokenCategory][] = [ + ["--background-", "background"], + ["--border-", "border"], + ["--text-", "text"], + ["--shadow-", "shadow"], + ["--radius", "radius"], + ["--spacing-", "spacing"], + ["--heading-", "typography"], + ["--body-", "typography"], + ["--label-", "typography"], + ["--display-", "typography"], + ["--pull-quote-", "typography"], + ["--animate-", "animation"], + ["--duration-", "animation"], + ["--ease-", "animation"], + ["--color-", "color"], + ["--font-", "font"], + ["--chart-", "chart"], + ["--sidebar-", "sidebar"], +]; + +function categorize(name: string): TokenCategory { + for (const [prefix, category] of CATEGORY_PREFIXES) { + if (name.startsWith(prefix)) return category; + } + return "other"; +} + +function getSelectorFromParent(node: postcss.Container): string { + if (node.type === "rule") return (node as Rule).selector; + if (node.type === "atrule") { + const atrule = node as AtRule; + return atrule.params ? `@${atrule.name} ${atrule.params}` : `@${atrule.name}`; + } + return "root"; +} + +export function parseCSS(css: string): CSSToken[] { + const root: Root = postcss.parse(css); + const tokens: CSSToken[] = []; + + root.walk((node) => { + if (node.type !== "decl") return; + const decl = node as Declaration; + if (!decl.prop.startsWith("--")) return; + + const parent = decl.parent; + if (!parent) return; + + const selector = getSelectorFromParent(parent); + + tokens.push({ + name: decl.prop, + value: decl.value, + selector, + category: categorize(decl.prop), + }); + }); + + return resolveVarReferences(tokens); +} + +const VAR_REGEX = /var\(\s*(--[a-zA-Z0-9-]+)\s*(?:,\s*([^)]+))?\)/; + +function resolveVarReferences(tokens: CSSToken[], maxPasses = 5): CSSToken[] { + const valueMap = new Map(); + for (const token of tokens) { + valueMap.set(token.name, token.value); + } + + for (let pass = 0; pass < maxPasses; pass++) { + let changed = false; + for (const token of tokens) { + const current = token.resolvedValue ?? token.value; + const match = VAR_REGEX.exec(current); + if (!match) { + if (!token.resolvedValue) token.resolvedValue = current; + continue; + } + + const refName = match[1]; + const fallback = match[2]?.trim(); + const refValue = valueMap.get(refName); + + if (refValue && !VAR_REGEX.test(refValue)) { + token.resolvedValue = current.replace(match[0], refValue); + changed = true; + } else if (refValue) { + token.resolvedValue = current.replace(match[0], refValue); + changed = true; + } else if (fallback) { + token.resolvedValue = current.replace(match[0], fallback); + changed = true; + } else { + token.resolvedValue = current; + } + } + if (!changed) break; + } + + return tokens; +} + +export function buildTokenMap( + tokens: CSSToken[], +): Map { + const map = new Map(); + for (const token of tokens) { + map.set(`${token.selector}::${token.name}`, token); + } + return map; +} + +export function buildReverseValueMap( + tokens: CSSToken[], +): Map { + const map = new Map(); + for (const token of tokens) { + const resolved = token.resolvedValue ?? token.value; + if (!VAR_REGEX.test(resolved) && !map.has(resolved)) { + map.set(resolved, token.name); + } + } + return map; +} diff --git a/packages/ghost-core/src/resolvers/registry.ts b/packages/ghost-core/src/resolvers/registry.ts new file mode 100644 index 0000000..3f4868a --- /dev/null +++ b/packages/ghost-core/src/resolvers/registry.ts @@ -0,0 +1,147 @@ +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { resolve, dirname, join } from "node:path"; +import type { + Registry, + RegistryItem, + ResolvedRegistry, + CSSToken, +} from "../types.js"; +import { parseCSS } from "./css.js"; + +function isURL(str: string): boolean { + return str.startsWith("http://") || str.startsWith("https://"); +} + +async function fetchJSON(url: string): Promise { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`); + } + return res.json() as Promise; +} + +async function resolveItemContent( + item: RegistryItem, + registryDir: string, +): Promise { + const resolvedFiles = await Promise.all( + item.files.map(async (file) => { + if (file.content) return file; + + // Try built output first: out/r/[name].json + const builtPath = join(registryDir, "out", "r", `${item.name}.json`); + if (existsSync(builtPath)) { + const built = JSON.parse(await readFile(builtPath, "utf-8")) as RegistryItem; + const builtFile = built.files?.find((f) => f.path === file.path); + if (builtFile?.content) { + return { ...file, content: builtFile.content }; + } + } + + // Fall back to reading source file directly + const sourcePath = resolve(registryDir, file.path); + if (existsSync(sourcePath)) { + const content = await readFile(sourcePath, "utf-8"); + return { ...file, content }; + } + + return file; + }), + ); + + return { ...item, files: resolvedFiles }; +} + +async function resolveItemContentFromURL( + item: RegistryItem, + baseURL: string, +): Promise { + const allFilesHaveContent = item.files.every((f) => f.content); + if (allFilesHaveContent) return item; + + try { + const itemURL = `${baseURL}/r/${item.name}.json`; + const built = await fetchJSON(itemURL); + const contentMap = new Map( + built.files?.map((f) => [f.path, f.content]) ?? [], + ); + + const resolvedFiles = item.files.map((file) => { + if (file.content) return file; + const content = contentMap.get(file.path); + return content ? { ...file, content } : file; + }); + + return { ...item, files: resolvedFiles }; + } catch { + return item; + } +} + +function extractStyleTokens(items: RegistryItem[]): CSSToken[] { + for (const item of items) { + if (item.type !== "registry:style") continue; + for (const file of item.files) { + if (file.content && (file.path.endsWith(".css") || file.type === "registry:theme")) { + return parseCSS(file.content); + } + } + } + return []; +} + +export async function resolveRegistry( + registryPath: string, +): Promise { + if (isURL(registryPath)) { + return resolveRemoteRegistry(registryPath); + } + return resolveLocalRegistry(registryPath); +} + +async function resolveLocalRegistry( + registryPath: string, +): Promise { + const fullPath = resolve(registryPath); + const registryDir = dirname(fullPath); + + const raw = JSON.parse(await readFile(fullPath, "utf-8")) as Registry; + + const items = await Promise.all( + raw.items.map((item) => resolveItemContent(item, registryDir)), + ); + + const tokens = extractStyleTokens(items); + + return { + name: raw.name, + homepage: raw.homepage, + items, + tokens, + }; +} + +async function resolveRemoteRegistry( + registryURL: string, +): Promise { + const raw = await fetchJSON(registryURL); + + // Derive base URL: if URL is https://example.com/r/registry.json -> https://example.com + // If URL is https://example.com/registry.json -> https://example.com + const urlObj = new URL(registryURL); + const baseURL = `${urlObj.origin}${urlObj.pathname.replace(/\/r?\/registry\.json$/, "")}`; + + const items = await Promise.all( + raw.items.map((item) => resolveItemContentFromURL(item, baseURL)), + ); + + const tokens = extractStyleTokens(items); + + return { + name: raw.name, + homepage: raw.homepage, + items, + tokens, + }; +} diff --git a/packages/ghost-core/src/scan.ts b/packages/ghost-core/src/scan.ts new file mode 100644 index 0000000..0455432 --- /dev/null +++ b/packages/ghost-core/src/scan.ts @@ -0,0 +1,102 @@ +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import type { + GhostConfig, + DriftReport, + DesignSystemReport, + DriftSummary, + ValueDrift, + StructureDrift, +} from "./types.js"; +import { resolveRegistry } from "./resolvers/registry.js"; +import { parseCSS } from "./resolvers/css.js"; +import { scanValues } from "./scanners/values.js"; +import { scanStructure } from "./scanners/structure.js"; + +export async function scan( + config: GhostConfig, + cwd: string = process.cwd(), +): Promise { + const systems: DesignSystemReport[] = []; + let totalTokensScanned = 0; + let totalComponentsScanned = 0; + + for (const ds of config.designSystems) { + const registry = await resolveRegistry(ds.registry); + + let values: ValueDrift[] = []; + let structure: StructureDrift[] = []; + + // Values scan + if (config.scan.values) { + const styleEntryPath = resolve(cwd, ds.styleEntry); + if (existsSync(styleEntryPath)) { + const consumerCSS = await readFile(styleEntryPath, "utf-8"); + const consumerTokens = parseCSS(consumerCSS); + totalTokensScanned += consumerTokens.length; + + values = scanValues({ + registryTokens: registry.tokens, + consumerTokens, + consumerCSS, + rules: config.rules, + styleFile: ds.styleEntry, + }); + } + } + + // Structure scan + if (config.scan.structure) { + const uiItems = registry.items.filter((i) => i.type === "registry:ui"); + totalComponentsScanned += uiItems.length; + + structure = await scanStructure({ + registryItems: registry.items, + consumerDir: cwd, + componentDir: ds.componentDir, + rules: config.rules, + ignore: config.ignore, + }); + } + + systems.push({ + designSystem: ds.name, + values, + structure, + }); + } + + const summary = computeSummary(systems, totalTokensScanned, totalComponentsScanned); + + return { + timestamp: new Date().toISOString(), + systems, + summary, + }; +} + +function computeSummary( + systems: DesignSystemReport[], + tokensScanned: number, + componentsScanned: number, +): DriftSummary { + let errors = 0; + let warnings = 0; + let info = 0; + + for (const system of systems) { + for (const v of system.values) { + if (v.severity === "error") errors++; + else if (v.severity === "warn") warnings++; + else info++; + } + for (const s of system.structure) { + if (s.severity === "error") errors++; + else if (s.severity === "warn") warnings++; + else info++; + } + } + + return { errors, warnings, info, tokensScanned, componentsScanned }; +} diff --git a/packages/ghost-core/src/scanners/structure.ts b/packages/ghost-core/src/scanners/structure.ts new file mode 100644 index 0000000..2ee5386 --- /dev/null +++ b/packages/ghost-core/src/scanners/structure.ts @@ -0,0 +1,115 @@ +import { readFile, readdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { resolve, relative, basename } from "node:path"; +import { createPatch } from "diff"; +import type { RegistryItem, StructureDrift, RuleSeverity } from "../types.js"; + +export interface StructureScannerOptions { + registryItems: RegistryItem[]; + consumerDir: string; + componentDir: string; + rules: Record; + ignore: string[]; +} + +function matchesIgnore(filePath: string, patterns: string[]): boolean { + for (const pattern of patterns) { + // Simple glob matching: support * wildcard + const regex = new RegExp( + "^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$", + ); + if (regex.test(filePath)) return true; + } + return false; +} + +function countDiffLines(diff: string): { added: number; removed: number } { + let added = 0; + let removed = 0; + for (const line of diff.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) added++; + if (line.startsWith("-") && !line.startsWith("---")) removed++; + } + return { added, removed }; +} + +export async function scanStructure( + options: StructureScannerOptions, +): Promise { + const { registryItems, consumerDir, componentDir, rules, ignore } = options; + const drifts: StructureDrift[] = []; + + const uiItems = registryItems.filter((item) => item.type === "registry:ui"); + const fullComponentDir = resolve(consumerDir, componentDir); + + if (!existsSync(fullComponentDir)) { + return []; + } + + const divergenceSeverity = rules["structural-divergence"] ?? "error"; + const missingSeverity = rules["missing-component"] ?? "warn"; + + for (const item of uiItems) { + for (const file of item.files) { + if (!file.content) continue; + + // Determine the expected consumer file path + const targetPath = file.target; + const consumerFilePath = resolve(consumerDir, targetPath); + const relativePath = relative(consumerDir, consumerFilePath); + + if (matchesIgnore(relativePath, ignore)) continue; + + if (!existsSync(consumerFilePath)) { + if (missingSeverity !== "off") { + drifts.push({ + component: item.name, + rule: "missing-component", + severity: missingSeverity, + message: `Component "${item.name}" not found at ${relativePath}`, + linesAdded: 0, + linesRemoved: 0, + registryFile: file.path, + consumerFile: relativePath, + }); + } + continue; + } + + if (divergenceSeverity === "off") continue; + + const consumerContent = await readFile(consumerFilePath, "utf-8"); + const registryContent = file.content; + + // Normalize line endings + const normalizedConsumer = consumerContent.replace(/\r\n/g, "\n").trimEnd(); + const normalizedRegistry = registryContent.replace(/\r\n/g, "\n").trimEnd(); + + if (normalizedConsumer === normalizedRegistry) continue; + + const diff = createPatch( + relativePath, + normalizedRegistry, + normalizedConsumer, + "registry", + "consumer", + ); + + const { added, removed } = countDiffLines(diff); + + drifts.push({ + component: item.name, + rule: "structural-divergence", + severity: divergenceSeverity, + message: `Component "${item.name}" has diverged from registry (+${added}, -${removed})`, + diff, + linesAdded: added, + linesRemoved: removed, + registryFile: file.path, + consumerFile: relativePath, + }); + } + } + + return drifts; +} diff --git a/packages/ghost-core/src/scanners/values.ts b/packages/ghost-core/src/scanners/values.ts new file mode 100644 index 0000000..b0e2dd1 --- /dev/null +++ b/packages/ghost-core/src/scanners/values.ts @@ -0,0 +1,173 @@ +import type { CSSToken, ValueDrift, RuleSeverity } from "../types.js"; +import { buildTokenMap, buildReverseValueMap } from "../resolvers/css.js"; + +const COLOR_REGEX = /#(?:[0-9a-fA-F]{3,8})\b|rgba?\([^)]+\)|hsla?\([^)]+\)/g; + +export interface ValuesScannerOptions { + registryTokens: CSSToken[]; + consumerTokens: CSSToken[]; + consumerCSS: string; + rules: Record; + styleFile: string; +} + +export function scanValues(options: ValuesScannerOptions): ValueDrift[] { + const { registryTokens, consumerTokens, consumerCSS, rules, styleFile } = options; + const results: ValueDrift[] = []; + + if (rules["token-override"] !== "off") { + results.push( + ...detectTokenOverrides(registryTokens, consumerTokens, rules["token-override"] ?? "warn", styleFile), + ); + } + + if (rules["hardcoded-color"] !== "off") { + results.push( + ...detectHardcodedColors(registryTokens, consumerCSS, rules["hardcoded-color"] ?? "error", styleFile), + ); + } + + if (rules["missing-token"] !== "off") { + results.push( + ...detectMissingTokens(registryTokens, consumerTokens, rules["missing-token"] ?? "warn", styleFile), + ); + } + + return results; +} + +function detectTokenOverrides( + registryTokens: CSSToken[], + consumerTokens: CSSToken[], + severity: RuleSeverity, + styleFile: string, +): ValueDrift[] { + const registryMap = buildTokenMap(registryTokens); + const consumerMap = buildTokenMap(consumerTokens); + const drifts: ValueDrift[] = []; + + for (const [key, consumerToken] of consumerMap) { + const registryToken = registryMap.get(key); + if (!registryToken) continue; + + if (consumerToken.value !== registryToken.value) { + drifts.push({ + token: consumerToken.name, + rule: "token-override", + severity, + message: `Token "${consumerToken.name}" value differs from registry`, + registryValue: registryToken.value, + consumerValue: consumerToken.value, + selector: consumerToken.selector, + file: styleFile, + }); + } + } + + return drifts; +} + +function detectHardcodedColors( + registryTokens: CSSToken[], + consumerCSS: string, + severity: RuleSeverity, + styleFile: string, +): ValueDrift[] { + const reverseMap = buildReverseValueMap(registryTokens); + const drifts: ValueDrift[] = []; + const lines = consumerCSS.split("\n"); + + let inThemeBlock = false; + let inKeyframes = false; + let inFontFace = false; + let braceDepth = 0; + let blockStartDepth = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Track @theme, @keyframes, @font-face blocks + if (trimmed.startsWith("@theme")) { + inThemeBlock = true; + blockStartDepth = braceDepth; + } + if (trimmed.startsWith("@keyframes")) { + inKeyframes = true; + blockStartDepth = braceDepth; + } + if (trimmed.startsWith("@font-face")) { + inFontFace = true; + blockStartDepth = braceDepth; + } + + for (const ch of line) { + if (ch === "{") braceDepth++; + if (ch === "}") { + braceDepth--; + if (braceDepth <= blockStartDepth) { + if (inThemeBlock) inThemeBlock = false; + if (inKeyframes) inKeyframes = false; + if (inFontFace) inFontFace = false; + } + } + } + + // Skip lines inside @theme, @keyframes, @font-face + if (inThemeBlock || inKeyframes || inFontFace) continue; + + // Skip custom property definitions (--*: value) + if (/^\s*--[a-zA-Z]/.test(trimmed)) continue; + + // Skip comments + if (trimmed.startsWith("/*") || trimmed.startsWith("//")) continue; + + let match: RegExpExecArray | null; + COLOR_REGEX.lastIndex = 0; + while ((match = COLOR_REGEX.exec(line)) !== null) { + const colorValue = match[0]; + const tokenName = reverseMap.get(colorValue); + const suggestion = tokenName ? `var(${tokenName})` : undefined; + + drifts.push({ + token: colorValue, + rule: "hardcoded-color", + severity, + message: `Hardcoded color "${colorValue}" found${suggestion ? ` — use ${suggestion} instead` : ""}`, + consumerValue: colorValue, + file: styleFile, + line: i + 1, + suggestion, + }); + } + } + + return drifts; +} + +function detectMissingTokens( + registryTokens: CSSToken[], + consumerTokens: CSSToken[], + severity: RuleSeverity, + styleFile: string, +): ValueDrift[] { + // Only check :root tokens — these are the semantic tokens consumers should have + const registryRootTokens = registryTokens.filter((t) => t.selector === ":root"); + const consumerNames = new Set(consumerTokens.map((t) => t.name)); + const drifts: ValueDrift[] = []; + + for (const token of registryRootTokens) { + if (!consumerNames.has(token.name)) { + drifts.push({ + token: token.name, + rule: "missing-token", + severity, + message: `Token "${token.name}" defined in registry but missing from consumer`, + registryValue: token.value, + file: styleFile, + }); + } + } + + return drifts; +} diff --git a/packages/ghost-core/src/types.ts b/packages/ghost-core/src/types.ts new file mode 100644 index 0000000..94ae4c1 --- /dev/null +++ b/packages/ghost-core/src/types.ts @@ -0,0 +1,128 @@ +// --- Registry types (mirrors shadcn registry schema) --- + +export interface Registry { + $schema?: string; + name: string; + homepage?: string; + items: RegistryItem[]; +} + +export interface RegistryItem { + name: string; + type: "registry:ui" | "registry:style" | "registry:lib"; + dependencies?: string[]; + devDependencies?: string[]; + registryDependencies?: string[]; + files: RegistryFile[]; + categories?: string[]; +} + +export interface RegistryFile { + path: string; + content?: string; + type: string; + target: string; +} + +export interface ResolvedRegistry { + name: string; + homepage?: string; + items: RegistryItem[]; + tokens: CSSToken[]; +} + +// --- Token types --- + +export type TokenCategory = + | "background" + | "border" + | "text" + | "shadow" + | "radius" + | "spacing" + | "typography" + | "animation" + | "color" + | "font" + | "chart" + | "sidebar" + | "other"; + +export interface CSSToken { + name: string; + value: string; + resolvedValue?: string; + selector: string; + category: TokenCategory; +} + +// --- Config types --- + +export type RuleSeverity = "error" | "warn" | "off"; + +export interface DesignSystemConfig { + name: string; + registry: string; + componentDir: string; + styleEntry: string; +} + +export interface ScanOptions { + values: boolean; + structure: boolean; + analysis: boolean; +} + +export interface GhostConfig { + designSystems: DesignSystemConfig[]; + scan: ScanOptions; + rules: Record; + ignore: string[]; +} + +// --- Drift report types --- + +export interface ValueDrift { + token: string; + rule: string; + severity: RuleSeverity; + message: string; + registryValue?: string; + consumerValue?: string; + selector?: string; + file?: string; + line?: number; + suggestion?: string; +} + +export interface StructureDrift { + component: string; + rule: string; + severity: RuleSeverity; + message: string; + diff?: string; + linesAdded: number; + linesRemoved: number; + registryFile?: string; + consumerFile?: string; +} + +export interface DriftSummary { + errors: number; + warnings: number; + info: number; + componentsScanned: number; + tokensScanned: number; +} + +export interface DesignSystemReport { + designSystem: string; + values: ValueDrift[]; + structure: StructureDrift[]; +} + +export interface DriftReport { + timestamp: string; + systems: DesignSystemReport[]; + summary: DriftSummary; +} diff --git a/packages/ghost-core/test/e2e/scan.test.ts b/packages/ghost-core/test/e2e/scan.test.ts new file mode 100644 index 0000000..e7b130f --- /dev/null +++ b/packages/ghost-core/test/e2e/scan.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { resolve } from "node:path"; +import { scan } from "../../src/scan.js"; +import type { GhostConfig } from "../../src/types.js"; + +const registryPath = resolve(__dirname, "../fixtures/registry/registry.json"); + +function makeConfig(consumerDir: string): GhostConfig { + return { + designSystems: [ + { + name: "test-ds", + registry: registryPath, + componentDir: "components/ui", + styleEntry: resolve(consumerDir, "src/styles/main.css"), + }, + ], + scan: { values: true, structure: true, analysis: false }, + rules: { + "hardcoded-color": "error", + "token-override": "warn", + "missing-token": "warn", + "structural-divergence": "error", + "missing-component": "warn", + }, + ignore: [], + }; +} + +describe("scan() e2e - clean consumer", () => { + it("reports zero errors and warnings for clean consumer", async () => { + const cleanDir = resolve(__dirname, "../fixtures/consumer-clean"); + const config = makeConfig(cleanDir); + const report = await scan(config, cleanDir); + + expect(report.summary.errors).toBe(0); + expect(report.summary.warnings).toBe(0); + expect(report.systems).toHaveLength(1); + expect(report.systems[0].designSystem).toBe("test-ds"); + }); +}); + +describe("scan() e2e - drifted consumer", () => { + it("detects all forms of drift", async () => { + const driftedDir = resolve(__dirname, "../fixtures/consumer-drifted"); + const config = makeConfig(driftedDir); + const report = await scan(config, driftedDir); + + expect(report.summary.errors).toBeGreaterThan(0); + expect(report.summary.warnings).toBeGreaterThan(0); + + const system = report.systems[0]; + expect(system.values.length).toBeGreaterThan(0); + expect(system.structure.length).toBeGreaterThan(0); + }); + + it("includes timestamp", async () => { + const driftedDir = resolve(__dirname, "../fixtures/consumer-drifted"); + const config = makeConfig(driftedDir); + const report = await scan(config, driftedDir); + + expect(report.timestamp).toBeDefined(); + expect(new Date(report.timestamp).getTime()).not.toBeNaN(); + }); +}); diff --git a/packages/ghost-core/test/fixtures/consumer-clean/components/ui/button.tsx b/packages/ghost-core/test/fixtures/consumer-clean/components/ui/button.tsx new file mode 100644 index 0000000..3d26f73 --- /dev/null +++ b/packages/ghost-core/test/fixtures/consumer-clean/components/ui/button.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-full text-sm transition-all", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + outline: "border border-input bg-background hover:bg-muted", + ghost: "hover:bg-muted", + }, + size: { + default: "h-9 px-6", + sm: "h-8 px-4", + lg: "h-10 px-8", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "button"; + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/packages/ghost-core/test/fixtures/consumer-clean/components/ui/card.tsx b/packages/ghost-core/test/fixtures/consumer-clean/components/ui/card.tsx new file mode 100644 index 0000000..20ad5b7 --- /dev/null +++ b/packages/ghost-core/test/fixtures/consumer-clean/components/ui/card.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +export { Card, CardHeader, CardTitle, CardContent }; diff --git a/packages/ghost-core/test/fixtures/consumer-clean/ghost.config.ts b/packages/ghost-core/test/fixtures/consumer-clean/ghost.config.ts new file mode 100644 index 0000000..8e6e4f3 --- /dev/null +++ b/packages/ghost-core/test/fixtures/consumer-clean/ghost.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "@ghost/core"; + +export default defineConfig({ + designSystems: [ + { + name: "test-ds", + registry: "../registry/registry.json", + componentDir: "components/ui", + styleEntry: "src/styles/main.css", + }, + ], + scan: { values: true, structure: true, analysis: false }, + rules: { + "hardcoded-color": "error", + "token-override": "warn", + "missing-token": "warn", + "structural-divergence": "error", + "missing-component": "warn", + }, + ignore: [], +}); diff --git a/packages/ghost-core/test/fixtures/consumer-clean/src/styles/main.css b/packages/ghost-core/test/fixtures/consumer-clean/src/styles/main.css new file mode 100644 index 0000000..45fd419 --- /dev/null +++ b/packages/ghost-core/test/fixtures/consumer-clean/src/styles/main.css @@ -0,0 +1,30 @@ +@theme { + --color-white: #ffffff; + --color-black: #000000; + --color-gray-50: #f5f5f5; + --color-gray-200: #e8e8e8; + --color-gray-500: #999999; + --color-gray-900: #1a1a1a; + --color-red-200: #f94b4b; +} + +:root { + --radius: 20px; + --background-default: var(--color-white); + --background-alt: var(--color-gray-50); + --background-accent: var(--color-gray-900); + --border-default: var(--color-gray-200); + --border-strong: var(--color-gray-900); + --text-default: var(--color-gray-900); + --text-muted: var(--color-gray-500); + --text-inverse: var(--color-white); + --text-danger: var(--color-red-200); + --shadow-card: 0 2px 8px rgba(76,76,76, 0.15); +} + +.dark { + --background-default: var(--color-gray-900); + --background-alt: var(--color-black); + --text-default: var(--color-white); + --text-muted: var(--color-gray-500); +} diff --git a/packages/ghost-core/test/fixtures/consumer-drifted/components/ui/button.tsx b/packages/ghost-core/test/fixtures/consumer-drifted/components/ui/button.tsx new file mode 100644 index 0000000..6000ff8 --- /dev/null +++ b/packages/ghost-core/test/fixtures/consumer-drifted/components/ui/button.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +/* DRIFT: added a "destructive" variant and changed default border-radius */ +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-lg text-sm transition-all", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-red-500 text-white hover:bg-red-600", + outline: "border border-input bg-background hover:bg-muted", + ghost: "hover:bg-muted", + }, + size: { + default: "h-9 px-6", + sm: "h-8 px-4", + lg: "h-10 px-8", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "button"; + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/packages/ghost-core/test/fixtures/consumer-drifted/ghost.config.ts b/packages/ghost-core/test/fixtures/consumer-drifted/ghost.config.ts new file mode 100644 index 0000000..8e6e4f3 --- /dev/null +++ b/packages/ghost-core/test/fixtures/consumer-drifted/ghost.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "@ghost/core"; + +export default defineConfig({ + designSystems: [ + { + name: "test-ds", + registry: "../registry/registry.json", + componentDir: "components/ui", + styleEntry: "src/styles/main.css", + }, + ], + scan: { values: true, structure: true, analysis: false }, + rules: { + "hardcoded-color": "error", + "token-override": "warn", + "missing-token": "warn", + "structural-divergence": "error", + "missing-component": "warn", + }, + ignore: [], +}); diff --git a/packages/ghost-core/test/fixtures/consumer-drifted/src/styles/main.css b/packages/ghost-core/test/fixtures/consumer-drifted/src/styles/main.css new file mode 100644 index 0000000..4ecf181 --- /dev/null +++ b/packages/ghost-core/test/fixtures/consumer-drifted/src/styles/main.css @@ -0,0 +1,35 @@ +@theme { + --color-white: #ffffff; + --color-black: #000000; + --color-gray-50: #f5f5f5; + --color-gray-200: #e8e8e8; + --color-gray-500: #999999; + --color-gray-900: #1a1a1a; + --color-red-200: #f94b4b; +} + +:root { + --radius: 20px; + --background-default: var(--color-white); + --background-alt: var(--color-gray-50); + --background-accent: var(--color-gray-900); + /* DRIFT: token value changed */ + --border-default: var(--color-gray-500); + --border-strong: var(--color-gray-900); + --text-default: var(--color-gray-900); + /* DRIFT: missing --text-muted, --text-inverse, --text-danger */ + --shadow-card: 0 2px 8px rgba(76,76,76, 0.15); +} + +/* DRIFT: hardcoded color outside @theme */ +.custom-banner { + background: #ff6b6b; + color: rgb(255, 255, 255); +} + +.dark { + --background-default: var(--color-gray-900); + --background-alt: var(--color-black); + --text-default: var(--color-white); + --text-muted: var(--color-gray-500); +} diff --git a/packages/ghost-core/test/fixtures/registry/out/r/button.json b/packages/ghost-core/test/fixtures/registry/out/r/button.json new file mode 100644 index 0000000..c0f081c --- /dev/null +++ b/packages/ghost-core/test/fixtures/registry/out/r/button.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "button", + "dependencies": ["@radix-ui/react-slot", "class-variance-authority"], + "registryDependencies": ["utils"], + "files": [ + { + "path": "src/components/ui/button.tsx", + "content": "import * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\n\nconst buttonVariants = cva(\n \"inline-flex items-center justify-center rounded-full text-sm transition-all\",\n {\n variants: {\n variant: {\n default: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n outline: \"border border-input bg-background hover:bg-muted\",\n ghost: \"hover:bg-muted\",\n },\n size: {\n default: \"h-9 px-6\",\n sm: \"h-8 px-4\",\n lg: \"h-10 px-8\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n);\n\nfunction Button({\n className,\n variant,\n size,\n asChild = false,\n ...props\n}: React.ComponentProps<\"button\"> & VariantProps & { asChild?: boolean }) {\n const Comp = asChild ? Slot : \"button\";\n return (\n \n );\n}\n\nexport { Button, buttonVariants };", + "type": "registry:ui", + "target": "components/ui/button.tsx" + } + ], + "categories": ["input"], + "type": "registry:ui" +} diff --git a/packages/ghost-core/test/fixtures/registry/out/r/card.json b/packages/ghost-core/test/fixtures/registry/out/r/card.json new file mode 100644 index 0000000..0da31aa --- /dev/null +++ b/packages/ghost-core/test/fixtures/registry/out/r/card.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "card", + "registryDependencies": ["utils"], + "files": [ + { + "path": "src/components/ui/card.tsx", + "content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nfunction Card({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction CardHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return
;\n}\n\nfunction CardTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n return
;\n}\n\nfunction CardContent({ className, ...props }: React.ComponentProps<\"div\">) {\n return
;\n}\n\nexport { Card, CardHeader, CardTitle, CardContent };", + "type": "registry:ui", + "target": "components/ui/card.tsx" + } + ], + "categories": ["display"], + "type": "registry:ui" +} diff --git a/packages/ghost-core/test/fixtures/registry/out/r/styles-main.json b/packages/ghost-core/test/fixtures/registry/out/r/styles-main.json new file mode 100644 index 0000000..d25b602 --- /dev/null +++ b/packages/ghost-core/test/fixtures/registry/out/r/styles-main.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "styles-main", + "type": "registry:style", + "files": [ + { + "path": "src/styles/main.css", + "content": "@theme {\n --color-white: #ffffff;\n --color-black: #000000;\n --color-gray-50: #f5f5f5;\n --color-gray-200: #e8e8e8;\n --color-gray-500: #999999;\n --color-gray-900: #1a1a1a;\n --color-red-200: #f94b4b;\n}\n\n:root {\n --radius: 20px;\n --background-default: var(--color-white);\n --background-alt: var(--color-gray-50);\n --background-accent: var(--color-gray-900);\n --border-default: var(--color-gray-200);\n --border-strong: var(--color-gray-900);\n --text-default: var(--color-gray-900);\n --text-muted: var(--color-gray-500);\n --text-inverse: var(--color-white);\n --text-danger: var(--color-red-200);\n --shadow-card: 0 2px 8px rgba(76,76,76, 0.15);\n}\n\n.dark {\n --background-default: var(--color-gray-900);\n --background-alt: var(--color-black);\n --text-default: var(--color-white);\n --text-muted: var(--color-gray-500);\n}", + "type": "registry:theme", + "target": "styles/main.css" + } + ] +} diff --git a/packages/ghost-core/test/fixtures/registry/registry.json b/packages/ghost-core/test/fixtures/registry/registry.json new file mode 100644 index 0000000..1486a16 --- /dev/null +++ b/packages/ghost-core/test/fixtures/registry/registry.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "name": "test-ds", + "homepage": "https://test-ds.example.com", + "items": [ + { + "name": "styles-main", + "type": "registry:style", + "files": [ + { + "type": "registry:theme", + "target": "styles/main.css", + "path": "src/styles/main.css" + } + ] + }, + { + "name": "utils", + "type": "registry:lib", + "dependencies": ["clsx", "tailwind-merge"], + "files": [ + { + "type": "registry:lib", + "target": "lib/utils.ts", + "path": "src/lib/utils.ts" + } + ] + }, + { + "name": "button", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-slot", "class-variance-authority"], + "registryDependencies": ["utils"], + "files": [ + { + "type": "registry:ui", + "target": "components/ui/button.tsx", + "path": "src/components/ui/button.tsx" + } + ], + "categories": ["input"] + }, + { + "name": "card", + "type": "registry:ui", + "registryDependencies": ["utils"], + "files": [ + { + "type": "registry:ui", + "target": "components/ui/card.tsx", + "path": "src/components/ui/card.tsx" + } + ], + "categories": ["display"] + } + ] +} diff --git a/packages/ghost-core/test/fixtures/registry/src/components/ui/button.tsx b/packages/ghost-core/test/fixtures/registry/src/components/ui/button.tsx new file mode 100644 index 0000000..3d26f73 --- /dev/null +++ b/packages/ghost-core/test/fixtures/registry/src/components/ui/button.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-full text-sm transition-all", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + outline: "border border-input bg-background hover:bg-muted", + ghost: "hover:bg-muted", + }, + size: { + default: "h-9 px-6", + sm: "h-8 px-4", + lg: "h-10 px-8", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "button"; + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/packages/ghost-core/test/fixtures/registry/src/components/ui/card.tsx b/packages/ghost-core/test/fixtures/registry/src/components/ui/card.tsx new file mode 100644 index 0000000..20ad5b7 --- /dev/null +++ b/packages/ghost-core/test/fixtures/registry/src/components/ui/card.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +export { Card, CardHeader, CardTitle, CardContent }; diff --git a/packages/ghost-core/test/fixtures/registry/src/styles/main.css b/packages/ghost-core/test/fixtures/registry/src/styles/main.css new file mode 100644 index 0000000..45fd419 --- /dev/null +++ b/packages/ghost-core/test/fixtures/registry/src/styles/main.css @@ -0,0 +1,30 @@ +@theme { + --color-white: #ffffff; + --color-black: #000000; + --color-gray-50: #f5f5f5; + --color-gray-200: #e8e8e8; + --color-gray-500: #999999; + --color-gray-900: #1a1a1a; + --color-red-200: #f94b4b; +} + +:root { + --radius: 20px; + --background-default: var(--color-white); + --background-alt: var(--color-gray-50); + --background-accent: var(--color-gray-900); + --border-default: var(--color-gray-200); + --border-strong: var(--color-gray-900); + --text-default: var(--color-gray-900); + --text-muted: var(--color-gray-500); + --text-inverse: var(--color-white); + --text-danger: var(--color-red-200); + --shadow-card: 0 2px 8px rgba(76,76,76, 0.15); +} + +.dark { + --background-default: var(--color-gray-900); + --background-alt: var(--color-black); + --text-default: var(--color-white); + --text-muted: var(--color-gray-500); +} diff --git a/packages/ghost-core/test/resolvers/css.test.ts b/packages/ghost-core/test/resolvers/css.test.ts new file mode 100644 index 0000000..696d6ae --- /dev/null +++ b/packages/ghost-core/test/resolvers/css.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parseCSS, buildTokenMap, buildReverseValueMap } from "../../src/resolvers/css.js"; + +const fixtureCSS = readFileSync( + resolve(__dirname, "../fixtures/registry/src/styles/main.css"), + "utf-8", +); + +describe("parseCSS", () => { + const tokens = parseCSS(fixtureCSS); + + it("extracts tokens from @theme block", () => { + const themeTokens = tokens.filter((t) => t.selector === "@theme"); + expect(themeTokens.length).toBeGreaterThan(0); + + const white = themeTokens.find((t) => t.name === "--color-white"); + expect(white).toBeDefined(); + expect(white!.value).toBe("#ffffff"); + expect(white!.category).toBe("color"); + }); + + it("extracts tokens from :root block", () => { + const rootTokens = tokens.filter((t) => t.selector === ":root"); + expect(rootTokens.length).toBeGreaterThan(0); + + const bgDefault = rootTokens.find((t) => t.name === "--background-default"); + expect(bgDefault).toBeDefined(); + expect(bgDefault!.value).toBe("var(--color-white)"); + expect(bgDefault!.category).toBe("background"); + }); + + it("extracts tokens from .dark block", () => { + const darkTokens = tokens.filter((t) => t.selector === ".dark"); + expect(darkTokens.length).toBe(4); + }); + + it("resolves var() references", () => { + const bgDefault = tokens.find( + (t) => t.name === "--background-default" && t.selector === ":root", + ); + expect(bgDefault!.resolvedValue).toBe("#ffffff"); + }); + + it("categorizes tokens correctly", () => { + const border = tokens.find((t) => t.name === "--border-default"); + expect(border!.category).toBe("border"); + + const text = tokens.find((t) => t.name === "--text-muted" && t.selector === ":root"); + expect(text!.category).toBe("text"); + + const shadow = tokens.find((t) => t.name === "--shadow-card"); + expect(shadow!.category).toBe("shadow"); + + const radius = tokens.find((t) => t.name === "--radius"); + expect(radius!.category).toBe("radius"); + }); +}); + +describe("buildTokenMap", () => { + it("keys tokens by selector::name", () => { + const tokens = parseCSS(fixtureCSS); + const map = buildTokenMap(tokens); + expect(map.has(":root::--background-default")).toBe(true); + expect(map.has(".dark::--background-default")).toBe(true); + expect(map.has("@theme::--color-white")).toBe(true); + }); +}); + +describe("buildReverseValueMap", () => { + it("maps resolved values to token names", () => { + const tokens = parseCSS(fixtureCSS); + const map = buildReverseValueMap(tokens); + expect(map.get("#ffffff")).toBe("--color-white"); + expect(map.get("#1a1a1a")).toBe("--color-gray-900"); + }); +}); diff --git a/packages/ghost-core/test/resolvers/registry.test.ts b/packages/ghost-core/test/resolvers/registry.test.ts new file mode 100644 index 0000000..e718ae4 --- /dev/null +++ b/packages/ghost-core/test/resolvers/registry.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { resolve } from "node:path"; +import { resolveRegistry } from "../../src/resolvers/registry.js"; + +const registryPath = resolve(__dirname, "../fixtures/registry/registry.json"); + +describe("resolveRegistry (local)", () => { + it("loads registry metadata", async () => { + const registry = await resolveRegistry(registryPath); + expect(registry.name).toBe("test-ds"); + expect(registry.items.length).toBe(4); + }); + + it("resolves component content from out/r/ files", async () => { + const registry = await resolveRegistry(registryPath); + const button = registry.items.find((i) => i.name === "button"); + expect(button).toBeDefined(); + expect(button!.files[0].content).toBeDefined(); + expect(button!.files[0].content).toContain("buttonVariants"); + }); + + it("resolves style content and extracts tokens", async () => { + const registry = await resolveRegistry(registryPath); + expect(registry.tokens.length).toBeGreaterThan(0); + + const bgDefault = registry.tokens.find((t) => t.name === "--background-default"); + expect(bgDefault).toBeDefined(); + }); + + it("preserves registry dependencies", async () => { + const registry = await resolveRegistry(registryPath); + const button = registry.items.find((i) => i.name === "button"); + expect(button!.registryDependencies).toContain("utils"); + }); +}); diff --git a/packages/ghost-core/test/scanners/structure.test.ts b/packages/ghost-core/test/scanners/structure.test.ts new file mode 100644 index 0000000..74b0367 --- /dev/null +++ b/packages/ghost-core/test/scanners/structure.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { resolve } from "node:path"; +import { resolveRegistry } from "../../src/resolvers/registry.js"; +import { scanStructure } from "../../src/scanners/structure.js"; + +const registryPath = resolve(__dirname, "../fixtures/registry/registry.json"); +const cleanDir = resolve(__dirname, "../fixtures/consumer-clean"); +const driftedDir = resolve(__dirname, "../fixtures/consumer-drifted"); + +const defaultRules = { + "structural-divergence": "error" as const, + "missing-component": "warn" as const, +}; + +describe("scanStructure - clean consumer", () => { + it("reports no drift for identical components", async () => { + const registry = await resolveRegistry(registryPath); + const results = await scanStructure({ + registryItems: registry.items, + consumerDir: cleanDir, + componentDir: "components/ui", + rules: defaultRules, + ignore: [], + }); + + expect(results).toHaveLength(0); + }); +}); + +describe("scanStructure - drifted consumer", () => { + it("detects modified components", async () => { + const registry = await resolveRegistry(registryPath); + const results = await scanStructure({ + registryItems: registry.items, + consumerDir: driftedDir, + componentDir: "components/ui", + rules: defaultRules, + ignore: [], + }); + + const modified = results.filter((r) => r.rule === "structural-divergence"); + expect(modified.length).toBeGreaterThan(0); + + const buttonDrift = modified.find((r) => r.component === "button"); + expect(buttonDrift).toBeDefined(); + expect(buttonDrift!.linesAdded).toBeGreaterThan(0); + expect(buttonDrift!.diff).toBeDefined(); + }); + + it("detects missing components", async () => { + const registry = await resolveRegistry(registryPath); + const results = await scanStructure({ + registryItems: registry.items, + consumerDir: driftedDir, + componentDir: "components/ui", + rules: defaultRules, + ignore: [], + }); + + const missing = results.filter((r) => r.rule === "missing-component"); + expect(missing.length).toBeGreaterThan(0); + + const cardMissing = missing.find((r) => r.component === "card"); + expect(cardMissing).toBeDefined(); + }); + + it("respects ignore patterns", async () => { + const registry = await resolveRegistry(registryPath); + const results = await scanStructure({ + registryItems: registry.items, + consumerDir: driftedDir, + componentDir: "components/ui", + rules: defaultRules, + ignore: ["components/ui/button.tsx", "components/ui/card.tsx"], + }); + + expect(results).toHaveLength(0); + }); +}); diff --git a/packages/ghost-core/test/scanners/values.test.ts b/packages/ghost-core/test/scanners/values.test.ts new file mode 100644 index 0000000..1a6205f --- /dev/null +++ b/packages/ghost-core/test/scanners/values.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parseCSS } from "../../src/resolvers/css.js"; +import { scanValues } from "../../src/scanners/values.js"; + +const registryCSS = readFileSync( + resolve(__dirname, "../fixtures/registry/src/styles/main.css"), + "utf-8", +); +const cleanCSS = readFileSync( + resolve(__dirname, "../fixtures/consumer-clean/src/styles/main.css"), + "utf-8", +); +const driftedCSS = readFileSync( + resolve(__dirname, "../fixtures/consumer-drifted/src/styles/main.css"), + "utf-8", +); + +const registryTokens = parseCSS(registryCSS); +const defaultRules = { + "hardcoded-color": "error" as const, + "token-override": "warn" as const, + "missing-token": "warn" as const, +}; + +describe("scanValues - clean consumer", () => { + it("reports no drift for identical tokens", () => { + const cleanTokens = parseCSS(cleanCSS); + const results = scanValues({ + registryTokens, + consumerTokens: cleanTokens, + consumerCSS: cleanCSS, + rules: defaultRules, + styleFile: "src/styles/main.css", + }); + + const overrides = results.filter((r) => r.rule === "token-override"); + const missing = results.filter((r) => r.rule === "missing-token"); + expect(overrides).toHaveLength(0); + expect(missing).toHaveLength(0); + }); +}); + +describe("scanValues - drifted consumer", () => { + const driftedTokens = parseCSS(driftedCSS); + const results = scanValues({ + registryTokens, + consumerTokens: driftedTokens, + consumerCSS: driftedCSS, + rules: defaultRules, + styleFile: "src/styles/main.css", + }); + + it("detects token overrides", () => { + const overrides = results.filter((r) => r.rule === "token-override"); + expect(overrides.length).toBeGreaterThan(0); + + const borderDrift = overrides.find((r) => r.token === "--border-default"); + expect(borderDrift).toBeDefined(); + expect(borderDrift!.registryValue).toBe("var(--color-gray-200)"); + expect(borderDrift!.consumerValue).toBe("var(--color-gray-500)"); + }); + + it("detects hardcoded colors", () => { + const hardcoded = results.filter((r) => r.rule === "hardcoded-color"); + expect(hardcoded.length).toBeGreaterThan(0); + + const messages = hardcoded.map((r) => r.token); + expect(messages).toContain("#ff6b6b"); + }); + + it("detects missing tokens", () => { + const missing = results.filter((r) => r.rule === "missing-token"); + expect(missing.length).toBeGreaterThan(0); + + const missingNames = missing.map((r) => r.token); + // --text-muted exists in .dark block so it's found by name + expect(missingNames).toContain("--text-inverse"); + expect(missingNames).toContain("--text-danger"); + }); + + it("respects rule severity", () => { + const hardcoded = results.filter((r) => r.rule === "hardcoded-color"); + for (const r of hardcoded) { + expect(r.severity).toBe("error"); + } + + const overrides = results.filter((r) => r.rule === "token-override"); + for (const r of overrides) { + expect(r.severity).toBe("warn"); + } + }); +}); + +describe("scanValues - rules can be disabled", () => { + it("skips disabled rules", () => { + const driftedTokens = parseCSS(driftedCSS); + const results = scanValues({ + registryTokens, + consumerTokens: driftedTokens, + consumerCSS: driftedCSS, + rules: { + "hardcoded-color": "off", + "token-override": "off", + "missing-token": "off", + }, + styleFile: "src/styles/main.css", + }); + expect(results).toHaveLength(0); + }); +}); diff --git a/packages/ghost-core/tsconfig.json b/packages/ghost-core/tsconfig.json new file mode 100644 index 0000000..f724352 --- /dev/null +++ b/packages/ghost-core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..8013f75 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1070 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@22.19.17)(jiti@2.6.1) + + packages/ghost-cli: + dependencies: + '@ghost/core': + specifier: workspace:* + version: link:../ghost-core + citty: + specifier: ^0.1.6 + version: 0.1.6 + + packages/ghost-core: + dependencies: + diff: + specifier: ^7.0.0 + version: 7.0.0 + jiti: + specifier: ^2.4.0 + version: 2.6.1 + postcss: + specifier: ^8.5.0 + version: 8.5.8 + devDependencies: + '@types/diff': + specifier: ^6.0.0 + version: 6.0.0 + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/diff@6.0.0': + resolution: {integrity: sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + diff@7.0.0: + resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} + engines: {node: '>=0.3.1'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/diff@6.0.0': {} + + '@types/estree@1.0.8': {} + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.17)(jiti@2.6.1) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + consola@3.4.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + diff@7.0.0: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + jiti@2.6.1: {} + + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + vite-node@3.2.4(@types/node@22.19.17)(jiti@2.6.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@22.19.17)(jiti@2.6.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + jiti: 2.6.1 + + vitest@3.2.4(@types/node@22.19.17)(jiti@2.6.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@22.19.17)(jiti@2.6.1) + vite-node: 3.2.4(@types/node@22.19.17)(jiti@2.6.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..dee51e9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f3e398f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "forceConsistentCasingInFileNames": true + }, + "files": [], + "references": [ + { "path": "packages/ghost-core" }, + { "path": "packages/ghost-cli" } + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..69823ca --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + root: "packages/ghost-core", + }, +}); From e0ce1b1ab27cdaccd77534c4fb285fd78222416d Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Fri, 3 Apr 2026 21:21:46 -0400 Subject: [PATCH 2/5] =?UTF-8?q?Add=20visual=20drift=20scanner=20=E2=80=94?= =?UTF-8?q?=20render=20components=20and=20pixel-diff=20screenshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the third core scanner alongside values and structure. When enabled (scan.visual: true), Ghost creates a temporary Vite+React project, renders registry and consumer component variants via Playwright, and compares screenshots with pixelmatch. Diff images and percentage scores are written to .ghost/visual/ and reported in CLI/JSON output. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ghost-core/package.json | 8 +- packages/ghost-core/src/config.ts | 5 +- packages/ghost-core/src/index.ts | 3 + packages/ghost-core/src/reporters/cli.ts | 34 +- packages/ghost-core/src/scan.ts | 33 ++ .../ghost-core/src/scanners/visual-harness.ts | 446 ++++++++++++++++++ packages/ghost-core/src/scanners/visual.ts | 169 +++++++ packages/ghost-core/src/types.ts | 23 + 8 files changed, 717 insertions(+), 4 deletions(-) create mode 100644 packages/ghost-core/src/scanners/visual-harness.ts create mode 100644 packages/ghost-core/src/scanners/visual.ts diff --git a/packages/ghost-core/package.json b/packages/ghost-core/package.json index e7a0a6f..847aa9e 100644 --- a/packages/ghost-core/package.json +++ b/packages/ghost-core/package.json @@ -19,9 +19,15 @@ "dependencies": { "diff": "^7.0.0", "jiti": "^2.4.0", + "pixelmatch": "^6.0.0", + "pngjs": "^7.0.0", "postcss": "^8.5.0" }, + "optionalDependencies": { + "playwright": "^1.50.0" + }, "devDependencies": { - "@types/diff": "^6.0.0" + "@types/diff": "^6.0.0", + "@types/pngjs": "^6.0.0" } } diff --git a/packages/ghost-core/src/config.ts b/packages/ghost-core/src/config.ts index 7ebcdfb..031f0ad 100644 --- a/packages/ghost-core/src/config.ts +++ b/packages/ghost-core/src/config.ts @@ -10,13 +10,15 @@ const CONFIG_FILES = [ ]; const DEFAULT_CONFIG: Omit = { - scan: { values: true, structure: true, analysis: false }, + scan: { values: true, structure: true, visual: false, analysis: false }, rules: { "hardcoded-color": "error", "token-override": "warn", "missing-token": "warn", "structural-divergence": "error", "missing-component": "warn", + "visual-regression": "warn", + "visual-render-failure": "warn", }, ignore: [], }; @@ -71,5 +73,6 @@ export async function loadConfig( scan: { ...DEFAULT_CONFIG.scan, ...raw.scan }, rules: { ...DEFAULT_CONFIG.rules, ...raw.rules }, ignore: raw.ignore ?? DEFAULT_CONFIG.ignore, + visual: raw.visual, }; } diff --git a/packages/ghost-core/src/index.ts b/packages/ghost-core/src/index.ts index ec3fc35..92ac406 100644 --- a/packages/ghost-core/src/index.ts +++ b/packages/ghost-core/src/index.ts @@ -4,6 +4,7 @@ export { resolveRegistry } from "./resolvers/registry.js"; export { parseCSS } from "./resolvers/css.js"; export { formatReport as formatCLIReport } from "./reporters/cli.js"; export { formatReport as formatJSONReport } from "./reporters/json.js"; +export { scanVisual } from "./scanners/visual.js"; export type { GhostConfig, DesignSystemConfig, @@ -18,6 +19,8 @@ export type { DesignSystemReport, ValueDrift, StructureDrift, + VisualDrift, + VisualScanConfig, ScanOptions, RuleSeverity, } from "./types.js"; diff --git a/packages/ghost-core/src/reporters/cli.ts b/packages/ghost-core/src/reporters/cli.ts index 39e0627..6c568df 100644 --- a/packages/ghost-core/src/reporters/cli.ts +++ b/packages/ghost-core/src/reporters/cli.ts @@ -1,4 +1,4 @@ -import type { DriftReport, ValueDrift, StructureDrift } from "../types.js"; +import type { DriftReport, ValueDrift, StructureDrift, VisualDrift } from "../types.js"; const useColor = !process.env["NO_COLOR"] && @@ -59,6 +59,22 @@ function formatStructureDrift(drift: StructureDrift): string { return lines.join("\n"); } +function formatVisualDrift(drift: VisualDrift): string { + const lines: string[] = []; + const location = drift.consumerFile ?? drift.component; + + lines.push( + ` ${severityTag(drift.severity)} ${c.dim(drift.rule)} ${c.dim(location)}`, + ); + lines.push(` ${drift.message}`); + + if (drift.diffImagePath) { + lines.push(` ${c.dim("diff image:")} ${drift.diffImagePath}`); + } + + return lines.join("\n"); +} + export function formatReport(report: DriftReport): string { const lines: string[] = []; @@ -85,7 +101,21 @@ export function formatReport(report: DriftReport): string { } } - if (system.values.length === 0 && system.structure.length === 0) { + if (system.visual.length > 0) { + lines.push(""); + lines.push( + c.bold(`Visual ${c.dim(`(${system.visual.length} issues)`)}`), + ); + for (const drift of system.visual) { + lines.push(formatVisualDrift(drift)); + } + } + + if ( + system.values.length === 0 && + system.structure.length === 0 && + system.visual.length === 0 + ) { lines.push(""); lines.push(c.green(" No drift detected.")); } diff --git a/packages/ghost-core/src/scan.ts b/packages/ghost-core/src/scan.ts index 0455432..2adab50 100644 --- a/packages/ghost-core/src/scan.ts +++ b/packages/ghost-core/src/scan.ts @@ -8,11 +8,13 @@ import type { DriftSummary, ValueDrift, StructureDrift, + VisualDrift, } from "./types.js"; import { resolveRegistry } from "./resolvers/registry.js"; import { parseCSS } from "./resolvers/css.js"; import { scanValues } from "./scanners/values.js"; import { scanStructure } from "./scanners/structure.js"; +import { scanVisual } from "./scanners/visual.js"; export async function scan( config: GhostConfig, @@ -27,6 +29,7 @@ export async function scan( let values: ValueDrift[] = []; let structure: StructureDrift[] = []; + let visual: VisualDrift[] = []; // Values scan if (config.scan.values) { @@ -60,10 +63,35 @@ export async function scan( }); } + // Visual scan + if (config.scan.visual) { + const styleItem = registry.items.find( + (i) => i.type === "registry:style", + ); + const registryCSS = styleItem?.files[0]?.content ?? ""; + const consumerCSS = + existsSync(resolve(cwd, ds.styleEntry)) + ? await readFile(resolve(cwd, ds.styleEntry), "utf-8") + : ""; + + visual = await scanVisual({ + registryItems: registry.items, + consumerDir: cwd, + componentDir: ds.componentDir, + styleContent: registryCSS, + consumerStyleContent: consumerCSS, + rules: config.rules, + ignore: config.ignore, + visual: config.visual ?? {}, + cwd, + }); + } + systems.push({ designSystem: ds.name, values, structure, + visual, }); } @@ -96,6 +124,11 @@ function computeSummary( else if (s.severity === "warn") warnings++; else info++; } + for (const v of system.visual) { + if (v.severity === "error") errors++; + else if (v.severity === "warn") warnings++; + else info++; + } } return { errors, warnings, info, tokensScanned, componentsScanned }; diff --git a/packages/ghost-core/src/scanners/visual-harness.ts b/packages/ghost-core/src/scanners/visual-harness.ts new file mode 100644 index 0000000..33867e3 --- /dev/null +++ b/packages/ghost-core/src/scanners/visual-harness.ts @@ -0,0 +1,446 @@ +import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, basename, extname } from "node:path"; +import { execSync } from "node:child_process"; +import type { RegistryItem, VisualScanConfig } from "../types.js"; + +// Dynamic import helper — bypasses TypeScript's compile-time module resolution +// so optional peer dependencies don't cause build errors when not installed. +async function optionalImport(id: string): Promise { + return import(/* webpackIgnore: true */ id); +} + +export interface ComponentEntry { + name: string; + registrySource: string; + consumerSource: string; + registryFile: string; + consumerFile: string; +} + +export interface ScreenshotResult { + component: string; + registryPng: Buffer | null; + consumerPng: Buffer | null; + error?: string; +} + +export interface CompareResult { + component: string; + diffPercentage: number; + diffImagePath: string | null; + registryFile: string; + consumerFile: string; + error?: string; +} + +interface HarnessOptions { + registryItems: RegistryItem[]; + styleContent: string; + consumerStyleContent: string; + config: Required; + outputDir: string; +} + +const VISUAL_DEFAULTS: Required = { + threshold: 0.1, + viewport: { width: 1280, height: 720 }, + timeout: 10000, + outputDir: ".ghost/visual", +}; + +export function resolveVisualConfig( + config: VisualScanConfig = {}, +): Required { + return { + threshold: config.threshold ?? VISUAL_DEFAULTS.threshold, + viewport: config.viewport ?? VISUAL_DEFAULTS.viewport, + timeout: config.timeout ?? VISUAL_DEFAULTS.timeout, + outputDir: config.outputDir ?? VISUAL_DEFAULTS.outputDir, + }; +} + +export async function createVisualHarness(options: HarnessOptions) { + const { registryItems, styleContent, consumerStyleContent, config, outputDir } = + options; + + const tempDir = await mkdtemp(join(tmpdir(), "ghost-visual-")); + const components: ComponentEntry[] = []; + + // Collect registry:lib items for shared utilities (e.g., @/lib/utils) + const libItems = registryItems.filter((i) => i.type === "registry:lib"); + // Collect all ui items for resolving registryDependencies + const uiItemsByName = new Map( + registryItems + .filter((i) => i.type === "registry:ui") + .map((i) => [i.name, i]), + ); + + // Collect all npm dependencies across components + const allNpmDeps = new Set(["react", "react-dom"]); + for (const item of registryItems) { + for (const dep of item.dependencies ?? []) { + allNpmDeps.add(dep); + } + } + + async function scaffold() { + // Write package.json + const pkgJson = { + name: "ghost-visual-harness", + private: true, + type: "module", + dependencies: Object.fromEntries( + [...allNpmDeps].map((d) => [d, "latest"]), + ), + devDependencies: { + "@vitejs/plugin-react": "latest", + vite: "latest", + "@types/react": "latest", + "@types/react-dom": "latest", + }, + }; + await writeFile( + join(tempDir, "package.json"), + JSON.stringify(pkgJson, null, 2), + ); + + // Write vite.config.ts + const viteConfig = ` +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": resolve(__dirname, "src"), + }, + }, + build: { + rollupOptions: { + input: {}, + }, + }, +}); +`; + await writeFile(join(tempDir, "vite.config.ts"), viteConfig); + + // Write tsconfig.json + const tsConfig = { + compilerOptions: { + target: "ES2020", + module: "ESNext", + moduleResolution: "bundler", + jsx: "react-jsx", + strict: true, + paths: { "@/*": ["./src/*"] }, + baseUrl: ".", + }, + include: ["src"], + }; + await writeFile( + join(tempDir, "tsconfig.json"), + JSON.stringify(tsConfig, null, 2), + ); + + // Write shared lib files + await mkdir(join(tempDir, "src", "lib"), { recursive: true }); + for (const item of libItems) { + for (const file of item.files) { + if (!file.content) continue; + const targetPath = join(tempDir, "src", "lib", basename(file.path)); + await writeFile(targetPath, file.content); + } + } + + // Write registry dependency components (shared across all renders) + await mkdir(join(tempDir, "src", "components", "ui"), { recursive: true }); + for (const [, item] of uiItemsByName) { + for (const file of item.files) { + if (!file.content) continue; + const targetPath = join( + tempDir, + "src", + "components", + "ui", + basename(file.path), + ); + await writeFile(targetPath, file.content); + } + } + } + + async function addComponent(entry: ComponentEntry) { + components.push(entry); + const { name, registrySource, consumerSource } = entry; + + // Create registry variant + const regDir = join(tempDir, "src", "registry", name); + await mkdir(regDir, { recursive: true }); + await writeFile(join(regDir, "style.css"), styleContent); + await writeFile( + join(regDir, `component${extname(entry.registryFile) || ".tsx"}`), + registrySource, + ); + await writeFile(join(regDir, "index.html"), makeHtml(name, "registry")); + await writeFile(join(regDir, "main.tsx"), makeMain("registry", name)); + + // Create consumer variant + const conDir = join(tempDir, "src", "consumer", name); + await mkdir(conDir, { recursive: true }); + await writeFile(join(conDir, "style.css"), consumerStyleContent); + await writeFile( + join(conDir, `component${extname(entry.consumerFile) || ".tsx"}`), + consumerSource, + ); + await writeFile(join(conDir, "index.html"), makeHtml(name, "consumer")); + await writeFile(join(conDir, "main.tsx"), makeMain("consumer", name)); + } + + async function renderAll(): Promise { + // Install dependencies + execSync("npm install --prefer-offline --no-audit --no-fund", { + cwd: tempDir, + stdio: "pipe", + timeout: 60000, + }); + + // Dynamically import optional dependencies + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let createServer: any; + let chromium: any; + let pixelmatch: any; + let PNG: any; + + try { + const vite = await optionalImport("vite"); + createServer = vite.createServer; + } catch { + throw new Error( + "Visual scanner requires 'vite' — install it as a dev dependency", + ); + } + + try { + const pw = await optionalImport("playwright"); + chromium = pw.chromium; + } catch { + throw new Error( + "Visual scanner requires 'playwright' — run: npm install -D playwright && npx playwright install chromium", + ); + } + + try { + const pm = await optionalImport("pixelmatch"); + pixelmatch = pm.default; + } catch { + throw new Error( + "Visual scanner requires 'pixelmatch' — install it as a dependency", + ); + } + + try { + const pngjs = await optionalImport("pngjs"); + PNG = pngjs.PNG; + } catch { + throw new Error( + "Visual scanner requires 'pngjs' — install it as a dependency", + ); + } + + // Start Vite dev server + const server = await createServer({ + root: tempDir, + server: { port: 0 }, + logLevel: "silent", + }); + await server.listen(); + const address = server.httpServer?.address(); + const port = + typeof address === "object" && address ? address.port : 5173; + const baseUrl = `http://localhost:${port}`; + + // Launch browser + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: config.viewport, + }); + + const results: CompareResult[] = []; + + // Ensure output dir exists + await mkdir(outputDir, { recursive: true }); + + for (const comp of components) { + try { + // Screenshot registry version + const regPage = await context.newPage(); + await regPage.goto( + `${baseUrl}/src/registry/${comp.name}/index.html`, + { timeout: config.timeout }, + ); + await regPage + .waitForFunction(() => (window as any).__GHOST_READY === true, { + timeout: config.timeout, + }) + .catch(() => {}); + const regScreenshot = await regPage.screenshot({ fullPage: true }); + await regPage.close(); + + // Screenshot consumer version + const conPage = await context.newPage(); + await conPage.goto( + `${baseUrl}/src/consumer/${comp.name}/index.html`, + { timeout: config.timeout }, + ); + await conPage + .waitForFunction(() => (window as any).__GHOST_READY === true, { + timeout: config.timeout, + }) + .catch(() => {}); + const conScreenshot = await conPage.screenshot({ fullPage: true }); + await conPage.close(); + + // Decode PNGs + const regImg = PNG.sync.read(regScreenshot); + const conImg = PNG.sync.read(conScreenshot); + + // Ensure same dimensions (use larger canvas) + const width = Math.max(regImg.width, conImg.width); + const height = Math.max(regImg.height, conImg.height); + + const regResized = resizePng(PNG, regImg, width, height); + const conResized = resizePng(PNG, conImg, width, height); + + const diffImg = new PNG({ width, height }); + const numDiffPixels = pixelmatch( + regResized.data, + conResized.data, + diffImg.data, + width, + height, + { threshold: 0.1 }, + ); + + const totalPixels = width * height; + const diffPercentage = + totalPixels > 0 ? (numDiffPixels / totalPixels) * 100 : 0; + + // Write diff image + let diffImagePath: string | null = null; + if (diffPercentage > 0) { + diffImagePath = join(outputDir, `${comp.name}-diff.png`); + const diffBuffer = PNG.sync.write(diffImg); + await writeFile(diffImagePath, diffBuffer); + + // Also write individual screenshots for reference + await writeFile( + join(outputDir, `${comp.name}-registry.png`), + regScreenshot, + ); + await writeFile( + join(outputDir, `${comp.name}-consumer.png`), + conScreenshot, + ); + } + + results.push({ + component: comp.name, + diffPercentage, + diffImagePath, + registryFile: comp.registryFile, + consumerFile: comp.consumerFile, + }); + } catch (err) { + results.push({ + component: comp.name, + diffPercentage: -1, + diffImagePath: null, + registryFile: comp.registryFile, + consumerFile: comp.consumerFile, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + await context.close(); + await browser.close(); + await server.close(); + + return results; + } + + async function cleanup() { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } + + return { scaffold, addComponent, renderAll, cleanup, tempDir }; +} + +function makeHtml(name: string, variant: string): string { + return ` + + + + + ${variant}/${name} + + +
+ + +`; +} + +function makeMain(variant: string, name: string): string { + return `import React from "react"; +import { createRoot } from "react-dom/client"; +import "./style.css"; + +// Import the component — default or named export +import * as Mod from "./component"; + +const Component = (Mod as any).default ?? Object.values(Mod)[0] ?? (() =>
No export found
); + +function App() { + return ( +
+ +
+ ); +} + +const root = createRoot(document.getElementById("root")!); +root.render(); +(window as any).__GHOST_READY = true; +`; +} + +function resizePng( + PNG: any, + img: any, + width: number, + height: number, +): any { + if (img.width === width && img.height === height) return img; + + const resized = new PNG({ width, height }); + // Fill with white background + resized.data.fill(255); + + // Copy original image data + for (let y = 0; y < img.height; y++) { + for (let x = 0; x < img.width; x++) { + const srcIdx = (y * img.width + x) * 4; + const dstIdx = (y * width + x) * 4; + resized.data[dstIdx] = img.data[srcIdx]!; + resized.data[dstIdx + 1] = img.data[srcIdx + 1]!; + resized.data[dstIdx + 2] = img.data[srcIdx + 2]!; + resized.data[dstIdx + 3] = img.data[srcIdx + 3]!; + } + } + + return resized; +} diff --git a/packages/ghost-core/src/scanners/visual.ts b/packages/ghost-core/src/scanners/visual.ts new file mode 100644 index 0000000..567c89f --- /dev/null +++ b/packages/ghost-core/src/scanners/visual.ts @@ -0,0 +1,169 @@ +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { resolve, relative } from "node:path"; +import type { + RegistryItem, + VisualDrift, + VisualScanConfig, + RuleSeverity, +} from "../types.js"; +import { + createVisualHarness, + resolveVisualConfig, +} from "./visual-harness.js"; + +export interface VisualScannerOptions { + registryItems: RegistryItem[]; + consumerDir: string; + componentDir: string; + styleContent: string; + consumerStyleContent: string; + rules: Record; + ignore: string[]; + visual: VisualScanConfig; + cwd: string; +} + +function matchesIgnore(filePath: string, patterns: string[]): boolean { + for (const pattern of patterns) { + const regex = new RegExp( + "^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$", + ); + if (regex.test(filePath)) return true; + } + return false; +} + +export async function scanVisual( + options: VisualScannerOptions, +): Promise { + const { + registryItems, + consumerDir, + componentDir, + styleContent, + consumerStyleContent, + rules, + ignore, + visual, + cwd, + } = options; + + const regressionSeverity = rules["visual-regression"] ?? "warn"; + const failureSeverity = rules["visual-render-failure"] ?? "warn"; + + if (regressionSeverity === "off" && failureSeverity === "off") { + return []; + } + + const config = resolveVisualConfig(visual); + const outputDir = resolve(cwd, config.outputDir); + + const uiItems = registryItems.filter((item) => item.type === "registry:ui"); + const fullComponentDir = resolve(consumerDir, componentDir); + + if (!existsSync(fullComponentDir)) { + return []; + } + + // Build list of components that have both registry and consumer versions + const componentsToCompare: Array<{ + item: RegistryItem; + registryContent: string; + consumerContent: string; + registryFile: string; + consumerFile: string; + }> = []; + + for (const item of uiItems) { + for (const file of item.files) { + if (!file.content) continue; + + const consumerFilePath = resolve(consumerDir, file.target); + const relativePath = relative(consumerDir, consumerFilePath); + + if (matchesIgnore(relativePath, ignore)) continue; + if (!existsSync(consumerFilePath)) continue; + + const consumerContent = await readFile(consumerFilePath, "utf-8"); + + componentsToCompare.push({ + item, + registryContent: file.content, + consumerContent, + registryFile: file.path, + consumerFile: relativePath, + }); + } + } + + if (componentsToCompare.length === 0) { + return []; + } + + const harness = await createVisualHarness({ + registryItems, + styleContent, + consumerStyleContent, + config, + outputDir, + }); + + try { + await harness.scaffold(); + + for (const comp of componentsToCompare) { + await harness.addComponent({ + name: comp.item.name, + registrySource: comp.registryContent, + consumerSource: comp.consumerContent, + registryFile: comp.registryFile, + consumerFile: comp.consumerFile, + }); + } + + const results = await harness.renderAll(); + const drifts: VisualDrift[] = []; + + for (const result of results) { + if (result.error) { + if (failureSeverity !== "off") { + drifts.push({ + component: result.component, + rule: "visual-render-failure", + severity: failureSeverity, + message: `Component "${result.component}" failed to render: ${result.error}`, + diffPercentage: -1, + threshold: config.threshold, + registryFile: result.registryFile, + consumerFile: result.consumerFile, + error: result.error, + }); + } + continue; + } + + if (result.diffPercentage > config.threshold) { + if (regressionSeverity !== "off") { + drifts.push({ + component: result.component, + rule: "visual-regression", + severity: regressionSeverity, + message: `Component "${result.component}" visual drift: ${result.diffPercentage.toFixed(1)}% pixel difference (threshold: ${config.threshold}%)`, + diffPercentage: result.diffPercentage, + threshold: config.threshold, + registryFile: result.registryFile, + consumerFile: result.consumerFile, + diffImagePath: result.diffImagePath + ? relative(cwd, result.diffImagePath) + : undefined, + }); + } + } + } + + return drifts; + } finally { + await harness.cleanup(); + } +} diff --git a/packages/ghost-core/src/types.ts b/packages/ghost-core/src/types.ts index 94ae4c1..3100be3 100644 --- a/packages/ghost-core/src/types.ts +++ b/packages/ghost-core/src/types.ts @@ -70,14 +70,23 @@ export interface DesignSystemConfig { export interface ScanOptions { values: boolean; structure: boolean; + visual: boolean; analysis: boolean; } +export interface VisualScanConfig { + threshold?: number; + viewport?: { width: number; height: number }; + timeout?: number; + outputDir?: string; +} + export interface GhostConfig { designSystems: DesignSystemConfig[]; scan: ScanOptions; rules: Record; ignore: string[]; + visual?: VisualScanConfig; } // --- Drift report types --- @@ -115,10 +124,24 @@ export interface DriftSummary { tokensScanned: number; } +export interface VisualDrift { + component: string; + rule: string; + severity: RuleSeverity; + message: string; + diffPercentage: number; + threshold: number; + registryFile?: string; + consumerFile?: string; + diffImagePath?: string; + error?: string; +} + export interface DesignSystemReport { designSystem: string; values: ValueDrift[]; structure: StructureDrift[]; + visual: VisualDrift[]; } export interface DriftReport { From 7378161d74fc3f3bfc1cd749fb43e6c3eee69f16 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Fri, 3 Apr 2026 22:07:05 -0400 Subject: [PATCH 3/5] =?UTF-8?q?Add=20DX=20tooling=20=E2=80=94=20biome,=20l?= =?UTF-8?q?efthook,=20CI,=20and=20task=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set up biome for linting/formatting, lefthook for git hooks, GitHub Actions CI pipeline, justfile for common tasks, and a file size check script. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 18 +++ .github/workflows/ci.yml | 40 +++++ biome.json | 30 ++++ justfile | 47 ++++++ lefthook.yml | 16 ++ package.json | 8 +- packages/ghost-cli/package.json | 4 +- packages/ghost-core/package.json | 4 +- pnpm-lock.yaml | 253 +++++++++++++++++++++++++++++++ scripts/check-file-sizes.mjs | 68 +++++++++ 10 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .github/workflows/ci.yml create mode 100644 biome.json create mode 100644 justfile create mode 100644 lefthook.yml create mode 100644 scripts/check-file-sizes.mjs diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8c6b304 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(git -C /Users/nahiyan/Development/ghost log --oneline)", + "Bash(git:*)", + "Bash(pnpm install:*)", + "Bash(pnpm build:*)", + "Bash(pnpm test:*)", + "Bash(node:*)", + "Bash(npx tsc:*)", + "Bash(npx vitest:*)", + "Bash(npx biome:*)", + "Bash(just check:*)", + "Bash(just build:*)", + "Bash(npx lefthook:*)" + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a536d01 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint & Format + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm check + + test: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..70f4aec --- /dev/null +++ b/biome.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!**/test/fixtures"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "warn", + "noAssignInExpressions": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..d223cd3 --- /dev/null +++ b/justfile @@ -0,0 +1,47 @@ +# Default recipe +default: + @just --list + +# ── Dev Environment ────────────────────────────────────────── + +# Install dependencies +setup: + pnpm install + +# ── Build & Check ──────────────────────────────────────────── + +# Run all checks (lint, format, typecheck, file sizes) +check: + pnpm check + +# Format code +fmt: + pnpm fmt + +# Check formatting without modifying +fmt-check: + pnpm exec biome format . + +# Build all packages +build: + pnpm build + +# Full CI gate +ci: check test build + +# ── Test ───────────────────────────────────────────────────── + +# Run unit tests +test: + pnpm test + +# Run tests in watch mode +test-watch: + pnpm test:watch + +# ── Utilities ──────────────────────────────────────────────── + +# Clean build artifacts +clean: + pnpm clean + rm -rf node_modules packages/*/node_modules diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..33aaee4 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,16 @@ +pre-commit: + commands: + format: + run: npx biome format --write . && npx biome check --fix . && git add -u + check: + run: just check + +pre-push: + parallel: true + commands: + check: + run: just check + test: + run: just test + build: + run: just build diff --git a/package.json b/package.json index 44284e8..62b842b 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,16 @@ "clean": "tsc --build --clean", "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --build --noEmit" + "typecheck": "tsc --build", + "check": "biome check . && pnpm typecheck && pnpm check:file-sizes", + "check:file-sizes": "node scripts/check-file-sizes.mjs", + "fmt": "biome format --write .", + "lint": "biome lint ." }, "devDependencies": { + "@biomejs/biome": "^2.4.0", "@types/node": "^22.0.0", + "lefthook": "^2.1.0", "typescript": "^5.7.0", "vitest": "^3.0.0" } diff --git a/packages/ghost-cli/package.json b/packages/ghost-cli/package.json index 350045b..b7b59ab 100644 --- a/packages/ghost-cli/package.json +++ b/packages/ghost-cli/package.json @@ -7,7 +7,9 @@ "bin": { "ghost": "./dist/bin.js" }, - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "tsc --build" }, diff --git a/packages/ghost-core/package.json b/packages/ghost-core/package.json index 847aa9e..09c759e 100644 --- a/packages/ghost-core/package.json +++ b/packages/ghost-core/package.json @@ -12,7 +12,9 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "tsc --build" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8013f75..f1aee23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,15 @@ importers: .: devDependencies: + '@biomejs/biome': + specifier: ^2.4.0 + version: 2.4.10 '@types/node': specifier: ^22.0.0 version: 22.19.17 + lefthook: + specifier: ^2.1.0 + version: 2.1.4 typescript: specifier: ^5.7.0 version: 5.9.3 @@ -35,6 +41,12 @@ importers: jiti: specifier: ^2.4.0 version: 2.6.1 + pixelmatch: + specifier: ^6.0.0 + version: 6.0.0 + pngjs: + specifier: ^7.0.0 + version: 7.0.0 postcss: specifier: ^8.5.0 version: 8.5.8 @@ -42,9 +54,69 @@ importers: '@types/diff': specifier: ^6.0.0 version: 6.0.0 + '@types/pngjs': + specifier: ^6.0.0 + version: 6.0.5 + optionalDependencies: + playwright: + specifier: ^1.50.0 + version: 1.59.1 packages: + '@biomejs/biome@2.4.10': + resolution: {integrity: sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.10': + resolution: {integrity: sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.10': + resolution: {integrity: sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.10': + resolution: {integrity: sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.4.10': + resolution: {integrity: sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.4.10': + resolution: {integrity: sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.4.10': + resolution: {integrity: sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.4.10': + resolution: {integrity: sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.10': + resolution: {integrity: sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -347,6 +419,9 @@ packages: '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/pngjs@6.0.5': + resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -440,6 +515,11 @@ packages: picomatch: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -452,6 +532,60 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + lefthook-darwin-arm64@2.1.4: + resolution: {integrity: sha512-BUAAE9+rUrjr39a+wH/1zHmGrDdwUQ2Yq/z6BQbM/yUb9qtXBRcQ5eOXxApqWW177VhGBpX31aqIlfAZ5Q7wzw==} + cpu: [arm64] + os: [darwin] + + lefthook-darwin-x64@2.1.4: + resolution: {integrity: sha512-K1ncIMEe84fe+ss1hQNO7rIvqiKy2TJvTFpkypvqFodT7mJXZn7GLKYTIXdIuyPAYthRa9DwFnx5uMoHwD2F1Q==} + cpu: [x64] + os: [darwin] + + lefthook-freebsd-arm64@2.1.4: + resolution: {integrity: sha512-PVUhjOhVN71YaYsVdQyNbFZ4a2jFB2Tg5hKrrn9kaWpx64aLz/XivLjwr8sEuTaP1GRlEWBpW6Bhrcsyo39qFw==} + cpu: [arm64] + os: [freebsd] + + lefthook-freebsd-x64@2.1.4: + resolution: {integrity: sha512-ZWV9o/LeyWNEBoVO+BhLqxH3rGTba05nkm5NvMjEFSj7LbUNUDbQmupZwtHl1OMGJO66eZP0CalzRfUH6GhBxQ==} + cpu: [x64] + os: [freebsd] + + lefthook-linux-arm64@2.1.4: + resolution: {integrity: sha512-iWN0pGnTjrIvNIcSI1vQBJXUbybTqJ5CLMniPA0olabMXQfPDrdMKVQe+mgdwHK+E3/Y0H0ZNL3lnOj6Sk6szA==} + cpu: [arm64] + os: [linux] + + lefthook-linux-x64@2.1.4: + resolution: {integrity: sha512-96bTBE/JdYgqWYAJDh+/e/0MaxJ25XTOAk7iy/fKoZ1ugf6S0W9bEFbnCFNooXOcxNVTan5xWKfcjJmPIKtsJA==} + cpu: [x64] + os: [linux] + + lefthook-openbsd-arm64@2.1.4: + resolution: {integrity: sha512-oYUoK6AIJNEr9lUSpIMj6g7sWzotvtc3ryw7yoOyQM6uqmEduw73URV/qGoUcm4nqqmR93ZalZwR2r3Gd61zvw==} + cpu: [arm64] + os: [openbsd] + + lefthook-openbsd-x64@2.1.4: + resolution: {integrity: sha512-i/Dv9Jcm68y9cggr1PhyUhOabBGP9+hzQPoiyOhKks7y9qrJl79A8XfG6LHekSuYc2VpiSu5wdnnrE1cj2nfTg==} + cpu: [x64] + os: [openbsd] + + lefthook-windows-arm64@2.1.4: + resolution: {integrity: sha512-hSww7z+QX4YMnw2lK7DMrs3+w7NtxksuMKOkCKGyxUAC/0m1LAICo0ZbtdDtZ7agxRQQQ/SEbzFRhU5ysNcbjA==} + cpu: [arm64] + os: [win32] + + lefthook-windows-x64@2.1.4: + resolution: {integrity: sha512-eE68LwnogxwcPgGsbVGPGxmghyMGmU9SdGwcc+uhGnUxPz1jL89oECMWJNc36zjVK24umNeDAzB5KA3lw1MuWw==} + cpu: [x64] + os: [win32] + + lefthook@2.1.4: + resolution: {integrity: sha512-JNfJ5gAn0KADvJ1I6/xMcx70+/6TL6U9gqGkKvPw5RNMfatC7jIg0Evl97HN846xmfz959BV70l8r3QsBJk30w==} + hasBin: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -480,6 +614,24 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pixelmatch@6.0.0: + resolution: {integrity: sha512-FYpL4XiIWakTnIqLqvt3uN4L9B3TsuHIvhLILzTiJZMJUsGvmKNeL4H3b6I99LRyerK9W4IuOXw+N28AtRgK2g==} + hasBin: true + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -615,6 +767,41 @@ packages: snapshots: + '@biomejs/biome@2.4.10': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.10 + '@biomejs/cli-darwin-x64': 2.4.10 + '@biomejs/cli-linux-arm64': 2.4.10 + '@biomejs/cli-linux-arm64-musl': 2.4.10 + '@biomejs/cli-linux-x64': 2.4.10 + '@biomejs/cli-linux-x64-musl': 2.4.10 + '@biomejs/cli-win32-arm64': 2.4.10 + '@biomejs/cli-win32-x64': 2.4.10 + + '@biomejs/cli-darwin-arm64@2.4.10': + optional: true + + '@biomejs/cli-darwin-x64@2.4.10': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.10': + optional: true + + '@biomejs/cli-linux-arm64@2.4.10': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.10': + optional: true + + '@biomejs/cli-linux-x64@2.4.10': + optional: true + + '@biomejs/cli-win32-arm64@2.4.10': + optional: true + + '@biomejs/cli-win32-x64@2.4.10': + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -785,6 +972,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pngjs@6.0.5': + dependencies: + '@types/node': 22.19.17 + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -896,6 +1087,9 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -903,6 +1097,49 @@ snapshots: js-tokens@9.0.1: {} + lefthook-darwin-arm64@2.1.4: + optional: true + + lefthook-darwin-x64@2.1.4: + optional: true + + lefthook-freebsd-arm64@2.1.4: + optional: true + + lefthook-freebsd-x64@2.1.4: + optional: true + + lefthook-linux-arm64@2.1.4: + optional: true + + lefthook-linux-x64@2.1.4: + optional: true + + lefthook-openbsd-arm64@2.1.4: + optional: true + + lefthook-openbsd-x64@2.1.4: + optional: true + + lefthook-windows-arm64@2.1.4: + optional: true + + lefthook-windows-x64@2.1.4: + optional: true + + lefthook@2.1.4: + optionalDependencies: + lefthook-darwin-arm64: 2.1.4 + lefthook-darwin-x64: 2.1.4 + lefthook-freebsd-arm64: 2.1.4 + lefthook-freebsd-x64: 2.1.4 + lefthook-linux-arm64: 2.1.4 + lefthook-linux-x64: 2.1.4 + lefthook-openbsd-arm64: 2.1.4 + lefthook-openbsd-x64: 2.1.4 + lefthook-windows-arm64: 2.1.4 + lefthook-windows-x64: 2.1.4 + loupe@3.2.1: {} magic-string@0.30.21: @@ -921,6 +1158,22 @@ snapshots: picomatch@4.0.4: {} + pixelmatch@6.0.0: + dependencies: + pngjs: 7.0.0 + + playwright-core@1.59.1: + optional: true + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + optional: true + + pngjs@7.0.0: {} + postcss@8.5.8: dependencies: nanoid: 3.3.11 diff --git a/scripts/check-file-sizes.mjs b/scripts/check-file-sizes.mjs new file mode 100644 index 0000000..1bdd9aa --- /dev/null +++ b/scripts/check-file-sizes.mjs @@ -0,0 +1,68 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const DEFAULT_LIMIT = 500; + +// Add narrowly scoped exceptions here with justification +const EXCEPTIONS = { + // "packages/ghost-core/src/example.ts": { + // limit: 600, + // justification: "Reason for the exception", + // }, +}; + +const DIRS_TO_CHECK = [ + { dir: "packages/ghost-core/src", glob: /\.[jt]sx?$/ }, + { dir: "packages/ghost-cli/src", glob: /\.[jt]sx?$/ }, +]; + +function countLines(filePath) { + const content = readFileSync(filePath, "utf8"); + return content.split("\n").length; +} + +function walkDir(dir, pattern) { + const results = []; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return results; + } + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkDir(fullPath, pattern)); + } else if (pattern.test(entry.name)) { + results.push(fullPath); + } + } + return results; +} + +const violations = []; + +for (const { dir, glob } of DIRS_TO_CHECK) { + const files = walkDir(dir, glob); + for (const file of files) { + const rel = relative(".", file); + const limit = EXCEPTIONS[rel]?.limit ?? DEFAULT_LIMIT; + const lines = countLines(file); + if (lines > limit) { + violations.push({ file: rel, lines, limit }); + } + } +} + +if (violations.length > 0) { + console.error("File size check failed:"); + for (const v of violations) { + console.error(` - ${v.file}: ${v.lines} lines (limit ${v.limit})`); + } + console.error( + "\nSplit the file or add a narrowly scoped exception in `scripts/check-file-sizes.mjs`.", + ); + process.exit(1); +} else { + console.log("File size check passed."); +} From d4f9c5f6228160a58c969dfe5137c99315bbf33c Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Fri, 3 Apr 2026 22:10:47 -0400 Subject: [PATCH 4/5] Add packageManager field for CI pnpm version resolution Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 62b842b..8b47832 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "description": "Design drift detection system", "license": "Apache-2.0", + "packageManager": "pnpm@10.12.4", "engines": { "node": ">=18.0.0" }, From bf96ebbd1da5fcc5e76a05521ed725815ac6b9d6 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Fri, 3 Apr 2026 22:12:55 -0400 Subject: [PATCH 5/5] =?UTF-8?q?Fix=20biome=20lint=20errors=20=E2=80=94=20a?= =?UTF-8?q?pply=20safe=20and=20unsafe=20auto-fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 7 +++- packages/ghost-cli/src/bin.ts | 7 +++- packages/ghost-cli/tsconfig.json | 4 +- packages/ghost-core/src/config.ts | 24 ++++++----- packages/ghost-core/src/index.ts | 24 +++++------ packages/ghost-core/src/reporters/cli.ts | 19 +++++---- packages/ghost-core/src/resolvers/css.ts | 19 +++++---- packages/ghost-core/src/resolvers/registry.ts | 15 ++++--- packages/ghost-core/src/scan.ts | 37 +++++++++-------- packages/ghost-core/src/scanners/structure.ts | 16 +++++--- packages/ghost-core/src/scanners/values.ts | 32 +++++++++++---- .../ghost-core/src/scanners/visual-harness.ts | 41 +++++++++---------- packages/ghost-core/src/scanners/visual.ts | 13 +++--- packages/ghost-core/test/e2e/scan.test.ts | 2 +- .../ghost-core/test/resolvers/css.test.ts | 30 ++++++++------ .../test/resolvers/registry.test.ts | 12 +++--- .../test/scanners/structure.test.ts | 6 +-- .../ghost-core/test/scanners/values.test.ts | 6 +-- 18 files changed, 181 insertions(+), 133 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8c6b304..423ac49 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,12 @@ "Bash(npx biome:*)", "Bash(just check:*)", "Bash(just build:*)", - "Bash(npx lefthook:*)" + "Bash(npx lefthook:*)", + "Bash(mv commit-msg.pre-entire commit-msg)", + "Bash(mv post-commit.pre-entire post-commit)", + "Bash(mv pre-push.pre-entire pre-push)", + "Bash(mv prepare-commit-msg.pre-entire prepare-commit-msg)", + "Bash(pnpm:*)" ] } } diff --git a/packages/ghost-cli/src/bin.ts b/packages/ghost-cli/src/bin.ts index d3a7bcd..4257fb4 100644 --- a/packages/ghost-cli/src/bin.ts +++ b/packages/ghost-cli/src/bin.ts @@ -1,7 +1,12 @@ #!/usr/bin/env node +import { + formatCLIReport, + formatJSONReport, + loadConfig, + scan, +} from "@ghost/core"; import { defineCommand, runMain } from "citty"; -import { loadConfig, scan, formatCLIReport, formatJSONReport } from "@ghost/core"; const scanCommand = defineCommand({ meta: { diff --git a/packages/ghost-cli/tsconfig.json b/packages/ghost-cli/tsconfig.json index 6e791d9..f150571 100644 --- a/packages/ghost-cli/tsconfig.json +++ b/packages/ghost-cli/tsconfig.json @@ -6,7 +6,5 @@ "rootDir": "./src" }, "include": ["src"], - "references": [ - { "path": "../ghost-core" } - ] + "references": [{ "path": "../ghost-core" }] } diff --git a/packages/ghost-core/src/config.ts b/packages/ghost-core/src/config.ts index 031f0ad..53a1af7 100644 --- a/packages/ghost-core/src/config.ts +++ b/packages/ghost-core/src/config.ts @@ -1,13 +1,9 @@ -import { createJiti } from "jiti"; import { existsSync } from "node:fs"; -import { resolve, dirname } from "node:path"; +import { resolve } from "node:path"; +import { createJiti } from "jiti"; import type { GhostConfig } from "./types.js"; -const CONFIG_FILES = [ - "ghost.config.ts", - "ghost.config.js", - "ghost.config.mjs", -]; +const CONFIG_FILES = ["ghost.config.ts", "ghost.config.js", "ghost.config.mjs"]; const DEFAULT_CONFIG: Omit = { scan: { values: true, structure: true, visual: false, analysis: false }, @@ -55,7 +51,8 @@ export async function loadConfig( const jiti = createJiti(resolvedPath); const mod = await jiti.import(resolvedPath); - const raw = (mod as { default?: GhostConfig }).default ?? (mod as GhostConfig); + const raw = + (mod as { default?: GhostConfig }).default ?? (mod as GhostConfig); if (!raw.designSystems || !Array.isArray(raw.designSystems)) { throw new Error("Config must include a designSystems array"); @@ -63,9 +60,14 @@ export async function loadConfig( for (const ds of raw.designSystems) { if (!ds.name) throw new Error("Each design system must have a name"); - if (!ds.registry) throw new Error(`Design system "${ds.name}" must have a registry path or URL`); - if (!ds.componentDir) throw new Error(`Design system "${ds.name}" must have a componentDir`); - if (!ds.styleEntry) throw new Error(`Design system "${ds.name}" must have a styleEntry`); + if (!ds.registry) + throw new Error( + `Design system "${ds.name}" must have a registry path or URL`, + ); + if (!ds.componentDir) + throw new Error(`Design system "${ds.name}" must have a componentDir`); + if (!ds.styleEntry) + throw new Error(`Design system "${ds.name}" must have a styleEntry`); } return { diff --git a/packages/ghost-core/src/index.ts b/packages/ghost-core/src/index.ts index 92ac406..fbc0c8a 100644 --- a/packages/ghost-core/src/index.ts +++ b/packages/ghost-core/src/index.ts @@ -1,26 +1,26 @@ export { defineConfig, loadConfig } from "./config.js"; -export { scan } from "./scan.js"; -export { resolveRegistry } from "./resolvers/registry.js"; -export { parseCSS } from "./resolvers/css.js"; export { formatReport as formatCLIReport } from "./reporters/cli.js"; export { formatReport as formatJSONReport } from "./reporters/json.js"; +export { parseCSS } from "./resolvers/css.js"; +export { resolveRegistry } from "./resolvers/registry.js"; +export { scan } from "./scan.js"; export { scanVisual } from "./scanners/visual.js"; export type { - GhostConfig, + CSSToken, DesignSystemConfig, + DesignSystemReport, + DriftReport, + DriftSummary, + GhostConfig, Registry, - RegistryItem, RegistryFile, + RegistryItem, ResolvedRegistry, - CSSToken, + RuleSeverity, + ScanOptions, + StructureDrift, TokenCategory, - DriftReport, - DriftSummary, - DesignSystemReport, ValueDrift, - StructureDrift, VisualDrift, VisualScanConfig, - ScanOptions, - RuleSeverity, } from "./types.js"; diff --git a/packages/ghost-core/src/reporters/cli.ts b/packages/ghost-core/src/reporters/cli.ts index 6c568df..afb9877 100644 --- a/packages/ghost-core/src/reporters/cli.ts +++ b/packages/ghost-core/src/reporters/cli.ts @@ -1,7 +1,12 @@ -import type { DriftReport, ValueDrift, StructureDrift, VisualDrift } from "../types.js"; +import type { + DriftReport, + StructureDrift, + ValueDrift, + VisualDrift, +} from "../types.js"; const useColor = - !process.env["NO_COLOR"] && + !process.env.NO_COLOR && !process.argv.includes("--no-color") && process.stdout.isTTY; @@ -103,9 +108,7 @@ export function formatReport(report: DriftReport): string { if (system.visual.length > 0) { lines.push(""); - lines.push( - c.bold(`Visual ${c.dim(`(${system.visual.length} issues)`)}`), - ); + lines.push(c.bold(`Visual ${c.dim(`(${system.visual.length} issues)`)}`)); for (const drift of system.visual) { lines.push(formatVisualDrift(drift)); } @@ -126,8 +129,10 @@ export function formatReport(report: DriftReport): string { const { errors, warnings, info } = report.summary; const parts: string[] = []; - if (errors > 0) parts.push(c.red(`${errors} error${errors !== 1 ? "s" : ""}`)); - if (warnings > 0) parts.push(c.yellow(`${warnings} warning${warnings !== 1 ? "s" : ""}`)); + if (errors > 0) + parts.push(c.red(`${errors} error${errors !== 1 ? "s" : ""}`)); + if (warnings > 0) + parts.push(c.yellow(`${warnings} warning${warnings !== 1 ? "s" : ""}`)); if (info > 0) parts.push(c.cyan(`${info} info`)); if (parts.length === 0) { diff --git a/packages/ghost-core/src/resolvers/css.ts b/packages/ghost-core/src/resolvers/css.ts index c69e61c..8f93525 100644 --- a/packages/ghost-core/src/resolvers/css.ts +++ b/packages/ghost-core/src/resolvers/css.ts @@ -1,4 +1,9 @@ -import postcss, { type Root, type Rule, type AtRule, type Declaration } from "postcss"; +import postcss, { + type AtRule, + type Declaration, + type Root, + type Rule, +} from "postcss"; import type { CSSToken, TokenCategory } from "../types.js"; const CATEGORY_PREFIXES: [string, TokenCategory][] = [ @@ -33,7 +38,9 @@ function getSelectorFromParent(node: postcss.Container): string { if (node.type === "rule") return (node as Rule).selector; if (node.type === "atrule") { const atrule = node as AtRule; - return atrule.params ? `@${atrule.name} ${atrule.params}` : `@${atrule.name}`; + return atrule.params + ? `@${atrule.name} ${atrule.params}` + : `@${atrule.name}`; } return "root"; } @@ -104,9 +111,7 @@ function resolveVarReferences(tokens: CSSToken[], maxPasses = 5): CSSToken[] { return tokens; } -export function buildTokenMap( - tokens: CSSToken[], -): Map { +export function buildTokenMap(tokens: CSSToken[]): Map { const map = new Map(); for (const token of tokens) { map.set(`${token.selector}::${token.name}`, token); @@ -114,9 +119,7 @@ export function buildTokenMap( return map; } -export function buildReverseValueMap( - tokens: CSSToken[], -): Map { +export function buildReverseValueMap(tokens: CSSToken[]): Map { const map = new Map(); for (const token of tokens) { const resolved = token.resolvedValue ?? token.value; diff --git a/packages/ghost-core/src/resolvers/registry.ts b/packages/ghost-core/src/resolvers/registry.ts index 3f4868a..f58d062 100644 --- a/packages/ghost-core/src/resolvers/registry.ts +++ b/packages/ghost-core/src/resolvers/registry.ts @@ -1,11 +1,11 @@ -import { readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { resolve, dirname, join } from "node:path"; +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; import type { + CSSToken, Registry, RegistryItem, ResolvedRegistry, - CSSToken, } from "../types.js"; import { parseCSS } from "./css.js"; @@ -32,7 +32,9 @@ async function resolveItemContent( // Try built output first: out/r/[name].json const builtPath = join(registryDir, "out", "r", `${item.name}.json`); if (existsSync(builtPath)) { - const built = JSON.parse(await readFile(builtPath, "utf-8")) as RegistryItem; + const built = JSON.parse( + await readFile(builtPath, "utf-8"), + ) as RegistryItem; const builtFile = built.files?.find((f) => f.path === file.path); if (builtFile?.content) { return { ...file, content: builtFile.content }; @@ -83,7 +85,10 @@ function extractStyleTokens(items: RegistryItem[]): CSSToken[] { for (const item of items) { if (item.type !== "registry:style") continue; for (const file of item.files) { - if (file.content && (file.path.endsWith(".css") || file.type === "registry:theme")) { + if ( + file.content && + (file.path.endsWith(".css") || file.type === "registry:theme") + ) { return parseCSS(file.content); } } diff --git a/packages/ghost-core/src/scan.ts b/packages/ghost-core/src/scan.ts index 2adab50..1fb713e 100644 --- a/packages/ghost-core/src/scan.ts +++ b/packages/ghost-core/src/scan.ts @@ -1,20 +1,20 @@ -import { readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { resolve, dirname } from "node:path"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { parseCSS } from "./resolvers/css.js"; +import { resolveRegistry } from "./resolvers/registry.js"; +import { scanStructure } from "./scanners/structure.js"; +import { scanValues } from "./scanners/values.js"; +import { scanVisual } from "./scanners/visual.js"; import type { - GhostConfig, - DriftReport, DesignSystemReport, + DriftReport, DriftSummary, - ValueDrift, + GhostConfig, StructureDrift, + ValueDrift, VisualDrift, } from "./types.js"; -import { resolveRegistry } from "./resolvers/registry.js"; -import { parseCSS } from "./resolvers/css.js"; -import { scanValues } from "./scanners/values.js"; -import { scanStructure } from "./scanners/structure.js"; -import { scanVisual } from "./scanners/visual.js"; export async function scan( config: GhostConfig, @@ -65,14 +65,11 @@ export async function scan( // Visual scan if (config.scan.visual) { - const styleItem = registry.items.find( - (i) => i.type === "registry:style", - ); + const styleItem = registry.items.find((i) => i.type === "registry:style"); const registryCSS = styleItem?.files[0]?.content ?? ""; - const consumerCSS = - existsSync(resolve(cwd, ds.styleEntry)) - ? await readFile(resolve(cwd, ds.styleEntry), "utf-8") - : ""; + const consumerCSS = existsSync(resolve(cwd, ds.styleEntry)) + ? await readFile(resolve(cwd, ds.styleEntry), "utf-8") + : ""; visual = await scanVisual({ registryItems: registry.items, @@ -95,7 +92,11 @@ export async function scan( }); } - const summary = computeSummary(systems, totalTokensScanned, totalComponentsScanned); + const summary = computeSummary( + systems, + totalTokensScanned, + totalComponentsScanned, + ); return { timestamp: new Date().toISOString(), diff --git a/packages/ghost-core/src/scanners/structure.ts b/packages/ghost-core/src/scanners/structure.ts index 2ee5386..9bfcae5 100644 --- a/packages/ghost-core/src/scanners/structure.ts +++ b/packages/ghost-core/src/scanners/structure.ts @@ -1,8 +1,8 @@ -import { readFile, readdir } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { resolve, relative, basename } from "node:path"; +import { readFile } from "node:fs/promises"; +import { relative, resolve } from "node:path"; import { createPatch } from "diff"; -import type { RegistryItem, StructureDrift, RuleSeverity } from "../types.js"; +import type { RegistryItem, RuleSeverity, StructureDrift } from "../types.js"; export interface StructureScannerOptions { registryItems: RegistryItem[]; @@ -16,7 +16,7 @@ function matchesIgnore(filePath: string, patterns: string[]): boolean { for (const pattern of patterns) { // Simple glob matching: support * wildcard const regex = new RegExp( - "^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$", + `^${pattern.replace(/\*/g, ".*").replace(/\?/g, ".")}$`, ); if (regex.test(filePath)) return true; } @@ -82,8 +82,12 @@ export async function scanStructure( const registryContent = file.content; // Normalize line endings - const normalizedConsumer = consumerContent.replace(/\r\n/g, "\n").trimEnd(); - const normalizedRegistry = registryContent.replace(/\r\n/g, "\n").trimEnd(); + const normalizedConsumer = consumerContent + .replace(/\r\n/g, "\n") + .trimEnd(); + const normalizedRegistry = registryContent + .replace(/\r\n/g, "\n") + .trimEnd(); if (normalizedConsumer === normalizedRegistry) continue; diff --git a/packages/ghost-core/src/scanners/values.ts b/packages/ghost-core/src/scanners/values.ts index b0e2dd1..5561ee5 100644 --- a/packages/ghost-core/src/scanners/values.ts +++ b/packages/ghost-core/src/scanners/values.ts @@ -1,5 +1,5 @@ -import type { CSSToken, ValueDrift, RuleSeverity } from "../types.js"; -import { buildTokenMap, buildReverseValueMap } from "../resolvers/css.js"; +import { buildReverseValueMap, buildTokenMap } from "../resolvers/css.js"; +import type { CSSToken, RuleSeverity, ValueDrift } from "../types.js"; const COLOR_REGEX = /#(?:[0-9a-fA-F]{3,8})\b|rgba?\([^)]+\)|hsla?\([^)]+\)/g; @@ -12,24 +12,40 @@ export interface ValuesScannerOptions { } export function scanValues(options: ValuesScannerOptions): ValueDrift[] { - const { registryTokens, consumerTokens, consumerCSS, rules, styleFile } = options; + const { registryTokens, consumerTokens, consumerCSS, rules, styleFile } = + options; const results: ValueDrift[] = []; if (rules["token-override"] !== "off") { results.push( - ...detectTokenOverrides(registryTokens, consumerTokens, rules["token-override"] ?? "warn", styleFile), + ...detectTokenOverrides( + registryTokens, + consumerTokens, + rules["token-override"] ?? "warn", + styleFile, + ), ); } if (rules["hardcoded-color"] !== "off") { results.push( - ...detectHardcodedColors(registryTokens, consumerCSS, rules["hardcoded-color"] ?? "error", styleFile), + ...detectHardcodedColors( + registryTokens, + consumerCSS, + rules["hardcoded-color"] ?? "error", + styleFile, + ), ); } if (rules["missing-token"] !== "off") { results.push( - ...detectMissingTokens(registryTokens, consumerTokens, rules["missing-token"] ?? "warn", styleFile), + ...detectMissingTokens( + registryTokens, + consumerTokens, + rules["missing-token"] ?? "warn", + styleFile, + ), ); } @@ -152,7 +168,9 @@ function detectMissingTokens( styleFile: string, ): ValueDrift[] { // Only check :root tokens — these are the semantic tokens consumers should have - const registryRootTokens = registryTokens.filter((t) => t.selector === ":root"); + const registryRootTokens = registryTokens.filter( + (t) => t.selector === ":root", + ); const consumerNames = new Set(consumerTokens.map((t) => t.name)); const drifts: ValueDrift[] = []; diff --git a/packages/ghost-core/src/scanners/visual-harness.ts b/packages/ghost-core/src/scanners/visual-harness.ts index 33867e3..4d7a199 100644 --- a/packages/ghost-core/src/scanners/visual-harness.ts +++ b/packages/ghost-core/src/scanners/visual-harness.ts @@ -1,7 +1,7 @@ -import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, basename, extname } from "node:path"; import { execSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, extname, join } from "node:path"; import type { RegistryItem, VisualScanConfig } from "../types.js"; // Dynamic import helper — bypasses TypeScript's compile-time module resolution @@ -61,8 +61,13 @@ export function resolveVisualConfig( } export async function createVisualHarness(options: HarnessOptions) { - const { registryItems, styleContent, consumerStyleContent, config, outputDir } = - options; + const { + registryItems, + styleContent, + consumerStyleContent, + config, + outputDir, + } = options; const tempDir = await mkdtemp(join(tmpdir(), "ghost-visual-")); const components: ComponentEntry[] = []; @@ -258,8 +263,7 @@ export default defineConfig({ }); await server.listen(); const address = server.httpServer?.address(); - const port = - typeof address === "object" && address ? address.port : 5173; + const port = typeof address === "object" && address ? address.port : 5173; const baseUrl = `http://localhost:${port}`; // Launch browser @@ -277,10 +281,9 @@ export default defineConfig({ try { // Screenshot registry version const regPage = await context.newPage(); - await regPage.goto( - `${baseUrl}/src/registry/${comp.name}/index.html`, - { timeout: config.timeout }, - ); + await regPage.goto(`${baseUrl}/src/registry/${comp.name}/index.html`, { + timeout: config.timeout, + }); await regPage .waitForFunction(() => (window as any).__GHOST_READY === true, { timeout: config.timeout, @@ -291,10 +294,9 @@ export default defineConfig({ // Screenshot consumer version const conPage = await context.newPage(); - await conPage.goto( - `${baseUrl}/src/consumer/${comp.name}/index.html`, - { timeout: config.timeout }, - ); + await conPage.goto(`${baseUrl}/src/consumer/${comp.name}/index.html`, { + timeout: config.timeout, + }); await conPage .waitForFunction(() => (window as any).__GHOST_READY === true, { timeout: config.timeout, @@ -394,7 +396,7 @@ function makeHtml(name: string, variant: string): string { `; } -function makeMain(variant: string, name: string): string { +function makeMain(_variant: string, _name: string): string { return `import React from "react"; import { createRoot } from "react-dom/client"; import "./style.css"; @@ -418,12 +420,7 @@ root.render(); `; } -function resizePng( - PNG: any, - img: any, - width: number, - height: number, -): any { +function resizePng(PNG: any, img: any, width: number, height: number): any { if (img.width === width && img.height === height) return img; const resized = new PNG({ width, height }); diff --git a/packages/ghost-core/src/scanners/visual.ts b/packages/ghost-core/src/scanners/visual.ts index 567c89f..3b8187a 100644 --- a/packages/ghost-core/src/scanners/visual.ts +++ b/packages/ghost-core/src/scanners/visual.ts @@ -1,16 +1,13 @@ -import { readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { resolve, relative } from "node:path"; +import { readFile } from "node:fs/promises"; +import { relative, resolve } from "node:path"; import type { RegistryItem, + RuleSeverity, VisualDrift, VisualScanConfig, - RuleSeverity, } from "../types.js"; -import { - createVisualHarness, - resolveVisualConfig, -} from "./visual-harness.js"; +import { createVisualHarness, resolveVisualConfig } from "./visual-harness.js"; export interface VisualScannerOptions { registryItems: RegistryItem[]; @@ -27,7 +24,7 @@ export interface VisualScannerOptions { function matchesIgnore(filePath: string, patterns: string[]): boolean { for (const pattern of patterns) { const regex = new RegExp( - "^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$", + `^${pattern.replace(/\*/g, ".*").replace(/\?/g, ".")}$`, ); if (regex.test(filePath)) return true; } diff --git a/packages/ghost-core/test/e2e/scan.test.ts b/packages/ghost-core/test/e2e/scan.test.ts index e7b130f..67a8356 100644 --- a/packages/ghost-core/test/e2e/scan.test.ts +++ b/packages/ghost-core/test/e2e/scan.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; import { scan } from "../../src/scan.js"; import type { GhostConfig } from "../../src/types.js"; diff --git a/packages/ghost-core/test/resolvers/css.test.ts b/packages/ghost-core/test/resolvers/css.test.ts index 696d6ae..6eee132 100644 --- a/packages/ghost-core/test/resolvers/css.test.ts +++ b/packages/ghost-core/test/resolvers/css.test.ts @@ -1,7 +1,11 @@ -import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { parseCSS, buildTokenMap, buildReverseValueMap } from "../../src/resolvers/css.js"; +import { describe, expect, it } from "vitest"; +import { + buildReverseValueMap, + buildTokenMap, + parseCSS, +} from "../../src/resolvers/css.js"; const fixtureCSS = readFileSync( resolve(__dirname, "../fixtures/registry/src/styles/main.css"), @@ -17,8 +21,8 @@ describe("parseCSS", () => { const white = themeTokens.find((t) => t.name === "--color-white"); expect(white).toBeDefined(); - expect(white!.value).toBe("#ffffff"); - expect(white!.category).toBe("color"); + expect(white?.value).toBe("#ffffff"); + expect(white?.category).toBe("color"); }); it("extracts tokens from :root block", () => { @@ -27,8 +31,8 @@ describe("parseCSS", () => { const bgDefault = rootTokens.find((t) => t.name === "--background-default"); expect(bgDefault).toBeDefined(); - expect(bgDefault!.value).toBe("var(--color-white)"); - expect(bgDefault!.category).toBe("background"); + expect(bgDefault?.value).toBe("var(--color-white)"); + expect(bgDefault?.category).toBe("background"); }); it("extracts tokens from .dark block", () => { @@ -40,21 +44,23 @@ describe("parseCSS", () => { const bgDefault = tokens.find( (t) => t.name === "--background-default" && t.selector === ":root", ); - expect(bgDefault!.resolvedValue).toBe("#ffffff"); + expect(bgDefault?.resolvedValue).toBe("#ffffff"); }); it("categorizes tokens correctly", () => { const border = tokens.find((t) => t.name === "--border-default"); - expect(border!.category).toBe("border"); + expect(border?.category).toBe("border"); - const text = tokens.find((t) => t.name === "--text-muted" && t.selector === ":root"); - expect(text!.category).toBe("text"); + const text = tokens.find( + (t) => t.name === "--text-muted" && t.selector === ":root", + ); + expect(text?.category).toBe("text"); const shadow = tokens.find((t) => t.name === "--shadow-card"); - expect(shadow!.category).toBe("shadow"); + expect(shadow?.category).toBe("shadow"); const radius = tokens.find((t) => t.name === "--radius"); - expect(radius!.category).toBe("radius"); + expect(radius?.category).toBe("radius"); }); }); diff --git a/packages/ghost-core/test/resolvers/registry.test.ts b/packages/ghost-core/test/resolvers/registry.test.ts index e718ae4..105c92b 100644 --- a/packages/ghost-core/test/resolvers/registry.test.ts +++ b/packages/ghost-core/test/resolvers/registry.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; import { resolveRegistry } from "../../src/resolvers/registry.js"; const registryPath = resolve(__dirname, "../fixtures/registry/registry.json"); @@ -15,21 +15,23 @@ describe("resolveRegistry (local)", () => { const registry = await resolveRegistry(registryPath); const button = registry.items.find((i) => i.name === "button"); expect(button).toBeDefined(); - expect(button!.files[0].content).toBeDefined(); - expect(button!.files[0].content).toContain("buttonVariants"); + expect(button?.files[0].content).toBeDefined(); + expect(button?.files[0].content).toContain("buttonVariants"); }); it("resolves style content and extracts tokens", async () => { const registry = await resolveRegistry(registryPath); expect(registry.tokens.length).toBeGreaterThan(0); - const bgDefault = registry.tokens.find((t) => t.name === "--background-default"); + const bgDefault = registry.tokens.find( + (t) => t.name === "--background-default", + ); expect(bgDefault).toBeDefined(); }); it("preserves registry dependencies", async () => { const registry = await resolveRegistry(registryPath); const button = registry.items.find((i) => i.name === "button"); - expect(button!.registryDependencies).toContain("utils"); + expect(button?.registryDependencies).toContain("utils"); }); }); diff --git a/packages/ghost-core/test/scanners/structure.test.ts b/packages/ghost-core/test/scanners/structure.test.ts index 74b0367..fb31e12 100644 --- a/packages/ghost-core/test/scanners/structure.test.ts +++ b/packages/ghost-core/test/scanners/structure.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; import { resolveRegistry } from "../../src/resolvers/registry.js"; import { scanStructure } from "../../src/scanners/structure.js"; @@ -43,8 +43,8 @@ describe("scanStructure - drifted consumer", () => { const buttonDrift = modified.find((r) => r.component === "button"); expect(buttonDrift).toBeDefined(); - expect(buttonDrift!.linesAdded).toBeGreaterThan(0); - expect(buttonDrift!.diff).toBeDefined(); + expect(buttonDrift?.linesAdded).toBeGreaterThan(0); + expect(buttonDrift?.diff).toBeDefined(); }); it("detects missing components", async () => { diff --git a/packages/ghost-core/test/scanners/values.test.ts b/packages/ghost-core/test/scanners/values.test.ts index 1a6205f..61fdfbb 100644 --- a/packages/ghost-core/test/scanners/values.test.ts +++ b/packages/ghost-core/test/scanners/values.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; import { parseCSS } from "../../src/resolvers/css.js"; import { scanValues } from "../../src/scanners/values.js"; @@ -58,8 +58,8 @@ describe("scanValues - drifted consumer", () => { const borderDrift = overrides.find((r) => r.token === "--border-default"); expect(borderDrift).toBeDefined(); - expect(borderDrift!.registryValue).toBe("var(--color-gray-200)"); - expect(borderDrift!.consumerValue).toBe("var(--color-gray-500)"); + expect(borderDrift?.registryValue).toBe("var(--color-gray-200)"); + expect(borderDrift?.consumerValue).toBe("var(--color-gray-500)"); }); it("detects hardcoded colors", () => {