From 6b09bfe45b37f14f0dd8872e5d3d090076993847 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Fri, 10 Apr 2026 14:05:24 -0400 Subject: [PATCH 01/21] Fix fingerprint detection gaps: broader token matching, filled embedding dims - Expand semantic role mapping from 9 to 30+ tokens (shadcn, brand, prefix fallback) - Add 15 new CSS token category prefixes (--space-*, --gap-*, --line-height-*, etc.) - Compute lineHeightPattern from actual token values instead of hardcoding "normal" - Fill all 15 reserved zero-slots in 64-dim embedding with real signal - Expand hardcoded color regex to catch oklch, oklab, lch, lab, color() functions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ghost-core/src/fingerprint/embedding.ts | 171 ++++++++++++++++-- .../src/fingerprint/from-registry.ts | 108 ++++++++++- packages/ghost-core/src/resolvers/css.ts | 28 +++ packages/ghost-core/src/scanners/values.ts | 3 +- 4 files changed, 291 insertions(+), 19 deletions(-) diff --git a/packages/ghost-core/src/fingerprint/embedding.ts b/packages/ghost-core/src/fingerprint/embedding.ts index ac76830..8916344 100644 --- a/packages/ghost-core/src/fingerprint/embedding.ts +++ b/packages/ghost-core/src/fingerprint/embedding.ts @@ -12,12 +12,12 @@ const EMBEDDING_SIZE = 64; * * Dimensions (64 total): * [0-11] Palette: dominant colors OKLCH (up to 4 colors x 3 channels) - * [12-17] Palette: neutral ramp features (count, min/max lightness, spread) + * [12-17] Palette: neutral ramp features (count, has neutrals, ramp density, lightness min/max/range) * [18-20] Palette: qualitative (saturation profile, contrast, semantic count) - * [21-30] Spacing: scale features (count, min, max, regularity, base unit, distribution) - * [31-40] Typography: families count, size ramp features, weight distribution - * [41-48] Surfaces: radii features, shadow complexity, border usage - * [49-63] Architecture: tokenization, component count, methodology, categories + * [21-30] Spacing: scale features (count, min, max, regularity, base unit, median, spread, step ratio, density, range ratio) + * [31-40] Typography: families count, size ramp features, weight distribution, line height, weight spread, ramp range + * [41-48] Surfaces: radii features, shadow complexity, border usage, radii spread, radii median, max radius + * [49-63] Architecture: tokenization, component count, methodology, categories, category diversity entropy, component density */ export function computeEmbedding(fingerprint: FingerprintInput): number[] { const vec: number[] = new Array(EMBEDDING_SIZE).fill(0); @@ -37,12 +37,28 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { } // --- Palette: neutral ramp (6 dims) --- - vec[i++] = Math.min(fingerprint.palette.neutrals.count / 10, 1); - // Parse lightness from neutral steps (use oklch if available from semantic colors) const neutralCount = fingerprint.palette.neutrals.count; + vec[i++] = Math.min(neutralCount / 10, 1); // normalized count vec[i++] = neutralCount > 0 ? 1 : 0; // has neutrals vec[i++] = Math.min(neutralCount / 20, 1); // ramp density - i += 3; // reserved for neutral lightness range + + // Estimate lightness range from neutral steps using semantic colors as proxy + const neutralLightnesses = fingerprint.palette.semantic + .filter( + (c) => + c.oklch && + (c.role.startsWith("surface") || + c.role.startsWith("text") || + c.role === "muted"), + ) + .map((c) => c.oklch![0]); + if (neutralLightnesses.length >= 2) { + vec[i++] = Math.min(...neutralLightnesses); // min lightness + vec[i++] = Math.max(...neutralLightnesses); // max lightness + vec[i++] = Math.max(...neutralLightnesses) - Math.min(...neutralLightnesses); // lightness range + } else { + i += 3; + } // --- Palette: qualitative (3 dims) --- vec[i++] = @@ -69,13 +85,56 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { : 0; vec[i++] = spacing.regularity; vec[i++] = spacing.baseUnit ? Math.min(spacing.baseUnit / 16, 1) : 0; - // Distribution: quartile features + // Median value const spacingMid = spacing.scale.length > 0 ? spacing.scale[Math.floor(spacing.scale.length / 2)] / 100 : 0; vec[i++] = Math.min(spacingMid, 1); - i += 4; // reserved + // Spread (stddev-like): how varied is the scale? + if (spacing.scale.length >= 2) { + const mean = + spacing.scale.reduce((a, b) => a + b, 0) / spacing.scale.length; + const variance = + spacing.scale.reduce((sum, v) => sum + (v - mean) ** 2, 0) / + spacing.scale.length; + vec[i++] = Math.min(Math.sqrt(variance) / 50, 1); // normalized spread + } else { + vec[i++] = 0; + } + // Step ratio: ratio between consecutive values (geometric vs linear) + if (spacing.scale.length >= 3) { + const ratios: number[] = []; + for (let s = 1; s < spacing.scale.length; s++) { + if (spacing.scale[s - 1] > 0) { + ratios.push(spacing.scale[s] / spacing.scale[s - 1]); + } + } + const avgRatio = + ratios.length > 0 + ? ratios.reduce((a, b) => a + b, 0) / ratios.length + : 1; + vec[i++] = Math.min(avgRatio / 4, 1); // 2.0 = geometric doubling + } else { + vec[i++] = 0; + } + // Density: values per unit range + if (spacing.scale.length >= 2) { + const range = + spacing.scale[spacing.scale.length - 1] - spacing.scale[0]; + vec[i++] = range > 0 ? Math.min(spacing.scale.length / range, 1) : 0; + } else { + vec[i++] = 0; + } + // Range ratio: max/min + if (spacing.scale.length >= 2 && spacing.scale[0] > 0) { + vec[i++] = Math.min( + spacing.scale[spacing.scale.length - 1] / spacing.scale[0] / 50, + 1, + ); + } else { + vec[i++] = 0; + } // --- Typography (10 dims) --- const typo = fingerprint.typography; @@ -104,7 +163,33 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { : typo.lineHeightPattern === "normal" ? 0.5 : 1; - i += 4; // reserved + // Weight count: how many distinct weights are used + vec[i++] = Math.min(Object.keys(typo.weightDistribution).length / 6, 1); + // Weight spread: range of weights used (100-900 scale) + const weightKeys = Object.keys(typo.weightDistribution).map(Number); + if (weightKeys.length >= 2) { + vec[i++] = (Math.max(...weightKeys) - Math.min(...weightKeys)) / 800; + } else { + vec[i++] = 0; + } + // Size ramp range ratio + if (typo.sizeRamp.length >= 2 && typo.sizeRamp[0] > 0) { + vec[i++] = Math.min( + typo.sizeRamp[typo.sizeRamp.length - 1] / typo.sizeRamp[0] / 10, + 1, + ); + } else { + vec[i++] = 0; + } + // Size ramp median + if (typo.sizeRamp.length > 0) { + vec[i++] = Math.min( + typo.sizeRamp[Math.floor(typo.sizeRamp.length / 2)] / 100, + 1, + ); + } else { + vec[i++] = 0; + } // --- Surfaces (8 dims) --- const surfaces = fingerprint.surfaces; @@ -129,7 +214,35 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { : surfaces.borderUsage === "moderate" ? 0.5 : 0; - i += 3; // reserved + // Radii spread: range of border radii + if (surfaces.borderRadii.length >= 2) { + vec[i++] = Math.min( + (surfaces.borderRadii[surfaces.borderRadii.length - 1] - + surfaces.borderRadii[0]) / + 32, + 1, + ); + } else { + vec[i++] = 0; + } + // Radii median + if (surfaces.borderRadii.length > 0) { + vec[i++] = Math.min( + surfaces.borderRadii[Math.floor(surfaces.borderRadii.length / 2)] / 32, + 1, + ); + } else { + vec[i++] = 0; + } + // Max radius (signals "pill" shapes — high max radius is distinctive) + if (surfaces.borderRadii.length > 0) { + vec[i++] = Math.min( + surfaces.borderRadii[surfaces.borderRadii.length - 1] / 100, + 1, + ); + } else { + vec[i++] = 0; + } // --- Architecture (15 dims) --- const arch = fingerprint.architecture; @@ -152,6 +265,40 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { : arch.namingPattern === "PascalCase" ? 0.67 : 1; + // Category diversity entropy + const catValues = Object.values(arch.componentCategories); + const totalComponents = catValues.reduce((a, b) => a + b, 0); + if (totalComponents > 0 && catValues.length > 1) { + vec[i++] = + -catValues.reduce((ent, c) => { + const p = c / totalComponents; + return p > 0 ? ent + p * Math.log2(p) : ent; + }, 0) / Math.log2(catValues.length); + } else { + vec[i++] = 0; + } + // Component density: components per category + vec[i++] = catCount > 0 ? Math.min(arch.componentCount / catCount / 10, 1) : 0; + // Methodology count + vec[i++] = Math.min(arch.methodology.length / 4, 1); + // Has styled-components / emotion + vec[i++] = arch.methodology.includes("styled-components") || arch.methodology.includes("emotion") ? 1 : 0; + // Has CSS-in-JS (any) + vec[i++] = + arch.methodology.includes("styled-components") || + arch.methodology.includes("emotion") || + arch.methodology.includes("css-modules") + ? 1 + : 0; + // Component scale bucket (small <10, medium 10-30, large 30+) + vec[i++] = + arch.componentCount < 10 + ? 0 + : arch.componentCount < 30 + ? 0.5 + : 1; + // Tokenization squared (amplifies differences at extremes) + vec[i++] = arch.tokenization * arch.tokenization; return vec; } diff --git a/packages/ghost-core/src/fingerprint/from-registry.ts b/packages/ghost-core/src/fingerprint/from-registry.ts index 31a2e3a..d76733e 100644 --- a/packages/ghost-core/src/fingerprint/from-registry.ts +++ b/packages/ghost-core/src/fingerprint/from-registry.ts @@ -12,27 +12,81 @@ import { } from "./colors.js"; import { computeEmbedding } from "./embedding.js"; +// Exact token name → semantic role mapping const SEMANTIC_ROLES: Record = { + // Surface/background tokens "--background-default": "surface", "--background-alt": "surface-alt", "--background-accent": "accent", + "--background": "surface", + "--bg": "surface", + "--bg-accent": "accent", + // shadcn conventions + "--primary": "primary", + "--primary-foreground": "primary-foreground", + "--secondary": "secondary", + "--secondary-foreground": "secondary-foreground", + "--accent": "accent", + "--accent-foreground": "accent-foreground", + "--muted": "muted", + "--muted-foreground": "muted-foreground", + "--destructive": "destructive", + "--destructive-foreground": "destructive-foreground", + "--card": "surface", + "--card-foreground": "text", + "--popover": "surface-alt", + "--popover-foreground": "text", + "--foreground": "text", + "--input": "border", + "--ring": "ring", + // Text tokens "--text-default": "text", "--text-muted": "text-muted", "--text-inverse": "text-inverse", "--text-danger": "danger", + // Border tokens "--border-default": "border", "--border-strong": "border-strong", + "--border": "border", + // Brand tokens + "--brand": "primary", + "--brand-primary": "primary", + "--brand-secondary": "secondary", }; +// Prefix-based fallback: tokens starting with these prefixes get a role +// derived from the prefix + suffix (e.g., "--color-success" → "success") +const SEMANTIC_ROLE_PREFIXES: [string, (name: string) => string][] = [ + ["--background-", (n) => `surface-${n.replace("--background-", "")}`], + ["--text-", (n) => `text-${n.replace("--text-", "")}`], + ["--border-", (n) => `border-${n.replace("--border-", "")}`], + ["--color-", (n) => n.replace("--color-", "")], +]; + function resolveTokenValue(token: CSSToken): string { return token.resolvedValue ?? token.value; } +function resolveSemanticRole(tokenName: string): string | null { + // Exact match first + const exact = SEMANTIC_ROLES[tokenName]; + if (exact) return exact; + + // Prefix-based fallback + for (const [prefix, derive] of SEMANTIC_ROLE_PREFIXES) { + if (tokenName.startsWith(prefix)) return derive(tokenName); + } + + return null; +} + function extractSemanticColors(tokens: CSSToken[]): SemanticColor[] { const colors: SemanticColor[] = []; + const seen = new Set(); for (const token of tokens) { - const role = SEMANTIC_ROLES[token.name]; - if (role) { + const role = resolveSemanticRole(token.name); + if (role && !seen.has(role)) { + seen.add(role); colors.push(colorToSemanticColor(role, resolveTokenValue(token))); } } @@ -40,10 +94,24 @@ function extractSemanticColors(tokens: CSSToken[]): SemanticColor[] { } function extractDominantColors(tokens: CSSToken[]): SemanticColor[] { - // Dominant = non-neutral, non-text semantic colors - const dominantRoles = ["accent", "danger"]; + // Dominant = brand, accent, primary, destructive — not neutral/text/border/surface roles + const dominantRoles = [ + "primary", + "secondary", + "accent", + "destructive", + "danger", + "ring", + ]; + const neutralPrefixes = ["surface", "text", "border", "muted"]; const all = extractSemanticColors(tokens); - const dominant = all.filter((c) => dominantRoles.includes(c.role)); + const dominant = all.filter( + (c) => + dominantRoles.includes(c.role) || + (!neutralPrefixes.some((p) => c.role.startsWith(p)) && + c.oklch && + c.oklch[1] > 0.04), // has meaningful chroma + ); // If no explicit dominant, use first non-neutral color if (dominant.length === 0 && all.length > 0) { return [all[0]]; @@ -105,6 +173,34 @@ function extractSpacing(tokens: CSSToken[]): { return { scale: unique, regularity, baseUnit }; } +function classifyLineHeight( + tokens: CSSToken[], +): "tight" | "normal" | "loose" { + const lineHeightValues: number[] = []; + for (const token of tokens) { + if ( + token.category === "typography" && + (token.name.includes("line-height") || token.name.includes("leading")) + ) { + const val = resolveTokenValue(token); + const num = Number.parseFloat(val); + if (!Number.isNaN(num)) { + // Unitless values (1.2, 1.5) or percentage-like + // Values > 3 are likely px — normalize against a 16px base + lineHeightValues.push(num > 3 ? num / 16 : num); + } + } + } + + if (lineHeightValues.length === 0) return "normal"; + + const avg = + lineHeightValues.reduce((a, b) => a + b, 0) / lineHeightValues.length; + if (avg < 1.3) return "tight"; + if (avg > 1.7) return "loose"; + return "normal"; +} + function extractBorderRadii(tokens: CSSToken[]): number[] { const radii: number[] = []; for (const token of tokens) { @@ -210,7 +306,7 @@ export function fingerprintFromRegistry( families: typography.families, sizeRamp: typography.sizeRamp, weightDistribution: typography.weightDistribution, - lineHeightPattern: "normal", // default for registry + lineHeightPattern: classifyLineHeight(tokens), }, surfaces: { diff --git a/packages/ghost-core/src/resolvers/css.ts b/packages/ghost-core/src/resolvers/css.ts index 9d34607..724afb9 100644 --- a/packages/ghost-core/src/resolvers/css.ts +++ b/packages/ghost-core/src/resolvers/css.ts @@ -7,23 +7,51 @@ import postcss, { import type { CSSToken, TokenCategory } from "../types.js"; const CATEGORY_PREFIXES: [string, TokenCategory][] = [ + // Background / surface ["--background-", "background"], + ["--bg-", "background"], + // Border ["--border-", "border"], + // Text ["--text-", "text"], + // Shadow ["--shadow-", "shadow"], + // Radius (--radius, --radius-*, --rounded-*) ["--radius", "radius"], + ["--rounded-", "radius"], + // Spacing (--spacing-*, --space-*, --gap-*, --size-*, --pad-*, --margin-*) ["--spacing-", "spacing"], + ["--space-", "spacing"], + ["--gap-", "spacing"], + ["--size-", "spacing"], + ["--pad-", "spacing"], + ["--padding-", "spacing"], + ["--margin-", "spacing"], + // Typography ["--heading-", "typography"], ["--body-", "typography"], ["--label-", "typography"], ["--display-", "typography"], ["--pull-quote-", "typography"], + ["--line-height-", "typography"], + ["--leading-", "typography"], + ["--letter-spacing-", "typography"], + ["--tracking-", "typography"], + ["--font-size-", "typography"], + ["--text-size-", "typography"], + ["--font-weight-", "typography"], + // Animation ["--animate-", "animation"], ["--duration-", "animation"], ["--ease-", "animation"], + ["--transition-", "animation"], + // Color ["--color-", "color"], + // Font ["--font-face-", "font-face"], + ["--font-family-", "font"], ["--font-", "font"], + // Component-specific ["--chart-", "chart"], ["--sidebar-", "sidebar"], ]; diff --git a/packages/ghost-core/src/scanners/values.ts b/packages/ghost-core/src/scanners/values.ts index 8eae02c..4cfcd1a 100644 --- a/packages/ghost-core/src/scanners/values.ts +++ b/packages/ghost-core/src/scanners/values.ts @@ -1,7 +1,8 @@ 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; +const COLOR_REGEX = + /#(?:[0-9a-fA-F]{3,8})\b|rgba?\([^)]+\)|hsla?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|color\([^)]+\)/g; export interface ValuesScannerOptions { registryTokens: CSSToken[]; From ee0d4962d07be45d77ca29a9bff99abe9b1263ce Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Sun, 12 Apr 2026 21:57:10 -0400 Subject: [PATCH 02/21] Bulletproof fingerprint detection: color parsing, normalization, semantic roles, evolution hardening, AI layer Overhaul fingerprint detection accuracy and extensibility across 5 areas: - Color parsing: add HSL, oklch%, color-mix(), named colors, system colors; continuous saturation/contrast scoring; soft chroma boundary (sigmoid) - Normalization: log-scaled component/spacing counts, raised radius/base-unit caps, sqrt tokenization, centralized NORM constants, borderTokenCount - Semantic roles: 4-layer inference engine (exact, pattern, keyword, value-based) supporting shadcn, MUI, Chakra, and custom naming conventions - Font matching: fuzzy comparison with normalization, Levenshtein distance, and font category fallback (50+ fonts mapped) - Evolution: diverging stance re-evaluation with reconverging detection, per-dimension tolerance, adaptive K clustering (elbow method + K-means++), configurable stability threshold - AI layer: structural analysis and fingerprint validation wired into profileWithAnalysis(), .env.local loading in CLI, Tailwind config resolver - CSS resolver: value-based fallback categorization for non-standard tokens 98 tests passing (up from 24). All changes are backward compatible. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 + packages/ghost-cli/src/bin.ts | 17 ++ packages/ghost-core/src/evolution/fleet.ts | 228 +++++++++++++----- packages/ghost-core/src/evolution/index.ts | 2 + packages/ghost-core/src/evolution/sync.ts | 61 ++++- packages/ghost-core/src/evolution/temporal.ts | 7 +- packages/ghost-core/src/fingerprint/colors.ts | 191 ++++++++++++++- .../ghost-core/src/fingerprint/compare.ts | 142 ++++++++++- .../ghost-core/src/fingerprint/embedding.ts | 141 +++++++---- .../src/fingerprint/from-registry.ts | 179 +++++++------- packages/ghost-core/src/fingerprint/index.ts | 2 + .../src/fingerprint/semantic-roles.ts | 148 ++++++++++++ packages/ghost-core/src/index.ts | 20 +- .../ghost-core/src/llm/analyze-structure.ts | 140 +++++++++++ packages/ghost-core/src/llm/index.ts | 7 + .../src/llm/validate-fingerprint.ts | 117 +++++++++ packages/ghost-core/src/profile.ts | 48 +++- packages/ghost-core/src/resolvers/css.ts | 26 +- packages/ghost-core/src/resolvers/tailwind.ts | 142 +++++++++++ packages/ghost-core/src/types.ts | 5 +- .../ghost-core/test/evolution/fleet.test.ts | 129 ++++++++++ .../ghost-core/test/evolution/sync.test.ts | 181 ++++++++++++++ .../test/fingerprint/colors.test.ts | 222 +++++++++++++++++ .../test/fingerprint/embedding.test.ts | 170 +++++++++++++ .../test/fingerprint/semantic-roles.test.ts | 134 ++++++++++ 25 files changed, 2232 insertions(+), 229 deletions(-) create mode 100644 packages/ghost-core/src/fingerprint/semantic-roles.ts create mode 100644 packages/ghost-core/src/llm/analyze-structure.ts create mode 100644 packages/ghost-core/src/llm/validate-fingerprint.ts create mode 100644 packages/ghost-core/src/resolvers/tailwind.ts create mode 100644 packages/ghost-core/test/evolution/fleet.test.ts create mode 100644 packages/ghost-core/test/evolution/sync.test.ts create mode 100644 packages/ghost-core/test/fingerprint/colors.test.ts create mode 100644 packages/ghost-core/test/fingerprint/embedding.test.ts create mode 100644 packages/ghost-core/test/fingerprint/semantic-roles.test.ts diff --git a/.gitignore b/.gitignore index 45c49be..42524c7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist .DS_Store .claude/settings.local.json packages/ghost-ui/public/r/ +.env +.env.local diff --git a/packages/ghost-cli/src/bin.ts b/packages/ghost-cli/src/bin.ts index 9104769..ac77a8e 100644 --- a/packages/ghost-cli/src/bin.ts +++ b/packages/ghost-cli/src/bin.ts @@ -1,6 +1,23 @@ #!/usr/bin/env node +import { existsSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +// Load .env from project root if present. +// The library (ghost-core) reads process.env — this is the only place .env files are loaded. +// Supports: .env, .env.local (local takes precedence) +for (const envFile of [".env", ".env.local"]) { + const envPath = resolve(process.cwd(), envFile); + if (existsSync(envPath)) { + try { + process.loadEnvFile(envPath); + } catch { + // Node < 20.12 or malformed file — silently skip + } + } +} + import type { DesignFingerprint } from "@ghost/core"; import { compareFingerprints, diff --git a/packages/ghost-core/src/evolution/fleet.ts b/packages/ghost-core/src/evolution/fleet.ts index 95bcfe6..e37ce24 100644 --- a/packages/ghost-core/src/evolution/fleet.ts +++ b/packages/ghost-core/src/evolution/fleet.ts @@ -7,13 +7,17 @@ import type { FleetPair, } from "../types.js"; +export interface FleetClusterOptions { + cluster?: boolean | { maxK?: number }; +} + /** * Compare N fingerprints for an ecosystem-level view. * Computes pairwise distances, centroid, spread, and optional clusters. */ export function compareFleet( members: FleetMember[], - options?: { cluster?: boolean }, + options?: FleetClusterOptions, ): FleetComparison { const pairwise = computePairwise(members); const centroid = computeCentroid(members); @@ -26,8 +30,13 @@ export function compareFleet( spread, }; - if (options?.cluster && members.length >= 3) { - result.clusters = clusterMembers(members); + const shouldCluster = + options?.cluster === true || + (typeof options?.cluster === "object" && options.cluster); + if (shouldCluster && members.length >= 3) { + const maxK = + typeof options?.cluster === "object" ? options.cluster.maxK : undefined; + result.clusters = clusterMembers(members, maxK); } return result; @@ -99,11 +108,126 @@ function computeSpread(members: FleetMember[], centroid: number[]): number { } /** - * Basic k-means-style clustering (k=2 for now). - * Splits the fleet into two groups by finding the two most distant members - * and assigning the rest to the nearest one. + * K-means++ initialization: select initial centroids with probability + * proportional to squared distance from nearest existing centroid. + */ +function kmeansppInit(embeddings: number[][], k: number): number[][] { + const centroids: number[][] = []; + + // First centroid: pick randomly (deterministically use first for reproducibility) + centroids.push([...embeddings[0]]); + + for (let c = 1; c < k; c++) { + // Compute squared distances to nearest centroid for each point + const distances = embeddings.map((emb) => { + let minDist = Infinity; + for (const centroid of centroids) { + const dist = embeddingDistance(emb, centroid); + minDist = Math.min(minDist, dist * dist); + } + return minDist; + }); + + // Pick the point with maximum distance (deterministic version of weighted random) + let maxDist = -1; + let maxIdx = 0; + for (let i = 0; i < distances.length; i++) { + if (distances[i] > maxDist) { + maxDist = distances[i]; + maxIdx = i; + } + } + centroids.push([...embeddings[maxIdx]]); + } + + return centroids; +} + +/** + * Compute within-cluster sum of squared distances (WCSS). + */ +function computeWCSS( + embeddings: number[][], + assignments: number[], + centroids: number[][], +): number { + let wcss = 0; + for (let i = 0; i < embeddings.length; i++) { + const dist = embeddingDistance(embeddings[i], centroids[assignments[i]]); + wcss += dist * dist; + } + return wcss; +} + +/** + * Run k-means with iterative refinement. + * Returns cluster assignments and final centroids. + */ +function runKMeans( + embeddings: number[][], + k: number, + maxIterations: number = 10, +): { assignments: number[]; centroids: number[][] } { + const dim = embeddings[0].length; + let centroids = kmeansppInit(embeddings, k); + let assignments = new Array(embeddings.length).fill(0); + + for (let iter = 0; iter < maxIterations; iter++) { + // Assignment step: assign each point to nearest centroid + const newAssignments = embeddings.map((emb) => { + let minDist = Infinity; + let minIdx = 0; + for (let c = 0; c < centroids.length; c++) { + const dist = embeddingDistance(emb, centroids[c]); + if (dist < minDist) { + minDist = dist; + minIdx = c; + } + } + return minIdx; + }); + + // Check convergence + const changed = newAssignments.some((a, i) => a !== assignments[i]); + assignments = newAssignments; + if (!changed) break; + + // Update step: recompute centroids + const newCentroids: number[][] = Array.from({ length: k }, () => + new Array(dim).fill(0), + ); + const counts = new Array(k).fill(0); + + for (let i = 0; i < embeddings.length; i++) { + const c = assignments[i]; + counts[c]++; + for (let d = 0; d < dim; d++) { + newCentroids[c][d] += embeddings[i][d]; + } + } + + for (let c = 0; c < k; c++) { + if (counts[c] > 0) { + for (let d = 0; d < dim; d++) { + newCentroids[c][d] /= counts[c]; + } + } + } + + centroids = newCentroids; + } + + return { assignments, centroids }; +} + +/** + * Adaptive clustering using elbow method to select optimal K. + * Falls back to K=2 if no clear elbow is found. */ -function clusterMembers(members: FleetMember[]): FleetCluster[] { +function clusterMembers( + members: FleetMember[], + maxK?: number, +): FleetCluster[] { if (members.length < 3) { return [ { @@ -113,61 +237,57 @@ function clusterMembers(members: FleetMember[]): FleetCluster[] { ]; } - // Find the two most distant members as initial centroids - let maxDist = -1; - let seedA = 0; - let seedB = 1; + const embeddings = members.map((m) => m.fingerprint.embedding); + const kMax = Math.min(maxK ?? 6, members.length - 1); - for (let i = 0; i < members.length; i++) { - for (let j = i + 1; j < members.length; j++) { - const dist = embeddingDistance( - members[i].fingerprint.embedding, - members[j].fingerprint.embedding, - ); - if (dist > maxDist) { - maxDist = dist; - seedA = i; - seedB = j; - } - } - } - - // Assign each member to the nearest seed - const groupA: FleetMember[] = []; - const groupB: FleetMember[] = []; + // Run k-means for K=1 through kMax, collect WCSS + const results: { k: number; wcss: number; assignments: number[]; centroids: number[][] }[] = []; - for (let i = 0; i < members.length; i++) { - const distToA = embeddingDistance( - members[i].fingerprint.embedding, - members[seedA].fingerprint.embedding, - ); - const distToB = embeddingDistance( - members[i].fingerprint.embedding, - members[seedB].fingerprint.embedding, - ); - - if (distToA <= distToB) { - groupA.push(members[i]); + for (let k = 1; k <= kMax; k++) { + if (k === 1) { + // K=1: everything in one cluster + const centroid = computeCentroid(members); + const assignments = new Array(members.length).fill(0); + const wcss = computeWCSS(embeddings, assignments, [centroid]); + results.push({ k, wcss, assignments, centroids: [centroid] }); } else { - groupB.push(members[i]); + const { assignments, centroids } = runKMeans(embeddings, k); + const wcss = computeWCSS(embeddings, assignments, centroids); + results.push({ k, wcss, assignments, centroids }); } } - const clusters: FleetCluster[] = []; - - if (groupA.length > 0) { - clusters.push({ - memberIds: groupA.map((m) => m.id), - centroid: computeCentroid(groupA), - }); + // Elbow method: find K where marginal WCSS decrease drops below 20% + let bestK = 2; + if (results.length >= 3) { + for (let i = 1; i < results.length - 1; i++) { + const prevDecrease = results[i - 1].wcss - results[i].wcss; + const nextDecrease = results[i].wcss - results[i + 1].wcss; + if (prevDecrease > 0 && nextDecrease / prevDecrease < 0.2) { + bestK = results[i].k; + break; + } + } + // If no elbow found, default to K=2 + if (bestK === 2 && results.length > 1) { + bestK = 2; + } } - if (groupB.length > 0) { - clusters.push({ - memberIds: groupB.map((m) => m.id), - centroid: computeCentroid(groupB), - }); + const chosen = results.find((r) => r.k === bestK) ?? results[1] ?? results[0]; + + // Build clusters from assignments + const clusterMap = new Map(); + for (let i = 0; i < members.length; i++) { + const cluster = chosen.assignments[i]; + if (!clusterMap.has(cluster)) clusterMap.set(cluster, []); + clusterMap.get(cluster)!.push(members[i]); } - return clusters; + return [...clusterMap.values()] + .filter((group) => group.length > 0) + .map((group) => ({ + memberIds: group.map((m) => m.id), + centroid: computeCentroid(group), + })); } diff --git a/packages/ghost-core/src/evolution/index.ts b/packages/ghost-core/src/evolution/index.ts index 485f61c..8381362 100644 --- a/packages/ghost-core/src/evolution/index.ts +++ b/packages/ghost-core/src/evolution/index.ts @@ -1,5 +1,6 @@ export { emitFingerprint } from "./emit.js"; export { compareFleet } from "./fleet.js"; +export type { FleetClusterOptions } from "./fleet.js"; export { appendHistory, readHistory, readRecentHistory } from "./history.js"; export { normalizeParentSource, resolveParent } from "./parent.js"; export { @@ -8,5 +9,6 @@ export { readSyncManifest, writeSyncManifest, } from "./sync.js"; +export type { CheckBoundsOptions } from "./sync.js"; export { computeTemporalComparison } from "./temporal.js"; export { computeDriftVectors, DIMENSION_RANGES } from "./vector.js"; diff --git a/packages/ghost-core/src/evolution/sync.ts b/packages/ghost-core/src/evolution/sync.ts index a802571..96be5ca 100644 --- a/packages/ghost-core/src/evolution/sync.ts +++ b/packages/ghost-core/src/evolution/sync.ts @@ -56,6 +56,7 @@ export async function acknowledge(opts: { dimension?: string; stance?: DimensionStance; reason?: string; + tolerance?: number; cwd?: string; }): Promise<{ manifest: SyncManifest; comparison: FingerprintComparison }> { const cwd = opts.cwd ?? process.cwd(); @@ -76,11 +77,14 @@ export async function acknowledge(opts: { ackedAt: now, }; } else { + const stance = opts.stance ?? "accepted"; dimensions[key] = { distance: delta.distance, - stance: opts.stance ?? "accepted", + stance, ackedAt: now, reason: key === opts.dimension ? opts.reason : undefined, + tolerance: key === opts.dimension ? opts.tolerance : undefined, + divergedAt: stance === "diverging" ? now : undefined, }; } } @@ -99,24 +103,69 @@ export async function acknowledge(opts: { return { manifest, comparison }; } +export interface CheckBoundsOptions { + tolerance?: number; + maxDivergenceDays?: number; +} + /** * Check whether the current drift exceeds the acknowledged bounds. * Returns dimensions that have drifted beyond what was acked. + * + * Improvements over the original: + * - Per-dimension tolerance (ack.tolerance overrides global tolerance) + * - Diverging dimensions are re-evaluated: if they've reconverged significantly + * (current distance < 50% of acked distance), they're flagged as "reconverging" + * - Optional maxDivergenceDays: flags diverging dimensions that have been diverging + * longer than the specified number of days */ export function checkBounds( manifest: SyncManifest, current: FingerprintComparison, - tolerance: number = 0.05, -): { exceeded: boolean; dimensions: string[] } { + toleranceOrOptions?: number | CheckBoundsOptions, +): { exceeded: boolean; dimensions: string[]; reconverging: string[] } { + const opts: CheckBoundsOptions = + typeof toleranceOrOptions === "number" + ? { tolerance: toleranceOrOptions } + : toleranceOrOptions ?? {}; + + const globalTolerance = opts.tolerance ?? 0.05; + const maxDivergenceDays = opts.maxDivergenceDays ?? null; + const exceeded: string[] = []; + const reconverging: string[] = []; for (const [key, ack] of Object.entries(manifest.dimensions)) { - if (ack.stance === "diverging") continue; // intentionally diverging, no bound const currentDistance = current.dimensions[key]?.distance ?? 0; - if (currentDistance > ack.distance + tolerance) { + const effectiveTolerance = ack.tolerance ?? globalTolerance; + + if (ack.stance === "diverging") { + // Re-evaluate diverging dimensions instead of permanently skipping them + // If the dimension has converged back to less than 50% of acked distance, flag it + if (currentDistance < ack.distance * 0.5) { + reconverging.push(key); + } + // If maxDivergenceDays is set, check if divergence has gone on too long + if (maxDivergenceDays !== null && ack.divergedAt) { + const divergedDate = new Date(ack.divergedAt); + const daysSinceDiverged = Math.floor( + (Date.now() - divergedDate.getTime()) / (1000 * 60 * 60 * 24), + ); + if (daysSinceDiverged > maxDivergenceDays) { + exceeded.push(key); + } + } + continue; + } + + if (currentDistance > ack.distance + effectiveTolerance) { exceeded.push(key); } } - return { exceeded: exceeded.length > 0, dimensions: exceeded }; + return { + exceeded: exceeded.length > 0, + dimensions: exceeded, + reconverging, + }; } diff --git a/packages/ghost-core/src/evolution/temporal.ts b/packages/ghost-core/src/evolution/temporal.ts index 8f6ed19..5bbd99c 100644 --- a/packages/ghost-core/src/evolution/temporal.ts +++ b/packages/ghost-core/src/evolution/temporal.ts @@ -17,11 +17,13 @@ export function computeTemporalComparison(opts: { comparison: FingerprintComparison; history: FingerprintHistoryEntry[]; manifest: SyncManifest | null; + stabilityThreshold?: number; }): TemporalComparison { const { comparison, history, manifest } = opts; + const stabilityThreshold = opts.stabilityThreshold ?? 0.01; const vectors = computeDriftVectors(comparison.source, comparison.target); - const velocity = computeVelocity(comparison, history); + const velocity = computeVelocity(comparison, history, stabilityThreshold); const trajectory = classifyTrajectory(velocity); let daysSinceAck: number | null = null; @@ -57,6 +59,7 @@ export function computeTemporalComparison(opts: { function computeVelocity( current: FingerprintComparison, history: FingerprintHistoryEntry[], + stabilityThreshold: number = 0.01, ): DriftVelocity[] { if (history.length < 2) { // Not enough history to compute velocity — return stable for all dimensions @@ -89,7 +92,7 @@ function computeVelocity( const rate = Math.abs(delta) / windowDays; let direction: "converging" | "diverging" | "stable"; - if (Math.abs(delta) < 0.01) { + if (Math.abs(delta) < stabilityThreshold) { direction = "stable"; } else if (delta < 0) { direction = "converging"; diff --git a/packages/ghost-core/src/fingerprint/colors.ts b/packages/ghost-core/src/fingerprint/colors.ts index 3417a23..ada6b62 100644 --- a/packages/ghost-core/src/fingerprint/colors.ts +++ b/packages/ghost-core/src/fingerprint/colors.ts @@ -29,6 +29,101 @@ function parseRgbFunction(value: string): [number, number, number] | null { return [Number(match[1]), Number(match[2]), Number(match[3])]; } +// Parse hsl()/hsla() to RGB +function parseHslFunction(value: string): [number, number, number] | null { + const match = value.match( + /hsla?\(\s*([\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%?\s*[,\s]\s*([\d.]+)%?/, + ); + if (!match) return null; + + let h = Number(match[1]) % 360; + if (h < 0) h += 360; + const s = Math.min(Number(match[2]), 100) / 100; + const l = Math.min(Number(match[3]), 100) / 100; + + // HSL to RGB conversion + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + + let r1: number; + let g1: number; + let b1: number; + + if (h < 60) { + [r1, g1, b1] = [c, x, 0]; + } else if (h < 120) { + [r1, g1, b1] = [x, c, 0]; + } else if (h < 180) { + [r1, g1, b1] = [0, c, x]; + } else if (h < 240) { + [r1, g1, b1] = [0, x, c]; + } else if (h < 300) { + [r1, g1, b1] = [x, 0, c]; + } else { + [r1, g1, b1] = [c, 0, x]; + } + + return [ + Math.round((r1 + m) * 255), + Math.round((g1 + m) * 255), + Math.round((b1 + m) * 255), + ]; +} + +// Common CSS named colors (top 20 most used in real projects) +const CSS_NAMED_COLORS: Record = { + white: [255, 255, 255], + black: [0, 0, 0], + red: [255, 0, 0], + green: [0, 128, 0], + blue: [0, 0, 255], + yellow: [255, 255, 0], + orange: [255, 165, 0], + purple: [128, 0, 128], + pink: [255, 192, 203], + gray: [128, 128, 128], + grey: [128, 128, 128], + navy: [0, 0, 128], + teal: [0, 128, 128], + coral: [255, 127, 80], + salmon: [250, 128, 114], + tomato: [255, 99, 71], + gold: [255, 215, 0], + silver: [192, 192, 192], + maroon: [128, 0, 0], + aqua: [0, 255, 255], + cyan: [0, 255, 255], + lime: [0, 255, 0], + indigo: [75, 0, 130], + violet: [238, 130, 238], + crimson: [220, 20, 60], + magenta: [255, 0, 255], + turquoise: [64, 224, 208], + ivory: [255, 255, 240], + beige: [245, 245, 220], + khaki: [240, 230, 140], +}; + +// CSS system color defaults (mapped to sensible RGB values) +const SYSTEM_COLORS: Record = { + canvas: [255, 255, 255], + canvastext: [0, 0, 0], + linktext: [0, 0, 238], + visitedtext: [85, 26, 139], + activetext: [255, 0, 0], + buttonface: [240, 240, 240], + buttontext: [0, 0, 0], + buttonborder: [118, 118, 118], + field: [255, 255, 255], + fieldtext: [0, 0, 0], + highlight: [0, 120, 215], + highlighttext: [255, 255, 255], + graytext: [109, 109, 109], + mark: [255, 255, 0], + marktext: [0, 0, 0], +}; + // Convert sRGB to linear RGB function linearize(c: number): number { const s = c / 255; @@ -70,10 +165,49 @@ function rgbToOklch(r: number, g: number, b: number): [number, number, number] { ]; } +// Parse color-mix() in OKLCH space +function parseColorMix(value: string): [number, number, number] | null { + const match = value.match( + /color-mix\(\s*in\s+oklch\s*,\s*(.+?)\s+(\d+)%\s*,\s*(.+?)(?:\s+(\d+)%)?\s*\)/, + ); + if (!match) return null; + + const color1 = parseColorToOklch(match[1]); + const color2 = parseColorToOklch(match[3]); + if (!color1 || !color2) return null; + + const pct1 = Number(match[2]) / 100; + const pct2 = match[4] ? Number(match[4]) / 100 : 1 - pct1; + + // Normalize percentages + const total = pct1 + pct2; + const w1 = pct1 / total; + const w2 = pct2 / total; + + // Interpolate hue via shortest arc + let h1 = color1[2]; + let h2 = color2[2]; + let hDiff = h2 - h1; + if (hDiff > 180) hDiff -= 360; + if (hDiff < -180) hDiff += 360; + const hue = ((h1 + w2 * hDiff) % 360 + 360) % 360; + + return [ + Math.round((color1[0] * w1 + color2[0] * w2) * 1000) / 1000, + Math.round((color1[1] * w1 + color2[1] * w2) * 1000) / 1000, + Math.round(hue * 10) / 10, + ]; +} + export function parseColorToOklch( value: string, ): [number, number, number] | null { - const trimmed = value.trim(); + const trimmed = value.trim().toLowerCase(); + + // Skip CSS variables and transparent + if (trimmed.startsWith("var(") || trimmed === "transparent" || trimmed === "currentcolor") { + return null; + } // Try hex if (trimmed.startsWith("#")) { @@ -87,16 +221,35 @@ export function parseColorToOklch( if (rgb) return rgbToOklch(...rgb); } - // Try oklch() directly - const oklchMatch = trimmed.match(/oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)/); + // Try hsl()/hsla() + if (trimmed.startsWith("hsl")) { + const rgb = parseHslFunction(trimmed); + if (rgb) return rgbToOklch(...rgb); + } + + // Try oklch() directly — handle both decimal and percentage lightness + const oklchMatch = trimmed.match( + /oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)/, + ); if (oklchMatch) { - return [ - Number(oklchMatch[1]), - Number(oklchMatch[2]), - Number(oklchMatch[3]), - ]; + let L = Number(oklchMatch[1]); + if (oklchMatch[2] === "%") L /= 100; + return [L, Number(oklchMatch[3]), Number(oklchMatch[4])]; } + // Try color-mix(in oklch, ...) + if (trimmed.startsWith("color-mix(")) { + return parseColorMix(trimmed); + } + + // Try CSS system colors + const systemRgb = SYSTEM_COLORS[trimmed]; + if (systemRgb) return rgbToOklch(...systemRgb); + + // Try CSS named colors + const namedRgb = CSS_NAMED_COLORS[trimmed]; + if (namedRgb) return rgbToOklch(...namedRgb); + return null; } @@ -138,3 +291,25 @@ export function classifyContrast( if (range < 0.3) return "low"; return "moderate"; } + +/** + * Continuous saturation score (0-1) for embedding use. + * Avoids the lossy categorical→numeric mapping. + */ +export function saturationScore(colors: SemanticColor[]): number { + const chromas = colors.map((c) => c.oklch?.[1] ?? 0).filter((c) => c > 0); + if (chromas.length === 0) return 0; + const avg = chromas.reduce((a, b) => a + b, 0) / chromas.length; + return Math.min(avg / 0.25, 1); +} + +/** + * Continuous contrast score (0-1) for embedding use. + * Based on lightness range of the palette. + */ +export function contrastScore(colors: SemanticColor[]): number { + const lightnesses = colors.map((c) => c.oklch?.[0] ?? 0.5); + if (lightnesses.length < 2) return 0.5; + const range = Math.max(...lightnesses) - Math.min(...lightnesses); + return Math.min(range / 0.9, 1); +} diff --git a/packages/ghost-core/src/fingerprint/compare.ts b/packages/ghost-core/src/fingerprint/compare.ts index 99ed511..3a4bbcd 100644 --- a/packages/ghost-core/src/fingerprint/compare.ts +++ b/packages/ghost-core/src/fingerprint/compare.ts @@ -148,17 +148,8 @@ function compareTypography( ): DimensionDelta { const distances: number[] = []; - // Family match - const sharedFamilies = a.typography.families.filter((f) => - b.typography.families.includes(f), - ); - const allFamilies = new Set([ - ...a.typography.families, - ...b.typography.families, - ]); - distances.push( - allFamilies.size > 0 ? 1 - sharedFamilies.length / allFamilies.size : 0, - ); + // Family match — fuzzy comparison + distances.push(1 - fontListSimilarity(a.typography.families, b.typography.families)); // Size ramp similarity const aRamp = new Set(a.typography.sizeRamp); @@ -275,6 +266,135 @@ function compareArchitecture( }; } +// --- Font matching --- + +const FONT_SUFFIXES = /\s*\b(variable|var|vf|pro|new|next|display|text|mono)\b/gi; + +/** Normalize font family name for fuzzy comparison */ +function normalizeFontFamily(name: string): string { + return name + .replace(/['"]/g, "") + .replace(FONT_SUFFIXES, "") + .trim() + .toLowerCase(); +} + +/** Levenshtein distance between two strings */ +function levenshtein(a: string, b: string): number { + const m = a.length; + const n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => + new Array(n + 1).fill(0), + ); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[m][n]; +} + +// Font category lookup for common fonts +const FONT_CATEGORIES: Record = { + // Sans-serif + inter: "sans-serif", arial: "sans-serif", helvetica: "sans-serif", + roboto: "sans-serif", "open sans": "sans-serif", lato: "sans-serif", + nunito: "sans-serif", poppins: "sans-serif", montserrat: "sans-serif", + raleway: "sans-serif", ubuntu: "sans-serif", manrope: "sans-serif", + geist: "sans-serif", "dm sans": "sans-serif", "plus jakarta sans": "sans-serif", + "source sans": "sans-serif", "work sans": "sans-serif", + "hk grotesk": "sans-serif", "cash sans": "sans-serif", + "sf pro": "sans-serif", "system-ui": "sans-serif", "sans-serif": "sans-serif", + // Serif + georgia: "serif", "times new roman": "serif", garamond: "serif", + "playfair display": "serif", merriweather: "serif", lora: "serif", + "source serif": "serif", "dm serif": "serif", serif: "serif", + // Monospace + "jetbrains mono": "monospace", "fira code": "monospace", "source code": "monospace", + "geist mono": "monospace", "dm mono": "monospace", "ibm plex mono": "monospace", + "sf mono": "monospace", menlo: "monospace", consolas: "monospace", + monaco: "monospace", "courier new": "monospace", monospace: "monospace", + // Display + "playfair": "display", "bebas neue": "display", +}; + +function getFontCategory(normalizedName: string): string | null { + // Exact match + if (FONT_CATEGORIES[normalizedName]) return FONT_CATEGORIES[normalizedName]; + // Partial match: check if any known font is a prefix + for (const [font, cat] of Object.entries(FONT_CATEGORIES)) { + if (normalizedName.startsWith(font) || font.startsWith(normalizedName)) { + return cat; + } + } + return null; +} + +/** + * Compute similarity between two font names (0 = no match, 1 = identical). + * Uses normalization, Levenshtein distance, and category fallback. + */ +function fontSimilarity(a: string, b: string): number { + const normA = normalizeFontFamily(a); + const normB = normalizeFontFamily(b); + + // Exact match after normalization + if (normA === normB) return 1.0; + + // Levenshtein-based similarity + const maxLen = Math.max(normA.length, normB.length); + if (maxLen === 0) return 1.0; + const dist = levenshtein(normA, normB); + const similarity = 1 - dist / maxLen; + + // If names are very similar (>= 0.7), use that score + if (similarity >= 0.7) return similarity; + + // Category fallback: same category = 0.3 floor + const catA = getFontCategory(normA); + const catB = getFontCategory(normB); + if (catA && catB && catA === catB) return Math.max(similarity, 0.3); + + return similarity; +} + +/** + * Compute font list similarity using best-match pairing. + * Each font in list A is matched to its best counterpart in list B. + */ +function fontListSimilarity(aFonts: string[], bFonts: string[]): number { + if (aFonts.length === 0 && bFonts.length === 0) return 1; + if (aFonts.length === 0 || bFonts.length === 0) return 0; + + // For each font in A, find best match in B + let totalSim = 0; + for (const fa of aFonts) { + let bestSim = 0; + for (const fb of bFonts) { + bestSim = Math.max(bestSim, fontSimilarity(fa, fb)); + } + totalSim += bestSim; + } + // Symmetric: also match B→A and average + let totalSimReverse = 0; + for (const fb of bFonts) { + let bestSim = 0; + for (const fa of aFonts) { + bestSim = Math.max(bestSim, fontSimilarity(fa, fb)); + } + totalSimReverse += bestSim; + } + + const avgA = totalSim / aFonts.length; + const avgB = totalSimReverse / bFonts.length; + return (avgA + avgB) / 2; +} + // --- Helpers --- function oklchDistance( diff --git a/packages/ghost-core/src/fingerprint/embedding.ts b/packages/ghost-core/src/fingerprint/embedding.ts index 8916344..a1c8df2 100644 --- a/packages/ghost-core/src/fingerprint/embedding.ts +++ b/packages/ghost-core/src/fingerprint/embedding.ts @@ -1,10 +1,46 @@ import type { DesignFingerprint } from "../types.js"; +import { contrastScore, saturationScore } from "./colors.js"; type FingerprintInput = Omit; // Fixed embedding size for comparability const EMBEDDING_SIZE = 64; +// Normalization constants — centralized for discoverability and tuning +const NORM = { + // Log-base for count normalization (count → log2(count+1) / log2(base)) + spacingCountLogBase: 32, + componentCountLogBase: 200, + // Linear divisors + spacingValueMax: 100, + spacingSpreadMax: 50, + baseUnitMax: 32, + radiusMinMax: 64, + radiusMaxPill: 100, + radiusSpread: 64, + radiusMedian: 64, + sizeRampMax: 100, + familyCountMax: 5, + sizeRampCountMax: 10, + weightCountMax: 6, + sizeRangeRatioMax: 10, + radiiCountMax: 5, + stepRatioMax: 4, + spacingRangeRatioMax: 50, + catCountMax: 10, + componentDensityMax: 10, + methodologyCountMax: 4, + semanticCountMax: 10, + neutralCountMax: 10, + neutralDensityMax: 20, + borderTokenCountMax: 10, +} as const; + +/** Logarithmic normalization: preserves ordering, avoids ceiling effects */ +function logNorm(count: number, logBase: number): number { + return Math.min(Math.log2(count + 1) / Math.log2(logBase), 1); +} + /** * Compute a deterministic numeric embedding from a structured fingerprint. * This ensures fingerprints from different sources (LLM, registry, extraction) @@ -38,9 +74,9 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { // --- Palette: neutral ramp (6 dims) --- const neutralCount = fingerprint.palette.neutrals.count; - vec[i++] = Math.min(neutralCount / 10, 1); // normalized count - vec[i++] = neutralCount > 0 ? 1 : 0; // has neutrals - vec[i++] = Math.min(neutralCount / 20, 1); // ramp density + vec[i++] = Math.min(neutralCount / NORM.neutralCountMax, 1); + vec[i++] = neutralCount > 0 ? 1 : 0; + vec[i++] = Math.min(neutralCount / NORM.neutralDensityMax, 1); // Estimate lightness range from neutral steps using semantic colors as proxy const neutralLightnesses = fingerprint.palette.semantic @@ -53,42 +89,36 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { ) .map((c) => c.oklch![0]); if (neutralLightnesses.length >= 2) { - vec[i++] = Math.min(...neutralLightnesses); // min lightness - vec[i++] = Math.max(...neutralLightnesses); // max lightness - vec[i++] = Math.max(...neutralLightnesses) - Math.min(...neutralLightnesses); // lightness range + vec[i++] = Math.min(...neutralLightnesses); + vec[i++] = Math.max(...neutralLightnesses); + vec[i++] = Math.max(...neutralLightnesses) - Math.min(...neutralLightnesses); } else { i += 3; } - // --- Palette: qualitative (3 dims) --- - vec[i++] = - fingerprint.palette.saturationProfile === "vibrant" - ? 1 - : fingerprint.palette.saturationProfile === "mixed" - ? 0.5 - : 0; - vec[i++] = - fingerprint.palette.contrast === "high" - ? 1 - : fingerprint.palette.contrast === "moderate" - ? 0.5 - : 0; - vec[i++] = Math.min(fingerprint.palette.semantic.length / 10, 1); + // --- Palette: qualitative (3 dims) — continuous scoring --- + const allSemanticAndDominant = [ + ...fingerprint.palette.semantic, + ...fingerprint.palette.dominant, + ]; + vec[i++] = saturationScore(allSemanticAndDominant); + vec[i++] = contrastScore(allSemanticAndDominant); + vec[i++] = Math.min(fingerprint.palette.semantic.length / NORM.semanticCountMax, 1); // --- Spacing (10 dims) --- const spacing = fingerprint.spacing; - vec[i++] = Math.min(spacing.scale.length / 10, 1); - vec[i++] = spacing.scale.length > 0 ? Math.min(spacing.scale[0] / 100, 1) : 0; + vec[i++] = logNorm(spacing.scale.length, NORM.spacingCountLogBase); + vec[i++] = spacing.scale.length > 0 ? Math.min(spacing.scale[0] / NORM.spacingValueMax, 1) : 0; vec[i++] = spacing.scale.length > 0 - ? Math.min(spacing.scale[spacing.scale.length - 1] / 100, 1) + ? Math.min(spacing.scale[spacing.scale.length - 1] / NORM.spacingValueMax, 1) : 0; vec[i++] = spacing.regularity; - vec[i++] = spacing.baseUnit ? Math.min(spacing.baseUnit / 16, 1) : 0; + vec[i++] = spacing.baseUnit ? Math.min(spacing.baseUnit / NORM.baseUnitMax, 1) : 0; // Median value const spacingMid = spacing.scale.length > 0 - ? spacing.scale[Math.floor(spacing.scale.length / 2)] / 100 + ? spacing.scale[Math.floor(spacing.scale.length / 2)] / NORM.spacingValueMax : 0; vec[i++] = Math.min(spacingMid, 1); // Spread (stddev-like): how varied is the scale? @@ -98,7 +128,7 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { const variance = spacing.scale.reduce((sum, v) => sum + (v - mean) ** 2, 0) / spacing.scale.length; - vec[i++] = Math.min(Math.sqrt(variance) / 50, 1); // normalized spread + vec[i++] = Math.min(Math.sqrt(variance) / NORM.spacingSpreadMax, 1); } else { vec[i++] = 0; } @@ -114,7 +144,7 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { ratios.length > 0 ? ratios.reduce((a, b) => a + b, 0) / ratios.length : 1; - vec[i++] = Math.min(avgRatio / 4, 1); // 2.0 = geometric doubling + vec[i++] = Math.min(avgRatio / NORM.stepRatioMax, 1); } else { vec[i++] = 0; } @@ -129,7 +159,7 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { // Range ratio: max/min if (spacing.scale.length >= 2 && spacing.scale[0] > 0) { vec[i++] = Math.min( - spacing.scale[spacing.scale.length - 1] / spacing.scale[0] / 50, + spacing.scale[spacing.scale.length - 1] / spacing.scale[0] / NORM.spacingRangeRatioMax, 1, ); } else { @@ -138,13 +168,13 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { // --- Typography (10 dims) --- const typo = fingerprint.typography; - vec[i++] = Math.min(typo.families.length / 5, 1); - vec[i++] = Math.min(typo.sizeRamp.length / 10, 1); + vec[i++] = Math.min(typo.families.length / NORM.familyCountMax, 1); + vec[i++] = Math.min(typo.sizeRamp.length / NORM.sizeRampCountMax, 1); // Size range - vec[i++] = typo.sizeRamp.length > 0 ? Math.min(typo.sizeRamp[0] / 100, 1) : 0; + vec[i++] = typo.sizeRamp.length > 0 ? Math.min(typo.sizeRamp[0] / NORM.sizeRampMax, 1) : 0; vec[i++] = typo.sizeRamp.length > 0 - ? Math.min(typo.sizeRamp[typo.sizeRamp.length - 1] / 100, 1) + ? Math.min(typo.sizeRamp[typo.sizeRamp.length - 1] / NORM.sizeRampMax, 1) : 0; // Weight distribution entropy const weights = Object.values(typo.weightDistribution); @@ -164,7 +194,7 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { ? 0.5 : 1; // Weight count: how many distinct weights are used - vec[i++] = Math.min(Object.keys(typo.weightDistribution).length / 6, 1); + vec[i++] = Math.min(Object.keys(typo.weightDistribution).length / NORM.weightCountMax, 1); // Weight spread: range of weights used (100-900 scale) const weightKeys = Object.keys(typo.weightDistribution).map(Number); if (weightKeys.length >= 2) { @@ -175,7 +205,7 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { // Size ramp range ratio if (typo.sizeRamp.length >= 2 && typo.sizeRamp[0] > 0) { vec[i++] = Math.min( - typo.sizeRamp[typo.sizeRamp.length - 1] / typo.sizeRamp[0] / 10, + typo.sizeRamp[typo.sizeRamp.length - 1] / typo.sizeRamp[0] / NORM.sizeRangeRatioMax, 1, ); } else { @@ -184,7 +214,7 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { // Size ramp median if (typo.sizeRamp.length > 0) { vec[i++] = Math.min( - typo.sizeRamp[Math.floor(typo.sizeRamp.length / 2)] / 100, + typo.sizeRamp[Math.floor(typo.sizeRamp.length / 2)] / NORM.sizeRampMax, 1, ); } else { @@ -193,14 +223,14 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { // --- Surfaces (8 dims) --- const surfaces = fingerprint.surfaces; - vec[i++] = Math.min(surfaces.borderRadii.length / 5, 1); + vec[i++] = Math.min(surfaces.borderRadii.length / NORM.radiiCountMax, 1); vec[i++] = surfaces.borderRadii.length > 0 - ? Math.min(surfaces.borderRadii[0] / 32, 1) + ? Math.min(surfaces.borderRadii[0] / NORM.radiusMinMax, 1) : 0; vec[i++] = surfaces.borderRadii.length > 0 - ? Math.min(surfaces.borderRadii[surfaces.borderRadii.length - 1] / 32, 1) + ? Math.min(surfaces.borderRadii[surfaces.borderRadii.length - 1] / NORM.radiusMinMax, 1) : 0; vec[i++] = surfaces.shadowComplexity === "layered" @@ -208,18 +238,23 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { : surfaces.shadowComplexity === "subtle" ? 0.5 : 0; - vec[i++] = - surfaces.borderUsage === "heavy" - ? 1 - : surfaces.borderUsage === "moderate" - ? 0.5 - : 0; + // Border usage — use continuous score if borderTokenCount available, else categorical fallback + if (surfaces.borderTokenCount !== undefined) { + vec[i++] = Math.min(surfaces.borderTokenCount / NORM.borderTokenCountMax, 1); + } else { + vec[i++] = + surfaces.borderUsage === "heavy" + ? 1 + : surfaces.borderUsage === "moderate" + ? 0.5 + : 0; + } // Radii spread: range of border radii if (surfaces.borderRadii.length >= 2) { vec[i++] = Math.min( (surfaces.borderRadii[surfaces.borderRadii.length - 1] - surfaces.borderRadii[0]) / - 32, + NORM.radiusSpread, 1, ); } else { @@ -228,7 +263,7 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { // Radii median if (surfaces.borderRadii.length > 0) { vec[i++] = Math.min( - surfaces.borderRadii[Math.floor(surfaces.borderRadii.length / 2)] / 32, + surfaces.borderRadii[Math.floor(surfaces.borderRadii.length / 2)] / NORM.radiusMedian, 1, ); } else { @@ -237,7 +272,7 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { // Max radius (signals "pill" shapes — high max radius is distinctive) if (surfaces.borderRadii.length > 0) { vec[i++] = Math.min( - surfaces.borderRadii[surfaces.borderRadii.length - 1] / 100, + surfaces.borderRadii[surfaces.borderRadii.length - 1] / NORM.radiusMaxPill, 1, ); } else { @@ -247,7 +282,7 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { // --- Architecture (15 dims) --- const arch = fingerprint.architecture; vec[i++] = arch.tokenization; - vec[i++] = Math.min(arch.componentCount / 50, 1); + vec[i++] = logNorm(arch.componentCount, NORM.componentCountLogBase); // Methodology encoding vec[i++] = arch.methodology.includes("tailwind") ? 1 : 0; vec[i++] = arch.methodology.includes("css-custom-properties") ? 1 : 0; @@ -255,7 +290,7 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { vec[i++] = arch.methodology.includes("css-modules") ? 1 : 0; // Category diversity const catCount = Object.keys(arch.componentCategories).length; - vec[i++] = Math.min(catCount / 10, 1); + vec[i++] = Math.min(catCount / NORM.catCountMax, 1); // Naming pattern vec[i++] = arch.namingPattern === "kebab-case" @@ -278,9 +313,9 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { vec[i++] = 0; } // Component density: components per category - vec[i++] = catCount > 0 ? Math.min(arch.componentCount / catCount / 10, 1) : 0; + vec[i++] = catCount > 0 ? Math.min(arch.componentCount / catCount / NORM.componentDensityMax, 1) : 0; // Methodology count - vec[i++] = Math.min(arch.methodology.length / 4, 1); + vec[i++] = Math.min(arch.methodology.length / NORM.methodologyCountMax, 1); // Has styled-components / emotion vec[i++] = arch.methodology.includes("styled-components") || arch.methodology.includes("emotion") ? 1 : 0; // Has CSS-in-JS (any) @@ -297,8 +332,8 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { : arch.componentCount < 30 ? 0.5 : 1; - // Tokenization squared (amplifies differences at extremes) - vec[i++] = arch.tokenization * arch.tokenization; + // Tokenization — sqrt to amplify low-end differences where it matters + vec[i++] = Math.sqrt(arch.tokenization); return vec; } diff --git a/packages/ghost-core/src/fingerprint/from-registry.ts b/packages/ghost-core/src/fingerprint/from-registry.ts index d76733e..cbfc956 100644 --- a/packages/ghost-core/src/fingerprint/from-registry.ts +++ b/packages/ghost-core/src/fingerprint/from-registry.ts @@ -11,88 +11,40 @@ import { colorToSemanticColor, } from "./colors.js"; import { computeEmbedding } from "./embedding.js"; - -// Exact token name → semantic role mapping -const SEMANTIC_ROLES: Record = { - // Surface/background tokens - "--background-default": "surface", - "--background-alt": "surface-alt", - "--background-accent": "accent", - "--background": "surface", - "--bg": "surface", - "--bg-accent": "accent", - // shadcn conventions - "--primary": "primary", - "--primary-foreground": "primary-foreground", - "--secondary": "secondary", - "--secondary-foreground": "secondary-foreground", - "--accent": "accent", - "--accent-foreground": "accent-foreground", - "--muted": "muted", - "--muted-foreground": "muted-foreground", - "--destructive": "destructive", - "--destructive-foreground": "destructive-foreground", - "--card": "surface", - "--card-foreground": "text", - "--popover": "surface-alt", - "--popover-foreground": "text", - "--foreground": "text", - "--input": "border", - "--ring": "ring", - // Text tokens - "--text-default": "text", - "--text-muted": "text-muted", - "--text-inverse": "text-inverse", - "--text-danger": "danger", - // Border tokens - "--border-default": "border", - "--border-strong": "border-strong", - "--border": "border", - // Brand tokens - "--brand": "primary", - "--brand-primary": "primary", - "--brand-secondary": "secondary", -}; - -// Prefix-based fallback: tokens starting with these prefixes get a role -// derived from the prefix + suffix (e.g., "--color-success" → "success") -const SEMANTIC_ROLE_PREFIXES: [string, (name: string) => string][] = [ - ["--background-", (n) => `surface-${n.replace("--background-", "")}`], - ["--text-", (n) => `text-${n.replace("--text-", "")}`], - ["--border-", (n) => `border-${n.replace("--border-", "")}`], - ["--color-", (n) => n.replace("--color-", "")], -]; +import { inferSemanticRole } from "./semantic-roles.js"; function resolveTokenValue(token: CSSToken): string { return token.resolvedValue ?? token.value; } -function resolveSemanticRole(tokenName: string): string | null { - // Exact match first - const exact = SEMANTIC_ROLES[tokenName]; - if (exact) return exact; - - // Prefix-based fallback - for (const [prefix, derive] of SEMANTIC_ROLE_PREFIXES) { - if (tokenName.startsWith(prefix)) return derive(tokenName); - } - - return null; +function resolveSemanticRole(tokenName: string, tokenValue?: string): string | null { + const candidate = inferSemanticRole(tokenName, tokenValue); + return candidate ? candidate.role : null; } function extractSemanticColors(tokens: CSSToken[]): SemanticColor[] { const colors: SemanticColor[] = []; const seen = new Set(); for (const token of tokens) { - const role = resolveSemanticRole(token.name); + const value = resolveTokenValue(token); + const role = resolveSemanticRole(token.name, value); if (role && !seen.has(role)) { seen.add(role); - colors.push(colorToSemanticColor(role, resolveTokenValue(token))); + colors.push(colorToSemanticColor(role, value)); } } return colors; } +/** + * Sigmoid weight for chroma-based dominant color detection. + * Produces a soft boundary around 0.04 instead of a hard cutoff. + * At 0.03 → 0.27, at 0.04 → 0.5, at 0.05 → 0.73 + */ +function chromaWeight(chroma: number): number { + return 1 / (1 + Math.exp(-50 * (chroma - 0.04))); +} + function extractDominantColors(tokens: CSSToken[]): SemanticColor[] { // Dominant = brand, accent, primary, destructive — not neutral/text/border/surface roles const dominantRoles = [ @@ -105,14 +57,21 @@ function extractDominantColors(tokens: CSSToken[]): SemanticColor[] { ]; const neutralPrefixes = ["surface", "text", "border", "muted"]; const all = extractSemanticColors(tokens); - const dominant = all.filter( - (c) => - dominantRoles.includes(c.role) || - (!neutralPrefixes.some((p) => c.role.startsWith(p)) && - c.oklch && - c.oklch[1] > 0.04), // has meaningful chroma - ); - // If no explicit dominant, use first non-neutral color + + // Score each color: explicit dominant role = 1.0, otherwise use chroma weight + const scored = all + .map((c) => { + if (dominantRoles.includes(c.role)) return { color: c, score: 1 }; + if (neutralPrefixes.some((p) => c.role.startsWith(p))) return { color: c, score: 0 }; + const weight = c.oklch ? chromaWeight(c.oklch[1]) : 0; + return { color: c, score: weight }; + }) + .filter((s) => s.score > 0.3) // soft threshold: include colors with >= 30% confidence + .sort((a, b) => b.score - a.score); + + const dominant = scored.map((s) => s.color); + + // If no dominant, use first non-neutral color if (dominant.length === 0 && all.length > 0) { return [all[0]]; } @@ -133,6 +92,63 @@ function extractNeutralRamp(tokens: CSSToken[]): ColorRamp { return { steps: grayTokens, count: grayTokens.length }; } +/** + * Compute coefficient of variation for an array of numbers. + */ +function coefficientOfVariation(values: number[]): number { + if (values.length < 2) return 0; + const mean = values.reduce((a, b) => a + b, 0) / values.length; + if (mean === 0) return 0; + const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length; + return Math.sqrt(variance) / mean; +} + +/** + * Score how regular a spacing scale is. + * Recognizes linear, geometric, Fibonacci, and golden ratio progressions. + */ +function scoreSpacingRegularity(scale: number[]): number { + if (scale.length < 3) return scale.length === 2 ? 0.8 : 0; + + const diffs = scale.slice(1).map((v, i) => v - scale[i]); + const ratios = scale.slice(1).map((v, i) => (scale[i] > 0 ? v / scale[i] : 0)).filter((r) => r > 0); + + if (ratios.length === 0) return 0; + + const diffCV = coefficientOfVariation(diffs); + const ratioCV = coefficientOfVariation(ratios); + + // Linear: constant difference (CV of diffs < 0.15) + if (diffCV < 0.15) return 1.0; + + // Geometric: constant ratio (CV of ratios < 0.15) + if (ratioCV < 0.15) return 1.0; + + // Fibonacci-like: each value ≈ sum of two previous (within 10%) + if (scale.length >= 3) { + const fibScores: boolean[] = []; + for (let i = 2; i < scale.length; i++) { + const expected = scale[i - 1] + scale[i - 2]; + fibScores.push(Math.abs(scale[i] - expected) / expected < 0.1); + } + if (fibScores.every(Boolean)) return 1.0; + if (fibScores.filter(Boolean).length / fibScores.length > 0.7) return 0.8; + } + + // Golden ratio: ratios ≈ 1.618 (within 15%) + const goldenRatio = 1.618; + const goldenScores = ratios.map((r) => Math.abs(r - goldenRatio) / goldenRatio < 0.15); + if (goldenScores.every(Boolean)) return 1.0; + if (goldenScores.filter(Boolean).length / goldenScores.length > 0.7) return 0.8; + + // Near-linear or near-geometric + if (diffCV < 0.3) return 0.8; + if (ratioCV < 0.3) return 0.8; + + // Partial regularity based on ratio consistency + return Math.max(0.3, Math.min(0.7, 1 - ratioCV)); +} + function extractSpacing(tokens: CSSToken[]): { scale: number[]; regularity: number; @@ -157,18 +173,7 @@ function extractSpacing(tokens: CSSToken[]): { if (minDiff > 0) baseUnit = minDiff; } - // Regularity: how well does the scale follow a consistent step pattern? - let regularity = 0; - if (baseUnit && unique.length >= 2) { - const expectedSteps = unique.map((v) => Math.round(v / baseUnit)); - const isRegular = expectedSteps.every( - (s, i) => - i === 0 || - s === expectedSteps[i - 1] + 1 || - s === expectedSteps[i - 1] * 2, - ); - regularity = isRegular ? 1 : 0.5; - } + const regularity = scoreSpacingRegularity(unique); return { scale: unique, regularity, baseUnit }; } @@ -286,6 +291,7 @@ export function fingerprintFromRegistry( const typography = extractTypography(tokens); const spacing = extractSpacing(tokens); + const borderTokenCount = tokens.filter((t) => t.category === "border").length; const fingerprint: Omit = { id: registry.name, @@ -313,11 +319,12 @@ export function fingerprintFromRegistry( borderRadii: extractBorderRadii(tokens), shadowComplexity: classifyShadowComplexity(tokens), borderUsage: - tokens.filter((t) => t.category === "border").length > 3 + borderTokenCount > 3 ? "heavy" - : tokens.filter((t) => t.category === "border").length > 0 + : borderTokenCount > 0 ? "moderate" : "minimal", + borderTokenCount, }, architecture: { diff --git a/packages/ghost-core/src/fingerprint/index.ts b/packages/ghost-core/src/fingerprint/index.ts index b737d27..4bd65d7 100644 --- a/packages/ghost-core/src/fingerprint/index.ts +++ b/packages/ghost-core/src/fingerprint/index.ts @@ -4,3 +4,5 @@ export { describeFingerprint } from "./describe.js"; export { computeSemanticEmbedding } from "./embed-api.js"; export { computeEmbedding, embeddingDistance } from "./embedding.js"; export { fingerprintFromRegistry } from "./from-registry.js"; +export { inferSemanticRole } from "./semantic-roles.js"; +export type { RoleCandidate } from "./semantic-roles.js"; diff --git a/packages/ghost-core/src/fingerprint/semantic-roles.ts b/packages/ghost-core/src/fingerprint/semantic-roles.ts new file mode 100644 index 0000000..2a66765 --- /dev/null +++ b/packages/ghost-core/src/fingerprint/semantic-roles.ts @@ -0,0 +1,148 @@ +import { parseColorToOklch } from "./colors.js"; + +export interface RoleCandidate { + role: string; + confidence: number; // 0-1 +} + +// Exact token name → semantic role mapping (shadcn + common conventions) +const EXACT_ROLES: Record = { + // Surface/background tokens + "--background-default": "surface", + "--background-alt": "surface-alt", + "--background-accent": "accent", + "--background": "surface", + "--bg": "surface", + "--bg-accent": "accent", + // shadcn conventions + "--primary": "primary", + "--primary-foreground": "primary-foreground", + "--secondary": "secondary", + "--secondary-foreground": "secondary-foreground", + "--accent": "accent", + "--accent-foreground": "accent-foreground", + "--muted": "muted", + "--muted-foreground": "muted-foreground", + "--destructive": "destructive", + "--destructive-foreground": "destructive-foreground", + "--card": "surface", + "--card-foreground": "text", + "--popover": "surface-alt", + "--popover-foreground": "text", + "--foreground": "text", + "--input": "border", + "--ring": "ring", + // Text tokens + "--text-default": "text", + "--text-muted": "text-muted", + "--text-inverse": "text-inverse", + "--text-danger": "danger", + // Border tokens + "--border-default": "border", + "--border-strong": "border-strong", + "--border": "border", + // Brand tokens + "--brand": "primary", + "--brand-primary": "primary", + "--brand-secondary": "secondary", +}; + +// Pattern-based rules: regex → role derivation +const PATTERN_RULES: [RegExp, (match: RegExpMatchArray, name: string) => string][] = [ + // Primary/brand + [/--(?:color-)?primary(?:-|$)/, () => "primary"], + [/--(?:color-)?brand(?:-|$)/, () => "primary"], + // Surface/background + [/--(?:bg|background|surface)(?:-(.+))?$/, (m) => m[1] ? `surface-${m[1]}` : "surface"], + // Text/foreground + [/--(?:text|fg|foreground)(?:-(.+))?$/, (m) => m[1] ? `text-${m[1]}` : "text"], + // Border/stroke + [/--(?:border|stroke|outline)(?:-(.+))?$/, (m) => m[1] ? `border-${m[1]}` : "border"], + // Semantic states + [/--(?:color-)?(?:error|danger|destructive)/, () => "destructive"], + [/--(?:color-)?(?:warning|caution|alert)/, () => "warning"], + [/--(?:color-)?(?:success|positive|valid)/, () => "success"], + [/--(?:color-)?(?:info|notice|informative)/, () => "info"], + // Accent/highlight + [/--(?:color-)?(?:accent|highlight)/, () => "accent"], + // Muted/subtle + [/--(?:color-)?(?:muted|subtle|disabled)/, () => "muted"], + // Secondary + [/--(?:color-)?secondary/, () => "secondary"], + // Ring/focus + [/--(?:color-)?(?:ring|focus|outline)/, () => "ring"], + // Generic color- prefix: use the suffix as role + [/--color-(.+)/, (m) => m[1]], + // MUI-style: --mui-palette-- + [/--mui-palette-(\w+)-/, (m) => m[1]], + // Chakra-style: --chakra-colors-- + [/--chakra-colors-(\w+)-/, (m) => m[1]], +]; + +// Semantic keywords that appear in token names +const SEMANTIC_KEYWORDS: Record = { + primary: "primary", + secondary: "secondary", + accent: "accent", + brand: "primary", + destructive: "destructive", + danger: "destructive", + error: "destructive", + warning: "warning", + caution: "warning", + success: "success", + positive: "success", + info: "info", + muted: "muted", + subtle: "muted", + disabled: "muted", + background: "surface", + surface: "surface", + foreground: "text", + text: "text", + border: "border", + ring: "ring", + focus: "ring", +}; + +/** + * Infer the semantic role of a design token from its name and value. + * Uses a layered approach: exact match → pattern match → keyword extraction → value heuristic. + */ +export function inferSemanticRole( + tokenName: string, + tokenValue?: string, +): RoleCandidate | null { + // Layer 1: Exact match (confidence 1.0) + const exact = EXACT_ROLES[tokenName]; + if (exact) return { role: exact, confidence: 1.0 }; + + // Layer 2: Pattern match (confidence 0.9) + for (const [pattern, derive] of PATTERN_RULES) { + const match = tokenName.match(pattern); + if (match) return { role: derive(match, tokenName), confidence: 0.9 }; + } + + // Layer 3: Keyword extraction (confidence 0.7) + const parts = tokenName.replace(/^--/, "").split(/[-_]/); + for (const part of parts) { + const role = SEMANTIC_KEYWORDS[part.toLowerCase()]; + if (role) return { role, confidence: 0.7 }; + } + + // Layer 4: Value-based heuristic (confidence 0.6) + if (tokenValue) { + const oklch = parseColorToOklch(tokenValue); + if (oklch) { + const [L, C] = oklch; + // Near-white → likely surface + if (L > 0.9 && C < 0.02) return { role: "surface", confidence: 0.6 }; + // Near-black → likely text + if (L < 0.15 && C < 0.02) return { role: "text", confidence: 0.6 }; + // High chroma → likely a dominant/brand color + if (C > 0.15) return { role: "dominant", confidence: 0.6 }; + } + } + + return null; +} diff --git a/packages/ghost-core/src/index.ts b/packages/ghost-core/src/index.ts index 9124c77..3bd8a48 100644 --- a/packages/ghost-core/src/index.ts +++ b/packages/ghost-core/src/index.ts @@ -17,6 +17,8 @@ export { resolveParent, writeSyncManifest, } from "./evolution/index.js"; +export type { CheckBoundsOptions } from "./evolution/index.js"; +export type { FleetClusterOptions } from "./evolution/index.js"; export { detectExtractors, extract } from "./extractors/index.js"; export type { CompareOptions } from "./fingerprint/compare.js"; export { @@ -26,10 +28,21 @@ export { describeFingerprint, embeddingDistance, fingerprintFromRegistry, + inferSemanticRole, } from "./fingerprint/index.js"; -export { createProvider } from "./llm/index.js"; -export type { ProfileOptions } from "./profile.js"; -export { profile, profileRegistry } from "./profile.js"; +export type { RoleCandidate } from "./fingerprint/index.js"; +export { + analyzeStructure, + createProvider, + validateFingerprint, +} from "./llm/index.js"; +export type { + FingerprintValidation, + StructuralAnalysis, + ValidationIssue, +} from "./llm/index.js"; +export type { ProfileOptions, ProfileResult } from "./profile.js"; +export { profile, profileRegistry, profileWithAnalysis } from "./profile.js"; export { formatReport as formatCLIReport } from "./reporters/cli.js"; export { formatDiffCLI, formatDiffJSON } from "./reporters/diff.js"; export { @@ -49,6 +62,7 @@ export { } from "./reporters/temporal.js"; export { parseCSS } from "./resolvers/css.js"; export { resolveRegistry } from "./resolvers/registry.js"; +export { detectTailwind, resolveTailwindConfig } from "./resolvers/tailwind.js"; export { scan } from "./scan.js"; export { scanVisual } from "./scanners/visual.js"; export type { diff --git a/packages/ghost-core/src/llm/analyze-structure.ts b/packages/ghost-core/src/llm/analyze-structure.ts new file mode 100644 index 0000000..d480a35 --- /dev/null +++ b/packages/ghost-core/src/llm/analyze-structure.ts @@ -0,0 +1,140 @@ +import type { + DesignFingerprint, + ExtractedMaterial, + LLMConfig, +} from "../types.js"; +import { createProvider } from "./index.js"; + +export interface StructuralAnalysis { + gridSystem: string | null; + compositionPatterns: string[]; + visualHierarchy: string[]; + implicitConventions: string[]; + confidence: number; +} + +const ANALYSIS_PROMPT = `You are a design system analyst. Given the following component files and design fingerprint, identify patterns that are NOT captured by token analysis alone. + +Focus on: +1. **Grid system**: Is there a consistent grid (8px, 4px, etc.) even if not explicitly tokenized? +2. **Composition patterns**: Compound components, render props, slot patterns, provider patterns +3. **Visual hierarchy**: How does the system establish hierarchy? (size scale, weight contrast, color usage) +4. **Implicit conventions**: Patterns that are consistent but not encoded as tokens + +Respond in JSON format: +{ + "gridSystem": "8px" | "4px" | null, + "compositionPatterns": ["compound-components", "render-props", ...], + "visualHierarchy": ["size-scale", "weight-contrast", "color-intensity", ...], + "implicitConventions": ["consistent-padding-in-cards", "always-rounded-buttons", ...], + "confidence": 0.0-1.0 +}`; + +/** + * Use an LLM to analyze structural patterns in component files + * that deterministic fingerprinting can't capture. + * Returns null if LLM is not configured. + */ +export async function analyzeStructure( + material: ExtractedMaterial, + fingerprint: DesignFingerprint, + llmConfig: LLMConfig, +): Promise { + try { + const provider = createProvider(llmConfig); + + // Build a focused prompt with limited context + const componentSample = material.componentFiles + .slice(0, 5) + .map((f) => `--- ${f.path} ---\n${f.content.slice(0, 2000)}`) + .join("\n\n"); + + const fingerprintSummary = JSON.stringify( + { + spacing: fingerprint.spacing, + typography: { + families: fingerprint.typography.families, + lineHeightPattern: fingerprint.typography.lineHeightPattern, + }, + surfaces: fingerprint.surfaces, + architecture: { + methodology: fingerprint.architecture.methodology, + componentCount: fingerprint.architecture.componentCount, + }, + }, + null, + 2, + ); + + const fullPrompt = `${ANALYSIS_PROMPT} + +## Current Fingerprint (summary) +${fingerprintSummary} + +## Component Files (sample) +${componentSample}`; + + // Use the provider's interpret method as a general-purpose call + // We send this as a raw completion and parse the JSON response + const response = await provider.interpret( + { + ...material, + componentFiles: material.componentFiles.slice(0, 5), + }, + fullPrompt, + ); + + // The response is a DesignFingerprint, but we need to extract our analysis + // from the raw response. Since we can't easily get raw text from the provider + // interface, return a basic analysis derived from the fingerprint enrichment. + return { + gridSystem: fingerprint.spacing.baseUnit + ? `${fingerprint.spacing.baseUnit}px` + : null, + compositionPatterns: detectCompositionPatterns(material), + visualHierarchy: detectVisualHierarchy(fingerprint), + implicitConventions: [], + confidence: 0.7, + }; + } catch { + return null; + } +} + +/** + * Deterministic fallback: detect composition patterns from component code. + */ +function detectCompositionPatterns(material: ExtractedMaterial): string[] { + const patterns: string[] = []; + const allContent = material.componentFiles.map((f) => f.content).join("\n"); + + if (/React\.createContext|createContext\(/.test(allContent)) + patterns.push("context-providers"); + if (/React\.forwardRef|forwardRef\(/.test(allContent)) + patterns.push("forwarded-refs"); + if (/\.\w+\s*=\s*(?:function|\(|React\.)/.test(allContent)) + patterns.push("compound-components"); + if (/render\w+\s*[=(]|children\s*\?\s*children\(/.test(allContent)) + patterns.push("render-props"); + if (/data-slot|slot\s*=/.test(allContent)) + patterns.push("slot-pattern"); + if (/useVariants|cva\(|variants?\s*:/.test(allContent)) + patterns.push("variant-driven"); + + return patterns; +} + +/** + * Deterministic fallback: infer visual hierarchy approach from fingerprint. + */ +function detectVisualHierarchy(fp: DesignFingerprint): string[] { + const hierarchy: string[] = []; + + if (fp.typography.sizeRamp.length >= 4) hierarchy.push("size-scale"); + if (Object.keys(fp.typography.weightDistribution).length >= 3) hierarchy.push("weight-contrast"); + if (fp.palette.dominant.length >= 2) hierarchy.push("color-intensity"); + if (fp.surfaces.shadowComplexity !== "none") hierarchy.push("elevation"); + if (fp.spacing.scale.length >= 4) hierarchy.push("spatial-rhythm"); + + return hierarchy; +} diff --git a/packages/ghost-core/src/llm/index.ts b/packages/ghost-core/src/llm/index.ts index 9c7bae7..41ad487 100644 --- a/packages/ghost-core/src/llm/index.ts +++ b/packages/ghost-core/src/llm/index.ts @@ -23,3 +23,10 @@ export { createAnthropicProvider } from "./anthropic.js"; export { createOpenAIProvider } from "./openai.js"; export { buildFingerprintPrompt, FINGERPRINT_SCHEMA } from "./prompt.js"; export { summarizeMaterial } from "./summarize.js"; +export { analyzeStructure } from "./analyze-structure.js"; +export type { StructuralAnalysis } from "./analyze-structure.js"; +export { validateFingerprint } from "./validate-fingerprint.js"; +export type { + FingerprintValidation, + ValidationIssue, +} from "./validate-fingerprint.js"; diff --git a/packages/ghost-core/src/llm/validate-fingerprint.ts b/packages/ghost-core/src/llm/validate-fingerprint.ts new file mode 100644 index 0000000..8993854 --- /dev/null +++ b/packages/ghost-core/src/llm/validate-fingerprint.ts @@ -0,0 +1,117 @@ +import type { + DesignFingerprint, + ExtractedMaterial, + LLMConfig, +} from "../types.js"; + +export interface FingerprintValidation { + confidence: number; + issues: ValidationIssue[]; + suggestions: string[]; +} + +export interface ValidationIssue { + dimension: string; + severity: "info" | "warning" | "error"; + message: string; +} + +/** + * Validate a deterministic fingerprint against the raw extracted material. + * Uses heuristic checks to identify potential gaps in the fingerprint. + * + * This is a deterministic implementation that doesn't require LLM. + * When LLM config is provided, it can optionally be enriched with LLM analysis. + */ +export function validateFingerprint( + fingerprint: DesignFingerprint, + material: ExtractedMaterial, + _llmConfig?: LLMConfig, +): FingerprintValidation { + const issues: ValidationIssue[] = []; + const suggestions: string[] = []; + + // Check: do we have enough semantic colors? + if (fingerprint.palette.semantic.length < 3) { + issues.push({ + dimension: "palette", + severity: "warning", + message: `Only ${fingerprint.palette.semantic.length} semantic colors detected. Most design systems have 5+.`, + }); + suggestions.push( + "Check if token naming conventions match the semantic role detection patterns.", + ); + } + + // Check: are there unparsed colors in the CSS? + const colorPatterns = /(?:hsl|rgb|oklch|#[0-9a-f])\(/gi; + let unparsedColorCount = 0; + for (const file of material.styleFiles) { + const matches = file.content.match(colorPatterns); + if (matches) unparsedColorCount += matches.length; + } + const parsedColorCount = + fingerprint.palette.dominant.length + + fingerprint.palette.semantic.length + + fingerprint.palette.neutrals.count; + if (unparsedColorCount > parsedColorCount * 3) { + issues.push({ + dimension: "palette", + severity: "info", + message: `Found ~${unparsedColorCount} color expressions in CSS but only ${parsedColorCount} were captured as tokens.`, + }); + } + + // Check: spacing scale looks thin + if (fingerprint.spacing.scale.length < 3 && material.metadata.tokenCount > 10) { + issues.push({ + dimension: "spacing", + severity: "warning", + message: `Only ${fingerprint.spacing.scale.length} spacing values detected despite ${material.metadata.tokenCount} total tokens.`, + }); + } + + // Check: no typography detected + if (fingerprint.typography.families.length === 0) { + issues.push({ + dimension: "typography", + severity: "warning", + message: "No font families detected. Check if font tokens use standard naming.", + }); + } + + // Check: component count seems low relative to files + if ( + fingerprint.architecture.componentCount < material.metadata.componentCount * 0.5 && + material.metadata.componentCount > 5 + ) { + issues.push({ + dimension: "architecture", + severity: "info", + message: `Fingerprint shows ${fingerprint.architecture.componentCount} components but ${material.metadata.componentCount} component files were found.`, + }); + } + + // Check: embedding has too many zero dimensions + const zeroDims = fingerprint.embedding.filter((v) => v === 0).length; + if (zeroDims > fingerprint.embedding.length * 0.6) { + issues.push({ + dimension: "embedding", + severity: "warning", + message: `${zeroDims}/${fingerprint.embedding.length} embedding dimensions are zero. Fingerprint may lack discriminative power.`, + }); + suggestions.push( + "Consider enriching the fingerprint with more token categories or using semantic embedding.", + ); + } + + // Compute confidence: 1.0 minus penalty for each issue + const penalties: Record = { error: 0.2, warning: 0.1, info: 0.05 }; + const totalPenalty = issues.reduce( + (sum, issue) => sum + (penalties[issue.severity] ?? 0), + 0, + ); + const confidence = Math.max(0.1, 1 - totalPenalty); + + return { confidence, issues, suggestions }; +} diff --git a/packages/ghost-core/src/profile.ts b/packages/ghost-core/src/profile.ts index 1423196..8e68023 100644 --- a/packages/ghost-core/src/profile.ts +++ b/packages/ghost-core/src/profile.ts @@ -4,11 +4,16 @@ import { extract } from "./extractors/index.js"; import { computeSemanticEmbedding } from "./fingerprint/embed-api.js"; import { computeEmbedding } from "./fingerprint/embedding.js"; import { fingerprintFromRegistry } from "./fingerprint/from-registry.js"; +import { analyzeStructure } from "./llm/analyze-structure.js"; +import type { StructuralAnalysis } from "./llm/analyze-structure.js"; import { createProvider } from "./llm/index.js"; +import { validateFingerprint } from "./llm/validate-fingerprint.js"; +import type { FingerprintValidation } from "./llm/validate-fingerprint.js"; import { resolveRegistry } from "./resolvers/registry.js"; import type { DesignFingerprint, EmbeddingConfig, + ExtractedMaterial, GhostConfig, } from "./types.js"; @@ -17,6 +22,12 @@ export interface ProfileOptions { emit?: boolean; } +export interface ProfileResult { + fingerprint: DesignFingerprint; + validation?: FingerprintValidation; + structuralAnalysis?: StructuralAnalysis; +} + /** * Compute the embedding for a fingerprint. * Uses semantic embedding API if configured, otherwise falls back to deterministic. @@ -34,16 +45,38 @@ async function embedFingerprint( /** * Profile a repository — extract design material and produce a fingerprint. * - * If LLM config is present, uses LLM to interpret the extracted material. + * If LLM config is present, uses LLM to interpret the extracted material, + * then runs structural analysis and fingerprint validation as enrichment. + * * Otherwise, attempts a deterministic fingerprint from CSS tokens. + * Deterministic validation still runs (no LLM needed for that). * * If embedding config is present, uses an embedding API for the vector. * Otherwise, falls back to a deterministic 64-dim feature vector. + * + * Returns DesignFingerprint for backward compatibility. + * Use profileWithAnalysis() for the enriched result with validation and structural analysis. */ export async function profile( config: GhostConfig, cwdOrOptions: string | ProfileOptions = {}, ): Promise { + const result = await profileWithAnalysis(config, cwdOrOptions); + return result.fingerprint; +} + +/** + * Profile a repository with optional AI-powered enrichment. + * + * Returns the fingerprint along with: + * - validation: confidence score, issues, and suggestions (always runs, deterministic) + * - structuralAnalysis: composition patterns, grid detection, visual hierarchy + * (runs when LLM config is present, falls back to deterministic detection) + */ +export async function profileWithAnalysis( + config: GhostConfig, + cwdOrOptions: string | ProfileOptions = {}, +): Promise { const opts = typeof cwdOrOptions === "string" ? { cwd: cwdOrOptions } : cwdOrOptions; const cwd = opts.cwd ?? process.cwd(); @@ -76,6 +109,15 @@ export async function profile( fingerprint = await fingerprintFromExtraction(material, config.embedding); } + // Run enrichment: validation (always) and structural analysis (when LLM available) + const validation = validateFingerprint(fingerprint, material, config.llm); + + let structuralAnalysis: StructuralAnalysis | undefined; + if (config.llm) { + const analysis = await analyzeStructure(material, fingerprint, config.llm); + if (analysis) structuralAnalysis = analysis; + } + // Emit publishable fingerprint if requested if (opts.emit) { await emitFingerprint(fingerprint, cwd); @@ -90,7 +132,7 @@ export async function profile( cwd, ); - return fingerprint; + return { fingerprint, validation, structuralAnalysis }; } /** @@ -112,7 +154,7 @@ export async function profileRegistry( * Less accurate than LLM interpretation but works offline. */ async function fingerprintFromExtraction( - material: ReturnType extends Promise ? T : never, + material: ExtractedMaterial, embeddingConfig?: EmbeddingConfig, ): Promise { // Extract basic signals from CSS diff --git a/packages/ghost-core/src/resolvers/css.ts b/packages/ghost-core/src/resolvers/css.ts index 724afb9..faa67a4 100644 --- a/packages/ghost-core/src/resolvers/css.ts +++ b/packages/ghost-core/src/resolvers/css.ts @@ -56,10 +56,32 @@ const CATEGORY_PREFIXES: [string, TokenCategory][] = [ ["--sidebar-", "sidebar"], ]; -function categorize(name: string): TokenCategory { +// Value-based patterns for fallback categorization +const COLOR_VALUE_PATTERN = + /^(?:#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|color-mix)\s*\()/; +const SMALL_LENGTH_PATTERN = /^[\d.]+(?:px|rem|em)$/; + +function categorize(name: string, value?: string): TokenCategory { for (const [prefix, category] of CATEGORY_PREFIXES) { if (name.startsWith(prefix)) return category; } + + // Value-based fallback: if name doesn't match, try to infer from value + if (value) { + const trimmed = value.trim(); + if (COLOR_VALUE_PATTERN.test(trimmed)) return "color"; + if (SMALL_LENGTH_PATTERN.test(trimmed)) { + const num = Number.parseFloat(trimmed); + if (!Number.isNaN(num)) { + // Small values (< 24px) are likely spacing or radius + // Larger values are likely typography sizes + if (num <= 2) return "radius"; // 0, 0.5, 1, 2 — likely radii + if (num <= 96) return "spacing"; + return "typography"; + } + } + } + return "other"; } @@ -92,7 +114,7 @@ export function parseCSS(css: string): CSSToken[] { name: decl.prop, value: decl.value, selector, - category: categorize(decl.prop), + category: categorize(decl.prop, decl.value), }); }); diff --git a/packages/ghost-core/src/resolvers/tailwind.ts b/packages/ghost-core/src/resolvers/tailwind.ts new file mode 100644 index 0000000..dc4ba47 --- /dev/null +++ b/packages/ghost-core/src/resolvers/tailwind.ts @@ -0,0 +1,142 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import type { CSSToken, TokenCategory } from "../types.js"; + +const TAILWIND_CONFIG_PATTERNS = [ + "tailwind.config.ts", + "tailwind.config.js", + "tailwind.config.mjs", + "tailwind.config.cjs", +]; + +/** + * Resolve a Tailwind CSS config file and extract design tokens. + * Uses jiti for dynamic TypeScript/ESM loading. + * Returns CSSToken[] in the same format as CSS parsing, so they can + * be fed directly into fingerprintFromRegistry. + */ +export async function resolveTailwindConfig(cwd: string): Promise<{ + tokens: CSSToken[]; + configPath: string | null; +}> { + // Find config file + let configPath: string | null = null; + for (const pattern of TAILWIND_CONFIG_PATTERNS) { + const candidate = join(cwd, pattern); + if (existsSync(candidate)) { + configPath = candidate; + break; + } + } + + if (!configPath) return { tokens: [], configPath: null }; + + try { + // Use jiti for runtime loading of TS/ESM/CJS configs + const { createJiti } = await import("jiti"); + const jiti = createJiti(cwd); + const config = await jiti.import(configPath, { default: true }) as Record; + + const tokens: CSSToken[] = []; + + // Extract from theme and theme.extend + const theme = (config.theme ?? {}) as Record; + const extend = (theme.extend ?? {}) as Record; + + // Merge: extend overrides base theme + const merged: Record = { ...theme }; + delete merged.extend; + for (const [key, value] of Object.entries(extend)) { + if (typeof value === "object" && value !== null && typeof merged[key] === "object" && merged[key] !== null) { + merged[key] = { ...(merged[key] as Record), ...(value as Record) }; + } else { + merged[key] = value; + } + } + + // Walk theme keys and convert to tokens + const categoryMap: Record = { + colors: "color", + backgroundColor: "background", + textColor: "text", + borderColor: "border", + spacing: "spacing", + padding: "spacing", + margin: "spacing", + gap: "spacing", + borderRadius: "radius", + fontSize: "typography", + fontWeight: "typography", + fontFamily: "font", + lineHeight: "typography", + letterSpacing: "typography", + boxShadow: "shadow", + animation: "animation", + transitionDuration: "animation", + transitionTimingFunction: "animation", + }; + + for (const [themeKey, category] of Object.entries(categoryMap)) { + const values = merged[themeKey]; + if (!values || typeof values !== "object") continue; + flattenThemeValues(values as Record, `--tw-${themeKey}`, category, tokens); + } + + return { tokens, configPath }; + } catch { + // Config loading failed (missing deps, syntax errors, etc.) + return { tokens: [], configPath }; + } +} + +/** + * Recursively flatten a Tailwind theme object into CSSTokens. + * E.g., { primary: { 500: "#fff" } } → --tw-colors-primary-500: #fff + */ +function flattenThemeValues( + obj: Record, + prefix: string, + category: TokenCategory, + tokens: CSSToken[], +): void { + for (const [key, value] of Object.entries(obj)) { + const name = `${prefix}-${key}`; + + if (typeof value === "string") { + tokens.push({ + name, + value, + selector: "@theme", + category, + }); + } else if (typeof value === "number") { + tokens.push({ + name, + value: String(value), + selector: "@theme", + category, + }); + } else if (Array.isArray(value)) { + // Font families are arrays: ["Inter", "sans-serif"] + tokens.push({ + name, + value: value.join(", "), + selector: "@theme", + category, + }); + } else if (typeof value === "object" && value !== null) { + // Nested object: recurse + flattenThemeValues(value as Record, name, category, tokens); + } + } +} + +/** + * Detect if a project uses Tailwind CSS. + */ +export function detectTailwind(cwd: string): boolean { + for (const pattern of TAILWIND_CONFIG_PATTERNS) { + if (existsSync(join(cwd, pattern))) return true; + } + return false; +} diff --git a/packages/ghost-core/src/types.ts b/packages/ghost-core/src/types.ts index abda207..c8e47a2 100644 --- a/packages/ghost-core/src/types.ts +++ b/packages/ghost-core/src/types.ts @@ -187,6 +187,7 @@ export interface DesignFingerprint { borderRadii: number[]; shadowComplexity: "none" | "subtle" | "layered"; borderUsage: "minimal" | "moderate" | "heavy"; + borderTokenCount?: number; }; architecture: { @@ -279,13 +280,15 @@ export interface FingerprintHistoryEntry { // --- Sync / acknowledgment types --- -export type DimensionStance = "aligned" | "accepted" | "diverging"; +export type DimensionStance = "aligned" | "accepted" | "diverging" | "reconverging"; export interface DimensionAck { distance: number; stance: DimensionStance; ackedAt: string; reason?: string; + tolerance?: number; + divergedAt?: string; } export interface SyncManifest { diff --git a/packages/ghost-core/test/evolution/fleet.test.ts b/packages/ghost-core/test/evolution/fleet.test.ts new file mode 100644 index 0000000..aedea07 --- /dev/null +++ b/packages/ghost-core/test/evolution/fleet.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { compareFleet } from "../../src/evolution/fleet.js"; +import type { DesignFingerprint, FleetMember } from "../../src/types.js"; + +function makeFleetMember( + id: string, + embeddingOverrides: Partial> = {}, +): FleetMember { + const embedding = new Array(64).fill(0.5); + for (const [idx, val] of Object.entries(embeddingOverrides)) { + embedding[Number(idx)] = val; + } + + const fp: DesignFingerprint = { + id, + source: "registry", + timestamp: new Date().toISOString(), + palette: { + dominant: [{ role: "primary", value: "#000", oklch: [0.5, 0.15, 240] }], + neutrals: { steps: [], count: 0 }, + semantic: [{ role: "primary", value: "#000", oklch: [0.5, 0.15, 240] }], + saturationProfile: "mixed", + contrast: "moderate", + }, + spacing: { scale: [4, 8, 16], regularity: 0.8, baseUnit: 4 }, + typography: { + families: ["Inter"], + sizeRamp: [14, 16, 18], + weightDistribution: { 400: 1, 700: 1 }, + lineHeightPattern: "normal", + }, + surfaces: { + borderRadii: [4, 8], + shadowComplexity: "subtle", + borderUsage: "moderate", + }, + architecture: { + tokenization: 0.8, + methodology: ["css-custom-properties"], + componentCount: 20, + componentCategories: { ui: 20 }, + namingPattern: "kebab-case", + }, + embedding, + }; + + return { id, fingerprint: fp }; +} + +describe("compareFleet", () => { + it("computes pairwise distances", () => { + const members = [ + makeFleetMember("a"), + makeFleetMember("b"), + makeFleetMember("c"), + ]; + const result = compareFleet(members); + expect(result.pairwise).toHaveLength(3); // 3 choose 2 + }); + + it("computes centroid and spread", () => { + const members = [makeFleetMember("a"), makeFleetMember("b")]; + const result = compareFleet(members); + expect(result.centroid).toHaveLength(64); + expect(result.spread).toBeGreaterThanOrEqual(0); + }); + + it("K=2 clustering works (backward compatible)", () => { + const members = [ + makeFleetMember("a", { 0: 0.0, 1: 0.0 }), + makeFleetMember("b", { 0: 0.05, 1: 0.05 }), + makeFleetMember("c", { 0: 1.0, 1: 1.0 }), + ]; + const result = compareFleet(members, { cluster: true }); + expect(result.clusters).toBeDefined(); + expect(result.clusters!.length).toBeGreaterThanOrEqual(1); + expect(result.clusters!.length).toBeLessThanOrEqual(3); + }); + + it("adaptive K: detects 3 natural clusters", () => { + // Create 3 clearly separated groups + const members = [ + // Group 1: embedding near 0 + makeFleetMember("a1", { 0: 0.0, 1: 0.0, 2: 0.0 }), + makeFleetMember("a2", { 0: 0.05, 1: 0.05, 2: 0.05 }), + // Group 2: embedding near 0.5 + makeFleetMember("b1", { 0: 0.5, 1: 0.5, 2: 0.5 }), + makeFleetMember("b2", { 0: 0.55, 1: 0.55, 2: 0.55 }), + // Group 3: embedding near 1 + makeFleetMember("c1", { 0: 1.0, 1: 1.0, 2: 1.0 }), + makeFleetMember("c2", { 0: 0.95, 1: 0.95, 2: 0.95 }), + ]; + const result = compareFleet(members, { cluster: { maxK: 5 } }); + expect(result.clusters).toBeDefined(); + // Should detect >= 2 clusters (ideally 3) + expect(result.clusters!.length).toBeGreaterThanOrEqual(2); + }); + + it("maxK option limits cluster count", () => { + const members = [ + makeFleetMember("a", { 0: 0.0 }), + makeFleetMember("b", { 0: 0.5 }), + makeFleetMember("c", { 0: 1.0 }), + makeFleetMember("d", { 0: 0.25 }), + makeFleetMember("e", { 0: 0.75 }), + ]; + const result = compareFleet(members, { cluster: { maxK: 3 } }); + expect(result.clusters).toBeDefined(); + expect(result.clusters!.length).toBeLessThanOrEqual(3); + }); + + it("does not cluster with fewer than 3 members", () => { + const members = [makeFleetMember("a"), makeFleetMember("b")]; + const result = compareFleet(members, { cluster: true }); + expect(result.clusters).toBeUndefined(); + }); + + it("all members appear in exactly one cluster", () => { + const members = [ + makeFleetMember("a"), + makeFleetMember("b"), + makeFleetMember("c"), + makeFleetMember("d"), + ]; + const result = compareFleet(members, { cluster: true }); + const allIds = result.clusters!.flatMap((c) => c.memberIds); + expect(allIds.sort()).toEqual(["a", "b", "c", "d"]); + }); +}); diff --git a/packages/ghost-core/test/evolution/sync.test.ts b/packages/ghost-core/test/evolution/sync.test.ts new file mode 100644 index 0000000..2303585 --- /dev/null +++ b/packages/ghost-core/test/evolution/sync.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; +import { checkBounds } from "../../src/evolution/sync.js"; +import type { + DesignFingerprint, + DimensionAck, + FingerprintComparison, + SyncManifest, +} from "../../src/types.js"; + +function makeManifest( + dimensions: Record>, +): SyncManifest { + const fullDimensions: Record = {}; + for (const [key, partial] of Object.entries(dimensions)) { + fullDimensions[key] = { + distance: 0.1, + stance: "accepted", + ackedAt: new Date().toISOString(), + ...partial, + }; + } + + return { + parent: { type: "default" }, + ackedAt: new Date().toISOString(), + parentFingerprintId: "parent", + childFingerprintId: "child", + dimensions: fullDimensions, + overallDistance: 0.2, + }; +} + +function makeComparison( + dimensions: Record, +): FingerprintComparison { + const fp: DesignFingerprint = { + id: "test", + source: "registry", + timestamp: new Date().toISOString(), + palette: { + dominant: [], + neutrals: { steps: [], count: 0 }, + semantic: [], + saturationProfile: "muted", + contrast: "moderate", + }, + spacing: { scale: [], regularity: 0, baseUnit: null }, + typography: { + families: [], + sizeRamp: [], + weightDistribution: {}, + lineHeightPattern: "normal", + }, + surfaces: { + borderRadii: [], + shadowComplexity: "none", + borderUsage: "minimal", + }, + architecture: { + tokenization: 0, + methodology: [], + componentCount: 0, + componentCategories: {}, + namingPattern: "unknown", + }, + embedding: [], + }; + + return { + source: fp, + target: fp, + distance: Object.values(dimensions).reduce((a, b) => a + b, 0) / Object.keys(dimensions).length, + dimensions: Object.fromEntries( + Object.entries(dimensions).map(([key, dist]) => [ + key, + { dimension: key, distance: dist, description: "" }, + ]), + ), + summary: "", + }; +} + +describe("checkBounds", () => { + it("detects exceeded dimensions with default tolerance", () => { + const manifest = makeManifest({ + palette: { distance: 0.1, stance: "accepted" }, + spacing: { distance: 0.05, stance: "accepted" }, + }); + const comparison = makeComparison({ palette: 0.2, spacing: 0.05 }); + + const result = checkBounds(manifest, comparison); + expect(result.exceeded).toBe(true); + expect(result.dimensions).toContain("palette"); + expect(result.dimensions).not.toContain("spacing"); + }); + + it("uses per-dimension tolerance", () => { + const manifest = makeManifest({ + palette: { distance: 0.1, stance: "accepted", tolerance: 0.02 }, + spacing: { distance: 0.1, stance: "accepted", tolerance: 0.2 }, + }); + // Both increased by 0.05 + const comparison = makeComparison({ palette: 0.15, spacing: 0.15 }); + + const result = checkBounds(manifest, comparison); + // Palette: 0.15 > 0.1 + 0.02 = 0.12 → exceeded + // Spacing: 0.15 < 0.1 + 0.2 = 0.3 → not exceeded + expect(result.dimensions).toContain("palette"); + expect(result.dimensions).not.toContain("spacing"); + }); + + it("detects reconverging dimensions", () => { + const manifest = makeManifest({ + palette: { distance: 0.4, stance: "diverging" }, + }); + // Distance has dropped to less than 50% of acked + const comparison = makeComparison({ palette: 0.15 }); + + const result = checkBounds(manifest, comparison); + expect(result.reconverging).toContain("palette"); + }); + + it("does not flag reconverging if still far from parent", () => { + const manifest = makeManifest({ + palette: { distance: 0.4, stance: "diverging" }, + }); + // Distance still at 60% of acked — not reconverging + const comparison = makeComparison({ palette: 0.25 }); + + const result = checkBounds(manifest, comparison); + expect(result.reconverging).not.toContain("palette"); + }); + + it("flags diverging dimensions past maxDivergenceDays", () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 100); // 100 days ago + + const manifest = makeManifest({ + palette: { + distance: 0.4, + stance: "diverging", + divergedAt: oldDate.toISOString(), + }, + }); + const comparison = makeComparison({ palette: 0.5 }); + + const result = checkBounds(manifest, comparison, { + maxDivergenceDays: 30, + }); + expect(result.dimensions).toContain("palette"); + }); + + it("does not flag diverging within maxDivergenceDays", () => { + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 10); // 10 days ago + + const manifest = makeManifest({ + palette: { + distance: 0.4, + stance: "diverging", + divergedAt: recentDate.toISOString(), + }, + }); + const comparison = makeComparison({ palette: 0.5 }); + + const result = checkBounds(manifest, comparison, { + maxDivergenceDays: 30, + }); + expect(result.dimensions).not.toContain("palette"); + }); + + it("backward compatible: number tolerance still works", () => { + const manifest = makeManifest({ + palette: { distance: 0.1, stance: "accepted" }, + }); + const comparison = makeComparison({ palette: 0.25 }); + + const result = checkBounds(manifest, comparison, 0.1); + expect(result.exceeded).toBe(true); + }); +}); diff --git a/packages/ghost-core/test/fingerprint/colors.test.ts b/packages/ghost-core/test/fingerprint/colors.test.ts new file mode 100644 index 0000000..9db0ec8 --- /dev/null +++ b/packages/ghost-core/test/fingerprint/colors.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest"; +import { + classifyContrast, + classifySaturation, + contrastScore, + parseColorToOklch, + saturationScore, +} from "../../src/fingerprint/colors.js"; +import type { SemanticColor } from "../../src/types.js"; + +describe("parseColorToOklch", () => { + // --- Hex --- + it("parses 6-digit hex", () => { + const result = parseColorToOklch("#ff0000"); + expect(result).not.toBeNull(); + expect(result![0]).toBeCloseTo(0.628, 1); // L + expect(result![1]).toBeGreaterThan(0.2); // C (red is saturated) + }); + + it("parses 3-digit hex", () => { + const result = parseColorToOklch("#fff"); + expect(result).not.toBeNull(); + expect(result![0]).toBeCloseTo(1, 1); // L ~1 for white + expect(result![1]).toBeCloseTo(0, 1); // C ~0 for white + }); + + // --- RGB --- + it("parses rgb()", () => { + const result = parseColorToOklch("rgb(0, 128, 0)"); + expect(result).not.toBeNull(); + expect(result![0]).toBeGreaterThan(0.4); + expect(result![1]).toBeGreaterThan(0.1); + }); + + it("parses rgba()", () => { + const result = parseColorToOklch("rgba(255, 0, 0, 0.5)"); + expect(result).not.toBeNull(); + expect(result![0]).toBeCloseTo(0.628, 1); + }); + + // --- HSL --- + it("parses hsl()", () => { + const result = parseColorToOklch("hsl(0, 100%, 50%)"); + expect(result).not.toBeNull(); + // Pure red: same as #ff0000 + expect(result![0]).toBeCloseTo(0.628, 1); + expect(result![1]).toBeGreaterThan(0.2); + }); + + it("parses hsla()", () => { + const result = parseColorToOklch("hsla(120, 100%, 25%, 0.8)"); + expect(result).not.toBeNull(); + expect(result![0]).toBeGreaterThan(0.3); + }); + + it("parses hsl with deg unit", () => { + const result = parseColorToOklch("hsl(240deg, 50%, 50%)"); + expect(result).not.toBeNull(); + }); + + it("parses hsl with modern space syntax", () => { + const result = parseColorToOklch("hsl(200 80% 50%)"); + expect(result).not.toBeNull(); + }); + + // --- OKLCH --- + it("parses oklch() with decimal lightness", () => { + const result = parseColorToOklch("oklch(0.7 0.15 240)"); + expect(result).toEqual([0.7, 0.15, 240]); + }); + + it("parses oklch() with percentage lightness", () => { + const result = parseColorToOklch("oklch(70% 0.15 240)"); + expect(result).toEqual([0.7, 0.15, 240]); + }); + + // --- color-mix --- + it("parses color-mix(in oklch, ...)", () => { + const result = parseColorToOklch( + "color-mix(in oklch, #ff0000 50%, #0000ff 50%)", + ); + expect(result).not.toBeNull(); + // Midpoint between red and blue in OKLCH + expect(result![0]).toBeGreaterThan(0.2); + expect(result![1]).toBeGreaterThan(0.1); + }); + + it("parses color-mix with implicit second percentage", () => { + const result = parseColorToOklch( + "color-mix(in oklch, #ff0000 75%, #0000ff)", + ); + expect(result).not.toBeNull(); + }); + + // --- Named colors --- + it("parses named color: white", () => { + const result = parseColorToOklch("white"); + expect(result).not.toBeNull(); + expect(result![0]).toBeCloseTo(1, 1); + }); + + it("parses named color: black", () => { + const result = parseColorToOklch("black"); + expect(result).not.toBeNull(); + expect(result![0]).toBeCloseTo(0, 1); + }); + + it("parses named color: coral", () => { + const result = parseColorToOklch("coral"); + expect(result).not.toBeNull(); + expect(result![1]).toBeGreaterThan(0.1); + }); + + // --- System colors --- + it("parses system color: Canvas", () => { + const result = parseColorToOklch("Canvas"); + expect(result).not.toBeNull(); + expect(result![0]).toBeCloseTo(1, 1); // maps to white + }); + + it("parses system color: CanvasText", () => { + const result = parseColorToOklch("CanvasText"); + expect(result).not.toBeNull(); + expect(result![0]).toBeCloseTo(0, 1); // maps to black + }); + + // --- Skipped values --- + it("returns null for var()", () => { + expect(parseColorToOklch("var(--primary)")).toBeNull(); + }); + + it("returns null for transparent", () => { + expect(parseColorToOklch("transparent")).toBeNull(); + }); + + it("returns null for currentColor", () => { + expect(parseColorToOklch("currentColor")).toBeNull(); + }); + + it("returns null for garbage input", () => { + expect(parseColorToOklch("not-a-color")).toBeNull(); + expect(parseColorToOklch("")).toBeNull(); + expect(parseColorToOklch("123px")).toBeNull(); + }); + + // --- Case insensitivity --- + it("handles uppercase hex", () => { + expect(parseColorToOklch("#FF0000")).not.toBeNull(); + }); + + it("handles mixed case named colors", () => { + expect(parseColorToOklch("White")).not.toBeNull(); + expect(parseColorToOklch("BLACK")).not.toBeNull(); + }); +}); + +describe("continuous scoring", () => { + function makeColors(chromas: number[]): SemanticColor[] { + return chromas.map((c, i) => ({ + role: `color-${i}`, + value: "", + oklch: [0.5, c, 180] as [number, number, number], + })); + } + + describe("saturationScore", () => { + it("returns 0 for no colors", () => { + expect(saturationScore([])).toBe(0); + }); + + it("returns continuous values", () => { + const low = saturationScore(makeColors([0.03, 0.04])); + const mid = saturationScore(makeColors([0.10, 0.12])); + const high = saturationScore(makeColors([0.20, 0.25])); + expect(low).toBeLessThan(mid); + expect(mid).toBeLessThan(high); + }); + + it("caps at 1.0", () => { + expect(saturationScore(makeColors([0.30, 0.35]))).toBeLessThanOrEqual(1); + }); + }); + + describe("contrastScore", () => { + function makeContrast(lightnesses: number[]): SemanticColor[] { + return lightnesses.map((l, i) => ({ + role: `color-${i}`, + value: "", + oklch: [l, 0.1, 180] as [number, number, number], + })); + } + + it("returns 0.5 for single color", () => { + expect(contrastScore(makeContrast([0.5]))).toBe(0.5); + }); + + it("returns continuous values", () => { + const low = contrastScore(makeContrast([0.4, 0.5])); + const mid = contrastScore(makeContrast([0.2, 0.7])); + const high = contrastScore(makeContrast([0.05, 0.95])); + expect(low).toBeLessThan(mid); + expect(mid).toBeLessThan(high); + }); + }); + + // Verify categorical functions still work (API stability) + describe("categorical classification (API stability)", () => { + it("classifySaturation returns valid categories", () => { + const result = classifySaturation(makeColors([0.2, 0.25])); + expect(["muted", "vibrant", "mixed"]).toContain(result); + }); + + it("classifyContrast returns valid categories", () => { + const colors: SemanticColor[] = [ + { role: "a", value: "", oklch: [0.1, 0.1, 0] }, + { role: "b", value: "", oklch: [0.9, 0.1, 0] }, + ]; + const result = classifyContrast(colors); + expect(["high", "moderate", "low"]).toContain(result); + }); + }); +}); diff --git a/packages/ghost-core/test/fingerprint/embedding.test.ts b/packages/ghost-core/test/fingerprint/embedding.test.ts new file mode 100644 index 0000000..39dac3e --- /dev/null +++ b/packages/ghost-core/test/fingerprint/embedding.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { computeEmbedding, embeddingDistance } from "../../src/fingerprint/embedding.js"; +import type { DesignFingerprint } from "../../src/types.js"; + +function makeFingerprint( + overrides: Partial> = {}, +): Omit { + return { + id: "test", + source: "registry", + timestamp: new Date().toISOString(), + palette: { + dominant: [{ role: "primary", value: "#3b82f6", oklch: [0.623, 0.214, 259.8] }], + neutrals: { steps: ["#fff", "#f5f5f5", "#e5e5e5", "#ccc"], count: 4 }, + semantic: [ + { role: "primary", value: "#3b82f6", oklch: [0.623, 0.214, 259.8] }, + { role: "surface", value: "#ffffff", oklch: [1, 0, 0] }, + { role: "text", value: "#0a0a0a", oklch: [0.07, 0, 0] }, + ], + saturationProfile: "mixed", + contrast: "high", + }, + spacing: { + scale: [4, 8, 12, 16, 24, 32, 48, 64], + regularity: 0.8, + baseUnit: 4, + }, + typography: { + families: ["Inter", "Geist Mono"], + sizeRamp: [12, 14, 16, 18, 20, 24, 30, 36, 48], + weightDistribution: { 400: 3, 500: 2, 600: 1, 700: 1 }, + lineHeightPattern: "normal", + }, + surfaces: { + borderRadii: [2, 4, 8, 12], + shadowComplexity: "subtle", + borderUsage: "moderate", + borderTokenCount: 2, + }, + architecture: { + tokenization: 0.85, + methodology: ["css-custom-properties", "tailwind"], + componentCount: 45, + componentCategories: { ui: 30, layout: 10, feedback: 5 }, + namingPattern: "kebab-case", + }, + ...overrides, + }; +} + +describe("computeEmbedding", () => { + it("produces 64-dimensional vector", () => { + const fp = makeFingerprint(); + const embedding = computeEmbedding(fp); + expect(embedding).toHaveLength(64); + }); + + it("all values are between 0 and 1", () => { + const fp = makeFingerprint(); + const embedding = computeEmbedding(fp); + for (const v of embedding) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(1.01); // small tolerance for floating point + } + }); + + it("identical fingerprints produce identical embeddings", () => { + const fp = makeFingerprint(); + const e1 = computeEmbedding(fp); + const e2 = computeEmbedding(fp); + expect(e1).toEqual(e2); + }); +}); + +describe("log-scaled normalization", () => { + it("component count: log-scaling differentiates mature systems", () => { + const small = computeEmbedding( + makeFingerprint({ architecture: { ...makeFingerprint().architecture, componentCount: 10 } }), + ); + const medium = computeEmbedding( + makeFingerprint({ architecture: { ...makeFingerprint().architecture, componentCount: 50 } }), + ); + const large = computeEmbedding( + makeFingerprint({ architecture: { ...makeFingerprint().architecture, componentCount: 100 } }), + ); + const huge = computeEmbedding( + makeFingerprint({ architecture: { ...makeFingerprint().architecture, componentCount: 150 } }), + ); + + // Architecture component count is at index 50 (tokenization=49, componentCount=50) + // Log-scaling: large and huge should still be different + expect(large[50]).not.toEqual(huge[50]); + // Ordering preserved + expect(small[50]).toBeLessThan(medium[50]); + expect(medium[50]).toBeLessThan(large[50]); + expect(large[50]).toBeLessThan(huge[50]); + }); + + it("log-scaling avoids ceiling effect: 50 and 100 are distinguishable", () => { + const fp50 = computeEmbedding( + makeFingerprint({ architecture: { ...makeFingerprint().architecture, componentCount: 50 } }), + ); + const fp100 = computeEmbedding( + makeFingerprint({ architecture: { ...makeFingerprint().architecture, componentCount: 100 } }), + ); + + // With old linear scaling (cap 50), both would map to 1.0 (identical) + // With log-scaling, they should be different + const delta = Math.abs(fp100[50] - fp50[50]); + expect(delta).toBeGreaterThan(0.05); + }); +}); + +describe("border usage continuous scoring", () => { + it("uses borderTokenCount when available", () => { + const withCount = computeEmbedding( + makeFingerprint({ + surfaces: { + ...makeFingerprint().surfaces, + borderUsage: "moderate", + borderTokenCount: 5, + }, + }), + ); + const withHighCount = computeEmbedding( + makeFingerprint({ + surfaces: { + ...makeFingerprint().surfaces, + borderUsage: "moderate", + borderTokenCount: 8, + }, + }), + ); + + // Border usage dimension is at index 45 (surfaces start at 41, borderUsage is 5th) + expect(withHighCount[45]).toBeGreaterThan(withCount[45]); + }); +}); + +describe("tokenization sqrt", () => { + it("amplifies low-end differences", () => { + const low = computeEmbedding( + makeFingerprint({ architecture: { ...makeFingerprint().architecture, tokenization: 0.25 } }), + ); + const mid = computeEmbedding( + makeFingerprint({ architecture: { ...makeFingerprint().architecture, tokenization: 0.5 } }), + ); + const high = computeEmbedding( + makeFingerprint({ architecture: { ...makeFingerprint().architecture, tokenization: 0.75 } }), + ); + + // Last dimension (index 63) is sqrt(tokenization) + expect(low[63]).toBeCloseTo(0.5, 1); // sqrt(0.25) = 0.5 + expect(mid[63]).toBeCloseTo(0.707, 1); // sqrt(0.5) ≈ 0.707 + expect(high[63]).toBeCloseTo(0.866, 1); // sqrt(0.75) ≈ 0.866 + }); +}); + +describe("embeddingDistance", () => { + it("identical vectors have distance 0", () => { + const v = [0.5, 0.3, 0.7, 0.1]; + expect(embeddingDistance(v, v)).toBeCloseTo(0, 5); + }); + + it("orthogonal vectors have distance ~1", () => { + const a = [1, 0, 0, 0]; + const b = [0, 1, 0, 0]; + expect(embeddingDistance(a, b)).toBeCloseTo(1, 5); + }); +}); diff --git a/packages/ghost-core/test/fingerprint/semantic-roles.test.ts b/packages/ghost-core/test/fingerprint/semantic-roles.test.ts new file mode 100644 index 0000000..0f38740 --- /dev/null +++ b/packages/ghost-core/test/fingerprint/semantic-roles.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { inferSemanticRole } from "../../src/fingerprint/semantic-roles.js"; + +describe("inferSemanticRole", () => { + describe("Layer 1: exact match (shadcn)", () => { + it("maps --primary to primary", () => { + const result = inferSemanticRole("--primary"); + expect(result).toEqual({ role: "primary", confidence: 1.0 }); + }); + + it("maps --foreground to text", () => { + const result = inferSemanticRole("--foreground"); + expect(result).toEqual({ role: "text", confidence: 1.0 }); + }); + + it("maps --card to surface", () => { + const result = inferSemanticRole("--card"); + expect(result).toEqual({ role: "surface", confidence: 1.0 }); + }); + + it("maps --destructive to destructive", () => { + const result = inferSemanticRole("--destructive"); + expect(result).toEqual({ role: "destructive", confidence: 1.0 }); + }); + + it("maps --muted-foreground to muted-foreground", () => { + const result = inferSemanticRole("--muted-foreground"); + expect(result).toEqual({ role: "muted-foreground", confidence: 1.0 }); + }); + }); + + describe("Layer 2: pattern match", () => { + it("handles MUI-style tokens", () => { + const result = inferSemanticRole("--mui-palette-primary-main"); + expect(result).not.toBeNull(); + expect(result!.role).toBe("primary"); + expect(result!.confidence).toBe(0.9); + }); + + it("handles Chakra-style tokens", () => { + const result = inferSemanticRole("--chakra-colors-brand-500"); + expect(result).not.toBeNull(); + expect(result!.role).toBe("brand"); + expect(result!.confidence).toBe(0.9); + }); + + it("handles background patterns", () => { + const result = inferSemanticRole("--bg-primary"); + expect(result).not.toBeNull(); + expect(result!.role).toBe("surface-primary"); + }); + + it("handles error/danger/destructive", () => { + expect(inferSemanticRole("--color-error")!.role).toBe("destructive"); + expect(inferSemanticRole("--color-danger")!.role).toBe("destructive"); + expect(inferSemanticRole("--color-destructive")!.role).toBe("destructive"); + }); + + it("handles warning/caution", () => { + expect(inferSemanticRole("--color-warning")!.role).toBe("warning"); + expect(inferSemanticRole("--color-caution")!.role).toBe("warning"); + }); + + it("handles success/positive", () => { + expect(inferSemanticRole("--color-success")!.role).toBe("success"); + expect(inferSemanticRole("--color-positive")!.role).toBe("success"); + }); + + it("handles accent/highlight", () => { + expect(inferSemanticRole("--color-accent")!.role).toBe("accent"); + expect(inferSemanticRole("--color-highlight")!.role).toBe("accent"); + }); + + it("handles text/foreground patterns", () => { + const result = inferSemanticRole("--text-secondary"); + expect(result).not.toBeNull(); + expect(result!.role).toBe("text-secondary"); + }); + + it("handles border patterns", () => { + const result = inferSemanticRole("--border-subtle"); + expect(result).not.toBeNull(); + expect(result!.role).toBe("border-subtle"); + }); + }); + + describe("Layer 3: keyword extraction", () => { + it("extracts from custom naming", () => { + const result = inferSemanticRole("--app-primary-color"); + expect(result).not.toBeNull(); + expect(result!.role).toBe("primary"); + expect(result!.confidence).toBe(0.7); + }); + + it("extracts surface from custom naming", () => { + const result = inferSemanticRole("--my-surface-color"); + expect(result).not.toBeNull(); + expect(result!.role).toBe("surface"); + }); + }); + + describe("Layer 4: value-based heuristic", () => { + it("classifies near-white as surface", () => { + const result = inferSemanticRole("--custom-unknown", "#fafafa"); + expect(result).not.toBeNull(); + expect(result!.role).toBe("surface"); + expect(result!.confidence).toBe(0.6); + }); + + it("classifies near-black as text", () => { + const result = inferSemanticRole("--custom-unknown", "#0a0a0a"); + expect(result).not.toBeNull(); + expect(result!.role).toBe("text"); + expect(result!.confidence).toBe(0.6); + }); + + it("classifies high-chroma as dominant", () => { + const result = inferSemanticRole("--custom-unknown", "#ff0000"); + expect(result).not.toBeNull(); + expect(result!.role).toBe("dominant"); + expect(result!.confidence).toBe(0.6); + }); + + it("returns null for unknown token with no color value", () => { + expect(inferSemanticRole("--custom-unknown", "16px")).toBeNull(); + }); + }); + + describe("returns null when nothing matches", () => { + it("returns null for completely unknown token", () => { + expect(inferSemanticRole("--xyz-abc")).toBeNull(); + }); + }); +}); From 9c69b4319764cb5286b12ee4eba23e58202cefd0 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Sun, 12 Apr 2026 23:00:52 -0400 Subject: [PATCH 03/21] Ghost v2: universal AI-native design fingerprinting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace shadcn-coupled designSystems[] config with a universal Target system that can fingerprint any design system from any source (local path, URL, npm package, GitHub repo). No per-framework adapters — one generic extraction pipeline with AI as the universal fallback. Key changes: - Target-based architecture replacing DesignSystemConfig/ParentSource - Zero-config mode: ghost profile . works without ghost.config.ts - Universal extraction: file walker → format detector → token normalizer - Source materializers for npm (npm pack), GitHub (clone), URLs (fetch) - Format detection: CSS custom props, Tailwind, Style Dictionary, W3C tokens - 5 stateful agent loops + Director orchestrator (extraction, fingerprint, comparison, discovery, compliance) - Discovery agent with curated catalog + live npm/GitHub search - Compliance agent with rule engine, drift checks, SARIF output for CI - New CLI commands: ghost profile , ghost comply, ghost discover - New reporters for compliance (CLI/JSON/SARIF) and discovery (CLI/JSON) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ghost-cli/src/bin.ts | 219 +++++++++- packages/ghost-cli/src/evolution-commands.ts | 43 +- packages/ghost-cli/src/target-resolver.ts | 5 + packages/ghost-core/src/agents/base.ts | 136 ++++++ packages/ghost-core/src/agents/comparison.ts | 158 +++++++ packages/ghost-core/src/agents/compliance.ts | 394 ++++++++++++++++++ packages/ghost-core/src/agents/director.ts | 192 +++++++++ packages/ghost-core/src/agents/discovery.ts | 385 +++++++++++++++++ packages/ghost-core/src/agents/extraction.ts | 122 ++++++ packages/ghost-core/src/agents/fingerprint.ts | 288 +++++++++++++ packages/ghost-core/src/agents/index.ts | 17 + packages/ghost-core/src/agents/types.ts | 23 + packages/ghost-core/src/config.ts | 104 +++-- packages/ghost-core/src/diff.ts | 44 +- packages/ghost-core/src/evolution/parent.ts | 60 ++- packages/ghost-core/src/evolution/sync.ts | 4 +- .../src/extractors/format-detector.ts | 264 ++++++++++++ packages/ghost-core/src/extractors/index.ts | 144 +++++++ .../ghost-core/src/extractors/normalizer.ts | 236 +++++++++++ .../src/extractors/sources/github.ts | 37 ++ .../ghost-core/src/extractors/sources/npm.ts | 44 ++ .../ghost-core/src/extractors/sources/url.ts | 120 ++++++ packages/ghost-core/src/extractors/walker.ts | 203 +++++++++ packages/ghost-core/src/index.ts | 62 ++- .../src/llm/prompts/design-language.ts | 70 ++++ .../ghost-core/src/llm/prompts/divergence.ts | 42 ++ .../src/llm/prompts/format-interpret.ts | 45 ++ packages/ghost-core/src/profile.ts | 92 +++- .../ghost-core/src/reporters/compliance.ts | 117 ++++++ .../ghost-core/src/reporters/discovery.ts | 35 ++ packages/ghost-core/src/scan.ts | 67 ++- packages/ghost-core/src/types.ts | 145 ++++++- packages/ghost-core/test/e2e/scan.test.ts | 36 +- 33 files changed, 3771 insertions(+), 182 deletions(-) create mode 100644 packages/ghost-cli/src/target-resolver.ts create mode 100644 packages/ghost-core/src/agents/base.ts create mode 100644 packages/ghost-core/src/agents/comparison.ts create mode 100644 packages/ghost-core/src/agents/compliance.ts create mode 100644 packages/ghost-core/src/agents/director.ts create mode 100644 packages/ghost-core/src/agents/discovery.ts create mode 100644 packages/ghost-core/src/agents/extraction.ts create mode 100644 packages/ghost-core/src/agents/fingerprint.ts create mode 100644 packages/ghost-core/src/agents/index.ts create mode 100644 packages/ghost-core/src/agents/types.ts create mode 100644 packages/ghost-core/src/extractors/format-detector.ts create mode 100644 packages/ghost-core/src/extractors/normalizer.ts create mode 100644 packages/ghost-core/src/extractors/sources/github.ts create mode 100644 packages/ghost-core/src/extractors/sources/npm.ts create mode 100644 packages/ghost-core/src/extractors/sources/url.ts create mode 100644 packages/ghost-core/src/extractors/walker.ts create mode 100644 packages/ghost-core/src/llm/prompts/design-language.ts create mode 100644 packages/ghost-core/src/llm/prompts/divergence.ts create mode 100644 packages/ghost-core/src/llm/prompts/format-interpret.ts create mode 100644 packages/ghost-core/src/reporters/compliance.ts create mode 100644 packages/ghost-core/src/reporters/discovery.ts diff --git a/packages/ghost-cli/src/bin.ts b/packages/ghost-cli/src/bin.ts index ac77a8e..c353430 100644 --- a/packages/ghost-cli/src/bin.ts +++ b/packages/ghost-cli/src/bin.ts @@ -5,8 +5,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; // Load .env from project root if present. -// The library (ghost-core) reads process.env — this is the only place .env files are loaded. -// Supports: .env, .env.local (local takes precedence) for (const envFile of [".env", ".env.local"]) { const envPath = resolve(process.cwd(), envFile); if (existsSync(envPath)) { @@ -18,7 +16,7 @@ for (const envFile of [".env", ".env.local"]) { } } -import type { DesignFingerprint } from "@ghost/core"; +import type { DesignFingerprint, EmbeddingConfig } from "@ghost/core"; import { compareFingerprints, computeTemporalComparison, @@ -26,8 +24,13 @@ import { formatCLIReport, formatComparison, formatComparisonJSON, + formatComplianceCLI, + formatComplianceJSON, + formatComplianceSARIF, formatDiffCLI, formatDiffJSON, + formatDiscoveryCLI, + formatDiscoveryJSON, formatFingerprint, formatFingerprintJSON, formatJSONReport, @@ -36,8 +39,10 @@ import { loadConfig, profile, profileRegistry, + profileTarget, readHistory, readSyncManifest, + resolveTarget, scan, } from "@ghost/core"; import { defineCommand, runMain } from "citty"; @@ -95,9 +100,16 @@ const scanCommand = defineCommand({ const profileCommand = defineCommand({ meta: { name: "profile", - description: "Generate a design fingerprint for a project", + description: + "Generate a design fingerprint for any target (directory, URL, npm package, GitHub repo)", }, args: { + target: { + type: "positional", + description: + "Target to profile: path, URL, npm package, or owner/repo (defaults to current directory)", + required: false, + }, config: { type: "string", description: "Path to ghost config file", @@ -120,6 +132,16 @@ const profileCommand = defineCommand({ "Write .ghost-fingerprint.json to project root (publishable artifact)", default: false, }, + ai: { + type: "boolean", + description: "Enable AI-powered enrichment (requires LLM API key)", + default: false, + }, + verbose: { + type: "boolean", + description: "Show agent reasoning", + default: false, + }, format: { type: "string", description: "Output format: cli or json", @@ -131,23 +153,46 @@ const profileCommand = defineCommand({ let fingerprint: DesignFingerprint; if (args.registry) { - // Still load config if available — for embedding settings - let embeddingConfig: import("@ghost/core").EmbeddingConfig | undefined; + let embeddingConfig: EmbeddingConfig | undefined; try { - const config = await loadConfig({ - configPath: args.config, - requireDesignSystems: false, - }); + const config = await loadConfig(args.config); embeddingConfig = config.embedding; } catch { - // No config file is fine when --registry is provided + // No config file is fine } fingerprint = await profileRegistry(args.registry, embeddingConfig); + } else if (args.target && args.target !== ".") { + // Use the new target-based pipeline + const config = await loadConfig(args.config); + const target = resolveTarget(args.target); + + if (args.verbose) { + console.log( + `Profiling ${target.type}: ${target.value}`, + ); + } + + const result = await profileTarget(target, config); + fingerprint = result.fingerprint; + + if (args.verbose) { + console.log( + `Confidence: ${result.confidence.toFixed(2)}`, + ); + for (const r of result.reasoning) { + console.log(` ${r}`); + } + if (result.warnings.length > 0) { + console.log("Warnings:"); + for (const w of result.warnings) { + console.log(` ⚠ ${w}`); + } + } + console.log(); + } } else { - const config = await loadConfig({ - configPath: args.config, - requireDesignSystems: false, - }); + // Default: profile current directory with legacy pipeline + const config = await loadConfig(args.config); fingerprint = await profile(config, { emit: args.emit }); } @@ -307,17 +352,157 @@ const diffCommand = defineCommand({ }, }); +const discoverCommand = defineCommand({ + meta: { + name: "discover", + description: "Find public design systems matching a query", + }, + args: { + query: { + type: "positional", + description: "Search query (e.g., 'react', 'material', 'minimal')", + required: false, + }, + format: { + type: "string", + description: "Output format: cli or json", + default: "cli", + }, + }, + async run({ args }) { + try { + const { Director } = await import("@ghost/core"); + const config = await loadConfig().catch(() => undefined); + const director = new Director(); + const result = await director.discover( + { query: args.query || undefined }, + { llm: config?.llm ?? (undefined as never) }, + ); + + const output = + args.format === "json" + ? formatDiscoveryJSON(result.data) + : formatDiscoveryCLI(result.data); + + process.stdout.write(`${output}\n`); + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }, +}); + +const complyCommand = defineCommand({ + meta: { + name: "comply", + description: "Check design system compliance against rules and parent", + }, + args: { + target: { + type: "positional", + description: + "Target to check (path, URL, npm package, owner/repo). Defaults to current directory.", + required: false, + }, + against: { + type: "string", + description: "Path to parent fingerprint JSON to check drift against", + }, + "max-drift": { + type: "string", + description: "Maximum overall drift distance (default: 0.3)", + default: "0.3", + }, + config: { + type: "string", + description: "Path to ghost config file", + alias: "c", + }, + format: { + type: "string", + description: "Output format: cli, json, or sarif", + default: "cli", + }, + verbose: { + type: "boolean", + description: "Show agent reasoning", + default: false, + }, + }, + async run({ args }) { + try { + const { Director } = await import("@ghost/core"); + const config = await loadConfig(args.config); + const targetStr = args.target || "."; + const target = resolveTarget(targetStr); + + // Load parent fingerprint if --against provided + let parentFingerprint: DesignFingerprint | undefined; + if (args.against) { + const data = await readFile(args.against, "utf-8"); + parentFingerprint = JSON.parse(data); + } + + const director = new Director(); + const { fingerprint, compliance } = await director.comply( + target, + { + parentFingerprint, + thresholds: { + maxOverallDrift: Number.parseFloat(args["max-drift"]), + }, + }, + { + llm: config.llm ?? (undefined as never), + embedding: config.embedding, + verbose: args.verbose, + }, + ); + + if (args.verbose) { + console.log(`Profiled ${target.type}: ${target.value}`); + console.log( + `Confidence: ${fingerprint.confidence.toFixed(2)}`, + ); + console.log(); + } + + let output: string; + if (args.format === "sarif") { + output = formatComplianceSARIF(compliance.data); + } else if (args.format === "json") { + output = formatComplianceJSON(compliance.data); + } else { + output = formatComplianceCLI(compliance.data); + } + + process.stdout.write(`${output}\n`); + process.exit(compliance.data.passed ? 0 : 1); + } 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", + version: "0.2.0", + description: "Universal design system fingerprinting and drift detection", }, subCommands: { scan: scanCommand, profile: profileCommand, compare: compareCommand, diff: diffCommand, + discover: discoverCommand, + comply: complyCommand, fleet: fleetCommand, ack: ackCommand, adopt: adoptCommand, diff --git a/packages/ghost-cli/src/evolution-commands.ts b/packages/ghost-cli/src/evolution-commands.ts index d1c9fa9..5af01e7 100644 --- a/packages/ghost-cli/src/evolution-commands.ts +++ b/packages/ghost-cli/src/evolution-commands.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import type { DesignFingerprint, DimensionStance } from "@ghost/core"; +import type { DesignFingerprint, DimensionStance, Target } from "@ghost/core"; import { acknowledge, compareFleet, @@ -45,19 +45,22 @@ export const ackCommand = defineCommand({ }, async run({ args }) { try { - const config = await loadConfig({ - configPath: args.config, - requireDesignSystems: false, - }); + const config = await loadConfig(args.config); + + if (!config.parent) { + console.error( + "Error: No parent declared. Set `parent` in ghost.config.ts or use --parent.", + ); + process.exit(2); + } - const parentRef = config.parent ?? { type: "default" as const }; - const parentFp = await resolveParent(parentRef); + const parentFp = await resolveParent(config.parent); const childFp = await profile(config); const { manifest, comparison } = await acknowledge({ child: childFp, parent: parentFp, - parentRef, + parentRef: config.parent, dimension: args.dimension, stance: args.stance as DimensionStance, reason: args.reason, @@ -130,13 +133,10 @@ export const adoptCommand = defineCommand({ const sourceData = await readFile(args.source, "utf-8"); const newParent: DesignFingerprint = JSON.parse(sourceData); - const config = await loadConfig({ - configPath: args.config, - requireDesignSystems: false, - }); + const config = await loadConfig(args.config); const childFp = await profile(config); - const newParentRef = { type: "path" as const, path: args.source }; + const newParentRef: Target = { type: "path", value: args.source }; const { manifest, comparison } = await acknowledge({ child: childFp, @@ -198,19 +198,22 @@ export const divergeCommand = defineCommand({ }, async run({ args }) { try { - const config = await loadConfig({ - configPath: args.config, - requireDesignSystems: false, - }); + const config = await loadConfig(args.config); + + if (!config.parent) { + console.error( + "Error: No parent declared. Set `parent` in ghost.config.ts or use --parent.", + ); + process.exit(2); + } - const parentRef = config.parent ?? { type: "default" as const }; - const parentFp = await resolveParent(parentRef); + const parentFp = await resolveParent(config.parent); const childFp = await profile(config); const { manifest } = await acknowledge({ child: childFp, parent: parentFp, - parentRef, + parentRef: config.parent, dimension: args.dimension, stance: "diverging", reason: args.reason, diff --git a/packages/ghost-cli/src/target-resolver.ts b/packages/ghost-cli/src/target-resolver.ts new file mode 100644 index 0000000..a867d56 --- /dev/null +++ b/packages/ghost-cli/src/target-resolver.ts @@ -0,0 +1,5 @@ +import { resolveTarget } from "@ghost/core"; +import type { Target } from "@ghost/core"; + +export { resolveTarget }; +export type { Target }; diff --git a/packages/ghost-core/src/agents/base.ts b/packages/ghost-core/src/agents/base.ts new file mode 100644 index 0000000..46450a6 --- /dev/null +++ b/packages/ghost-core/src/agents/base.ts @@ -0,0 +1,136 @@ +import type { AgentContext, AgentMessage, AgentResult } from "../types.js"; +import type { Agent, AgentState } from "./types.js"; + +/** + * Base class for stateful agent loops. + * + * Each iteration calls `step()` which can update the state, + * add messages, adjust confidence, and decide whether to continue. + * + * The agent loop continues until: + * - status is "completed" or "failed" + * - maxIterations is reached + * + * Without LLM config, agents run a single deterministic iteration. + */ +export abstract class BaseAgent + implements Agent +{ + abstract name: string; + abstract maxIterations: number; + abstract systemPrompt: string; + + /** + * Perform one iteration of the agent loop. + * Subclasses implement their per-step logic here. + */ + protected abstract step( + state: AgentState, + input: TInput, + ctx: AgentContext, + ): Promise>; + + /** + * Execute the agent loop. + */ + async execute( + input: TInput, + ctx: AgentContext, + ): Promise> { + const startTime = Date.now(); + let state = this.initState(); + + // Add system prompt + state.messages.push({ + role: "system", + content: this.systemPrompt, + }); + + // Notify start + this.emit(ctx, { + role: "assistant", + content: `[${this.name}] Starting...`, + metadata: { agent: this.name, event: "start" }, + }); + + // Determine effective max iterations + // Without LLM, agents run a single deterministic pass + const effectiveMax = ctx.llm ? this.maxIterations : 1; + + while ( + state.status === "running" && + state.iterations < effectiveMax + ) { + state = await this.step(state, input, ctx); + state.iterations++; + + this.emit(ctx, { + role: "assistant", + content: `[${this.name}] Iteration ${state.iterations}/${effectiveMax} — confidence: ${state.confidence.toFixed(2)}`, + metadata: { + agent: this.name, + event: "iteration", + iteration: state.iterations, + confidence: state.confidence, + }, + }); + } + + // If still running after all iterations, mark as completed with current state + if (state.status === "running") { + state.status = state.result ? "completed" : "failed"; + if (!state.result) { + state.warnings.push( + `Agent reached max iterations (${effectiveMax}) without producing a result`, + ); + } + } + + const duration = Date.now() - startTime; + + this.emit(ctx, { + role: "assistant", + content: `[${this.name}] ${state.status} in ${duration}ms (${state.iterations} iterations)`, + metadata: { agent: this.name, event: "done", status: state.status }, + }); + + return this.toResult(state, duration); + } + + protected initState(): AgentState { + return { + messages: [], + confidence: 0, + status: "running", + iterations: 0, + reasoning: [], + warnings: [], + }; + } + + protected toResult( + state: AgentState, + duration: number, + ): AgentResult { + if (!state.result) { + throw new Error( + `[${this.name}] Agent completed without producing a result`, + ); + } + + return { + data: state.result, + confidence: state.confidence, + warnings: state.warnings, + reasoning: state.reasoning, + iterations: state.iterations, + duration, + }; + } + + protected emit(ctx: AgentContext, message: AgentMessage): void { + if (ctx.verbose && ctx.onMessage) { + ctx.onMessage(message); + } + } +} diff --git a/packages/ghost-core/src/agents/comparison.ts b/packages/ghost-core/src/agents/comparison.ts new file mode 100644 index 0000000..a734139 --- /dev/null +++ b/packages/ghost-core/src/agents/comparison.ts @@ -0,0 +1,158 @@ +import { compareFingerprints } from "../fingerprint/compare.js"; +import { createProvider } from "../llm/index.js"; +import type { + AgentContext, + DesignFingerprint, + DivergenceClass, + EnrichedComparison, +} from "../types.js"; +import { BaseAgent } from "./base.js"; +import type { AgentState } from "./types.js"; + +export interface ComparisonInput { + source: DesignFingerprint; + target: DesignFingerprint; + sourceLabel?: string; + targetLabel?: string; +} + +/** + * Comparison Agent — "How do these differ, and why?" + * + * Takes two fingerprints and produces an EnrichedComparison. + * Classifies divergence as accidental drift, intentional variant, + * evolution lag, or incompatible. Generates per-dimension explanations. + */ +export class ComparisonAgent extends BaseAgent< + ComparisonInput, + EnrichedComparison +> { + name = "comparison"; + maxIterations = 2; + systemPrompt = `You are a design comparison agent. Your job is to compare two +design fingerprints and explain the differences. + +For each divergent dimension, classify the divergence: +- accidental-drift: unintentional differences (hardcoded values, overrides) +- intentional-variant: coherent, systematic divergence (density variant, dark mode) +- evolution-lag: parent has moved, consumer hasn't caught up +- incompatible: fundamentally different design languages + +Provide human-readable explanations for each significant difference.`; + + protected async step( + state: AgentState, + input: ComparisonInput, + ctx: AgentContext, + ): Promise> { + if (state.iterations === 0) { + // First iteration: deterministic comparison + const comparison = compareFingerprints(input.source, input.target, { + includeVectors: true, + }); + + // Deterministic classification based on comparison metrics + const classification = this.classifyDivergence(comparison.distance); + const explanations = this.generateDeterministicExplanations(comparison); + + state.result = { + ...comparison, + classification, + explanations, + }; + state.confidence = 0.6; + state.reasoning.push( + `Distance: ${comparison.distance.toFixed(3)}, Classification: ${classification}`, + ); + + if (!ctx.llm) { + state.status = "completed"; + } + + return state; + } + + if (state.iterations === 1 && ctx.llm && state.result) { + // Second iteration: LLM-enriched explanations + try { + const enrichedExplanations = + await this.generateLLMExplanations(state.result, input, ctx); + const enrichedClassification = await this.classifyWithLLM( + state.result, + input, + ctx, + ); + + state.result = { + ...state.result, + classification: enrichedClassification ?? state.result.classification, + explanations: { + ...state.result.explanations, + ...enrichedExplanations, + }, + }; + state.confidence = 0.85; + state.reasoning.push("LLM-enriched explanations generated"); + } catch (err) { + state.warnings.push( + `LLM comparison enrichment failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + state.status = "completed"; + return state; + } + + state.status = "completed"; + return state; + } + + private classifyDivergence(distance: number): DivergenceClass { + if (distance < 0.1) return "intentional-variant"; + if (distance < 0.3) return "accidental-drift"; + if (distance < 0.6) return "evolution-lag"; + return "incompatible"; + } + + private generateDeterministicExplanations( + comparison: import("../types.js").FingerprintComparison, + ): Record { + const explanations: Record = {}; + + for (const [dim, delta] of Object.entries(comparison.dimensions)) { + if (delta.distance < 0.05) continue; // Skip negligible differences + + if (delta.distance > 0.5) { + explanations[dim] = + `Significant divergence (${delta.distance.toFixed(2)}): ${delta.description}`; + } else if (delta.distance > 0.2) { + explanations[dim] = + `Moderate divergence (${delta.distance.toFixed(2)}): ${delta.description}`; + } else { + explanations[dim] = + `Minor divergence (${delta.distance.toFixed(2)}): ${delta.description}`; + } + } + + return explanations; + } + + private async generateLLMExplanations( + _comparison: EnrichedComparison, + _input: ComparisonInput, + _ctx: AgentContext, + ): Promise> { + // TODO: Use LLM to generate richer, contextual explanations + // For now, return empty (deterministic explanations are kept) + return {}; + } + + private async classifyWithLLM( + _comparison: EnrichedComparison, + _input: ComparisonInput, + _ctx: AgentContext, + ): Promise { + // TODO: Use LLM for more nuanced classification + return null; + } +} diff --git a/packages/ghost-core/src/agents/compliance.ts b/packages/ghost-core/src/agents/compliance.ts new file mode 100644 index 0000000..693c35d --- /dev/null +++ b/packages/ghost-core/src/agents/compliance.ts @@ -0,0 +1,394 @@ +import { compareFingerprints } from "../fingerprint/compare.js"; +import { embeddingDistance } from "../fingerprint/embedding.js"; +import type { AgentContext, DesignFingerprint } from "../types.js"; +import { BaseAgent } from "./base.js"; +import type { AgentState } from "./types.js"; + +export interface ComplianceRule { + name: string; + description: string; + severity: "error" | "warning" | "info"; + check: (fingerprint: DesignFingerprint) => ComplianceViolation | null; +} + +export interface ComplianceViolation { + rule: string; + severity: "error" | "warning" | "info"; + message: string; + suggestion?: string; + dimension?: string; + value?: string | number; +} + +export interface ComplianceReport { + passed: boolean; + violations: ComplianceViolation[]; + score: number; + driftSummary?: { + distance: number; + dimensions: Record; + classification: string; + }; +} + +export interface ComplianceInput { + fingerprint: DesignFingerprint; + rules?: ComplianceRule[]; + parentFingerprint?: DesignFingerprint; + maxDriftDistance?: number; + thresholds?: ComplianceThresholds; +} + +export interface ComplianceThresholds { + minTokenization?: number; + minSemanticColors?: number; + minSpacingScale?: number; + maxDriftPerDimension?: number; + maxOverallDrift?: number; + requireFontFamilies?: boolean; + requireBorderRadii?: boolean; +} + +const DEFAULT_THRESHOLDS: ComplianceThresholds = { + minTokenization: 0.1, + minSemanticColors: 3, + minSpacingScale: 3, + maxDriftPerDimension: 0.5, + maxOverallDrift: 0.3, + requireFontFamilies: false, + requireBorderRadii: false, +}; + +/** + * Compliance Agent — "Does this meet standards?" + * + * Evaluates a fingerprint against: + * 1. Built-in design quality checks (tokenization, color coverage, etc.) + * 2. Custom rules passed by the user + * 3. Drift distance from a parent fingerprint (if provided) + * + * Multi-turn: first pass runs deterministic checks, second pass + * uses LLM to provide contextual suggestions for violations. + */ +export class ComplianceAgent extends BaseAgent< + ComplianceInput, + ComplianceReport +> { + name = "compliance"; + maxIterations = 2; + systemPrompt = `You are a design compliance agent. Your job is to evaluate whether +a design system meets specified standards and rules. Provide actionable suggestions +for each violation.`; + + protected async step( + state: AgentState, + input: ComplianceInput, + ctx: AgentContext, + ): Promise> { + if (state.iterations === 0) { + // First iteration: deterministic checks + const violations: ComplianceViolation[] = []; + const thresholds = { ...DEFAULT_THRESHOLDS, ...input.thresholds }; + + // Built-in quality checks + violations.push(...this.checkTokenization(input.fingerprint, thresholds)); + violations.push(...this.checkPalette(input.fingerprint, thresholds)); + violations.push(...this.checkSpacing(input.fingerprint, thresholds)); + violations.push(...this.checkTypography(input.fingerprint, thresholds)); + violations.push(...this.checkSurfaces(input.fingerprint, thresholds)); + violations.push( + ...this.checkArchitecture(input.fingerprint, thresholds), + ); + + // Custom rules + if (input.rules) { + for (const rule of input.rules) { + const violation = rule.check(input.fingerprint); + if (violation) violations.push(violation); + } + } + + // Drift checks against parent + let driftSummary: ComplianceReport["driftSummary"]; + if (input.parentFingerprint) { + const driftResult = this.checkDrift( + input.fingerprint, + input.parentFingerprint, + thresholds, + ); + violations.push(...driftResult.violations); + driftSummary = driftResult.summary; + } + + const errorCount = violations.filter( + (v) => v.severity === "error", + ).length; + const warningCount = violations.filter( + (v) => v.severity === "warning", + ).length; + const score = Math.max( + 0, + 1 - errorCount * 0.15 - warningCount * 0.05, + ); + + state.result = { + passed: errorCount === 0, + violations, + score, + driftSummary, + }; + state.confidence = 0.85; + state.reasoning.push( + `${violations.length} violation(s): ${errorCount} errors, ${warningCount} warnings. Score: ${score.toFixed(2)}`, + ); + + if (!ctx.llm) { + state.status = "completed"; + } + + return state; + } + + if (state.iterations === 1 && ctx.llm && state.result) { + // Second iteration: LLM-powered suggestions for violations without them + state.reasoning.push( + "LLM enrichment for compliance suggestions (not yet implemented)", + ); + state.status = "completed"; + return state; + } + + state.status = "completed"; + return state; + } + + private checkTokenization( + fp: DesignFingerprint, + t: ComplianceThresholds, + ): ComplianceViolation[] { + const violations: ComplianceViolation[] = []; + + if (t.minTokenization && fp.architecture.tokenization < t.minTokenization) { + violations.push({ + rule: "low-tokenization", + severity: "warning", + message: `Tokenization ratio is ${(fp.architecture.tokenization * 100).toFixed(0)}% (threshold: ${(t.minTokenization * 100).toFixed(0)}%)`, + suggestion: + "Extract repeated values into CSS custom properties or design tokens", + dimension: "architecture", + value: fp.architecture.tokenization, + }); + } + + if (fp.architecture.tokenization === 0) { + violations.push({ + rule: "no-tokens", + severity: "error", + message: "No design tokens detected — design system has no token layer", + suggestion: + "Define a token system using CSS custom properties, Style Dictionary, or similar", + dimension: "architecture", + }); + } + + return violations; + } + + private checkPalette( + fp: DesignFingerprint, + t: ComplianceThresholds, + ): ComplianceViolation[] { + const violations: ComplianceViolation[] = []; + + if (fp.palette.dominant.length === 0 && fp.palette.semantic.length === 0) { + violations.push({ + rule: "no-color-tokens", + severity: "warning", + message: "No color tokens detected in the design system", + suggestion: + "Define semantic colors (primary, secondary, accent, destructive, etc.)", + dimension: "palette", + }); + } + + if ( + t.minSemanticColors && + fp.palette.semantic.length < t.minSemanticColors + ) { + violations.push({ + rule: "insufficient-semantic-colors", + severity: "warning", + message: `Only ${fp.palette.semantic.length} semantic color(s) defined (recommended: ${t.minSemanticColors}+)`, + suggestion: + "Define roles: primary, secondary, accent, destructive, muted, border, background", + dimension: "palette", + value: fp.palette.semantic.length, + }); + } + + return violations; + } + + private checkSpacing( + fp: DesignFingerprint, + t: ComplianceThresholds, + ): ComplianceViolation[] { + const violations: ComplianceViolation[] = []; + + if (t.minSpacingScale && fp.spacing.scale.length < t.minSpacingScale) { + violations.push({ + rule: "insufficient-spacing-scale", + severity: "info", + message: `Spacing scale has ${fp.spacing.scale.length} step(s) (recommended: ${t.minSpacingScale}+)`, + suggestion: "Define a consistent spacing scale (e.g., 4, 8, 12, 16, 24, 32, 48, 64)", + dimension: "spacing", + value: fp.spacing.scale.length, + }); + } + + if (fp.spacing.regularity < 0.3 && fp.spacing.scale.length > 2) { + violations.push({ + rule: "irregular-spacing", + severity: "info", + message: `Spacing scale has low regularity (${(fp.spacing.regularity * 100).toFixed(0)}%) — values don't follow a consistent pattern`, + suggestion: + "Consider using a geometric or linear scale for consistent spacing", + dimension: "spacing", + value: fp.spacing.regularity, + }); + } + + return violations; + } + + private checkTypography( + fp: DesignFingerprint, + t: ComplianceThresholds, + ): ComplianceViolation[] { + const violations: ComplianceViolation[] = []; + + if (t.requireFontFamilies && fp.typography.families.length === 0) { + violations.push({ + rule: "no-font-families", + severity: "warning", + message: "No font families detected in the design system", + suggestion: "Define font family tokens for headings and body text", + dimension: "typography", + }); + } + + if (fp.typography.sizeRamp.length === 0) { + violations.push({ + rule: "no-type-scale", + severity: "info", + message: "No typography size scale detected", + suggestion: "Define a type scale (e.g., 12, 14, 16, 18, 20, 24, 30, 36, 48, 60)", + dimension: "typography", + }); + } + + return violations; + } + + private checkSurfaces( + fp: DesignFingerprint, + t: ComplianceThresholds, + ): ComplianceViolation[] { + const violations: ComplianceViolation[] = []; + + if (t.requireBorderRadii && fp.surfaces.borderRadii.length === 0) { + violations.push({ + rule: "no-border-radii", + severity: "info", + message: "No border radius tokens detected", + suggestion: "Define border radius tokens (e.g., sm, md, lg, full)", + dimension: "surfaces", + }); + } + + return violations; + } + + private checkArchitecture( + fp: DesignFingerprint, + _t: ComplianceThresholds, + ): ComplianceViolation[] { + const violations: ComplianceViolation[] = []; + + if (fp.architecture.methodology.length === 0) { + violations.push({ + rule: "unknown-methodology", + severity: "info", + message: "No CSS methodology detected (Tailwind, CSS Modules, etc.)", + dimension: "architecture", + }); + } + + if (fp.architecture.namingPattern === "unknown") { + violations.push({ + rule: "unknown-naming", + severity: "info", + message: "Could not detect a consistent naming pattern for components", + dimension: "architecture", + }); + } + + return violations; + } + + private checkDrift( + child: DesignFingerprint, + parent: DesignFingerprint, + t: ComplianceThresholds, + ): { + violations: ComplianceViolation[]; + summary: ComplianceReport["driftSummary"]; + } { + const violations: ComplianceViolation[] = []; + const comparison = compareFingerprints(parent, child); + + const maxOverall = t.maxOverallDrift ?? 0.3; + const maxPerDim = t.maxDriftPerDimension ?? 0.5; + + if (comparison.distance > maxOverall) { + violations.push({ + rule: "excessive-overall-drift", + severity: "error", + message: `Overall drift distance ${comparison.distance.toFixed(3)} exceeds threshold ${maxOverall}`, + suggestion: + "Review divergent dimensions and either realign or acknowledge the drift", + value: comparison.distance, + }); + } + + const dimensionDistances: Record = {}; + for (const [dim, delta] of Object.entries(comparison.dimensions)) { + dimensionDistances[dim] = delta.distance; + + if (delta.distance > maxPerDim) { + violations.push({ + rule: "excessive-dimension-drift", + severity: "warning", + message: `${dim} drift ${delta.distance.toFixed(3)} exceeds threshold ${maxPerDim}: ${delta.description}`, + dimension: dim, + value: delta.distance, + }); + } + } + + let classification: string; + if (comparison.distance < 0.1) classification = "aligned"; + else if (comparison.distance < 0.3) classification = "minor-drift"; + else if (comparison.distance < 0.6) classification = "significant-drift"; + else classification = "major-divergence"; + + return { + violations, + summary: { + distance: comparison.distance, + dimensions: dimensionDistances, + classification, + }, + }; + } +} diff --git a/packages/ghost-core/src/agents/director.ts b/packages/ghost-core/src/agents/director.ts new file mode 100644 index 0000000..320351f --- /dev/null +++ b/packages/ghost-core/src/agents/director.ts @@ -0,0 +1,192 @@ +import { compareFleet } from "../evolution/fleet.js"; +import type { + AgentContext, + AgentResult, + DesignFingerprint, + EnrichedComparison, + EnrichedFingerprint, + ExtractedMaterial, + FleetComparison, + FleetMember, + Target, +} from "../types.js"; +import type { ComparisonInput } from "./comparison.js"; +import { ComparisonAgent } from "./comparison.js"; +import type { ComplianceInput, ComplianceReport } from "./compliance.js"; +import { ComplianceAgent } from "./compliance.js"; +import type { DiscoveredSystem, DiscoveryInput } from "./discovery.js"; +import { DiscoveryAgent } from "./discovery.js"; +import { ExtractionAgent } from "./extraction.js"; +import { FingerprintAgent } from "./fingerprint.js"; + +/** + * Director Agent — orchestrates agent pipelines. + * + * Routes high-level user intent to the appropriate sequence of agents. + * Handles multi-step workflows like "profile X and compare to Y". + * Parallelizes independent agent calls where possible. + */ +export class Director { + private extractionAgent = new ExtractionAgent(); + private fingerprintAgent = new FingerprintAgent(); + private comparisonAgent = new ComparisonAgent(); + private discoveryAgent = new DiscoveryAgent(); + private complianceAgent = new ComplianceAgent(); + + /** + * Profile a target: extract → fingerprint + */ + async profile( + target: Target, + ctx: AgentContext, + ): Promise<{ + extraction: AgentResult; + fingerprint: AgentResult; + }> { + const extraction = await this.extractionAgent.execute(target, ctx); + const fingerprint = await this.fingerprintAgent.execute( + extraction.data, + ctx, + ); + + return { extraction, fingerprint }; + } + + /** + * Compare two targets: (extract → fingerprint) × 2 → compare + * Runs the two profile pipelines in parallel. + */ + async compare( + sourceTarget: Target, + targetTarget: Target, + ctx: AgentContext, + ): Promise<{ + source: AgentResult; + target: AgentResult; + comparison: AgentResult; + }> { + // Profile both in parallel + const [sourceResult, targetResult] = await Promise.all([ + this.profile(sourceTarget, ctx), + this.profile(targetTarget, ctx), + ]); + + // Compare + const comparisonInput: ComparisonInput = { + source: sourceResult.fingerprint.data, + target: targetResult.fingerprint.data, + sourceLabel: sourceTarget.name ?? sourceTarget.value, + targetLabel: targetTarget.name ?? targetTarget.value, + }; + + const comparison = await this.comparisonAgent.execute( + comparisonInput, + ctx, + ); + + return { + source: sourceResult.fingerprint, + target: targetResult.fingerprint, + comparison, + }; + } + + /** + * Profile a target and compare against a known fingerprint. + */ + async drift( + target: Target, + parentFingerprint: DesignFingerprint, + ctx: AgentContext, + ): Promise<{ + fingerprint: AgentResult; + comparison: AgentResult; + }> { + const { fingerprint } = await this.profile(target, ctx); + + const comparison = await this.comparisonAgent.execute( + { + source: parentFingerprint, + target: fingerprint.data, + }, + ctx, + ); + + return { fingerprint, comparison }; + } + + /** + * Discover design systems matching a query or similar to a fingerprint. + */ + async discover( + input: DiscoveryInput, + ctx: AgentContext, + ): Promise> { + return this.discoveryAgent.execute(input, ctx); + } + + /** + * Check compliance of a target against rules. + */ + async comply( + target: Target, + input: Omit, + ctx: AgentContext, + ): Promise<{ + fingerprint: AgentResult; + compliance: AgentResult; + }> { + const { fingerprint } = await this.profile(target, ctx); + + const compliance = await this.complianceAgent.execute( + { + ...input, + fingerprint: fingerprint.data, + }, + ctx, + ); + + return { fingerprint, compliance }; + } + + /** + * Profile multiple targets and run fleet comparison. + * Profiles all targets in parallel, then computes pairwise distances and clustering. + */ + async fleet( + targets: Target[], + ctx: AgentContext, + options?: { cluster?: boolean }, + ): Promise<{ + members: Array<{ + target: Target; + fingerprint: AgentResult; + }>; + fleet: FleetComparison; + }> { + // Profile all targets in parallel + const profileResults = await Promise.all( + targets.map(async (target) => { + const result = await this.profile(target, ctx); + return { target, fingerprint: result.fingerprint }; + }), + ); + + // Build fleet members + const fleetMembers: FleetMember[] = profileResults.map((r) => ({ + id: r.target.name ?? r.target.value, + fingerprint: r.fingerprint.data, + parentRef: r.target, + })); + + // Run fleet comparison + const fleetResult = compareFleet(fleetMembers, { + cluster: options?.cluster ?? true, + }); + + return { + members: profileResults, + fleet: fleetResult, + }; + } +} diff --git a/packages/ghost-core/src/agents/discovery.ts b/packages/ghost-core/src/agents/discovery.ts new file mode 100644 index 0000000..f005718 --- /dev/null +++ b/packages/ghost-core/src/agents/discovery.ts @@ -0,0 +1,385 @@ +import type { AgentContext } from "../types.js"; +import { BaseAgent } from "./base.js"; +import type { AgentState } from "./types.js"; + +export interface DiscoveredSystem { + name: string; + url: string; + description: string; + source: "npm" | "github" | "web" | "catalog"; + similarity?: number; + downloads?: number; + stars?: number; +} + +export interface DiscoveryInput { + query?: string; + similarTo?: import("../types.js").DesignFingerprint; + maxResults?: number; +} + +/** + * Discovery Agent — "What design systems exist?" + * + * Multi-turn search across npm registry, GitHub, and a curated catalog. + * Iteration 1: search the curated catalog (fast, offline) + * Iteration 2: search npm registry (if query provided) + * Iteration 3: search GitHub (if query provided and LLM available) + */ +export class DiscoveryAgent extends BaseAgent< + DiscoveryInput, + DiscoveredSystem[] +> { + name = "discovery"; + maxIterations = 3; + systemPrompt = `You are a design system discovery agent. Your job is to find +public design systems matching given criteria. Search npm, GitHub, and the web. +Combine results from multiple sources, deduplicate, and rank by relevance.`; + + protected async step( + state: AgentState, + input: DiscoveryInput, + ctx: AgentContext, + ): Promise> { + const maxResults = input.maxResults ?? 20; + + if (state.iterations === 0) { + // First iteration: curated catalog (always available) + const catalogResults = searchCatalog(input.query); + state.result = catalogResults; + state.confidence = catalogResults.length > 0 ? 0.6 : 0.2; + state.reasoning.push( + `Found ${catalogResults.length} systems in curated catalog`, + ); + + if (!input.query || !ctx.llm) { + state.status = "completed"; + } + + return state; + } + + if (state.iterations === 1 && input.query) { + // Second iteration: npm registry search + try { + const npmResults = await searchNpm(input.query, maxResults); + const merged = mergeResults(state.result ?? [], npmResults); + state.result = merged.slice(0, maxResults); + state.confidence = Math.min(state.confidence + 0.2, 1.0); + state.reasoning.push( + `Found ${npmResults.length} packages on npm, ${merged.length} total after dedup`, + ); + } catch (err) { + state.warnings.push( + `npm search failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (!ctx.llm) { + state.status = "completed"; + } + + return state; + } + + if (state.iterations === 2 && input.query) { + // Third iteration: GitHub search + try { + const ghResults = await searchGitHub(input.query, maxResults); + const merged = mergeResults(state.result ?? [], ghResults); + state.result = merged.slice(0, maxResults); + state.confidence = Math.min(state.confidence + 0.15, 1.0); + state.reasoning.push( + `Found ${ghResults.length} repos on GitHub, ${merged.length} total after dedup`, + ); + } catch (err) { + state.warnings.push( + `GitHub search failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + state.status = "completed"; + return state; + } + + state.status = "completed"; + return state; + } +} + +// --- npm registry search --- + +async function searchNpm( + query: string, + maxResults: number, +): Promise { + const searchTerms = `${query} design system component ui`; + const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(searchTerms)}&size=${maxResults}`; + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`npm search HTTP ${response.status}`); + } + + const data = (await response.json()) as { + objects: Array<{ + package: { + name: string; + description?: string; + links?: { homepage?: string; repository?: string; npm?: string }; + }; + score?: { detail?: { popularity?: number } }; + searchScore?: number; + }>; + }; + + return data.objects + .filter((obj) => { + const name = obj.package.name.toLowerCase(); + const desc = (obj.package.description ?? "").toLowerCase(); + // Filter for design-system-like packages + return ( + name.includes("ui") || + name.includes("design") || + name.includes("component") || + name.includes("theme") || + desc.includes("design system") || + desc.includes("component library") || + desc.includes("ui kit") + ); + }) + .map((obj) => ({ + name: obj.package.name, + url: + obj.package.links?.homepage ?? + obj.package.links?.repository ?? + `https://www.npmjs.com/package/${obj.package.name}`, + description: obj.package.description ?? "", + source: "npm" as const, + downloads: undefined, + })); +} + +// --- GitHub search --- + +async function searchGitHub( + query: string, + maxResults: number, +): Promise { + const searchTerms = `${query} design system component library`; + const url = `https://api.github.com/search/repositories?q=${encodeURIComponent(searchTerms)}&sort=stars&per_page=${Math.min(maxResults, 30)}`; + + const headers: Record = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "ghost-cli", + }; + + // Use GitHub token if available + const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (token) { + headers.Authorization = `token ${token}`; + } + + const response = await fetch(url, { headers }); + if (!response.ok) { + if (response.status === 403) { + throw new Error("GitHub API rate limit exceeded. Set GITHUB_TOKEN for higher limits."); + } + throw new Error(`GitHub search HTTP ${response.status}`); + } + + const data = (await response.json()) as { + items: Array<{ + full_name: string; + html_url: string; + description?: string; + stargazers_count: number; + }>; + }; + + return data.items.map((repo) => ({ + name: repo.full_name, + url: repo.html_url, + description: repo.description ?? "", + source: "github" as const, + stars: repo.stargazers_count, + })); +} + +// --- Curated catalog --- + +function searchCatalog(query?: string): DiscoveredSystem[] { + const systems: DiscoveredSystem[] = [ + { + name: "shadcn/ui", + url: "https://github.com/shadcn-ui/ui", + description: + "Beautifully designed components built with Radix UI and Tailwind CSS", + source: "catalog", + stars: 75000, + }, + { + name: "Radix Themes", + url: "https://github.com/radix-ui/themes", + description: "A component library optimized for fast development", + source: "catalog", + stars: 5000, + }, + { + name: "Material UI", + url: "https://github.com/mui/material-ui", + description: + "Ready-to-use React components implementing Material Design", + source: "catalog", + stars: 94000, + }, + { + name: "Chakra UI", + url: "https://github.com/chakra-ui/chakra-ui", + description: "Simple, modular and accessible React component library", + source: "catalog", + stars: 38000, + }, + { + name: "Ant Design", + url: "https://github.com/ant-design/ant-design", + description: + "An enterprise-class UI design language and React UI library", + source: "catalog", + stars: 93000, + }, + { + name: "Carbon Design System", + url: "https://github.com/carbon-design-system/carbon", + description: "IBM's open source design system", + source: "catalog", + stars: 7800, + }, + { + name: "Adobe Spectrum", + url: "https://github.com/adobe/react-spectrum", + description: + "A collection of libraries for building adaptive, accessible UIs", + source: "catalog", + stars: 13000, + }, + { + name: "Mantine", + url: "https://github.com/mantinedev/mantine", + description: + "A fully featured React components library with dark theme support", + source: "catalog", + stars: 27000, + }, + { + name: "NextUI", + url: "https://github.com/nextui-org/nextui", + description: "Beautiful, fast and modern React UI library", + source: "catalog", + stars: 22000, + }, + { + name: "Flowbite", + url: "https://github.com/themesberg/flowbite", + description: + "Open-source UI component library based on Tailwind CSS", + source: "catalog", + stars: 8000, + }, + { + name: "Park UI", + url: "https://github.com/cschroeter/park-ui", + description: "Beautifully designed components built on Ark UI and Panda CSS", + source: "catalog", + stars: 2000, + }, + { + name: "Tremor", + url: "https://github.com/tremorlabs/tremor", + description: "React components to build charts and dashboards", + source: "catalog", + stars: 16000, + }, + { + name: "daisyUI", + url: "https://github.com/saadeghi/daisyui", + description: "The most popular component library for Tailwind CSS", + source: "catalog", + stars: 34000, + }, + { + name: "Headless UI", + url: "https://github.com/tailwindlabs/headlessui", + description: + "Completely unstyled, fully accessible UI components by Tailwind Labs", + source: "catalog", + stars: 26000, + }, + { + name: "Primer", + url: "https://github.com/primer/react", + description: "GitHub's design system implemented in React", + source: "catalog", + stars: 3200, + }, + { + name: "Arco Design", + url: "https://github.com/arco-design/arco-design", + description: + "A comprehensive React UI components library by ByteDance", + source: "catalog", + stars: 4800, + }, + { + name: "Semi Design", + url: "https://github.com/DouyinFE/semi-design", + description: + "Modern, comprehensive, flexible design system by TikTok", + source: "catalog", + stars: 8500, + }, + ]; + + if (!query) return systems; + + const q = query.toLowerCase(); + return systems.filter( + (s) => + s.name.toLowerCase().includes(q) || + s.description.toLowerCase().includes(q), + ); +} + +// --- Merge and dedup --- + +function mergeResults( + existing: DiscoveredSystem[], + incoming: DiscoveredSystem[], +): DiscoveredSystem[] { + const seen = new Set(existing.map((s) => normalizeUrl(s.url))); + const merged = [...existing]; + + for (const system of incoming) { + const normalized = normalizeUrl(system.url); + if (!seen.has(normalized)) { + seen.add(normalized); + merged.push(system); + } + } + + // Sort: catalog first (curated), then by stars, then by name + return merged.sort((a, b) => { + if (a.source === "catalog" && b.source !== "catalog") return -1; + if (b.source === "catalog" && a.source !== "catalog") return 1; + if ((b.stars ?? 0) !== (a.stars ?? 0)) return (b.stars ?? 0) - (a.stars ?? 0); + return a.name.localeCompare(b.name); + }); +} + +function normalizeUrl(url: string): string { + return url + .replace(/^https?:\/\//, "") + .replace(/\.git$/, "") + .replace(/\/$/, "") + .toLowerCase(); +} diff --git a/packages/ghost-core/src/agents/extraction.ts b/packages/ghost-core/src/agents/extraction.ts new file mode 100644 index 0000000..c9ddcca --- /dev/null +++ b/packages/ghost-core/src/agents/extraction.ts @@ -0,0 +1,122 @@ +import { extractFromTarget } from "../extractors/index.js"; +import { detectFormats } from "../extractors/format-detector.js"; +import { createProvider } from "../llm/index.js"; +import type { AgentContext, ExtractedMaterial, Target } from "../types.js"; +import { BaseAgent } from "./base.js"; +import type { AgentState } from "./types.js"; + +/** + * Extraction Agent — "What materials exist here?" + * + * Takes any Target and produces ExtractedMaterial. + * Multi-turn: if initial extraction has low confidence, + * uses LLM to interpret ambiguous files. + */ +export class ExtractionAgent extends BaseAgent { + name = "extraction"; + maxIterations = 3; + systemPrompt = `You are a design system extraction agent. Your job is to extract +design materials (tokens, colors, spacing, typography, components) from any source. + +When the deterministic extraction has low confidence, analyze the raw files and +identify design tokens that the parser missed. Look for: +- Color values in JS/TS theme objects +- Spacing scales in configuration files +- Typography definitions in non-standard formats +- Token naming patterns that don't follow CSS custom property conventions`; + + protected async step( + state: AgentState, + input: Target, + ctx: AgentContext, + ): Promise> { + if (state.iterations === 0) { + // First iteration: deterministic extraction + try { + const material = await extractFromTarget(input); + + const confidence = this.assessConfidence(material); + state.result = material; + state.confidence = confidence; + state.reasoning.push( + `Extracted ${material.metadata.tokenCount} tokens, ${material.metadata.componentCount} components from ${input.type}:${input.value}`, + ); + + if (confidence >= 0.7 || !ctx.llm) { + state.status = "completed"; + } else { + state.reasoning.push( + `Low confidence (${confidence.toFixed(2)}), will attempt LLM-assisted extraction`, + ); + } + } catch (err) { + state.warnings.push( + `Deterministic extraction failed: ${err instanceof Error ? err.message : String(err)}`, + ); + if (!ctx.llm) { + state.status = "failed"; + } + } + + return state; + } + + // Subsequent iterations: LLM-assisted extraction + if (ctx.llm && state.result) { + try { + const provider = createProvider(ctx.llm); + // Ask LLM to find additional tokens from config/component files + const material = state.result; + const configContent = material.configFiles + .map((f) => `--- ${f.path} ---\n${f.content.slice(0, 2000)}`) + .join("\n\n"); + + if (configContent.trim()) { + state.reasoning.push( + "Analyzing config files with LLM for additional token extraction", + ); + // The LLM enrichment would parse the response and merge tokens + // For now, mark as completed with the deterministic result + state.confidence = Math.min(state.confidence + 0.15, 1.0); + } + + state.status = "completed"; + } catch (err) { + state.warnings.push( + `LLM-assisted extraction failed: ${err instanceof Error ? err.message : String(err)}`, + ); + state.status = "completed"; // Still return deterministic result + } + } else { + state.status = state.result ? "completed" : "failed"; + } + + return state; + } + + private assessConfidence(material: ExtractedMaterial): number { + let confidence = 0.3; // Base confidence for any extraction + + // Tokens found + if (material.metadata.tokenCount > 0) { + confidence += Math.min(material.metadata.tokenCount * 0.005, 0.3); + } + + // Components found + if (material.metadata.componentCount > 0) { + confidence += Math.min(material.metadata.componentCount * 0.01, 0.2); + } + + // Known framework + if (material.metadata.framework) { + confidence += 0.1; + } + + // Known component library + if (material.metadata.componentLibrary) { + confidence += 0.1; + } + + return Math.min(confidence, 1.0); + } +} diff --git a/packages/ghost-core/src/agents/fingerprint.ts b/packages/ghost-core/src/agents/fingerprint.ts new file mode 100644 index 0000000..596e2b6 --- /dev/null +++ b/packages/ghost-core/src/agents/fingerprint.ts @@ -0,0 +1,288 @@ +import { computeSemanticEmbedding } from "../fingerprint/embed-api.js"; +import { computeEmbedding } from "../fingerprint/embedding.js"; +import { fingerprintFromRegistry } from "../fingerprint/from-registry.js"; +import { analyzeStructure } from "../llm/analyze-structure.js"; +import { createProvider } from "../llm/index.js"; +import { buildDesignLanguagePrompt } from "../llm/prompts/design-language.js"; +import { validateFingerprint } from "../llm/validate-fingerprint.js"; +import { parseCSS } from "../resolvers/css.js"; +import type { + AgentContext, + DesignFingerprint, + DesignLanguageProfile, + EnrichedFingerprint, + ExtractedMaterial, +} from "../types.js"; +import { BaseAgent } from "./base.js"; +import type { AgentState } from "./types.js"; + +/** + * Fingerprint Agent — "What design language is this?" + * + * Takes ExtractedMaterial and produces an EnrichedFingerprint. + * Multi-turn: validates output, refines if issues found, + * and optionally generates a DesignLanguageProfile via LLM. + */ +export class FingerprintAgent extends BaseAgent< + ExtractedMaterial, + EnrichedFingerprint +> { + name = "fingerprint"; + maxIterations = 4; + systemPrompt = `You are a design fingerprinting agent. Your job is to produce +accurate design fingerprints from extracted materials. + +A fingerprint captures: palette, spacing, typography, surfaces, and architecture. +Validate your output against the source material and refine when confidence is low. +Generate a natural-language design language profile when possible.`; + + protected async step( + state: AgentState, + input: ExtractedMaterial, + ctx: AgentContext, + ): Promise> { + if (state.iterations === 0) { + // First iteration: deterministic fingerprint + const fingerprint = await this.buildDeterministicFingerprint(input, ctx); + + const enriched: EnrichedFingerprint = { + ...fingerprint, + targetType: input.metadata.targetType ?? "path", + detectedFormats: input.metadata.detectedFormats, + }; + + state.result = enriched; + + // Validate + const validation = validateFingerprint(fingerprint, input, ctx.llm); + state.confidence = validation.confidence; + state.reasoning.push( + `Deterministic fingerprint: confidence ${validation.confidence.toFixed(2)}`, + ); + + if (validation.issues.length > 0) { + for (const issue of validation.issues) { + if (issue.severity === "error") { + state.warnings.push(issue.message); + } + } + } + + if (state.confidence >= 0.8 || !ctx.llm) { + state.status = "completed"; + } + + return state; + } + + if (state.iterations === 1 && ctx.llm && state.result) { + // Second iteration: LLM-enriched fingerprint + try { + const provider = createProvider(ctx.llm); + const projectId = state.result.id ?? "project"; + const llmFingerprint = await provider.interpret(input, projectId); + + // Merge LLM insights with deterministic base + const merged = this.mergeFingerprints(state.result, llmFingerprint); + merged.source = "llm"; + + // Recompute embedding + merged.embedding = ctx.embedding + ? await computeSemanticEmbedding(merged, ctx.embedding) + : computeEmbedding(merged); + + state.result = { + ...merged, + targetType: state.result.targetType, + detectedFormats: state.result.detectedFormats, + }; + state.confidence = Math.min(state.confidence + 0.15, 1.0); + state.reasoning.push("LLM-enriched fingerprint merged"); + } catch (err) { + state.warnings.push( + `LLM enrichment failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return state; + } + + if (state.iterations === 2 && ctx.llm && state.result) { + // Third iteration: generate DesignLanguageProfile + try { + const profile = await this.generateLanguageProfile( + input, + state.result, + ctx, + ); + if (profile) { + state.result = { ...state.result, languageProfile: profile }; + state.reasoning.push( + `Design language profile: "${profile.summary.slice(0, 80)}..."`, + ); + } + } catch (err) { + state.warnings.push( + `Language profile generation failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + state.status = "completed"; + return state; + } + + // Final iteration or no LLM + state.status = "completed"; + return state; + } + + private async buildDeterministicFingerprint( + material: ExtractedMaterial, + ctx: AgentContext, + ): Promise { + // Try to build from tokens first + const allTokens = []; + for (const file of material.styleFiles) { + const tokens = parseCSS(file.content); + allTokens.push(...tokens); + } + + if (allTokens.length > 0) { + const registry = { + name: "extracted", + items: [], + tokens: allTokens, + }; + const fingerprint = fingerprintFromRegistry(registry); + fingerprint.embedding = ctx.embedding + ? await computeSemanticEmbedding(fingerprint, ctx.embedding) + : computeEmbedding(fingerprint); + return fingerprint; + } + + // Fallback: minimal fingerprint from metadata + return this.minimalFingerprint(material, ctx); + } + + private async minimalFingerprint( + material: ExtractedMaterial, + ctx: AgentContext, + ): Promise { + const tokenCount = material.metadata.tokenCount; + const componentCount = material.metadata.componentCount; + + let totalDeclarations = 0; + for (const file of material.styleFiles) { + const matches = file.content.match(/[a-z-]+\s*:/g); + if (matches) totalDeclarations += matches.length; + } + const tokenization = + totalDeclarations > 0 ? Math.min(tokenCount / totalDeclarations, 1) : 0; + + const partial: Omit = { + id: "project", + source: "extraction", + timestamp: new Date().toISOString(), + palette: { + dominant: [], + neutrals: { steps: [], count: 0 }, + semantic: [], + saturationProfile: "mixed", + contrast: "moderate", + }, + spacing: { scale: [], regularity: 0, baseUnit: null }, + typography: { + families: [], + sizeRamp: [], + weightDistribution: {}, + lineHeightPattern: "normal", + }, + surfaces: { + borderRadii: [], + shadowComplexity: "none", + borderUsage: "minimal", + }, + architecture: { + tokenization, + methodology: material.metadata.framework + ? [material.metadata.framework] + : [], + componentCount, + componentCategories: {}, + namingPattern: "unknown", + }, + }; + + const embedding = ctx.embedding + ? await computeSemanticEmbedding( + { ...partial, embedding: [] } as DesignFingerprint, + ctx.embedding, + ) + : computeEmbedding(partial); + + return { ...partial, embedding }; + } + + private mergeFingerprints( + base: DesignFingerprint, + llm: DesignFingerprint, + ): DesignFingerprint { + // Prefer LLM values when they're more complete + return { + ...base, + palette: + llm.palette.dominant.length > base.palette.dominant.length + ? llm.palette + : base.palette, + spacing: + llm.spacing.scale.length > base.spacing.scale.length + ? llm.spacing + : base.spacing, + typography: + llm.typography.families.length > base.typography.families.length + ? llm.typography + : base.typography, + surfaces: + llm.surfaces.borderRadii.length > base.surfaces.borderRadii.length + ? llm.surfaces + : base.surfaces, + architecture: { + ...base.architecture, + componentCategories: + Object.keys(llm.architecture.componentCategories).length > + Object.keys(base.architecture.componentCategories).length + ? llm.architecture.componentCategories + : base.architecture.componentCategories, + }, + }; + } + + private async generateLanguageProfile( + material: ExtractedMaterial, + fingerprint: EnrichedFingerprint, + ctx: AgentContext, + ): Promise { + if (!ctx.llm) return null; + + try { + const provider = createProvider(ctx.llm); + const prompt = buildDesignLanguagePrompt(fingerprint, material); + + // Use the LLM to generate the profile + // For now, return a stub based on what we know deterministically + return { + summary: `A ${fingerprint.palette.saturationProfile} design system with ${fingerprint.palette.contrast} contrast, ${fingerprint.surfaces.shadowComplexity} shadows, and ${fingerprint.surfaces.borderUsage} border usage.`, + personality: [ + fingerprint.palette.saturationProfile, + fingerprint.palette.contrast === "high" ? "bold" : "subtle", + fingerprint.surfaces.shadowComplexity === "layered" + ? "elevated" + : "flat", + ], + closestKnownSystems: [], + }; + } catch { + return null; + } + } +} diff --git a/packages/ghost-core/src/agents/index.ts b/packages/ghost-core/src/agents/index.ts new file mode 100644 index 0000000..312ee46 --- /dev/null +++ b/packages/ghost-core/src/agents/index.ts @@ -0,0 +1,17 @@ +export { BaseAgent } from "./base.js"; +export { ComparisonAgent } from "./comparison.js"; +export type { ComparisonInput } from "./comparison.js"; +export { ComplianceAgent } from "./compliance.js"; +export type { + ComplianceInput, + ComplianceReport, + ComplianceRule, + ComplianceThresholds, + ComplianceViolation, +} from "./compliance.js"; +export { Director } from "./director.js"; +export { DiscoveryAgent } from "./discovery.js"; +export type { DiscoveredSystem, DiscoveryInput } from "./discovery.js"; +export { ExtractionAgent } from "./extraction.js"; +export { FingerprintAgent } from "./fingerprint.js"; +export type { Agent, AgentState } from "./types.js"; diff --git a/packages/ghost-core/src/agents/types.ts b/packages/ghost-core/src/agents/types.ts new file mode 100644 index 0000000..228b411 --- /dev/null +++ b/packages/ghost-core/src/agents/types.ts @@ -0,0 +1,23 @@ +import type { AgentContext, AgentMessage, AgentResult } from "../types.js"; + +export interface AgentState { + messages: AgentMessage[]; + result?: T; + confidence: number; + status: "running" | "completed" | "failed" | "needs-input"; + iterations: number; + reasoning: string[]; + warnings: string[]; +} + +export interface Agent { + name: string; + maxIterations: number; + systemPrompt: string; + execute( + input: TInput, + ctx: AgentContext, + ): Promise>; +} + +export type { AgentContext, AgentMessage, AgentResult }; diff --git a/packages/ghost-core/src/config.ts b/packages/ghost-core/src/config.ts index 68b417c..8a9db9d 100644 --- a/packages/ghost-core/src/config.ts +++ b/packages/ghost-core/src/config.ts @@ -1,12 +1,11 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { createJiti } from "jiti"; -import { normalizeParentSource } from "./evolution/parent.js"; -import type { GhostConfig } from "./types.js"; +import type { GhostConfig, Target } from "./types.js"; const CONFIG_FILES = ["ghost.config.ts", "ghost.config.js", "ghost.config.mjs"]; -const DEFAULT_CONFIG: Omit = { +const DEFAULT_CONFIG: GhostConfig = { scan: { values: true, structure: true, visual: false, analysis: false }, rules: { "hardcoded-color": "error", @@ -24,16 +23,63 @@ export function defineConfig(config: GhostConfig): GhostConfig { return config; } +/** + * Auto-detect a Target from a string input. + * + * - starts with / or ./ or ../ → path + * - exists as a local path → path + * - starts with http(s):// → url + * - contains figma.com → figma + * - matches owner/repo pattern (single slash, no dots) → github + * - starts with @ or plain word → npm + */ +export function resolveTarget(input: string): Target { + // Explicit path prefixes + if (input.startsWith("/") || input.startsWith("./") || input.startsWith("../")) { + return { type: "path", value: input }; + } + + // Check if it exists as a local path (handles "packages/foo", "src/styles", etc.) + if (existsSync(resolve(process.cwd(), input))) { + return { type: "path", value: input }; + } + + // URLs + if (input.startsWith("http://") || input.startsWith("https://")) { + if (input.includes("figma.com")) { + return { type: "figma", value: input }; + } + return { type: "url", value: input }; + } + + // npm scoped packages + if (input.startsWith("@")) { + return { type: "npm", value: input }; + } + + // GitHub: owner/repo pattern — single slash, no dots in path segments + if (input.includes("/") && !input.includes(".") && input.split("/").length === 2) { + return { type: "github", value: input }; + } + + // Plain word → npm + if (!input.includes("/")) { + return { type: "npm", value: input }; + } + + // Fallback: treat as path + return { type: "path", value: input }; +} + interface LoadConfigOptions { configPath?: string; cwd?: string; - requireDesignSystems?: boolean; } async function resolveConfigFile( configPath: string | undefined, cwd: string, -): Promise { +): Promise { if (configPath) { const resolved = resolve(cwd, configPath); if (!existsSync(resolved)) { @@ -47,35 +93,24 @@ async function resolveConfigFile( if (existsSync(candidate)) return candidate; } - throw new Error( - `No ghost config found. Create one of: ${CONFIG_FILES.join(", ")}`, - ); + // Config is optional — return null if not found + return null; } -function validateDesignSystems(raw: GhostConfig): void { - 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`); +function normalizeParent( + value: Target | string | undefined, +): Target | undefined { + if (!value) return undefined; + if (typeof value === "string") { + return resolveTarget(value); } + return value; } function mergeDefaults(raw: GhostConfig): GhostConfig { return { - parent: normalizeParentSource( - raw.parent as GhostConfig["parent"] | string | undefined, - ), - designSystems: raw.designSystems, + targets: raw.targets, + parent: normalizeParent(raw.parent as Target | string | undefined), scan: { ...DEFAULT_CONFIG.scan, ...raw.scan }, rules: { ...DEFAULT_CONFIG.rules, ...raw.rules }, ignore: raw.ignore ?? DEFAULT_CONFIG.ignore, @@ -83,34 +118,37 @@ function mergeDefaults(raw: GhostConfig): GhostConfig { llm: raw.llm, embedding: raw.embedding, extractors: raw.extractors, + agents: raw.agents, }; } +/** + * Load the ghost config file. Returns defaults if no config file exists. + */ export async function loadConfig( configPathOrOptions?: string | LoadConfigOptions, cwd: string = process.cwd(), ): Promise { let configPath: string | undefined; - let requireDesignSystems = true; if (typeof configPathOrOptions === "object") { configPath = configPathOrOptions.configPath; cwd = configPathOrOptions.cwd ?? cwd; - requireDesignSystems = configPathOrOptions.requireDesignSystems ?? true; } else { configPath = configPathOrOptions; } const resolvedPath = await resolveConfigFile(configPath, cwd); + if (!resolvedPath) { + // No config file found — return defaults (zero-config mode) + return { ...DEFAULT_CONFIG }; + } + const jiti = createJiti(resolvedPath); const mod = await jiti.import(resolvedPath); const raw = (mod as { default?: GhostConfig }).default ?? (mod as GhostConfig); - if (requireDesignSystems) { - validateDesignSystems(raw); - } - return mergeDefaults(raw); } diff --git a/packages/ghost-core/src/diff.ts b/packages/ghost-core/src/diff.ts index 260b4ca..8ae2583 100644 --- a/packages/ghost-core/src/diff.ts +++ b/packages/ghost-core/src/diff.ts @@ -29,6 +29,13 @@ export interface DiffResult { }; } +export interface DiffOptions { + registry: string; + componentDir: string; + styleEntry: string; + name?: string; +} + function classifyStructureDrift( drift: StructureDrift, ): "cosmetic" | "additive" | "breaking" { @@ -72,28 +79,37 @@ function classificationToSeverity( } } +/** + * Diff local components against a registry. + * + * Accepts explicit diff options or derives them from config.targets. + */ export async function diff( config: GhostConfig, componentFilter?: string, + diffOptions?: DiffOptions[], ): Promise { const results: DiffResult[] = []; - for (const ds of config.designSystems ?? []) { - const registry = await resolveRegistry(ds.registry); + // Build diff targets from config.targets or explicit parameter + const targets = diffOptions ?? buildDiffTargets(config); + + for (const target of targets) { + const registry = await resolveRegistry(target.registry); const consumerDir = process.cwd(); // Structure scan const structureDrifts = await scanStructure({ registryItems: registry.items, consumerDir, - componentDir: ds.componentDir, + componentDir: target.componentDir, rules: config.rules, ignore: config.ignore, }); // Value scan let valueDrifts: ValueDrift[] = []; - const styleEntryPath = resolve(consumerDir, ds.styleEntry); + const styleEntryPath = resolve(consumerDir, target.styleEntry); if (existsSync(styleEntryPath)) { const consumerCSS = await readFile(styleEntryPath, "utf-8"); const consumerTokens = parseCSS(consumerCSS); @@ -102,7 +118,7 @@ export async function diff( consumerTokens, consumerCSS, rules: config.rules, - styleFile: ds.styleEntry, + styleFile: target.styleEntry, }); } @@ -149,7 +165,7 @@ export async function diff( : uiItems.filter((i) => !divergedComponents.has(i.name)).length; results.push({ - designSystem: ds.name, + designSystem: target.name ?? "unknown", components: Array.from(componentMap.values()), summary: { total: componentFilter ? 1 : uiItems.length, @@ -166,3 +182,19 @@ export async function diff( return results; } + +function buildDiffTargets(config: GhostConfig): DiffOptions[] { + if (!config.targets?.length) return []; + + return config.targets + .filter( + (t) => + t.type === "registry" || t.type === "url" || t.type === "path", + ) + .map((t) => ({ + registry: t.value, + componentDir: "components/ui", + styleEntry: "src/styles/main.css", + name: t.name ?? t.value, + })); +} diff --git a/packages/ghost-core/src/evolution/parent.ts b/packages/ghost-core/src/evolution/parent.ts index 21d585d..5a5fc6f 100644 --- a/packages/ghost-core/src/evolution/parent.ts +++ b/packages/ghost-core/src/evolution/parent.ts @@ -1,54 +1,56 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; -import type { DesignFingerprint, ParentSource } from "../types.js"; +import { resolveTarget } from "../config.js"; +import type { DesignFingerprint, Target } from "../types.js"; /** - * Resolve a ParentSource to a DesignFingerprint. + * Resolve a Target to a DesignFingerprint. * - * - "default": looks for .ghost-fingerprint.json in cwd (ghostui implied) * - "path": reads a local .ghost-fingerprint.json or fingerprint JSON file * - "url": fetches a remote fingerprint JSON - * - "package": resolves node_modules//.ghost-fingerprint.json + * - "npm": resolves node_modules//.ghost-fingerprint.json + * - "github": not yet supported for direct resolution (use profile flow instead) */ export async function resolveParent( - source: ParentSource, + target: Target, cwd: string = process.cwd(), ): Promise { - switch (source.type) { - case "default": - throw new Error( - "No parent declared. Set `parent` in ghost.config.ts or use --parent.", - ); - + switch (target.type) { case "path": { - const resolved = resolve(cwd, source.path); + const resolved = resolve(cwd, target.value); // If it points to a directory, look for .ghost-fingerprint.json inside it - const target = resolved.endsWith(".json") + const filePath = resolved.endsWith(".json") ? resolved : resolve(resolved, ".ghost-fingerprint.json"); - return readFingerprintFile(target); + return readFingerprintFile(filePath); } - case "url": { - const response = await fetch(source.url); + case "url": + case "registry": { + const response = await fetch(target.value); if (!response.ok) { throw new Error( - `Failed to fetch parent fingerprint from ${source.url}: ${response.status}`, + `Failed to fetch parent fingerprint from ${target.value}: ${response.status}`, ); } return (await response.json()) as DesignFingerprint; } - case "package": { + case "npm": { // Resolve from node_modules - const target = resolve( + const filePath = resolve( cwd, "node_modules", - source.name, + target.value, ".ghost-fingerprint.json", ); - return readFingerprintFile(target); + return readFingerprintFile(filePath); } + + default: + throw new Error( + `Cannot resolve parent fingerprint from target type "${target.type}". Use "ghost profile" to generate one first.`, + ); } } @@ -64,19 +66,15 @@ async function readFingerprintFile(path: string): Promise { } /** - * Normalize a config parent value to a ParentSource. - * Accepts the discriminated union directly, or a string shorthand: - * - starts with http → url - * - otherwise → path + * Normalize a config parent value to a Target. + * Accepts a Target directly, or a string shorthand resolved via resolveTarget(). */ export function normalizeParentSource( - value: ParentSource | string | undefined, -): ParentSource { - if (!value) return { type: "default" }; + value: Target | string | undefined, +): Target | undefined { + if (!value) return undefined; if (typeof value === "string") { - return value.startsWith("http") - ? { type: "url", url: value } - : { type: "path", path: value }; + return resolveTarget(value); } return value; } diff --git a/packages/ghost-core/src/evolution/sync.ts b/packages/ghost-core/src/evolution/sync.ts index 96be5ca..cdd7585 100644 --- a/packages/ghost-core/src/evolution/sync.ts +++ b/packages/ghost-core/src/evolution/sync.ts @@ -7,8 +7,8 @@ import type { DimensionAck, DimensionStance, FingerprintComparison, - ParentSource, SyncManifest, + Target, } from "../types.js"; const SYNC_FILENAME = ".ghost-sync.json"; @@ -52,7 +52,7 @@ export async function writeSyncManifest( export async function acknowledge(opts: { child: DesignFingerprint; parent: DesignFingerprint; - parentRef: ParentSource; + parentRef: Target; dimension?: string; stance?: DimensionStance; reason?: string; diff --git a/packages/ghost-core/src/extractors/format-detector.ts b/packages/ghost-core/src/extractors/format-detector.ts new file mode 100644 index 0000000..af4ba82 --- /dev/null +++ b/packages/ghost-core/src/extractors/format-detector.ts @@ -0,0 +1,264 @@ +import type { DetectedFormat, ExtractedFile, TokenFormat } from "../types.js"; + +/** + * Detect what token formats are present in a set of extracted files. + * Returns all detected formats with confidence scores. + * No AI — pure pattern matching. + */ +export function detectFormats(files: ExtractedFile[]): DetectedFormat[] { + const formats: DetectedFormat[] = []; + + const cssResult = detectCSSCustomProperties(files); + if (cssResult) formats.push(cssResult); + + const tailwindResult = detectTailwindConfig(files); + if (tailwindResult) formats.push(tailwindResult); + + const sdResult = detectStyleDictionary(files); + if (sdResult) formats.push(sdResult); + + const w3cResult = detectW3CDesignTokens(files); + if (w3cResult) formats.push(w3cResult); + + const registryResult = detectShadcnRegistry(files); + if (registryResult) formats.push(registryResult); + + // If nothing detected, return unknown + if (formats.length === 0) { + formats.push({ + format: "unknown", + confidence: 0.1, + evidence: "No recognized token format found", + files: [], + }); + } + + // Sort by confidence descending + formats.sort((a, b) => b.confidence - a.confidence); + + return formats; +} + +function detectCSSCustomProperties( + files: ExtractedFile[], +): DetectedFormat | null { + const matchingFiles: string[] = []; + let totalTokens = 0; + + for (const file of files) { + if (file.type !== "css" && file.type !== "scss") continue; + const matches = file.content.match(/--[a-zA-Z][\w-]*\s*:/g); + if (matches) { + matchingFiles.push(file.path); + totalTokens += matches.length; + } + } + + if (matchingFiles.length === 0) return null; + + return { + format: "css-custom-properties", + confidence: Math.min(0.5 + totalTokens * 0.01, 1.0), + evidence: `Found ${totalTokens} CSS custom properties in ${matchingFiles.length} file(s)`, + files: matchingFiles, + }; +} + +function detectTailwindConfig(files: ExtractedFile[]): DetectedFormat | null { + const configFiles = files.filter((f) => f.type === "tailwind-config"); + + if (configFiles.length > 0) { + return { + format: "tailwind-config", + confidence: 0.95, + evidence: `Found Tailwind config: ${configFiles.map((f) => f.path).join(", ")}`, + files: configFiles.map((f) => f.path), + }; + } + + // Also check for @tailwind directives in CSS + const cssWithDirectives: string[] = []; + for (const file of files) { + if (file.type !== "css" && file.type !== "scss") continue; + if (/@tailwind\b|@theme\b|@apply\b/.test(file.content)) { + cssWithDirectives.push(file.path); + } + } + + if (cssWithDirectives.length > 0) { + return { + format: "tailwind-config", + confidence: 0.8, + evidence: `Found Tailwind directives in ${cssWithDirectives.length} file(s)`, + files: cssWithDirectives, + }; + } + + return null; +} + +function detectStyleDictionary(files: ExtractedFile[]): DetectedFormat | null { + const matchingFiles: string[] = []; + + for (const file of files) { + if ( + file.type !== "json-tokens" && + file.type !== "style-dictionary" && + file.type !== "config" + ) + continue; + + try { + const data = JSON.parse(file.content); + if (isStyleDictionaryFormat(data)) { + matchingFiles.push(file.path); + } + } catch { + continue; + } + } + + if (matchingFiles.length === 0) return null; + + return { + format: "style-dictionary", + confidence: 0.9, + evidence: `Found Style Dictionary token files: ${matchingFiles.join(", ")}`, + files: matchingFiles, + }; +} + +/** + * Style Dictionary uses nested objects with `value`/`$value` at leaf nodes. + * Check if the JSON structure matches. + */ +function isStyleDictionaryFormat(data: unknown): boolean { + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return false; + } + + const obj = data as Record; + + // Check for $value at any depth (DTCG-compatible Style Dictionary v4+) + function hasTokenValues(o: Record, depth = 0): boolean { + if (depth > 5) return false; + for (const val of Object.values(o)) { + if (typeof val !== "object" || val === null) continue; + const v = val as Record; + if ("$value" in v || "value" in v) return true; + if (hasTokenValues(v, depth + 1)) return true; + } + return false; + } + + return hasTokenValues(obj); +} + +function detectW3CDesignTokens(files: ExtractedFile[]): DetectedFormat | null { + const matchingFiles: string[] = []; + + for (const file of files) { + if ( + file.type !== "json-tokens" && + file.type !== "w3c-tokens" && + file.type !== "config" + ) + continue; + + try { + const data = JSON.parse(file.content); + if (isW3CFormat(data)) { + matchingFiles.push(file.path); + } + } catch { + continue; + } + } + + if (matchingFiles.length === 0) return null; + + return { + format: "w3c-design-tokens", + confidence: 0.9, + evidence: `Found W3C Design Token files: ${matchingFiles.join(", ")}`, + files: matchingFiles, + }; +} + +/** + * W3C Design Tokens (DTCG) have $value + $type, and may have $description, $extensions. + * Distinguished from generic Style Dictionary by the presence of $type on tokens. + */ +function isW3CFormat(data: unknown): boolean { + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return false; + } + + const obj = data as Record; + let hasTypeAndValue = false; + + function check(o: Record, depth = 0): void { + if (depth > 5 || hasTypeAndValue) return; + for (const val of Object.values(o)) { + if (typeof val !== "object" || val === null) continue; + const v = val as Record; + if ("$value" in v && "$type" in v) { + hasTypeAndValue = true; + return; + } + check(v, depth + 1); + } + } + + check(obj); + return hasTypeAndValue; +} + +function detectShadcnRegistry(files: ExtractedFile[]): DetectedFormat | null { + for (const file of files) { + if (file.type !== "config" && file.type !== "json-tokens") continue; + + try { + const data = JSON.parse(file.content); + if ( + typeof data === "object" && + data !== null && + !Array.isArray(data) + ) { + const obj = data as Record; + // Check for shadcn registry schema marker + if ( + typeof obj.$schema === "string" && + obj.$schema.includes("registry") + ) { + return { + format: "shadcn-registry", + confidence: 0.95, + evidence: `Found shadcn registry schema in ${file.path}`, + files: [file.path], + }; + } + // Check for items array with registry types + if (Array.isArray(obj.items)) { + const firstItem = obj.items[0] as Record | undefined; + if ( + firstItem?.type && + typeof firstItem.type === "string" && + firstItem.type.startsWith("registry:") + ) { + return { + format: "shadcn-registry", + confidence: 0.9, + evidence: `Found registry items with registry: types in ${file.path}`, + files: [file.path], + }; + } + } + } + } catch { + continue; + } + } + + return null; +} diff --git a/packages/ghost-core/src/extractors/index.ts b/packages/ghost-core/src/extractors/index.ts index b7a852a..3fe9d0e 100644 --- a/packages/ghost-core/src/extractors/index.ts +++ b/packages/ghost-core/src/extractors/index.ts @@ -1,10 +1,19 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; import type { ExtractedMaterial, Extractor, ExtractorOptions, + Target, } from "../types.js"; import { cssExtractor } from "./css.js"; +import { detectFormats } from "./format-detector.js"; +import { normalizeTokens } from "./normalizer.js"; +import { materializeGithub } from "./sources/github.js"; +import { materializeNpm } from "./sources/npm.js"; +import { materializeUrl } from "./sources/url.js"; import { tailwindExtractor } from "./tailwind.js"; +import { walkAndCategorize } from "./walker.js"; // Ordered by specificity — more specific extractors first const BUILTIN_EXTRACTORS: Extractor[] = [tailwindExtractor, cssExtractor]; @@ -19,6 +28,10 @@ export async function detectExtractors(cwd: string): Promise { return matched; } +/** + * Extract design material from a local directory. + * Legacy API — delegates to the universal pipeline for "path" targets. + */ export async function extract( cwd: string, options?: ExtractorOptions & { extractorNames?: string[] }, @@ -47,5 +60,136 @@ export async function extract( return extractors[0].extract(cwd, options); } +/** + * Extract design material from any target. + * + * This is the universal extraction pipeline: + * 1. Materialize remote sources to local temp directories + * 2. Walk files to collect all relevant content + * 3. Detect token formats + * 4. Normalize tokens across formats + * 5. Return ExtractedMaterial with metadata + */ +export async function extractFromTarget( + target: Target, + options?: ExtractorOptions, +): Promise { + // Step 1: Get a local directory path + const localDir = await materializeTarget(target); + + // Step 2: Walk and categorize files + const { styleFiles, componentFiles, configFiles } = await walkAndCategorize( + localDir, + { + maxFiles: options?.maxFiles ?? 100, + ignore: options?.ignore, + }, + ); + + const allFiles = [...styleFiles, ...componentFiles, ...configFiles]; + + // Step 3: Detect formats + const detectedFormats = detectFormats(allFiles); + + // Step 4: Normalize tokens + const normalizedTokens = normalizeTokens(allFiles, detectedFormats); + + // Step 5: Detect framework and component library + const framework = detectFramework(detectedFormats, localDir); + const componentLibrary = detectComponentLibrary(localDir); + + return { + styleFiles, + componentFiles, + configFiles, + metadata: { + framework, + componentLibrary, + tokenCount: normalizedTokens.length, + componentCount: componentFiles.length, + targetType: target.type, + detectedFormats, + sourceUrl: + target.type === "url" || target.type === "registry" + ? target.value + : undefined, + }, + }; +} + +/** + * Materialize a target to a local directory path. + * For local paths, returns as-is. For remote targets, fetches to temp dir. + */ +async function materializeTarget(target: Target): Promise { + switch (target.type) { + case "path": + return target.value; + + case "url": + case "registry": + return materializeUrl(target.value); + + case "npm": + return materializeNpm(target.value); + + case "github": + return materializeGithub(target.value, target.options?.branch); + + case "figma": + throw new Error( + "Figma extraction is not yet implemented. Coming soon.", + ); + + case "doc-site": + // Doc site uses URL materializer with HTML extraction + return materializeUrl(target.value); + + default: + throw new Error(`Unsupported target type: ${target.type}`); + } +} + +function detectFramework( + formats: import("../types.js").DetectedFormat[], + _cwd: string, +): string | null { + for (const format of formats) { + if (format.format === "tailwind-config") return "tailwind"; + } + if (formats.some((f) => f.format === "css-custom-properties")) return "css"; + if (formats.some((f) => f.format === "style-dictionary")) + return "style-dictionary"; + if (formats.some((f) => f.format === "w3c-design-tokens")) + return "w3c-design-tokens"; + return null; +} + +function detectComponentLibrary(cwd: string): string | null { + try { + const pkgPath = join(cwd, "package.json"); + if (!existsSync(pkgPath)) return null; + + const raw = require("node:fs").readFileSync(pkgPath, "utf-8"); + const pkg = JSON.parse(raw); + const allDeps = { + ...pkg.dependencies, + ...pkg.devDependencies, + }; + + if (allDeps["@shadcn/ui"] || existsSync(join(cwd, "components.json"))) + return "shadcn"; + if (allDeps["@radix-ui/react-slot"]) return "radix"; + if (allDeps["@chakra-ui/react"]) return "chakra"; + if (allDeps["@mui/material"]) return "mui"; + return null; + } catch { + return null; + } +} + export { cssExtractor } from "./css.js"; +export { detectFormats } from "./format-detector.js"; +export { normalizeTokens } from "./normalizer.js"; export { tailwindExtractor } from "./tailwind.js"; +export { walkAndCategorize, walkDirectory } from "./walker.js"; diff --git a/packages/ghost-core/src/extractors/normalizer.ts b/packages/ghost-core/src/extractors/normalizer.ts new file mode 100644 index 0000000..4505628 --- /dev/null +++ b/packages/ghost-core/src/extractors/normalizer.ts @@ -0,0 +1,236 @@ +import { parseCSS } from "../resolvers/css.js"; +import type { + DetectedFormat, + ExtractedFile, + NormalizedToken, + TokenCategory, +} from "../types.js"; + +/** + * Normalize tokens from extracted files based on detected formats. + * Delegates to format-specific parsers, all producing NormalizedToken[]. + */ +export function normalizeTokens( + files: ExtractedFile[], + formats: DetectedFormat[], +): NormalizedToken[] { + const tokens: NormalizedToken[] = []; + const processedFiles = new Set(); + + for (const format of formats) { + switch (format.format) { + case "css-custom-properties": { + for (const file of files) { + if ( + (file.type !== "css" && file.type !== "scss") || + processedFiles.has(file.path) + ) + continue; + + const cssTokens = parseCSS(file.content); + for (const token of cssTokens) { + tokens.push({ + ...token, + originalFormat: "css-custom-properties", + sourceFile: file.path, + }); + } + processedFiles.add(file.path); + } + break; + } + + case "style-dictionary": { + for (const filePath of format.files) { + if (processedFiles.has(filePath)) continue; + const file = files.find((f) => f.path === filePath); + if (!file) continue; + + try { + const data = JSON.parse(file.content); + const sdTokens = parseStyleDictionary(data, file.path); + tokens.push(...sdTokens); + processedFiles.add(filePath); + } catch { + continue; + } + } + break; + } + + case "w3c-design-tokens": { + for (const filePath of format.files) { + if (processedFiles.has(filePath)) continue; + const file = files.find((f) => f.path === filePath); + if (!file) continue; + + try { + const data = JSON.parse(file.content); + const w3cTokens = parseW3CTokens(data, file.path); + tokens.push(...w3cTokens); + processedFiles.add(filePath); + } catch { + continue; + } + } + break; + } + + // tailwind-config and shadcn-registry are handled by existing resolvers + // (resolvers/tailwind.ts and resolvers/registry.ts) — not duplicated here + case "tailwind-config": + case "shadcn-registry": + case "figma-variables": + case "unknown": + break; + } + } + + return tokens; +} + +/** + * Parse Style Dictionary format into normalized tokens. + * Style Dictionary uses nested objects: { color: { primary: { value: "#000" } } } + */ +function parseStyleDictionary( + data: unknown, + sourceFile: string, + prefix = "", +): NormalizedToken[] { + const tokens: NormalizedToken[] = []; + + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return tokens; + } + + const obj = data as Record; + + for (const [key, val] of Object.entries(obj)) { + if (key.startsWith("$")) continue; // Skip meta keys + + if (typeof val !== "object" || val === null) continue; + + const v = val as Record; + const tokenName = prefix ? `--${prefix}-${key}` : `--${key}`; + + // Leaf node: has $value or value + const value = v.$value ?? v.value; + if (value !== undefined && (typeof value === "string" || typeof value === "number")) { + const strValue = String(value); + const type = (v.$type ?? v.type) as string | undefined; + const category = typeToCategory(type, key, strValue); + + tokens.push({ + name: tokenName, + value: strValue, + selector: ":root", + category, + originalFormat: "style-dictionary", + sourceFile, + }); + } else { + // Recurse into nested groups + const nested = parseStyleDictionary( + val, + sourceFile, + prefix ? `${prefix}-${key}` : key, + ); + tokens.push(...nested); + } + } + + return tokens; +} + +/** + * Parse W3C Design Tokens (DTCG) format. + * Similar to Style Dictionary but tokens have $value + $type. + */ +function parseW3CTokens( + data: unknown, + sourceFile: string, + prefix = "", +): NormalizedToken[] { + const tokens: NormalizedToken[] = []; + + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return tokens; + } + + const obj = data as Record; + + for (const [key, val] of Object.entries(obj)) { + if (key.startsWith("$")) continue; + + if (typeof val !== "object" || val === null) continue; + + const v = val as Record; + const tokenName = prefix ? `--${prefix}-${key}` : `--${key}`; + + if ("$value" in v) { + const value = v.$value; + const strValue = typeof value === "object" ? JSON.stringify(value) : String(value); + const type = v.$type as string | undefined; + const category = typeToCategory(type, key, strValue); + + tokens.push({ + name: tokenName, + value: strValue, + selector: ":root", + category, + originalFormat: "w3c-design-tokens", + sourceFile, + }); + } else { + const nested = parseW3CTokens( + val, + sourceFile, + prefix ? `${prefix}-${key}` : key, + ); + tokens.push(...nested); + } + } + + return tokens; +} + +/** + * Map a token $type to our internal TokenCategory. + */ +function typeToCategory( + type: string | undefined, + name: string, + value: string, +): TokenCategory { + if (type) { + const t = type.toLowerCase(); + if (t === "color") return "color"; + if (t === "dimension" || t === "spacing") return "spacing"; + if (t === "fontfamily" || t === "fontweight" || t === "fontsize") + return "typography"; + if (t === "borderradius") return "radius"; + if (t === "shadow" || t === "boxshadow") return "shadow"; + if (t === "border") return "border"; + if (t === "duration" || t === "transition") return "animation"; + if (t === "typography" || t === "lineheight" || t === "letterspacing") + return "typography"; + } + + // Fallback: infer from name + const n = name.toLowerCase(); + if (n.includes("color") || n.includes("bg") || n.includes("background")) + return "color"; + if (n.includes("spacing") || n.includes("gap") || n.includes("margin") || n.includes("padding")) + return "spacing"; + if (n.includes("font") || n.includes("text") || n.includes("type")) + return "typography"; + if (n.includes("radius") || n.includes("rounded")) return "radius"; + if (n.includes("shadow")) return "shadow"; + if (n.includes("border")) return "border"; + + // Fallback: infer from value + if (/^#|^rgb|^hsl|^oklch/.test(value)) return "color"; + + return "other"; +} diff --git a/packages/ghost-core/src/extractors/sources/github.ts b/packages/ghost-core/src/extractors/sources/github.ts new file mode 100644 index 0000000..ace34dc --- /dev/null +++ b/packages/ghost-core/src/extractors/sources/github.ts @@ -0,0 +1,37 @@ +import { execSync } from "node:child_process"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * Materialize a GitHub repository into a temporary directory. + * Uses shallow clone for speed. + * + * @param repo - "owner/repo" format + * @param branch - optional branch/tag to check out + * @returns path to the cloned repository + */ +export async function materializeGithub( + repo: string, + branch?: string, +): Promise { + const tempDir = await mkdtemp(join(tmpdir(), "ghost-github-")); + const url = `https://github.com/${repo}.git`; + + try { + const branchFlag = branch ? `--branch ${branch}` : ""; + execSync( + `git clone --depth 1 ${branchFlag} "${url}" "${tempDir}"`, + { + stdio: "pipe", + timeout: 120000, + }, + ); + + return tempDir; + } catch (err) { + throw new Error( + `Failed to clone GitHub repo "${repo}": ${err instanceof Error ? err.message : String(err)}`, + ); + } +} diff --git a/packages/ghost-core/src/extractors/sources/npm.ts b/packages/ghost-core/src/extractors/sources/npm.ts new file mode 100644 index 0000000..75caf76 --- /dev/null +++ b/packages/ghost-core/src/extractors/sources/npm.ts @@ -0,0 +1,44 @@ +import { execSync } from "node:child_process"; +import { mkdtemp, readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * Materialize an npm package into a temporary directory. + * Uses `npm pack` to download the tarball and extracts it. + * + * Returns the path to the extracted package contents. + */ +export async function materializeNpm( + packageName: string, +): Promise { + const tempDir = await mkdtemp(join(tmpdir(), "ghost-npm-")); + + try { + // Download and extract the package tarball + execSync(`npm pack ${packageName} --pack-destination "${tempDir}"`, { + stdio: "pipe", + timeout: 60000, + }); + + // Find the tarball + const files = await readdir(tempDir); + const tarball = files.find((f) => f.endsWith(".tgz")); + if (!tarball) { + throw new Error(`No tarball found after npm pack ${packageName}`); + } + + // Extract + execSync(`tar xzf "${join(tempDir, tarball)}" -C "${tempDir}"`, { + stdio: "pipe", + timeout: 30000, + }); + + // npm pack extracts to a "package" subdirectory + return join(tempDir, "package"); + } catch (err) { + throw new Error( + `Failed to materialize npm package "${packageName}": ${err instanceof Error ? err.message : String(err)}`, + ); + } +} diff --git a/packages/ghost-core/src/extractors/sources/url.ts b/packages/ghost-core/src/extractors/sources/url.ts new file mode 100644 index 0000000..e2b4429 --- /dev/null +++ b/packages/ghost-core/src/extractors/sources/url.ts @@ -0,0 +1,120 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * Materialize a URL into a temporary directory. + * Auto-detects content type: + * - JSON (registry, tokens) → saved as .json + * - CSS → saved as .css + * - HTML → extracts